This commit is contained in:
Your Name
2026-08-07 15:35:02 +08:00
parent 3fc94c4a89
commit 6119fdd767
25 changed files with 2126 additions and 355 deletions
+61 -7
View File
@@ -6,7 +6,14 @@ from sqlalchemy.ext.asyncio import AsyncSession
from models.database import get_db
from models.models import User
from .jwt_utils import decode_access_token
from .roles import can_manage_users, can_write, is_admin
from .permissions import (
ACCOUNTS_WRITE,
MESSAGES_WRITE,
RULES_WRITE,
USERS_MANAGE,
WRITE_PERMISSIONS,
)
from .roles import can_write, has_permission, is_admin
bearer_scheme = HTTPBearer(auto_error=False)
@@ -32,6 +39,20 @@ async def get_current_user(
return user
def require_permission(permission: str):
"""FastAPI dependency factory that checks a single permission code."""
async def _checker(user: User = Depends(get_current_user)) -> User:
if has_permission(user.role, permission):
return user
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"缺少权限:{permission}",
)
return _checker
async def require_admin(user: User = Depends(get_current_user)) -> User:
if not is_admin(user.role):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="需要管理员权限")
@@ -39,12 +60,45 @@ async def require_admin(user: User = Depends(get_current_user)) -> User:
async def require_write(user: User = Depends(get_current_user)) -> User:
if not can_write(user.role):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="当前角色只读,无法执行此操作")
return user
"""Any write-capable permission (accounts/messages/rules) or legacy can_write."""
if is_admin(user.role) or can_write(user.role):
return user
if any(has_permission(user.role, code) for code in WRITE_PERMISSIONS):
return user
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="当前角色只读,无法执行此操作",
)
async def require_accounts_write(user: User = Depends(get_current_user)) -> User:
if has_permission(user.role, ACCOUNTS_WRITE):
return user
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="缺少权限:accounts.write",
)
async def require_messages_write(user: User = Depends(get_current_user)) -> User:
if has_permission(user.role, MESSAGES_WRITE):
return user
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="缺少权限:messages.write",
)
async def require_rules_write(user: User = Depends(get_current_user)) -> User:
if has_permission(user.role, RULES_WRITE):
return user
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="缺少权限:rules.write",
)
async def require_user_manager(user: User = Depends(get_current_user)) -> User:
if not can_manage_users(user.role):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="需要管理员权限")
return user
if has_permission(user.role, USERS_MANAGE):
return user
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="需要用户管理权限")
+162
View File
@@ -0,0 +1,162 @@
"""Fixed permission catalog for menus and actions.
UI and APIs only select from this list; new codes must be added in code.
"""
from __future__ import annotations
from typing import Any
# Menu visibility
MENU_DASHBOARD = "menu.dashboard"
MENU_ACCOUNTS = "menu.accounts"
MENU_MESSAGES = "menu.messages"
MENU_RULES = "menu.rules"
MENU_LOGS = "menu.logs"
MENU_RECEIVED_MESSAGES = "menu.received_messages"
MENU_SYSTEM_LOGS = "menu.system_logs"
MENU_DOWNLOAD = "menu.download"
MENU_HELP = "menu.help"
MENU_USERS = "menu.users"
MENU_SETTINGS = "menu.settings"
MENU_DESKTOP_UPDATE = "menu.desktop_update"
MENU_PAYMENT_SETTINGS = "menu.payment_settings"
MENU_PAYMENT_ORDERS = "menu.payment_orders"
# Actions
ACCOUNTS_WRITE = "accounts.write"
MESSAGES_WRITE = "messages.write"
RULES_WRITE = "rules.write"
LOGS_READ = "logs.read"
RECEIVED_MESSAGES_READ = "received_messages.read"
SYSTEM_LOGS_READ = "system_logs.read"
USERS_MANAGE = "users.manage"
SETTINGS_MANAGE = "settings.manage"
DESKTOP_MANAGE = "desktop.manage"
PAYMENTS_MANAGE = "payments.manage"
ORDERS_READ = "orders.read"
ALL_PERMISSIONS: tuple[str, ...] = (
MENU_DASHBOARD,
MENU_ACCOUNTS,
MENU_MESSAGES,
MENU_RULES,
MENU_LOGS,
MENU_RECEIVED_MESSAGES,
MENU_SYSTEM_LOGS,
MENU_DOWNLOAD,
MENU_HELP,
MENU_USERS,
MENU_SETTINGS,
MENU_DESKTOP_UPDATE,
MENU_PAYMENT_SETTINGS,
MENU_PAYMENT_ORDERS,
ACCOUNTS_WRITE,
MESSAGES_WRITE,
RULES_WRITE,
LOGS_READ,
RECEIVED_MESSAGES_READ,
SYSTEM_LOGS_READ,
USERS_MANAGE,
SETTINGS_MANAGE,
DESKTOP_MANAGE,
PAYMENTS_MANAGE,
ORDERS_READ,
)
PERMISSION_SET = frozenset(ALL_PERMISSIONS)
WRITE_PERMISSIONS = frozenset(
{
ACCOUNTS_WRITE,
MESSAGES_WRITE,
RULES_WRITE,
}
)
_PERMISSION_META: dict[str, dict[str, str]] = {
MENU_DASHBOARD: {"group": "menu", "label": "数据概览"},
MENU_ACCOUNTS: {"group": "menu", "label": "账号管理"},
MENU_MESSAGES: {"group": "menu", "label": "私信收发"},
MENU_RULES: {"group": "menu", "label": "自动回复规则"},
MENU_LOGS: {"group": "menu", "label": "回复日志面板"},
MENU_RECEIVED_MESSAGES: {"group": "menu", "label": "接收消息日志"},
MENU_SYSTEM_LOGS: {"group": "menu", "label": "系统诊断日志"},
MENU_DOWNLOAD: {"group": "menu", "label": "软件下载"},
MENU_HELP: {"group": "menu", "label": "帮助中心"},
MENU_USERS: {"group": "menu", "label": "用户与角色"},
MENU_SETTINGS: {"group": "menu", "label": "系统设置"},
MENU_DESKTOP_UPDATE: {"group": "menu", "label": "桌面端升级"},
MENU_PAYMENT_SETTINGS: {"group": "menu", "label": "支付配置"},
MENU_PAYMENT_ORDERS: {"group": "menu", "label": "我的订单"},
ACCOUNTS_WRITE: {"group": "action", "label": "账号写操作(启动/停止/改凭证/删除)"},
MESSAGES_WRITE: {"group": "action", "label": "发送私信"},
RULES_WRITE: {"group": "action", "label": "编辑自动回复规则"},
LOGS_READ: {"group": "action", "label": "查看回复日志"},
RECEIVED_MESSAGES_READ: {"group": "action", "label": "查看接收消息日志"},
SYSTEM_LOGS_READ: {"group": "action", "label": "查看系统诊断日志"},
USERS_MANAGE: {"group": "action", "label": "管理用户与角色"},
SETTINGS_MANAGE: {"group": "action", "label": "管理系统设置"},
DESKTOP_MANAGE: {"group": "action", "label": "管理桌面端升级"},
PAYMENTS_MANAGE: {"group": "action", "label": "管理支付配置"},
ORDERS_READ: {"group": "action", "label": "查看我的订单"},
}
OPERATOR_PERMISSIONS: tuple[str, ...] = (
MENU_DASHBOARD,
MENU_ACCOUNTS,
MENU_MESSAGES,
MENU_RULES,
MENU_LOGS,
MENU_RECEIVED_MESSAGES,
MENU_DOWNLOAD,
MENU_HELP,
MENU_PAYMENT_ORDERS,
ACCOUNTS_WRITE,
MESSAGES_WRITE,
RULES_WRITE,
LOGS_READ,
RECEIVED_MESSAGES_READ,
ORDERS_READ,
)
VIEWER_PERMISSIONS: tuple[str, ...] = (
MENU_DASHBOARD,
MENU_ACCOUNTS,
MENU_MESSAGES,
MENU_RULES,
MENU_LOGS,
MENU_RECEIVED_MESSAGES,
MENU_DOWNLOAD,
MENU_HELP,
LOGS_READ,
RECEIVED_MESSAGES_READ,
)
def normalize_permissions(codes: list[str] | tuple[str, ...] | None) -> list[str]:
if not codes:
return []
seen: set[str] = set()
result: list[str] = []
for code in codes:
value = str(code or "").strip()
if not value or value not in PERMISSION_SET or value in seen:
continue
seen.add(value)
result.append(value)
return result
def permission_catalog() -> dict[str, Any]:
menus = []
actions = []
for code in ALL_PERMISSIONS:
meta = _PERMISSION_META[code]
item = {"code": code, "label": meta["label"]}
if meta["group"] == "menu":
menus.append(item)
else:
actions.append(item)
return {"menus": menus, "actions": actions}
+284
View File
@@ -0,0 +1,284 @@
"""Persist and cache roles; seed built-ins on startup."""
from __future__ import annotations
import json
import logging
import re
from datetime import datetime
from fastapi import HTTPException, status
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from models.models import Role, User
from .permissions import ALL_PERMISSIONS, normalize_permissions
from .roles import (
ROLE_ADMIN,
RoleRecord,
default_role_seeds,
ensure_role,
get_cached_role,
is_admin,
list_cached_roles,
role_label,
sanitize_role_permissions,
set_role_cache,
)
logger = logging.getLogger("auth.roles")
_ROLE_CODE_RE = re.compile(r"^[a-z][a-z0-9_]{1,49}$")
def _encode_permissions(codes: list[str]) -> str:
return json.dumps(codes, ensure_ascii=False)
def _decode_permissions(raw: str | None) -> list[str]:
if not raw:
return []
try:
data = json.loads(raw)
except Exception:
return []
if not isinstance(data, list):
return []
return normalize_permissions([str(item) for item in data])
def role_to_record(row: Role) -> RoleRecord:
perms = list(ALL_PERMISSIONS) if row.is_admin else _decode_permissions(row.permissions)
return RoleRecord(
code=row.code,
label=row.label,
description=row.description or "",
is_system=bool(row.is_system),
is_admin=bool(row.is_admin),
permissions=perms,
)
async def refresh_role_cache(db: AsyncSession) -> list[RoleRecord]:
result = await db.execute(select(Role).order_by(Role.id.asc()))
rows = result.scalars().all()
records = [role_to_record(row) for row in rows]
if not records:
records = default_role_seeds()
set_role_cache(records)
return records
async def seed_builtin_roles(db: AsyncSession) -> None:
"""Insert missing built-in roles and keep admin permissions complete."""
seeds = {seed.code: seed for seed in default_role_seeds()}
result = await db.execute(select(Role))
existing = {row.code: row for row in result.scalars().all()}
changed = False
for code, seed in seeds.items():
row = existing.get(code)
payload = _encode_permissions(seed.permissions)
if row is None:
db.add(
Role(
code=seed.code,
label=seed.label,
description=seed.description,
is_system=True,
is_admin=seed.is_admin,
permissions=payload,
)
)
changed = True
continue
# Keep system flags and admin full permission set in sync.
if not row.is_system:
row.is_system = True
changed = True
if seed.is_admin and (not row.is_admin or row.permissions != payload):
row.is_admin = True
row.permissions = payload
row.label = seed.label
changed = True
elif not row.label:
row.label = seed.label
changed = True
if changed:
await db.commit()
await refresh_role_cache(db)
logger.info("Role cache loaded: %s", ", ".join(r.code for r in list_cached_roles()))
async def list_roles(db: AsyncSession) -> list[RoleRecord]:
await refresh_role_cache(db)
return list_cached_roles()
async def get_role_or_404(db: AsyncSession, code: str) -> Role:
result = await db.execute(select(Role).where(Role.code == code))
row = result.scalar_one_or_none()
if not row:
raise HTTPException(status_code=404, detail="角色不存在")
return row
async def count_users_with_role(db: AsyncSession, code: str) -> int:
result = await db.execute(
select(func.count()).select_from(User).where(User.role == code)
)
return int(result.scalar() or 0)
async def count_admin_users(db: AsyncSession) -> int:
result = await db.execute(
select(func.count()).select_from(User).where(User.role == ROLE_ADMIN)
)
return int(result.scalar() or 0)
def validate_role_code(code: str) -> str:
value = str(code or "").strip().lower()
if not _ROLE_CODE_RE.match(value):
raise HTTPException(
status_code=400,
detail="角色码需为小写字母开头,仅含小写字母/数字/下划线,长度 2-50",
)
return value
async def create_role(
db: AsyncSession,
*,
code: str,
label: str,
description: str | None,
permissions: list[str] | None,
) -> RoleRecord:
role_code = validate_role_code(code)
if get_cached_role(role_code) or (
await db.execute(select(Role).where(Role.code == role_code))
).scalar_one_or_none():
raise HTTPException(status_code=400, detail="角色码已存在")
name = (label or "").strip() or role_code
perms = sanitize_role_permissions(permissions, force_all=False)
row = Role(
code=role_code,
label=name,
description=(description or "").strip() or None,
is_system=False,
is_admin=False,
permissions=_encode_permissions(perms),
)
db.add(row)
await db.commit()
await db.refresh(row)
await refresh_role_cache(db)
return role_to_record(row)
async def update_role(
db: AsyncSession,
code: str,
*,
label: str | None = None,
description: str | None = None,
permissions: list[str] | None = None,
) -> RoleRecord:
row = await get_role_or_404(db, code)
if row.is_admin or row.code == ROLE_ADMIN:
# Admin role always keeps full permissions; label/description may update.
if label is not None:
row.label = (label or "").strip() or row.label
if description is not None:
row.description = (description or "").strip() or None
row.permissions = _encode_permissions(list(ALL_PERMISSIONS))
row.is_admin = True
row.is_system = True
row.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(row)
await refresh_role_cache(db)
return role_to_record(row)
if label is not None:
row.label = (label or "").strip() or row.label
if description is not None:
row.description = (description or "").strip() or None
if permissions is not None:
row.permissions = _encode_permissions(
sanitize_role_permissions(permissions, force_all=False)
)
row.is_admin = False
row.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(row)
await refresh_role_cache(db)
return role_to_record(row)
async def delete_role(db: AsyncSession, code: str) -> None:
row = await get_role_or_404(db, code)
if row.is_system or row.is_admin or row.code == ROLE_ADMIN:
raise HTTPException(status_code=400, detail="系统内置角色不可删除")
used = await count_users_with_role(db, code)
if used > 0:
raise HTTPException(
status_code=400,
detail=f"仍有 {used} 个用户使用该角色,请先调整用户角色后再删除",
)
await db.delete(row)
await db.commit()
await refresh_role_cache(db)
async def ensure_role_assignable(db: AsyncSession, role_code: str) -> str:
"""Validate role exists in DB (refresh cache if needed)."""
code = str(role_code or "").strip()
if not code:
raise HTTPException(status_code=400, detail="角色不能为空")
if get_cached_role(code) is None:
await refresh_role_cache(db)
try:
return ensure_role(code)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
async def guard_last_admin_change(
db: AsyncSession,
*,
user: User,
new_role: str | None = None,
deactivating: bool = False,
deleting: bool = False,
) -> None:
"""Prevent removing the last admin user."""
if not is_admin(user.role):
return
admin_count = await count_admin_users(db)
if admin_count > 1:
return
if deleting or deactivating:
raise HTTPException(
status_code=400,
detail="不能删除或禁用最后一个管理员账号",
)
if new_role is not None and not is_admin(new_role):
raise HTTPException(
status_code=400,
detail="不能将最后一个管理员改为非管理员角色",
)
def user_permission_payload(role_code: str) -> dict:
record = get_cached_role(role_code)
admin = bool(record.is_admin) if record else is_admin(role_code)
from .roles import permissions_for_role
return {
"role_label": role_label(role_code),
"is_admin": admin,
"permissions": permissions_for_role(role_code),
}
+145 -9
View File
@@ -1,5 +1,18 @@
"""Role code helpers and an in-memory role registry backed by the roles table."""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Iterable
from .permissions import (
ALL_PERMISSIONS,
OPERATOR_PERMISSIONS,
VIEWER_PERMISSIONS,
WRITE_PERMISSIONS,
normalize_permissions,
)
ROLE_ADMIN = "admin"
ROLE_OPERATOR = "operator"
ROLE_VIEWER = "viewer"
@@ -13,19 +26,142 @@ ROLE_LABELS = {
}
def is_admin(role: str) -> bool:
return role == ROLE_ADMIN
@dataclass
class RoleRecord:
code: str
label: str
description: str = ""
is_system: bool = False
is_admin: bool = False
permissions: list[str] = field(default_factory=list)
def can_write(role: str) -> bool:
return role in (ROLE_ADMIN, ROLE_OPERATOR)
_ROLE_CACHE: dict[str, RoleRecord] = {}
def can_manage_users(role: str) -> bool:
return role == ROLE_ADMIN
def default_role_seeds() -> list[RoleRecord]:
return [
RoleRecord(
code=ROLE_ADMIN,
label=ROLE_LABELS[ROLE_ADMIN],
description="拥有全部菜单与操作权限,可管理全局数据",
is_system=True,
is_admin=True,
permissions=list(ALL_PERMISSIONS),
),
RoleRecord(
code=ROLE_OPERATOR,
label=ROLE_LABELS[ROLE_OPERATOR],
description="管理自己的账号、规则与私信",
is_system=True,
is_admin=False,
permissions=list(OPERATOR_PERMISSIONS),
),
RoleRecord(
code=ROLE_VIEWER,
label=ROLE_LABELS[ROLE_VIEWER],
description="仅查看自己的业务数据,不可修改",
is_system=True,
is_admin=False,
permissions=list(VIEWER_PERMISSIONS),
),
]
def set_role_cache(roles: Iterable[RoleRecord]) -> None:
global _ROLE_CACHE
_ROLE_CACHE = {role.code: role for role in roles}
def get_cached_role(code: str | None) -> RoleRecord | None:
if not code:
return None
return _ROLE_CACHE.get(str(code))
def list_cached_roles() -> list[RoleRecord]:
return list(_ROLE_CACHE.values())
def role_label(code: str | None) -> str:
role = get_cached_role(code)
if role:
return role.label
return ROLE_LABELS.get(str(code or ""), str(code or ""))
def is_admin(role: str | None) -> bool:
"""True when the role has global data scope (built-in admin)."""
record = get_cached_role(role)
if record is not None:
return bool(record.is_admin)
# Fallback before cache is warm / for unit tests.
return str(role or "") == ROLE_ADMIN
def can_write(role: str | None) -> bool:
record = get_cached_role(role)
if record is not None:
if record.is_admin:
return True
return any(code in WRITE_PERMISSIONS for code in record.permissions)
return str(role or "") in (ROLE_ADMIN, ROLE_OPERATOR)
def can_manage_users(role: str | None) -> bool:
return has_permission(role, "users.manage")
def has_permission(role: str | None, permission: str) -> bool:
code = str(permission or "").strip()
if not code:
return False
record = get_cached_role(role)
if record is None:
if str(role or "") == ROLE_ADMIN:
return True
if str(role or "") == ROLE_OPERATOR:
return code in OPERATOR_PERMISSIONS
if str(role or "") == ROLE_VIEWER:
return code in VIEWER_PERMISSIONS
return False
if record.is_admin:
return True
return code in record.permissions
def permissions_for_role(role: str | None) -> list[str]:
record = get_cached_role(role)
if record is None:
if str(role or "") == ROLE_ADMIN:
return list(ALL_PERMISSIONS)
if str(role or "") == ROLE_OPERATOR:
return list(OPERATOR_PERMISSIONS)
if str(role or "") == ROLE_VIEWER:
return list(VIEWER_PERMISSIONS)
return []
if record.is_admin:
return list(ALL_PERMISSIONS)
return list(record.permissions)
def ensure_role(role: str) -> str:
if role not in ALL_ROLES:
raise ValueError(f"无效角色: {role}")
return role
"""Validate that a role code exists (cache or built-in fallback)."""
code = str(role or "").strip()
if not code:
raise ValueError("角色不能为空")
if get_cached_role(code) is not None:
return code
if code in ALL_ROLES:
return code
raise ValueError(f"无效角色: {code}")
def sanitize_role_permissions(
codes: list[str] | None,
*,
force_all: bool = False,
) -> list[str]:
if force_all:
return list(ALL_PERMISSIONS)
return normalize_permissions(codes)
+134 -12
View File
@@ -25,16 +25,30 @@ from .email_verification import create_verification_token, mask_email, verify_em
from .password_reset import create_password_reset_token, verify_password_reset_token
from .jwt_utils import create_access_token
from .passwords import hash_password, verify_password
from .roles import ALL_ROLES, ROLE_LABELS, ROLE_OPERATOR, ensure_role, is_admin
from .permissions import permission_catalog
from .role_service import (
count_users_with_role,
create_role,
delete_role,
ensure_role_assignable,
guard_last_admin_change,
list_roles as list_role_records,
update_role,
user_permission_payload,
)
from .roles import ROLE_OPERATOR, is_admin
from .schemas import (
LoginRequest,
MessageResponse,
ForgotPasswordRequest,
ForgotPasswordResponse,
PermissionCatalogResponse,
RegisterRequest,
RegisterResponse,
ResendVerificationRequest,
ResetPasswordRequest,
RoleCreate,
RoleUpdate,
RolesResponse,
RoleInfo,
TokenResponse,
@@ -52,6 +66,10 @@ router = APIRouter(prefix="/api/auth", tags=["auth"])
async def _build_user_response(db: AsyncSession, user: User, with_count: bool = False) -> UserResponse:
payload = UserResponse.model_validate(user)
perm = user_permission_payload(user.role)
payload.role_label = perm["role_label"]
payload.is_admin = perm["is_admin"]
payload.permissions = perm["permissions"]
if with_count:
breakdown = await count_user_account_breakdown(db, user.id)
payload.account_count = breakdown["total"]
@@ -371,13 +389,29 @@ async def get_me(user: User = Depends(get_current_user), db: AsyncSession = Depe
@router.get("/roles", response_model=RolesResponse)
async def list_roles(_: User = Depends(get_current_user)):
async def list_auth_roles(
db: AsyncSession = Depends(get_db),
_: User = Depends(get_current_user),
):
"""Lightweight role list for dropdowns (any logged-in user)."""
records = await list_role_records(db)
return RolesResponse(
roles=[RoleInfo(value=r, label=ROLE_LABELS.get(r, r)) for r in ALL_ROLES]
roles=[
RoleInfo(
value=item.code,
label=item.label,
description=item.description or None,
is_system=item.is_system,
is_admin=item.is_admin,
permissions=list(item.permissions),
)
for item in records
]
)
users_router = APIRouter(prefix="/api/users", tags=["users"])
roles_router = APIRouter(prefix="/api/roles", tags=["roles"])
@users_router.get("", response_model=list[UserResponse])
@@ -403,10 +437,7 @@ async def create_user(
exists = await db.execute(select(User).where(User.username == body.username))
if exists.scalar_one_or_none():
raise HTTPException(status_code=400, detail="用户名已存在")
try:
role = ensure_role(body.role)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
role = await ensure_role_assignable(db, body.role)
email = await _ensure_email_available(db, str(body.email) if body.email else None)
if settings.email_binding_required and not is_admin(role) and not email:
raise HTTPException(status_code=400, detail="系统已开启「登录必须绑定邮箱」,请填写邮箱")
@@ -445,14 +476,19 @@ async def update_user(
settings = await load_settings(db)
if user.id == current.id and body.is_active is False:
raise HTTPException(status_code=400, detail="不能禁用当前登录账号")
updates = body.model_dump(exclude_unset=True)
if body.is_active is False:
await guard_last_admin_change(db, user=user, deactivating=True)
if body.role is not None:
new_role = await ensure_role_assignable(db, body.role)
await guard_last_admin_change(db, user=user, new_role=new_role)
if body.display_name is not None:
user.display_name = body.display_name
if body.role is not None:
prev_role = user.role
try:
user.role = ensure_role(body.role)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
user.role = await ensure_role_assignable(db, body.role)
if is_admin(user.role):
user.max_accounts = UNLIMITED_ACCOUNTS
await sync_user_account_quota(db, user, stop_worker=default_stop_worker)
@@ -465,7 +501,6 @@ async def update_user(
if body.password:
user.password_hash = hash_password(body.password)
updates = body.model_dump(exclude_unset=True)
if "max_accounts" in updates and not is_admin(user.role):
user.max_accounts = normalize_max_accounts(updates["max_accounts"], user.role)
await sync_user_account_quota(db, user, stop_worker=default_stop_worker)
@@ -506,6 +541,93 @@ async def delete_user(
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="用户不存在")
await guard_last_admin_change(db, user=user, deleting=True)
await db.delete(user)
await db.commit()
return {"message": "用户已删除"}
@roles_router.get("", response_model=RolesResponse)
async def admin_list_roles(
db: AsyncSession = Depends(get_db),
_: User = Depends(require_user_manager),
):
records = await list_role_records(db)
roles = []
for item in records:
roles.append(
RoleInfo(
value=item.code,
label=item.label,
description=item.description or None,
is_system=item.is_system,
is_admin=item.is_admin,
permissions=list(item.permissions),
user_count=await count_users_with_role(db, item.code),
)
)
return RolesResponse(roles=roles)
@roles_router.get("/catalog", response_model=PermissionCatalogResponse)
async def get_permission_catalog(_: User = Depends(require_user_manager)):
return PermissionCatalogResponse(**permission_catalog())
@roles_router.post("", response_model=RoleInfo)
async def create_custom_role(
body: RoleCreate,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_user_manager),
):
record = await create_role(
db,
code=body.code,
label=body.label,
description=body.description,
permissions=body.permissions,
)
return RoleInfo(
value=record.code,
label=record.label,
description=record.description or None,
is_system=record.is_system,
is_admin=record.is_admin,
permissions=list(record.permissions),
user_count=0,
)
@roles_router.put("/{code}", response_model=RoleInfo)
async def update_custom_role(
code: str,
body: RoleUpdate,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_user_manager),
):
record = await update_role(
db,
code,
label=body.label,
description=body.description,
permissions=body.permissions,
)
return RoleInfo(
value=record.code,
label=record.label,
description=record.description or None,
is_system=record.is_system,
is_admin=record.is_admin,
permissions=list(record.permissions),
user_count=await count_users_with_role(db, record.code),
)
@roles_router.delete("/{code}")
async def delete_custom_role(
code: str,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_user_manager),
):
await delete_role(db, code)
return {"message": "角色已删除"}
+26
View File
@@ -67,6 +67,9 @@ class UserResponse(BaseModel):
email: Optional[str] = None
display_name: Optional[str] = None
role: str
role_label: str = ""
is_admin: bool = False
permissions: list[str] = Field(default_factory=list)
is_active: bool
email_verified: bool = False
max_accounts: int = 3
@@ -102,7 +105,30 @@ class UserUpdate(BaseModel):
class RoleInfo(BaseModel):
value: str
label: str
description: Optional[str] = None
is_system: bool = False
is_admin: bool = False
permissions: list[str] = Field(default_factory=list)
user_count: Optional[int] = None
class RolesResponse(BaseModel):
roles: list[RoleInfo]
class RoleCreate(BaseModel):
code: str = Field(min_length=2, max_length=50)
label: str = Field(min_length=1, max_length=100)
description: Optional[str] = Field(default=None, max_length=255)
permissions: list[str] = Field(default_factory=list)
class RoleUpdate(BaseModel):
label: Optional[str] = Field(default=None, min_length=1, max_length=100)
description: Optional[str] = Field(default=None, max_length=255)
permissions: Optional[list[str]] = None
class PermissionCatalogResponse(BaseModel):
menus: list[dict[str, Any]]
actions: list[dict[str, Any]]
+5 -6
View File
@@ -5,7 +5,8 @@ from sqlalchemy import or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from models.models import Account, AutoReplyRule, MessageLog, ReceivedMessageLog, SystemLog, User
from .roles import is_admin
from .permissions import ACCOUNTS_WRITE, RULES_WRITE
from .roles import has_permission, is_admin
async def get_owned_account(
@@ -23,7 +24,7 @@ async def get_owned_account(
return account
if account.owner_id != user.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权访问该账号")
if write and user.role == "viewer":
if write and not has_permission(user.role, ACCOUNTS_WRITE):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="只读用户无法修改")
return account
@@ -79,17 +80,15 @@ async def get_accessible_rule(db: AsyncSession, user: User, rule_id: int, *, wri
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="规则不存在")
if is_admin(user.role):
if write and user.role == "viewer":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="只读用户无法修改")
return rule
if rule.account_id is None:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权访问全局规则")
account = await get_owned_account(db, user, rule.account_id, write=write)
account = await get_owned_account(db, user, rule.account_id, write=False)
if rule.owner_id and rule.owner_id != user.id and account.owner_id != user.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权访问该规则")
if write and user.role == "viewer":
if write and not has_permission(user.role, RULES_WRITE):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="只读用户无法修改")
return rule
+12 -11
View File
@@ -14,7 +14,8 @@ from models.db_config import (
)
from models.db_transfer import inspect_sqlite_source, migrate_sqlite_to_target
from models.models import User
from .dependencies import require_admin
from .dependencies import require_permission
from .permissions import SETTINGS_MANAGE
from .email_service import send_test_email
from .system_settings import (
PASSWORD_PLACEHOLDER,
@@ -208,7 +209,7 @@ async def get_public_settings(db: AsyncSession = Depends(get_db)):
@router.get("", response_model=SystemSettingsResponse)
async def get_system_settings(
db: AsyncSession = Depends(get_db),
_: User = Depends(require_admin),
_: User = Depends(require_permission(SETTINGS_MANAGE)),
):
data = await load_settings(db)
return SystemSettingsResponse(**settings_to_admin_response(data))
@@ -218,7 +219,7 @@ async def get_system_settings(
async def update_system_settings(
body: SystemSettingsUpdate,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_admin),
_: User = Depends(require_permission(SETTINGS_MANAGE)),
):
updates = body.model_dump(exclude_unset=True)
if "app_url" in updates and updates["app_url"]:
@@ -230,7 +231,7 @@ async def update_system_settings(
@router.get("/payment", response_model=PaymentSettingsResponse)
async def get_payment_settings(
db: AsyncSession = Depends(get_db),
_: User = Depends(require_admin),
_: User = Depends(require_permission(SETTINGS_MANAGE)),
):
data = await load_settings(db)
return PaymentSettingsResponse(**settings_to_payment_response(data))
@@ -240,7 +241,7 @@ async def get_payment_settings(
async def update_payment_settings(
body: PaymentSettingsUpdate,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_admin),
_: User = Depends(require_permission(SETTINGS_MANAGE)),
):
updates = body.model_dump(exclude_unset=True)
data = await save_settings(db, updates)
@@ -251,7 +252,7 @@ async def update_payment_settings(
async def test_smtp_email(
body: TestEmailRequest,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_admin),
_: User = Depends(require_permission(SETTINGS_MANAGE)),
):
data = await load_settings(db)
overrides = body.model_dump(exclude_unset=True, exclude={"to_email"})
@@ -276,14 +277,14 @@ async def test_smtp_email(
@router.get("/database", response_model=DatabaseSettingsResponse)
async def get_database_settings(_: User = Depends(require_admin)):
async def get_database_settings(_: User = Depends(require_permission(SETTINGS_MANAGE))):
return DatabaseSettingsResponse(**database_config_to_response())
@router.put("/database", response_model=MessageResponse)
async def update_database_settings(
body: DatabaseSettingsUpdate,
_: User = Depends(require_admin),
_: User = Depends(require_permission(SETTINGS_MANAGE)),
):
payload = body.model_dump(exclude_unset=True)
if payload.get("db_password") in (None, "", DB_PASSWORD_PLACEHOLDER):
@@ -302,7 +303,7 @@ async def update_database_settings(
@router.post("/database/test", response_model=MessageResponse)
async def test_database_settings(
body: DatabaseTestRequest,
_: User = Depends(require_admin),
_: User = Depends(require_permission(SETTINGS_MANAGE)),
):
payload = body.model_dump(exclude_unset=True)
if payload.get("db_password") in (None, "", DB_PASSWORD_PLACEHOLDER):
@@ -317,7 +318,7 @@ async def test_database_settings(
@router.get("/database/migrate/preview", response_model=DatabaseMigratePreviewResponse)
async def preview_database_migration(
source_db_path: str | None = None,
_: User = Depends(require_admin),
_: User = Depends(require_permission(SETTINGS_MANAGE)),
):
return DatabaseMigratePreviewResponse(**await inspect_sqlite_source(source_db_path))
@@ -325,7 +326,7 @@ async def preview_database_migration(
@router.post("/database/migrate", response_model=DatabaseMigrateResponse)
async def migrate_database_data(
body: DatabaseMigrateRequest,
_: User = Depends(require_admin),
_: User = Depends(require_permission(SETTINGS_MANAGE)),
):
payload = body.model_dump(exclude_unset=True)
clear_target = bool(payload.pop("clear_target", False))
+6 -5
View File
@@ -19,7 +19,8 @@ from fastapi.responses import FileResponse
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from auth.dependencies import require_admin
from auth.dependencies import require_permission
from auth.permissions import DESKTOP_MANAGE
from auth.system_settings import get_cached_settings
from desktop_release import (
INSTALLER_DIR,
@@ -120,7 +121,7 @@ async def desktop_download(db: AsyncSession = Depends(get_db)):
async def get_release(
request: Request,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_admin),
_: User = Depends(require_permission(DESKTOP_MANAGE)),
):
data = await load_release(db)
return _to_response(data, request)
@@ -131,7 +132,7 @@ async def update_release(
body: DesktopReleaseUpdate,
request: Request,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_admin),
_: User = Depends(require_permission(DESKTOP_MANAGE)),
):
updates = body.model_dump(exclude_unset=True)
if "version" in updates and updates["version"] is not None:
@@ -152,7 +153,7 @@ async def upload_installer(
request: Request,
file: UploadFile = File(...),
db: AsyncSession = Depends(get_db),
_: User = Depends(require_admin),
_: User = Depends(require_permission(DESKTOP_MANAGE)),
):
filename = (file.filename or "").strip()
if not filename.lower().endswith(".exe"):
@@ -194,7 +195,7 @@ async def upload_installer(
async def delete_installer(
request: Request,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_admin),
_: User = Depends(require_permission(DESKTOP_MANAGE)),
):
remove_installer()
data = await save_release(db, {"installer_name": "", "installer_size": 0})
Binary file not shown.
Binary file not shown.
+43 -21
View File
@@ -30,17 +30,26 @@ from models.db_migrate import (
migrate_account_videos_table as _migrate_account_videos_table,
migrate_message_logs_table as _migrate_message_logs_table,
migrate_payment_orders_table as _migrate_payment_orders_table,
migrate_roles_table as _migrate_roles_table,
migrate_rules_table as _migrate_rules_table,
migrate_users_table as _migrate_users_table,
)
from models.db_config import database_config_to_response
from models.models import Account, AccountProfileDetail, AccountVideo, AutoReplyRule, MessageLog, ReceivedMessageLog, SystemLog, User
from auth.router import router as auth_router, users_router
from auth.router import router as auth_router, users_router, roles_router
from auth.settings_router import router as settings_router
from auth.role_service import seed_builtin_roles
from payments.router import router as payments_router
from desktop_router import router as desktop_router
from link_cards_router import router as link_cards_router, UPLOAD_DIR as LINK_CARD_UPLOAD_DIR
from auth.dependencies import get_current_user, require_admin, require_write
from auth.dependencies import (
get_current_user,
require_accounts_write,
require_admin,
require_messages_write,
require_rules_write,
require_write,
)
from auth.account_limits import ensure_can_add_account
from auth.scopes import (
accounts_for_user,
@@ -52,7 +61,8 @@ from auth.scopes import (
received_logs_for_user,
system_logs_for_user,
)
from auth.roles import is_admin
from auth.roles import has_permission, is_admin
from auth.permissions import LOGS_READ, RECEIVED_MESSAGES_READ, SYSTEM_LOGS_READ
from auth.passwords import hash_password
from rpa_engine.batch_start import BatchStartQueue
from rpa_engine.playwright_worker import DouyinWorker
@@ -201,6 +211,7 @@ app.mount("/api/media/link-cards", StaticFiles(directory=LINK_CARD_UPLOAD_DIR),
app.include_router(auth_router)
app.include_router(users_router)
app.include_router(roles_router)
app.include_router(settings_router)
app.include_router(payments_router)
app.include_router(desktop_router)
@@ -610,6 +621,7 @@ async def startup():
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
await conn.run_sync(_migrate_roles_table)
await conn.run_sync(_migrate_accounts_table)
await conn.run_sync(_migrate_rules_table)
await conn.run_sync(_migrate_message_logs_table)
@@ -618,6 +630,8 @@ async def startup():
await conn.run_sync(_migrate_payment_orders_table)
await conn.run_sync(_migrate_accounts_quota_disabled)
await _seed_app_config()
async with AsyncSessionLocal() as db:
await seed_builtin_roles(db)
await _seed_admin_user()
# 进程启动时没有任何内存 Worker;复位异常退出遗留的运行状态。
# 同时,账号数量/并发限制已移除,清理历史“额度停用”标记。
@@ -1618,7 +1632,7 @@ async def update_account(
account_id: int,
body: AccountUpdate,
db: AsyncSession = Depends(get_db),
user: User = Depends(require_write),
user: User = Depends(require_accounts_write),
):
account = await get_owned_account(db, user, account_id, write=True)
follow_config_changed = bool(
@@ -1776,7 +1790,7 @@ async def send_account_queued_reply_now(
account_id: int,
job_id: str,
db: AsyncSession = Depends(get_db),
user: User = Depends(require_write),
user: User = Depends(require_messages_write),
):
"""将指定任务原子移入紧急队列,并把它后面的普通任务前移一槽。"""
await get_owned_account(db, user, account_id, write=True)
@@ -1931,7 +1945,7 @@ async def update_account_cookie(
account_id: int,
body: AccountCookieUpdate,
db: AsyncSession = Depends(get_db),
user: User = Depends(require_write),
user: User = Depends(require_accounts_write),
):
# Validate first: malformed input must not take a healthy hosted account
# offline. Filesystem and database mutations happen only after the worker
@@ -1980,7 +1994,7 @@ async def update_account_cookie(
async def delete_account_cookie(
account_id: int,
db: AsyncSession = Depends(get_db),
user: User = Depends(require_write),
user: User = Depends(require_accounts_write),
):
# Deleting credentials uses the same preparation lock as starting a
# worker, preventing a new worker from appearing after stop_worker but
@@ -2009,7 +2023,7 @@ async def delete_account_cookie(
async def create_account(
account_in: AccountCreate,
db: AsyncSession = Depends(get_db),
user: User = Depends(require_write),
user: User = Depends(require_accounts_write),
):
cookie_data = (account_in.cookie_data or "").strip()
standard_json_str = None
@@ -2047,7 +2061,7 @@ async def create_account(
async def delete_account(
account_id: int,
db: AsyncSession = Depends(get_db),
user: User = Depends(require_write),
user: User = Depends(require_accounts_write),
):
await get_owned_account(db, user, account_id, write=True)
# 停止运行中的任务
@@ -2082,7 +2096,7 @@ async def validate_account_credential(
async def reset_account_credentials(
account_id: int,
db: AsyncSession = Depends(get_db),
user: User = Depends(require_write),
user: User = Depends(require_accounts_write),
):
await get_owned_account(db, user, account_id, write=True)
await _release_db_connection(db)
@@ -2237,7 +2251,7 @@ async def start_account_rpa(
account_id: int,
body: StartAccountRequest = StartAccountRequest(),
db: AsyncSession = Depends(get_db),
user: User = Depends(require_write),
user: User = Depends(require_accounts_write),
):
account = await get_owned_account(db, user, account_id, write=True)
# Cancelling waits for an in-flight queued start, and the preparation lock
@@ -2253,7 +2267,7 @@ async def start_account_rpa(
async def submit_account_start_batch(
body: BatchStartRequest,
db: AsyncSession = Depends(get_db),
user: User = Depends(require_write),
user: User = Depends(require_accounts_write),
):
requested_ids = list(
dict.fromkeys(int(value) for value in body.account_ids if int(value) > 0)
@@ -2317,7 +2331,7 @@ async def get_account_start_batch(
async def stop_account_rpa(
account_id: int,
db: AsyncSession = Depends(get_db),
user: User = Depends(require_write),
user: User = Depends(require_accounts_write),
):
account = await get_owned_account(db, user, account_id, write=True)
@@ -2395,7 +2409,7 @@ async def get_rules(
async def create_rule(
rule_in: RuleCreate,
db: AsyncSession = Depends(get_db),
user: User = Depends(require_write),
user: User = Depends(require_rules_write),
):
if rule_in.match_type == "default":
rule_in.keyword = ""
@@ -2429,7 +2443,7 @@ async def update_rule(
rule_in: RuleCreate,
is_active: Optional[bool] = None,
db: AsyncSession = Depends(get_db),
user: User = Depends(require_write),
user: User = Depends(require_rules_write),
):
rule = await get_accessible_rule(db, user, rule_id, write=True)
@@ -2457,7 +2471,7 @@ async def update_rule(
async def toggle_rule(
rule_id: int,
db: AsyncSession = Depends(get_db),
user: User = Depends(require_write),
user: User = Depends(require_rules_write),
):
rule = await get_accessible_rule(db, user, rule_id, write=True)
@@ -2470,7 +2484,7 @@ async def toggle_rule(
async def delete_rule(
rule_id: int,
db: AsyncSession = Depends(get_db),
user: User = Depends(require_write),
user: User = Depends(require_rules_write),
):
await get_accessible_rule(db, user, rule_id, write=True)
await db.execute(delete(AutoReplyRule).where(AutoReplyRule.id == rule_id))
@@ -2487,7 +2501,7 @@ async def move_rule(
rule_id: int,
body: RuleMove,
db: AsyncSession = Depends(get_db),
user: User = Depends(require_write),
user: User = Depends(require_rules_write),
):
"""在同账号规则内上移/下移一位(服务端交换排序,适配前端分页)。"""
rule = await get_accessible_rule(db, user, rule_id, write=True)
@@ -2512,7 +2526,7 @@ async def move_rule(
async def reorder_rules(
body: RuleReorder,
db: AsyncSession = Depends(get_db),
user: User = Depends(require_write),
user: User = Depends(require_rules_write),
):
if not body.rule_ids:
return {"message": "No rules to reorder."}
@@ -2531,6 +2545,8 @@ async def get_logs_stats(
user: User = Depends(get_current_user),
):
"""消息日志全量统计(数据库计数,不受列表 limit 限制)。"""
if not has_permission(user.role, LOGS_READ):
raise HTTPException(status_code=403, detail="缺少权限:logs.read")
if account_id is not None:
await get_owned_account(db, user, account_id)
# Select only the indexed status column and calculate both counters in one
@@ -2564,6 +2580,8 @@ async def get_logs(
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
if not has_permission(user.role, LOGS_READ):
raise HTTPException(status_code=403, detail="缺少权限:logs.read")
if account_id is not None:
await get_owned_account(db, user, account_id)
limit = max(1, min(int(limit or 50), 500))
@@ -2585,6 +2603,8 @@ async def get_received_messages(
user: User = Depends(get_current_user),
):
"""接收消息原始日志:仅包含收到的消息,内容为接口/通道原样记录。"""
if not has_permission(user.role, RECEIVED_MESSAGES_READ):
raise HTTPException(status_code=403, detail="缺少权限:received_messages.read")
if account_id is not None:
await get_owned_account(db, user, account_id)
limit = max(1, min(int(limit or 100), 500))
@@ -2607,6 +2627,8 @@ async def get_system_logs(
user: User = Depends(get_current_user),
):
"""系统诊断日志:私信收发 / 实时连接 / 鉴权 等链路事件,用于排查失败原因。"""
if not has_permission(user.role, SYSTEM_LOGS_READ):
raise HTTPException(status_code=403, detail="缺少权限:system_logs.read")
if account_id is not None:
await get_owned_account(db, user, account_id)
entries = system_logger.get_logs(
@@ -2688,7 +2710,7 @@ async def upload_message_image(
account_id: int,
file: UploadFile = File(...),
db: AsyncSession = Depends(get_db),
user: User = Depends(require_write),
user: User = Depends(require_messages_write),
):
account = await get_owned_account(db, user, account_id, write=True)
if not file.content_type or not file.content_type.startswith("image/"):
@@ -2779,7 +2801,7 @@ async def send_account_message(
account_id: int,
body: SendMessageRequest,
db: AsyncSession = Depends(get_db),
user: User = Depends(require_write),
user: User = Depends(require_messages_write),
):
account = await get_owned_account(db, user, account_id, write=True)
+48
View File
@@ -196,6 +196,54 @@ def migrate_rules_table(conn) -> None:
)
def migrate_roles_table(conn) -> None:
"""Ensure roles table exists (create_all usually handles this; keep as safety net)."""
try:
insp = inspect(conn)
if insp.has_table("roles"):
return
except Exception:
return
dialect = _dialect(conn)
if dialect == "postgresql":
conn.execute(
text(
"""
CREATE TABLE roles (
id SERIAL PRIMARY KEY,
code VARCHAR(50) NOT NULL UNIQUE,
label VARCHAR(100) NOT NULL,
description VARCHAR(255),
is_system BOOLEAN DEFAULT FALSE,
is_admin BOOLEAN DEFAULT FALSE,
permissions TEXT NOT NULL DEFAULT '[]',
created_at TIMESTAMP,
updated_at TIMESTAMP
)
"""
)
)
else:
conn.execute(
text(
"""
CREATE TABLE IF NOT EXISTS roles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
code VARCHAR(50) NOT NULL UNIQUE,
label VARCHAR(100) NOT NULL,
description VARCHAR(255),
is_system BOOLEAN DEFAULT 0,
is_admin BOOLEAN DEFAULT 0,
permissions TEXT NOT NULL DEFAULT '[]',
created_at DATETIME,
updated_at DATETIME
)
"""
)
)
add_index_if_missing(conn, "roles", "ix_roles_code", ("code",))
def migrate_users_table(conn) -> None:
add_column_if_missing(
conn,
+23 -1
View File
@@ -5,6 +5,28 @@ from .database import Base
from utils.log_limits import bound_error_log_content, bound_message_log_content
class Role(Base):
"""Assignable role with a fixed permission-code list.
``users.role`` stores ``Role.code``. Built-in roles are seeded on startup;
custom roles are owned by admins via the users.manage permission.
"""
__tablename__ = "roles"
id = Column(Integer, primary_key=True, index=True)
code = Column(String(50), unique=True, index=True, nullable=False)
label = Column(String(100), nullable=False)
description = Column(String(255), nullable=True)
is_system = Column(Boolean, default=False)
# Global data scope + unlimited account quota. Only the built-in admin
# role may be true; custom roles are always own-scoped.
is_admin = Column(Boolean, default=False)
permissions = Column(Text, nullable=False, default="[]") # JSON string list
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
class User(Base):
__tablename__ = "users"
@@ -13,7 +35,7 @@ class User(Base):
email = Column(String(255), unique=True, index=True, nullable=True)
password_hash = Column(String(255), nullable=False)
display_name = Column(String(100), nullable=True)
role = Column(String(20), default="operator", index=True) # admin, operator, viewer
role = Column(String(50), default="operator", index=True) # roles.code
is_active = Column(Boolean, default=True)
email_verified = Column(Boolean, default=False)
email_verified_at = Column(DateTime, nullable=True)
+4 -3
View File
@@ -2,7 +2,8 @@ from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi.responses import PlainTextResponse
from sqlalchemy.ext.asyncio import AsyncSession
from auth.dependencies import get_current_user, require_admin
from auth.dependencies import get_current_user, require_permission
from auth.permissions import PAYMENTS_MANAGE
from auth.system_settings import load_settings
from models.database import get_db
from models.models import User
@@ -89,7 +90,7 @@ async def admin_update_payment_order_status(
order_no: str,
body: AdminUpdateOrderStatusRequest,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_admin),
_: User = Depends(require_permission(PAYMENTS_MANAGE)),
):
order = await service.admin_update_order_status(db, order_no, body.status)
return PaymentOrderListItem(**order)
@@ -99,7 +100,7 @@ async def admin_update_payment_order_status(
async def admin_delete_payment_order(
order_no: str,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_admin),
_: User = Depends(require_permission(PAYMENTS_MANAGE)),
):
await service.admin_delete_order(db, order_no)
return MessageResponse(message="订单已删除")
+133
View File
@@ -0,0 +1,133 @@
from __future__ import annotations
import os
import sys
import unittest
from pathlib import Path
from fastapi import HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from sqlalchemy.orm import sessionmaker
BACKEND_DIR = Path(__file__).resolve().parents[1]
os.environ["KEFU_DB_TYPE"] = "sqlite"
os.environ["KEFU_DATABASE_URL"] = "sqlite+aiosqlite:///:memory:"
if str(BACKEND_DIR) not in sys.path:
sys.path.insert(0, str(BACKEND_DIR))
from models.models import Base, Role, User # noqa: E402
from auth.passwords import hash_password # noqa: E402
from auth.permissions import ACCOUNTS_WRITE, ALL_PERMISSIONS, MENU_USERS # noqa: E402
from auth.role_service import ( # noqa: E402
create_role,
delete_role,
guard_last_admin_change,
seed_builtin_roles,
update_role,
)
from auth.roles import has_permission, is_admin, permissions_for_role # noqa: E402
from auth import router as auth_router # noqa: E402
class RolesRbacTests(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self):
self.engine = create_async_engine("sqlite+aiosqlite:///:memory:")
async with self.engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
self.session_factory = sessionmaker(
self.engine, class_=AsyncSession, expire_on_commit=False
)
self.db = self.session_factory()
await seed_builtin_roles(self.db)
async def asyncTearDown(self):
await self.db.close()
await self.engine.dispose()
async def test_seed_creates_builtin_roles(self):
result = await self.db.execute(select(Role))
codes = {row.code for row in result.scalars().all()}
self.assertEqual(codes, {"admin", "operator", "viewer"})
self.assertTrue(is_admin("admin"))
self.assertFalse(is_admin("operator"))
self.assertIn(MENU_USERS, permissions_for_role("admin"))
self.assertNotIn(MENU_USERS, permissions_for_role("operator"))
self.assertFalse(has_permission("viewer", ACCOUNTS_WRITE))
async def test_custom_role_crud(self):
created = await create_role(
self.db,
code="ops_leader",
label="运营主管",
description="可管账号",
permissions=[MENU_USERS, ACCOUNTS_WRITE],
)
self.assertEqual(created.code, "ops_leader")
self.assertTrue(has_permission("ops_leader", ACCOUNTS_WRITE))
self.assertFalse(is_admin("ops_leader"))
updated = await update_role(
self.db,
"ops_leader",
label="主管",
permissions=[ACCOUNTS_WRITE],
)
self.assertEqual(updated.label, "主管")
self.assertFalse(has_permission("ops_leader", MENU_USERS))
await delete_role(self.db, "ops_leader")
self.assertFalse(has_permission("ops_leader", ACCOUNTS_WRITE))
async def test_cannot_delete_system_role(self):
with self.assertRaises(HTTPException) as caught:
await delete_role(self.db, "operator")
self.assertEqual(caught.exception.status_code, 400)
async def test_admin_permissions_always_full(self):
await update_role(
self.db,
"admin",
label="管理员",
permissions=[ACCOUNTS_WRITE],
)
self.assertEqual(permissions_for_role("admin"), list(ALL_PERMISSIONS))
async def test_me_payload_includes_permissions(self):
user = User(
username="u1",
password_hash=hash_password("password1"),
display_name="U1",
role="operator",
is_active=True,
email_verified=True,
)
self.db.add(user)
await self.db.commit()
await self.db.refresh(user)
payload = await auth_router._build_user_response(self.db, user)
self.assertEqual(payload.role_label, "运营")
self.assertFalse(payload.is_admin)
self.assertIn(ACCOUNTS_WRITE, payload.permissions)
self.assertNotIn(MENU_USERS, payload.permissions)
async def test_last_admin_cannot_be_demoted(self):
admin = User(
username="admin1",
password_hash=hash_password("password1"),
role="admin",
is_active=True,
email_verified=True,
)
self.db.add(admin)
await self.db.commit()
await self.db.refresh(admin)
with self.assertRaises(HTTPException) as caught:
await guard_last_admin_change(self.db, user=admin, new_role="operator")
self.assertEqual(caught.exception.status_code, 400)
if __name__ == "__main__":
unittest.main()
+28 -60
View File
@@ -1,5 +1,5 @@
<script setup>
import { ref, computed } from 'vue'
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { message } from 'ant-design-vue'
import {
@@ -22,6 +22,24 @@ import {
InboxOutlined
} from '@ant-design/icons-vue'
import { useAuthStore } from './stores/auth'
import { HEADER_TITLES } from './config/menus'
const ICON_MAP = {
DashboardOutlined,
UserOutlined,
SettingOutlined,
FileTextOutlined,
MessageOutlined,
BugOutlined,
TeamOutlined,
ControlOutlined,
PayCircleOutlined,
UnorderedListOutlined,
QuestionCircleOutlined,
RocketOutlined,
CloudDownloadOutlined,
InboxOutlined
}
const route = useRoute()
const router = useRouter()
@@ -29,6 +47,9 @@ const auth = useAuthStore()
const selectedKeys = computed(() => [route.path])
const isLoginPage = computed(() => route.path === '/login')
const headerTitle = computed(
() => HEADER_TITLES[route.name] || route.name || '工作台'
)
const navigate = ({ key }) => {
router.push(key)
@@ -57,61 +78,11 @@ const handleLogout = () => {
@click="navigate"
class="custom-menu"
>
<a-menu-item key="/">
<template #icon><DashboardOutlined /></template>
<span>数据概览</span>
</a-menu-item>
<a-menu-item key="/accounts">
<template #icon><UserOutlined /></template>
<span>{{ auth.isAdmin ? '账号管理' : '我的账号' }}</span>
</a-menu-item>
<a-menu-item key="/messages">
<template #icon><MessageOutlined /></template>
<span>私信收发</span>
</a-menu-item>
<a-menu-item key="/rules">
<template #icon><SettingOutlined /></template>
<span>自动回复规则</span>
</a-menu-item>
<a-menu-item key="/logs">
<template #icon><FileTextOutlined /></template>
<span>回复日志面板</span>
</a-menu-item>
<a-menu-item key="/received-messages">
<template #icon><InboxOutlined /></template>
<span>接收消息日志</span>
</a-menu-item>
<a-menu-item key="/system-logs">
<template #icon><BugOutlined /></template>
<span>系统诊断日志</span>
</a-menu-item>
<a-menu-item key="/download">
<template #icon><CloudDownloadOutlined /></template>
<span>软件下载</span>
</a-menu-item>
<a-menu-item key="/help">
<template #icon><QuestionCircleOutlined /></template>
<span>帮助中心</span>
</a-menu-item>
<a-menu-item v-if="auth.isAdmin" key="/users">
<template #icon><TeamOutlined /></template>
<span>用户与角色</span>
</a-menu-item>
<a-menu-item v-if="auth.isAdmin" key="/settings">
<template #icon><ControlOutlined /></template>
<span>系统设置</span>
</a-menu-item>
<a-menu-item v-if="auth.isAdmin" key="/desktop-update">
<template #icon><RocketOutlined /></template>
<span>桌面端升级</span>
</a-menu-item>
<a-menu-item v-if="auth.isAdmin" key="/payment-settings">
<template #icon><PayCircleOutlined /></template>
<span>支付配置</span>
</a-menu-item>
<a-menu-item v-if="!auth.isAdmin" key="/payment-orders">
<template #icon><UnorderedListOutlined /></template>
<span>我的订单</span>
<a-menu-item v-for="item in auth.visibleMenus" :key="item.path">
<template #icon>
<component :is="ICON_MAP[item.icon]" />
</template>
<span>{{ item.title }}</span>
</a-menu-item>
</a-menu>
</a-layout-sider>
@@ -119,9 +90,7 @@ const handleLogout = () => {
<a-layout>
<a-layout-header class="app-header">
<div class="header-left">
<h2 class="header-title">
{{ route.name === 'Dashboard' ? '数据中心' : route.name === 'Accounts' ? '账号中心' : route.name === 'Messages' ? '私信中心' : route.name === 'Rules' ? '策略中心' : route.name === 'ReceivedMessages' ? '接收消息日志' : route.name === 'SystemLogs' ? '诊断中心' : route.name === 'Users' ? '权限中心' : route.name === 'Settings' ? '系统设置' : route.name === 'DesktopUpdate' ? '桌面端升级' : route.name === 'PaymentSettings' ? '支付配置' : route.name === 'MyPaymentOrders' ? '我的订单' : route.name === 'Download' ? '软件下载' : route.name === 'Help' ? '帮助中心' : '日志中心' }}
</h2>
<h2 class="header-title">{{ headerTitle }}</h2>
</div>
<div class="header-right">
<a-space size="middle">
@@ -180,7 +149,6 @@ const handleLogout = () => {
-webkit-text-fill-color: transparent;
}
/* 侧边栏折叠时只保留图标,避免标题文字竖排变形 */
.app-sider.ant-layout-sider-collapsed .logo-container {
justify-content: center;
padding: 0;
+126
View File
@@ -0,0 +1,126 @@
/**
* Sidebar menu catalog. Visibility is driven by permission codes from /auth/me.
*/
export const MENU_ITEMS = [
{
path: '/',
name: 'Dashboard',
title: '数据概览',
permission: 'menu.dashboard',
icon: 'DashboardOutlined'
},
{
path: '/accounts',
name: 'Accounts',
title: '我的账号',
adminTitle: '账号管理',
permission: 'menu.accounts',
icon: 'UserOutlined'
},
{
path: '/messages',
name: 'Messages',
title: '私信收发',
permission: 'menu.messages',
icon: 'MessageOutlined'
},
{
path: '/rules',
name: 'Rules',
title: '自动回复规则',
permission: 'menu.rules',
icon: 'SettingOutlined'
},
{
path: '/logs',
name: 'Logs',
title: '回复日志面板',
permission: 'menu.logs',
icon: 'FileTextOutlined'
},
{
path: '/received-messages',
name: 'ReceivedMessages',
title: '接收消息日志',
permission: 'menu.received_messages',
icon: 'InboxOutlined'
},
{
path: '/system-logs',
name: 'SystemLogs',
title: '系统诊断日志',
permission: 'menu.system_logs',
icon: 'BugOutlined'
},
{
path: '/download',
name: 'Download',
title: '软件下载',
permission: 'menu.download',
icon: 'CloudDownloadOutlined'
},
{
path: '/help',
name: 'Help',
title: '帮助中心',
permission: 'menu.help',
icon: 'QuestionCircleOutlined'
},
{
path: '/users',
name: 'Users',
title: '用户与角色',
permission: 'menu.users',
icon: 'TeamOutlined'
},
{
path: '/settings',
name: 'Settings',
title: '系统设置',
permission: 'menu.settings',
icon: 'ControlOutlined'
},
{
path: '/desktop-update',
name: 'DesktopUpdate',
title: '桌面端升级',
permission: 'menu.desktop_update',
icon: 'RocketOutlined'
},
{
path: '/payment-settings',
name: 'PaymentSettings',
title: '支付配置',
permission: 'menu.payment_settings',
icon: 'PayCircleOutlined'
},
{
path: '/payment-orders',
name: 'MyPaymentOrders',
title: '我的订单',
permission: 'menu.payment_orders',
icon: 'UnorderedListOutlined'
}
]
export const HEADER_TITLES = {
Dashboard: '数据中心',
Accounts: '账号中心',
Messages: '私信中心',
Rules: '策略中心',
ReceivedMessages: '接收消息日志',
SystemLogs: '诊断中心',
Users: '权限中心',
Settings: '系统设置',
DesktopUpdate: '桌面端升级',
PaymentSettings: '支付配置',
MyPaymentOrders: '我的订单',
Download: '软件下载',
Help: '帮助中心',
Logs: '日志中心'
}
export function firstAccessiblePath(hasPermission) {
const hit = MENU_ITEMS.find((item) => hasPermission(item.permission))
return hit?.path || '/help'
}
+52 -21
View File
@@ -15,23 +15,49 @@ import MyPaymentOrders from '../views/MyPaymentOrders.vue'
import Help from '../views/Help.vue'
import Download from '../views/Download.vue'
import { useAuthStore } from '../stores/auth'
import { firstAccessiblePath } from '../config/menus'
const routes = [
{ path: '/login', component: Login, name: 'Login', meta: { public: true } },
{ path: '/', component: Dashboard, name: 'Dashboard' },
{ path: '/accounts', component: Accounts, name: 'Accounts', meta: { write: true } },
{ path: '/messages', component: Messages, name: 'Messages', meta: { write: true } },
{ path: '/rules', component: Rules, name: 'Rules', meta: { write: true } },
{ path: '/logs', component: Logs, name: 'Logs' },
{ path: '/received-messages', component: ReceivedMessages, name: 'ReceivedMessages' },
{ path: '/system-logs', component: SystemLogs, name: 'SystemLogs' },
{ path: '/users', component: Users, name: 'Users', meta: { admin: true } },
{ path: '/settings', component: Settings, name: 'Settings', meta: { admin: true } },
{ path: '/desktop-update', component: DesktopUpdate, name: 'DesktopUpdate', meta: { admin: true } },
{ path: '/payment-settings', component: PaymentSettings, name: 'PaymentSettings', meta: { admin: true } },
{ path: '/payment-orders', component: MyPaymentOrders, name: 'MyPaymentOrders' },
{ path: '/help', component: Help, name: 'Help' },
{ path: '/download', component: Download, name: 'Download' }
{ path: '/', component: Dashboard, name: 'Dashboard', meta: { permission: 'menu.dashboard' } },
{ path: '/accounts', component: Accounts, name: 'Accounts', meta: { permission: 'menu.accounts' } },
{ path: '/messages', component: Messages, name: 'Messages', meta: { permission: 'menu.messages' } },
{ path: '/rules', component: Rules, name: 'Rules', meta: { permission: 'menu.rules' } },
{ path: '/logs', component: Logs, name: 'Logs', meta: { permission: 'menu.logs' } },
{
path: '/received-messages',
component: ReceivedMessages,
name: 'ReceivedMessages',
meta: { permission: 'menu.received_messages' }
},
{
path: '/system-logs',
component: SystemLogs,
name: 'SystemLogs',
meta: { permission: 'menu.system_logs' }
},
{ path: '/users', component: Users, name: 'Users', meta: { permission: 'menu.users' } },
{ path: '/settings', component: Settings, name: 'Settings', meta: { permission: 'menu.settings' } },
{
path: '/desktop-update',
component: DesktopUpdate,
name: 'DesktopUpdate',
meta: { permission: 'menu.desktop_update' }
},
{
path: '/payment-settings',
component: PaymentSettings,
name: 'PaymentSettings',
meta: { permission: 'menu.payment_settings' }
},
{
path: '/payment-orders',
component: MyPaymentOrders,
name: 'MyPaymentOrders',
meta: { permission: 'menu.payment_orders' }
},
{ path: '/help', component: Help, name: 'Help', meta: { permission: 'menu.help' } },
{ path: '/download', component: Download, name: 'Download', meta: { permission: 'menu.download' } }
]
const router = createRouter({
@@ -44,7 +70,7 @@ router.beforeEach(async (to) => {
if (to.meta.public) {
if (auth.isLoggedIn && to.path === '/login') {
return '/'
return firstAccessiblePath((code) => auth.hasPermission(code))
}
return true
}
@@ -60,14 +86,19 @@ router.beforeEach(async (to) => {
auth.clearSession()
return '/login'
}
} else if (!Array.isArray(auth.user.permissions)) {
// Old localStorage sessions lack the permission list.
try {
await auth.fetchMe()
} catch {
auth.clearSession()
return '/login'
}
}
if (to.meta.admin && !auth.isAdmin) {
return '/'
}
if (to.meta.write && auth.isViewer) {
return '/'
const required = to.meta.permission
if (required && !auth.hasPermission(required)) {
return firstAccessiblePath((code) => auth.hasPermission(code))
}
return true
+43 -105
View File
@@ -1,197 +1,135 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import api from '../api'
import { MENU_ITEMS } from '../config/menus'
export const useAuthStore = defineStore('auth', () => {
const token = ref(localStorage.getItem('kefu_token') || '')
const user = ref(JSON.parse(localStorage.getItem('kefu_user') || 'null'))
const isLoggedIn = computed(() => !!token.value)
const isAdmin = computed(() => user.value?.role === 'admin')
const canWrite = computed(() => ['admin', 'operator'].includes(user.value?.role))
const isViewer = computed(() => user.value?.role === 'viewer')
const roleLabel = computed(() => {
const map = { admin: '管理员', operator: '运营', viewer: '只读' }
return map[user.value?.role] || user.value?.role || ''
const permissions = computed(() => {
const list = user.value?.permissions
return Array.isArray(list) ? list : []
})
const permissionSet = computed(() => new Set(permissions.value))
const isAdmin = computed(() => {
if (typeof user.value?.is_admin === 'boolean') return user.value.is_admin
return user.value?.role === 'admin'
})
const hasPermission = (code) => {
if (!code) return true
if (isAdmin.value) return true
return permissionSet.value.has(code)
}
const canWrite = computed(() => hasPermission('accounts.write'))
const canWriteMessages = computed(() => hasPermission('messages.write'))
const canWriteRules = computed(() => hasPermission('rules.write'))
const canManageUsers = computed(() => hasPermission('users.manage'))
const isViewer = computed(() => !canWrite.value && !isAdmin.value)
const roleLabel = computed(
() => user.value?.role_label || user.value?.role || ''
)
const visibleMenus = computed(() =>
MENU_ITEMS.filter((item) => hasPermission(item.permission)).map((item) => ({
...item,
title: isAdmin.value && item.adminTitle ? item.adminTitle : item.title
}))
)
const setSession = (accessToken, userData) => {
token.value = accessToken
user.value = userData
localStorage.setItem('kefu_token', accessToken)
localStorage.setItem('kefu_user', JSON.stringify(userData))
}
const clearSession = () => {
token.value = ''
user.value = null
localStorage.removeItem('kefu_token')
localStorage.removeItem('kefu_user')
}
const login = async (username, password) => {
const res = await api.post('/auth/login', { username, password })
const accessToken = res.data.access_token
const me = await api.get('/auth/me', {
headers: { Authorization: `Bearer ${accessToken}` }
})
setSession(accessToken, me.data)
return me.data
}
const register = async (payload) => {
const res = await api.post('/auth/register', payload)
return res.data
}
const verifyEmail = async (verifyToken) => {
const res = await api.post('/auth/verify-email', { token: verifyToken })
return res.data
}
const resendVerification = async (payload) => {
const res = await api.post('/auth/resend-verification', payload)
return res.data
}
const forgotPassword = async (payload) => {
const res = await api.post('/auth/forgot-password', payload)
return res.data
}
const resetPassword = async (token, password) => {
const res = await api.post('/auth/reset-password', { token, password })
const resetPassword = async (resetToken, password) => {
const res = await api.post('/auth/reset-password', {
token: resetToken,
password
})
return res.data
}
const fetchMe = async () => {
if (!token.value) return null
const res = await api.get('/auth/me')
user.value = res.data
localStorage.setItem('kefu_user', JSON.stringify(res.data))
return res.data
}
const logout = () => {
clearSession()
}
return {
token,
user,
isLoggedIn,
permissions,
isAdmin,
canWrite,
canWriteMessages,
canWriteRules,
canManageUsers,
isViewer,
roleLabel,
visibleMenus,
hasPermission,
login,
register,
verifyEmail,
resendVerification,
forgotPassword,
resetPassword,
fetchMe,
logout,
clearSession
}
})
+138 -7
View File
@@ -19,12 +19,12 @@
--accent-green: hsl(150, 75%, 50%);
--accent-red: hsl(360, 75%, 60%);
--text-primary: hsl(0, 0%, 95%);
--text-secondary: hsl(230, 10%, 65%);
--text-muted: hsl(230, 10%, 45%);
--text-primary: hsl(0, 0%, 96%);
--text-secondary: hsl(230, 12%, 72%);
--text-muted: hsl(230, 10%, 58%);
--border-light: rgba(255, 255, 255, 0.06);
--border-glow: hsla(270, 85%, 65%, 0.2);
--border-light: rgba(255, 255, 255, 0.1);
--border-glow: hsla(270, 85%, 65%, 0.28);
--glass-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.37);
--transition-smooth: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
@@ -287,17 +287,148 @@ h1, h2, h3, h4, h5, h6 {
.ant-modal .ant-input-password .ant-input,
.ant-modal textarea.ant-input,
.ant-modal .ant-select-selector {
background: rgba(255, 255, 255, 0.05) !important;
border-color: var(--border-light) !important;
background: rgba(255, 255, 255, 0.08) !important;
border-color: rgba(255, 255, 255, 0.16) !important;
color: var(--text-primary) !important;
box-shadow: none !important;
}
.ant-modal .ant-input:hover,
.ant-modal .ant-input-affix-wrapper:hover,
.ant-modal .ant-select-selector:hover,
.ant-modal .ant-input-number:hover {
border-color: rgba(192, 132, 252, 0.45) !important;
}
.ant-modal .ant-input:focus,
.ant-modal .ant-input-focused,
.ant-modal .ant-input-affix-wrapper-focused,
.ant-modal .ant-select-focused .ant-select-selector,
.ant-modal .ant-input-number-focused {
border-color: rgba(192, 132, 252, 0.65) !important;
box-shadow: 0 0 0 2px rgba(170, 59, 255, 0.18) !important;
}
.ant-modal .ant-input::placeholder,
.ant-modal .ant-input-number-input::placeholder,
.ant-modal textarea.ant-input::placeholder {
color: hsl(230, 10%, 62%) !important;
opacity: 1 !important;
}
.ant-modal .ant-input-affix-wrapper input::placeholder {
color: hsl(230, 10%, 62%) !important;
opacity: 1 !important;
}
.ant-modal .ant-checkbox-wrapper {
color: var(--text-primary) !important;
}
.ant-modal .ant-checkbox-inner {
background: rgba(255, 255, 255, 0.06) !important;
border-color: rgba(255, 255, 255, 0.35) !important;
}
.ant-modal .ant-checkbox-checked .ant-checkbox-inner {
background: #7c3aed !important;
border-color: #a78bfa !important;
}
.ant-modal .ant-select-arrow,
.ant-modal .ant-select-selection-placeholder {
color: var(--text-muted) !important;
}
.ant-modal .ant-select-selection-item {
color: var(--text-primary) !important;
}
.ant-modal .ant-alert-info {
background: rgba(59, 130, 246, 0.12) !important;
border: 1px solid rgba(96, 165, 250, 0.35) !important;
}
.ant-modal .ant-alert-message {
color: #dbeafe !important;
}
/* Tabs / 搜索框 / 分页 — 暗色可读性 */
.ant-tabs-top > .ant-tabs-nav::before {
border-bottom-color: rgba(255, 255, 255, 0.08) !important;
}
.ant-tabs .ant-tabs-tab {
color: var(--text-secondary) !important;
}
.ant-tabs .ant-tabs-tab:hover {
color: #e9d5ff !important;
}
.ant-tabs .ant-tabs-tab-active .ant-tabs-tab-btn {
color: #f3e8ff !important;
text-shadow: none;
}
.ant-tabs .ant-tabs-ink-bar {
background: linear-gradient(90deg, #aa3bff, #c084fc) !important;
}
.ant-input,
.ant-input-affix-wrapper,
.ant-select:not(.ant-select-customize-input) .ant-select-selector {
background: rgba(255, 255, 255, 0.06) !important;
border-color: rgba(255, 255, 255, 0.14) !important;
color: var(--text-primary) !important;
}
.ant-input::placeholder,
.ant-input-affix-wrapper input::placeholder {
color: var(--text-muted) !important;
opacity: 1 !important;
}
.ant-pagination {
color: var(--text-secondary) !important;
}
.ant-pagination .ant-pagination-item {
background: rgba(255, 255, 255, 0.04) !important;
border-color: rgba(255, 255, 255, 0.12) !important;
}
.ant-pagination .ant-pagination-item a {
color: var(--text-secondary) !important;
}
.ant-pagination .ant-pagination-item-active {
background: rgba(147, 51, 234, 0.25) !important;
border-color: rgba(192, 132, 252, 0.55) !important;
}
.ant-pagination .ant-pagination-item-active a {
color: #f3e8ff !important;
}
.ant-pagination .ant-pagination-prev .ant-pagination-item-link,
.ant-pagination .ant-pagination-next .ant-pagination-item-link,
.ant-pagination .ant-select-selector {
background: rgba(255, 255, 255, 0.04) !important;
border-color: rgba(255, 255, 255, 0.12) !important;
color: var(--text-secondary) !important;
}
.ant-pagination-options-quick-jumper input {
background: rgba(255, 255, 255, 0.06) !important;
border-color: rgba(255, 255, 255, 0.14) !important;
color: var(--text-primary) !important;
}
.ant-table-pagination.ant-pagination {
margin: 16px 20px !important;
}
.ant-modal .ant-radio-button-wrapper {
color: var(--text-secondary) !important;
background: rgba(255, 255, 255, 0.03) !important;
+1 -1
View File
@@ -464,7 +464,7 @@ const onSelectAllChange = (e) => {
else clearSelection()
}
const showMyOrdersEntry = computed(() => !auth.isAdmin)
const showMyOrdersEntry = computed(() => auth.hasPermission('menu.payment_orders'))
const goMyOrders = () => {
router.push('/payment-orders')
+33 -18
View File
@@ -11,7 +11,9 @@ import {
buildStickerPayload,
parseMessageContent
} from '../utils/messageContent'
import { useAuthStore } from '../stores/auth'
const auth = useAuthStore()
const accounts = ref([])
const selectedAccount = ref(undefined)
const conversations = ref([])
@@ -96,6 +98,10 @@ const onPickSticker = (item) => {
const previewContent = (raw) => messagePreview(raw)
const sendMessage = async () => {
if (!auth.canWriteMessages) {
message.warning('当前角色无发送私信权限')
return
}
if (!selectedAccount.value || !selectedConv.value) {
message.warning('请选择账号和会话')
return
@@ -223,25 +229,34 @@ onMounted(fetchAccounts)
<MessageBubble :content="pendingPayload" compact />
<a-button type="link" size="small" @click="pendingPayload = ''">取消</a-button>
</div>
<div class="compose-toolbar">
<EmojiPicker @pick-emoji="onPickEmoji" @pick-sticker="onPickSticker" />
</div>
<a-textarea
v-model:value="sendContent"
:rows="6"
placeholder="输入文字,或使用上方按钮发送表情..."
:disabled="!selectedConv"
<template v-if="auth.canWriteMessages">
<div class="compose-toolbar">
<EmojiPicker @pick-emoji="onPickEmoji" @pick-sticker="onPickSticker" />
</div>
<a-textarea
v-model:value="sendContent"
:rows="6"
placeholder="输入文字,或使用上方按钮发送表情..."
:disabled="!selectedConv"
/>
<a-button
type="primary"
class="gradient-btn send-btn"
:loading="sending"
:disabled="!selectedConv || (!sendContent.trim() && !pendingPayload)"
@click="sendMessage"
>
<template #icon><SendOutlined /></template>
发送
</a-button>
</template>
<a-alert
v-else
type="info"
show-icon
message="当前角色为只读,可查看会话但无法发送私信"
style="margin-top: 12px;"
/>
<a-button
type="primary"
class="gradient-btn send-btn"
:loading="sending"
:disabled="!selectedConv || (!sendContent.trim() && !pendingPayload)"
@click="sendMessage"
>
<template #icon><SendOutlined /></template>
发送
</a-button>
</div>
</a-col>
</a-row>
+32 -7
View File
@@ -12,6 +12,7 @@ import {
FilterOutlined
} from '@ant-design/icons-vue'
import api from '../api'
import { useAuthStore } from '../stores/auth'
import ReplyRuleEditor from '../components/ReplyRuleEditor.vue'
import {
emptyReplyForm,
@@ -26,6 +27,7 @@ import {
const route = useRoute()
const router = useRouter()
const auth = useAuthStore()
const isMobile = useIsMobile()
const modalWidth = computed(() => (isMobile.value ? 'calc(100vw - 32px)' : 720))
@@ -452,7 +454,12 @@ watch(
>
<template #suffixIcon><FilterOutlined /></template>
</a-select>
<a-button type="primary" class="gradient-btn add-rule-btn" @click="openAddModal">
<a-button
v-if="auth.canWriteRules"
type="primary"
class="gradient-btn add-rule-btn"
@click="openAddModal"
>
<template #icon><PlusOutlined /></template>
添加规则
</a-button>
@@ -489,8 +496,15 @@ watch(
:max="86400"
style="width: 220px;"
:placeholder="`留空用全局默认 ${cooldownEffective} 秒`"
:disabled="!auth.canWriteRules"
/>
<a-button type="primary" class="gradient-btn" :loading="cooldownSaving" @click="saveCooldown">
<a-button
v-if="auth.canWriteRules"
type="primary"
class="gradient-btn"
:loading="cooldownSaving"
@click="saveCooldown"
>
保存冷却时间
</a-button>
</div>
@@ -535,7 +549,7 @@ watch(
</template>
<template v-if="column.key === 'sort_order'">
<a-space size="small">
<a-space v-if="auth.canWriteRules" size="small">
<a-button
type="text"
size="small"
@@ -553,6 +567,7 @@ watch(
<template #icon><ArrowDownOutlined /></template>
</a-button>
</a-space>
<span v-else>{{ record.sort_order ?? '-' }}</span>
</template>
<template v-if="column.key === 'account_id'">
@@ -562,11 +577,15 @@ watch(
</template>
<template v-if="column.key === 'is_active'">
<a-switch :checked="record.is_active" @change="handleToggleRule(record)" />
<a-switch
:checked="record.is_active"
:disabled="!auth.canWriteRules"
@change="handleToggleRule(record)"
/>
</template>
<template v-if="column.key === 'action'">
<a-space size="middle">
<a-space v-if="auth.canWriteRules" size="middle">
<a-button type="text" style="color: #c084fc;" @click="openEditModal(record)">
<template #icon><EditOutlined /></template>
编辑
@@ -584,6 +603,7 @@ watch(
</a-button>
</a-popconfirm>
</a-space>
<span v-else class="muted-readonly">只读</span>
</template>
</template>
</a-table>
@@ -597,7 +617,12 @@ watch(
<a-tag :color="matchTypeColor(record.match_type)">
{{ matchTypeLabel(record.match_type) }}
</a-tag>
<a-switch :checked="record.is_active" size="small" @change="handleToggleRule(record)" />
<a-switch
:checked="record.is_active"
size="small"
:disabled="!auth.canWriteRules"
@change="handleToggleRule(record)"
/>
</div>
<div class="rule-card-keyword">
@@ -618,7 +643,7 @@ watch(
</a-tag>
</div>
<div class="rule-card-actions">
<div v-if="auth.canWriteRules" class="rule-card-actions">
<a-space size="small">
<a-button type="text" size="small" class="sort-move-btn" @click="handleMoveRule(record, 'up')">
<ArrowUpOutlined />
+587 -61
View File
@@ -1,7 +1,7 @@
<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import { message, Modal } from 'ant-design-vue'
import { PlusOutlined, EditOutlined, DeleteOutlined, TeamOutlined } from '@ant-design/icons-vue'
import { PlusOutlined, EditOutlined, DeleteOutlined, TeamOutlined, SearchOutlined } from '@ant-design/icons-vue'
import api from '../api'
import { useAuthStore } from '../stores/auth'
import { useIsMobile } from '../composables/useIsMobile'
@@ -11,33 +11,69 @@ const modalWidth = computed(() => (isMobile.value ? 'calc(100vw - 32px)' : 520))
const pageCurrent = ref(1)
const pageSize = ref(10)
const searchKeyword = ref('')
const paginatedUsers = computed(() => {
const start = (pageCurrent.value - 1) * pageSize.value
return users.value.slice(start, start + pageSize.value)
})
const FIELD_LABELS = {
username: '用户名',
password: '密码',
display_name: '显示名称',
email: '邮箱',
role: '角色',
max_accounts: '可添加抖音账号数',
is_active: '账号状态',
email_verified: '邮箱验证状态'
}
const paginationConfig = computed(() => ({
current: pageCurrent.value,
pageSize: pageSize.value,
total: users.value.length,
showSizeChanger: !isMobile.value,
pageSizeOptions: ['10', '20', '50'],
showTotal: (total) => `${total}`,
size: isMobile.value ? 'small' : 'default',
onChange: (page, size) => {
pageCurrent.value = page
pageSize.value = size
/** FastAPI 422 detail 可能是字符串,也可能是校验错误对象数组。 */
const formatApiError = (error, fallback = '操作失败') => {
const detail = error?.response?.data?.detail
if (detail == null || detail === '') {
return error?.message || fallback
}
}))
if (typeof detail === 'string') return detail
if (Array.isArray(detail)) {
const parts = detail.map((item) => {
if (typeof item === 'string') return item
if (!item || typeof item !== 'object') return String(item)
const rawField = Array.isArray(item.loc)
? item.loc.filter((part) => part !== 'body' && part !== 'query').join('.')
: ''
const field = FIELD_LABELS[rawField] || rawField
let msg = item.msg || item.message || JSON.stringify(item)
if (/at least 2 characters/i.test(msg)) msg = '至少 2 个字符'
else if (/at least 6 characters/i.test(msg)) msg = '至少 6 位'
else if (/valid email/i.test(msg)) msg = '邮箱格式不正确'
return field ? `${field}${msg}` : msg
}).filter(Boolean)
return parts.length ? parts.join('') : fallback
}
if (typeof detail === 'object') {
return detail.msg || detail.message || JSON.stringify(detail)
}
return String(detail)
}
const auth = useAuthStore()
const activeTab = ref('users')
const users = ref([])
const roles = ref([])
const roleRecords = ref([])
const permissionCatalog = ref({ menus: [], actions: [] })
const loading = ref(false)
const rolesLoading = ref(false)
const modalVisible = ref(false)
const modalTitle = ref('新增用户')
const editingId = ref(null)
const roleModalVisible = ref(false)
const roleModalTitle = ref('新建角色')
const editingRoleCode = ref(null)
const roleSaving = ref(false)
const roleForm = ref({
code: '',
label: '',
description: '',
permissions: []
})
const userForm = ref({
username: '',
@@ -54,26 +90,73 @@ const defaultRegisterMaxAccounts = ref(3)
const emailVerificationRequired = ref(true)
const emailBindingRequired = ref(false)
const isAdminRole = computed(() => userForm.value.role === 'admin')
const roleOptions = ref([
{ value: 'admin', label: '管理员', is_admin: true },
{ value: 'operator', label: '运营', is_admin: false },
{ value: 'viewer', label: '只读', is_admin: false }
])
const isAdminRole = computed(() => {
const hit = roleOptions.value.find((r) => r.value === userForm.value.role)
return !!(hit?.is_admin || userForm.value.role === 'admin')
})
const emailRequiredForRole = computed(
() => emailBindingRequired.value && !isAdminRole.value
)
const roleOptions = ref([
{ value: 'admin', label: '管理员' },
{ value: 'operator', label: '运营' },
{ value: 'viewer', label: '只读' }
])
const rolePermissionsReadonly = computed(
() => editingRoleCode.value === 'admin'
)
const hasEmail = computed(() => !!userForm.value.email?.trim())
const getRoleLabel = (role) =>
roleOptions.value.find((r) => r.value === role)?.label ||
roleRecords.value.find((r) => r.value === role)?.label ||
role
const filteredUsers = computed(() => {
const keyword = searchKeyword.value.trim().toLowerCase()
if (!keyword) return users.value
return users.value.filter((user) => {
const roleLabel = (getRoleLabel(user.role) || '').toLowerCase()
const haystack = [
String(user.id ?? ''),
user.username || '',
user.display_name || '',
user.email || '',
user.role || '',
roleLabel
].join(' ').toLowerCase()
return haystack.includes(keyword)
})
})
const paginatedUsers = computed(() => {
const start = (pageCurrent.value - 1) * pageSize.value
return filteredUsers.value.slice(start, start + pageSize.value)
})
const paginationConfig = computed(() => ({
current: pageCurrent.value,
pageSize: pageSize.value,
total: filteredUsers.value.length,
showSizeChanger: !isMobile.value,
pageSizeOptions: ['10', '20', '50'],
showTotal: (total) => `${total}`,
size: isMobile.value ? 'small' : 'default',
onChange: (page, size) => {
pageCurrent.value = page
pageSize.value = size
}
}))
const fetchUsers = async () => {
loading.value = true
try {
const res = await api.get('/users')
users.value = res.data
} catch (error) {
message.error(error.response?.data?.detail || '获取用户列表失败')
message.error(formatApiError(error, '获取用户列表失败'))
} finally {
loading.value = false
}
@@ -82,15 +165,133 @@ const fetchUsers = async () => {
const fetchRoles = async () => {
try {
const res = await api.get('/auth/roles')
roles.value = res.data.roles
roles.value = res.data.roles || []
if (roles.value.length) {
roleOptions.value = roles.value
roleOptions.value = roles.value.map((r) => ({
value: r.value,
label: r.label,
is_admin: !!r.is_admin
}))
}
} catch {
// keep defaults
}
}
const fetchRoleRecords = async () => {
rolesLoading.value = true
try {
const [rolesRes, catalogRes] = await Promise.all([
api.get('/roles'),
api.get('/roles/catalog')
])
roleRecords.value = rolesRes.data.roles || []
permissionCatalog.value = catalogRes.data || { menus: [], actions: [] }
if (roleRecords.value.length) {
roleOptions.value = roleRecords.value.map((r) => ({
value: r.value,
label: r.label,
is_admin: !!r.is_admin
}))
}
} catch (error) {
message.error(formatApiError(error, '获取角色列表失败'))
} finally {
rolesLoading.value = false
}
}
const openAddRole = () => {
editingRoleCode.value = null
roleModalTitle.value = '新建角色'
roleForm.value = {
code: '',
label: '',
description: '',
permissions: [
'menu.dashboard',
'menu.accounts',
'menu.messages',
'menu.rules',
'menu.help',
'menu.download'
]
}
roleModalVisible.value = true
}
const openEditRole = (record) => {
editingRoleCode.value = record.value
roleModalTitle.value = record.is_admin ? '查看管理员角色' : '编辑角色'
roleForm.value = {
code: record.value,
label: record.label,
description: record.description || '',
permissions: [...(record.permissions || [])]
}
roleModalVisible.value = true
}
const handleSaveRole = async () => {
const code = (roleForm.value.code || '').trim().toLowerCase()
const label = (roleForm.value.label || '').trim()
if (!editingRoleCode.value && !code) {
message.warning('请填写角色码')
return
}
if (!label) {
message.warning('请填写角色名称')
return
}
roleSaving.value = true
try {
if (editingRoleCode.value) {
await api.put(`/roles/${encodeURIComponent(editingRoleCode.value)}`, {
label,
description: roleForm.value.description || null,
permissions: rolePermissionsReadonly.value
? undefined
: roleForm.value.permissions
})
message.success('角色已更新')
} else {
await api.post('/roles', {
code,
label,
description: roleForm.value.description || null,
permissions: roleForm.value.permissions
})
message.success('角色已创建')
}
roleModalVisible.value = false
await Promise.all([fetchRoleRecords(), fetchRoles()])
} catch (error) {
message.error(formatApiError(error, '保存角色失败'))
} finally {
roleSaving.value = false
}
}
const handleDeleteRole = (record) => {
if (record.is_system) {
message.warning('系统内置角色不可删除')
return
}
Modal.confirm({
title: `确定删除角色「${record.label}」吗?`,
okType: 'danger',
onOk: async () => {
try {
await api.delete(`/roles/${encodeURIComponent(record.value)}`)
message.success('角色已删除')
await Promise.all([fetchRoleRecords(), fetchRoles()])
} catch (error) {
message.error(formatApiError(error, '删除角色失败'))
}
}
})
}
const fetchDefaultMaxAccounts = async () => {
try {
const res = await api.get('/settings')
@@ -131,7 +332,9 @@ const openEdit = (record) => {
email_verified: !!record.email_verified,
role: record.role,
is_active: record.is_active,
max_accounts: record.role === 'admin' ? defaultRegisterMaxAccounts.value : resolveAccountLimit(record)
max_accounts: isUnlimitedQuota(record)
? defaultRegisterMaxAccounts.value
: resolveAccountLimit(record)
}
modalVisible.value = true
}
@@ -144,6 +347,8 @@ const onEmailChange = () => {
const handleSave = async () => {
const email = userForm.value.email?.trim() || null
const username = userForm.value.username.trim()
const password = userForm.value.password || ''
if (emailRequiredForRole.value && !email) {
message.warning('当前系统要求非管理员用户必须绑定邮箱')
@@ -151,15 +356,23 @@ const handleSave = async () => {
}
if (!editingId.value) {
if (!userForm.value.username.trim() || !userForm.value.password) {
if (!username || !password) {
message.warning('请填写用户名和密码')
return
}
if (username.length < 2) {
message.warning('用户名至少 2 个字符')
return
}
if (password.length < 6) {
message.warning('密码至少 6 位')
return
}
try {
await api.post('/users', {
username: userForm.value.username.trim(),
password: userForm.value.password,
display_name: userForm.value.display_name || userForm.value.username,
username,
password,
display_name: userForm.value.display_name || username,
role: userForm.value.role,
email: email || undefined,
email_verified: email ? userForm.value.email_verified : true,
@@ -169,11 +382,16 @@ const handleSave = async () => {
modalVisible.value = false
fetchUsers()
} catch (error) {
message.error(error.response?.data?.detail || '创建失败')
message.error(formatApiError(error, '创建失败'))
}
return
}
if (password && password.length < 6) {
message.warning('新密码至少 6 位')
return
}
const payload = {
display_name: userForm.value.display_name,
role: userForm.value.role,
@@ -183,8 +401,8 @@ const handleSave = async () => {
if (email) {
payload.email_verified = userForm.value.email_verified
}
if (userForm.value.password) {
payload.password = userForm.value.password
if (password) {
payload.password = password
}
if (!isAdminRole.value) {
payload.max_accounts = userForm.value.max_accounts
@@ -198,7 +416,7 @@ const handleSave = async () => {
await auth.fetchMe()
}
} catch (error) {
message.error(error.response?.data?.detail || '更新失败')
message.error(formatApiError(error, '更新失败'))
}
}
@@ -207,15 +425,17 @@ const handleDelete = (record) => {
title: `确定删除用户「${record.username}」吗?`,
okType: 'danger',
onOk: async () => {
await api.delete(`/users/${record.id}`)
message.success('已删除')
fetchUsers()
try {
await api.delete(`/users/${record.id}`)
message.success('已删除')
fetchUsers()
} catch (error) {
message.error(formatApiError(error, '删除失败'))
}
}
})
}
const getRoleLabel = (role) => roleOptions.value.find(r => r.value === role)?.label || role
const roleTagColor = (role) => {
if (role === 'admin') return 'purple'
if (role === 'operator') return 'geekblue'
@@ -244,7 +464,15 @@ const emailVerifyColor = (record) => {
return record.email_verified ? 'green' : 'gold'
}
const isUnlimitedQuota = (record) => record.role === 'admin'
const isUnlimitedQuota = (record) =>
!!(record.is_admin || record.role === 'admin' ||
roleOptions.value.find((r) => r.value === record.role)?.is_admin)
watch(activeTab, (tab) => {
if (tab === 'roles' && !roleRecords.value.length) {
fetchRoleRecords()
}
})
const resolveAccountLimit = (record) => {
if (isUnlimitedQuota(record)) return null
@@ -269,13 +497,17 @@ const accountQuotaLabel = (record) => {
return `${total}/${limit}`
}
watch(users, (list) => {
const maxPage = Math.max(1, Math.ceil(list.length / pageSize.value))
watch([filteredUsers, pageSize], () => {
const maxPage = Math.max(1, Math.ceil(filteredUsers.value.length / pageSize.value))
if (pageCurrent.value > maxPage) {
pageCurrent.value = maxPage
}
})
watch(searchKeyword, () => {
pageCurrent.value = 1
})
onMounted(() => {
fetchRoles()
fetchDefaultMaxAccounts()
@@ -291,12 +523,48 @@ onMounted(() => {
<TeamOutlined class="page-title-icon" />
用户与角色管理
</h2>
<p class="subtitle">管理员可创建用户并分配角色实现数据隔离与权限控制</p>
<p class="subtitle">创建用户自定义角色并勾选菜单与操作权限</p>
</div>
<a-button type="primary" class="gradient-btn add-user-btn" @click="openAdd">
<a-button
v-if="activeTab === 'users'"
type="primary"
class="gradient-btn add-user-btn"
@click="openAdd"
>
<template #icon><PlusOutlined /></template>
新增用户
</a-button>
<a-button
v-else
type="primary"
class="gradient-btn add-user-btn"
@click="openAddRole"
>
<template #icon><PlusOutlined /></template>
新建角色
</a-button>
</div>
<a-tabs v-model:activeKey="activeTab" class="users-tabs glass-card">
<a-tab-pane key="users" tab="用户管理" />
<a-tab-pane key="roles" tab="角色设定" />
</a-tabs>
<template v-if="activeTab === 'users'">
<div class="users-toolbar glass-card">
<a-input
v-model:value="searchKeyword"
allow-clear
placeholder="搜索用户名、显示名、邮箱、角色、ID"
class="users-search-input"
>
<template #prefix>
<SearchOutlined />
</template>
</a-input>
<span class="users-toolbar-meta">
{{ searchKeyword.trim() ? `匹配 ${filteredUsers.length} / 共 ${users.length}` : `${users.length}` }} 个用户
</span>
</div>
<!-- 桌面端表格 -->
@@ -435,20 +703,113 @@ onMounted(() => {
</div>
</div>
</div>
<a-empty v-else description="暂无用户" />
<a-empty
v-else
:description="searchKeyword.trim() ? '未找到匹配用户' : '暂无用户'"
/>
</a-spin>
<div v-if="users.length" class="users-mobile-pagination">
<div v-if="filteredUsers.length" class="users-mobile-pagination">
<a-pagination
v-model:current="pageCurrent"
v-model:page-size="pageSize"
:total="users.length"
:total="filteredUsers.length"
:show-size-changer="false"
size="small"
:show-total="(total) => `${total}`"
/>
</div>
</div>
</template>
<div v-else class="glass-card roles-panel">
<a-spin :spinning="rolesLoading">
<a-table
v-if="!isMobile"
:data-source="roleRecords"
row-key="value"
:pagination="false"
>
<a-table-column title="角色码" data-index="value" key="value" :width="140" />
<a-table-column title="名称" data-index="label" key="label" :width="140" />
<a-table-column title="说明" key="description">
<template #default="{ record }">
<span :class="{ 'text-muted': !record.description }">
{{ record.description || '—' }}
</span>
</template>
</a-table-column>
<a-table-column title="类型" key="type" :width="100">
<template #default="{ record }">
<a-tag v-if="record.is_admin" color="purple">超级管理员</a-tag>
<a-tag v-else-if="record.is_system" color="blue">系统</a-tag>
<a-tag v-else color="geekblue">自定义</a-tag>
</template>
</a-table-column>
<a-table-column title="权限数" key="perm_count" :width="90">
<template #default="{ record }">
{{ (record.permissions || []).length }}
</template>
</a-table-column>
<a-table-column title="用户数" data-index="user_count" key="user_count" :width="80" />
<a-table-column title="操作" key="action" :width="160">
<template #default="{ record }">
<a-space>
<a-button type="text" style="color: #c084fc;" @click="openEditRole(record)">
{{ record.is_admin ? '查看' : '编辑' }}
</a-button>
<a-button
v-if="!record.is_system"
type="text"
danger
@click="handleDeleteRole(record)"
>
删除
</a-button>
</a-space>
</template>
</a-table-column>
</a-table>
<div v-else class="user-card-list">
<div v-for="record in roleRecords" :key="record.value" class="user-card glass-card">
<div class="user-card-head">
<div>
<div class="user-card-name">{{ record.label }}</div>
<div class="user-card-display">{{ record.value }}</div>
</div>
<a-tag v-if="record.is_admin" color="purple">超级管理员</a-tag>
<a-tag v-else-if="record.is_system" color="blue">系统</a-tag>
<a-tag v-else color="geekblue">自定义</a-tag>
</div>
<div class="user-card-meta">
<div class="user-card-row">
<span class="user-card-label">权限</span>
<span class="user-card-value">{{ (record.permissions || []).length }} 项</span>
</div>
<div class="user-card-row">
<span class="user-card-label">用户</span>
<span class="user-card-value">{{ record.user_count ?? 0 }}</span>
</div>
</div>
<div class="user-card-actions">
<a-button type="text" class="edit-btn" @click="openEditRole(record)">
{{ record.is_admin ? '查看' : '编辑' }}
</a-button>
<a-button
v-if="!record.is_system"
type="text"
danger
@click="handleDeleteRole(record)"
>
删除
</a-button>
</div>
</div>
<a-empty v-if="!roleRecords.length" description="暂无角色" />
</div>
</a-spin>
</div>
<a-modal
v-model:visible="modalVisible"
@@ -460,10 +821,18 @@ onMounted(() => {
>
<a-form layout="vertical" class="user-form" style="margin-top: 16px;">
<a-form-item v-if="!editingId" label="用户名" required>
<a-input v-model:value="userForm.username" placeholder="登录用户名" />
<a-input
v-model:value="userForm.username"
placeholder="登录用户名至少 2 个字符"
:maxlength="50"
/>
</a-form-item>
<a-form-item :label="editingId ? '新密码(留空不修改)' : '密码'" :required="!editingId">
<a-input-password v-model:value="userForm.password" placeholder="至少 6 " />
<a-input-password
v-model:value="userForm.password"
placeholder="至少 6 "
:maxlength="128"
/>
</a-form-item>
<a-form-item label="显示名称">
<a-input v-model:value="userForm.display_name" placeholder="界面展示名称" />
@@ -511,12 +880,81 @@ onMounted(() => {
</a-form>
</a-modal>
<a-modal
v-model:visible="roleModalVisible"
:title="roleModalTitle"
:width="isMobile ? 'calc(100vw - 32px)' : 720"
:confirm-loading="roleSaving"
@ok="handleSaveRole"
ok-text="保存"
cancel-text="取消"
>
<a-form layout="vertical" class="user-form" style="margin-top: 16px;">
<a-form-item label="角色码" required>
<a-input
v-model:value="roleForm.code"
placeholder="小写字母开头 ops_leader"
:disabled="!!editingRoleCode"
:maxlength="50"
/>
<div class="field-hint">创建后不可修改;仅小写字母、数字、下划线</div>
</a-form-item>
<a-form-item label="显示名称" required>
<a-input v-model:value="roleForm.label" placeholder="界面展示名称" :maxlength="100" />
</a-form-item>
<a-form-item label="说明">
<a-input
v-model:value="roleForm.description"
placeholder="可选描述该角色的职责"
:maxlength="255"
/>
</a-form-item>
<a-alert
v-if="rolePermissionsReadonly"
type="info"
show-icon
message="管理员角色固定拥有全部权限不可取消勾选"
style="margin-bottom: 16px;"
/>
<a-form-item label="菜单权限">
<a-checkbox-group
v-model:value="roleForm.permissions"
:disabled="rolePermissionsReadonly"
class="perm-grid"
>
<a-checkbox
v-for="item in permissionCatalog.menus"
:key="item.code"
:value="item.code"
>
{{ item.label }}
</a-checkbox>
</a-checkbox-group>
</a-form-item>
<a-form-item label="操作权限">
<a-checkbox-group
v-model:value="roleForm.permissions"
:disabled="rolePermissionsReadonly"
class="perm-grid"
>
<a-checkbox
v-for="item in permissionCatalog.actions"
:key="item.code"
:value="item.code"
>
{{ item.label }}
</a-checkbox>
</a-checkbox-group>
</a-form-item>
</a-form>
</a-modal>
<div class="glass-card role-help">
<h3 style="margin-top: 0; color: #fff;">角色权限说明</h3>
<ul class="role-list">
<li><strong>管理员</strong>管理所有抖音账号用户全局规则与系统日志</li>
<li><strong>运营</strong>管理自己创建的抖音账号规则与私信不可见他人数据</li>
<li><strong>只读</strong>仅查看自己账号的数据不可修改或发送</li>
<li><strong>管理员</strong>全局数据范围 + 全部权限不可删除</li>
<li><strong>运营 / 只读 / 自定义角色</strong>仅能访问自己的账号数据菜单与操作由勾选权限决定</li>
<li>自定义角色可在角色设定中新建并分配给用户</li>
</ul>
</div>
</div>
@@ -562,6 +1000,70 @@ onMounted(() => {
line-height: 1.5;
}
.users-tabs {
padding: 8px 16px 0;
margin-bottom: 16px;
}
.users-tabs:hover,
.users-toolbar:hover,
.table-card:hover,
.roles-panel:hover,
.role-help:hover {
transform: none;
}
.users-tabs :deep(.ant-tabs-nav) {
margin-bottom: 0;
}
.users-tabs :deep(.ant-tabs-tab) {
font-size: 0.95rem;
font-weight: 500;
padding: 12px 4px;
}
.roles-panel {
padding: 16px;
margin-bottom: 16px;
overflow: hidden;
}
.perm-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 10px 14px;
width: 100%;
}
.perm-grid :deep(.ant-checkbox-wrapper) {
color: #e5e7eb !important;
margin-left: 0 !important;
line-height: 1.45;
}
.users-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
padding: 16px 20px;
margin-bottom: 16px;
}
.users-search-input {
flex: 1;
min-width: 220px;
max-width: 420px;
}
.users-toolbar-meta {
color: var(--text-secondary);
font-size: 0.88rem;
white-space: nowrap;
}
.gradient-btn {
background: linear-gradient(135deg, var(--primary-color) 0%, var(--accent-pink) 100%) !important;
border: none !important;
@@ -614,19 +1116,20 @@ onMounted(() => {
}
.users-page :deep(.ant-table-thead > tr > th) {
background: rgba(255, 255, 255, 0.03) !important;
color: var(--text-secondary) !important;
border-bottom: 1px solid var(--border-light) !important;
background: rgba(255, 255, 255, 0.05) !important;
color: #d1d5db !important;
border-bottom: 1px solid rgba(255, 255, 255, 0.1) !important;
font-weight: 600;
}
.users-page :deep(.ant-table-tbody > tr > td) {
background: transparent !important;
border-bottom: 1px solid var(--border-light) !important;
color: var(--text-primary);
border-bottom: 1px solid rgba(255, 255, 255, 0.08) !important;
color: #f3f4f6;
}
.users-page :deep(.ant-table-tbody > tr:hover > td) {
background: rgba(170, 59, 255, 0.05) !important;
background: rgba(170, 59, 255, 0.08) !important;
}
.user-card-head {
@@ -699,7 +1202,20 @@ onMounted(() => {
}
.edit-btn {
color: #c084fc !important;
color: #d8b4fe !important;
}
.edit-btn:hover {
color: #f3e8ff !important;
}
.users-page :deep(.ant-btn-dangerous.ant-btn-text) {
color: #fca5a5 !important;
}
.users-page :deep(.ant-btn-dangerous.ant-btn-text:hover) {
color: #fecaca !important;
background: rgba(239, 68, 68, 0.12) !important;
}
.users-mobile-pagination {
@@ -753,6 +1269,16 @@ onMounted(() => {
width: 100%;
}
.users-toolbar {
padding: 12px 16px;
margin-bottom: 12px;
}
.users-search-input {
max-width: none;
width: 100%;
}
.role-help {
margin-top: 16px !important;
padding: 16px !important;