更新
This commit is contained in:
@@ -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="需要用户管理权限")
|
||||
|
||||
@@ -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}
|
||||
@@ -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
@@ -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
@@ -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": "角色已删除"}
|
||||
|
||||
@@ -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,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
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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="订单已删除")
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user