新增
This commit is contained in:
@@ -7,9 +7,21 @@ from models.database import get_db
|
|||||||
from models.models import User
|
from models.models import User
|
||||||
from .jwt_utils import decode_access_token
|
from .jwt_utils import decode_access_token
|
||||||
from .permissions import (
|
from .permissions import (
|
||||||
|
ACCOUNTS_COOKIE,
|
||||||
|
ACCOUNTS_CREATE,
|
||||||
|
ACCOUNTS_DELETE,
|
||||||
|
ACCOUNTS_START,
|
||||||
|
ACCOUNTS_STOP,
|
||||||
|
ACCOUNTS_UPDATE,
|
||||||
ACCOUNTS_WRITE,
|
ACCOUNTS_WRITE,
|
||||||
|
ACCOUNTS_WRITE_GRANULAR,
|
||||||
|
LINK_CARDS_WRITE,
|
||||||
MESSAGES_WRITE,
|
MESSAGES_WRITE,
|
||||||
|
ORDERS_CREATE,
|
||||||
|
ROLES_MANAGE,
|
||||||
RULES_WRITE,
|
RULES_WRITE,
|
||||||
|
SETTINGS_DATABASE,
|
||||||
|
SYSTEM_LOGS_CLEAR,
|
||||||
USERS_MANAGE,
|
USERS_MANAGE,
|
||||||
WRITE_PERMISSIONS,
|
WRITE_PERMISSIONS,
|
||||||
)
|
)
|
||||||
@@ -71,34 +83,43 @@ async def require_write(user: User = Depends(get_current_user)) -> User:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def require_accounts_write(user: User = Depends(get_current_user)) -> User:
|
def _require_any(*codes: str, detail: str):
|
||||||
if has_permission(user.role, ACCOUNTS_WRITE):
|
async def _checker(user: User = Depends(get_current_user)) -> User:
|
||||||
return user
|
if any(has_permission(user.role, code) for code in codes):
|
||||||
raise HTTPException(
|
return user
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=detail)
|
||||||
detail="缺少权限:accounts.write",
|
|
||||||
)
|
return _checker
|
||||||
|
|
||||||
|
|
||||||
async def require_messages_write(user: User = Depends(get_current_user)) -> User:
|
# Backward-compatible: any account write capability.
|
||||||
if has_permission(user.role, MESSAGES_WRITE):
|
require_accounts_write = _require_any(
|
||||||
return user
|
ACCOUNTS_WRITE,
|
||||||
raise HTTPException(
|
*ACCOUNTS_WRITE_GRANULAR,
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
detail="缺少账号写权限",
|
||||||
detail="缺少权限:messages.write",
|
)
|
||||||
)
|
require_accounts_create = require_permission(ACCOUNTS_CREATE)
|
||||||
|
require_accounts_update = require_permission(ACCOUNTS_UPDATE)
|
||||||
|
require_accounts_delete = require_permission(ACCOUNTS_DELETE)
|
||||||
|
require_accounts_start = require_permission(ACCOUNTS_START)
|
||||||
|
require_accounts_stop = require_permission(ACCOUNTS_STOP)
|
||||||
|
require_accounts_cookie = require_permission(ACCOUNTS_COOKIE)
|
||||||
|
|
||||||
|
require_messages_write = require_permission(MESSAGES_WRITE)
|
||||||
async def require_rules_write(user: User = Depends(get_current_user)) -> User:
|
require_rules_write = require_permission(RULES_WRITE)
|
||||||
if has_permission(user.role, RULES_WRITE):
|
require_link_cards_write = require_permission(LINK_CARDS_WRITE)
|
||||||
return user
|
require_system_logs_clear = require_permission(SYSTEM_LOGS_CLEAR)
|
||||||
raise HTTPException(
|
require_settings_database = require_permission(SETTINGS_DATABASE)
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
require_orders_create = require_permission(ORDERS_CREATE)
|
||||||
detail="缺少权限:rules.write",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def require_user_manager(user: User = Depends(get_current_user)) -> User:
|
async def require_user_manager(user: User = Depends(get_current_user)) -> User:
|
||||||
if has_permission(user.role, USERS_MANAGE):
|
if has_permission(user.role, USERS_MANAGE):
|
||||||
return user
|
return user
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="需要用户管理权限")
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="需要用户管理权限")
|
||||||
|
|
||||||
|
|
||||||
|
async def require_role_manager(user: User = Depends(get_current_user)) -> User:
|
||||||
|
if has_permission(user.role, ROLES_MANAGE):
|
||||||
|
return user
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="需要角色管理权限")
|
||||||
|
|||||||
+228
-12
@@ -1,6 +1,6 @@
|
|||||||
"""Fixed permission catalog for menus and actions.
|
"""Fixed permission catalog: menus, button actions, and data scope.
|
||||||
|
|
||||||
UI and APIs only select from this list; new codes must be added in code.
|
UI only selects from this list; new codes must be added in code.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -8,7 +8,9 @@ from __future__ import annotations
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
# Menu visibility
|
# Menu visibility
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
MENU_DASHBOARD = "menu.dashboard"
|
MENU_DASHBOARD = "menu.dashboard"
|
||||||
MENU_ACCOUNTS = "menu.accounts"
|
MENU_ACCOUNTS = "menu.accounts"
|
||||||
MENU_MESSAGES = "menu.messages"
|
MENU_MESSAGES = "menu.messages"
|
||||||
@@ -19,23 +21,63 @@ MENU_SYSTEM_LOGS = "menu.system_logs"
|
|||||||
MENU_DOWNLOAD = "menu.download"
|
MENU_DOWNLOAD = "menu.download"
|
||||||
MENU_HELP = "menu.help"
|
MENU_HELP = "menu.help"
|
||||||
MENU_USERS = "menu.users"
|
MENU_USERS = "menu.users"
|
||||||
|
MENU_ROLES = "menu.roles"
|
||||||
MENU_SETTINGS = "menu.settings"
|
MENU_SETTINGS = "menu.settings"
|
||||||
MENU_DESKTOP_UPDATE = "menu.desktop_update"
|
MENU_DESKTOP_UPDATE = "menu.desktop_update"
|
||||||
MENU_PAYMENT_SETTINGS = "menu.payment_settings"
|
MENU_PAYMENT_SETTINGS = "menu.payment_settings"
|
||||||
MENU_PAYMENT_ORDERS = "menu.payment_orders"
|
MENU_PAYMENT_ORDERS = "menu.payment_orders"
|
||||||
|
|
||||||
# Actions
|
# ---------------------------------------------------------------------------
|
||||||
ACCOUNTS_WRITE = "accounts.write"
|
# Button / action permissions
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Accounts (granular). Legacy ``accounts.write`` expands to the full set.
|
||||||
|
ACCOUNTS_CREATE = "accounts.create"
|
||||||
|
ACCOUNTS_UPDATE = "accounts.update"
|
||||||
|
ACCOUNTS_DELETE = "accounts.delete"
|
||||||
|
ACCOUNTS_START = "accounts.start"
|
||||||
|
ACCOUNTS_STOP = "accounts.stop"
|
||||||
|
ACCOUNTS_COOKIE = "accounts.cookie"
|
||||||
|
ACCOUNTS_WRITE = "accounts.write" # legacy bundle
|
||||||
|
|
||||||
MESSAGES_WRITE = "messages.write"
|
MESSAGES_WRITE = "messages.write"
|
||||||
RULES_WRITE = "rules.write"
|
RULES_WRITE = "rules.write"
|
||||||
|
LINK_CARDS_WRITE = "link_cards.write"
|
||||||
|
|
||||||
LOGS_READ = "logs.read"
|
LOGS_READ = "logs.read"
|
||||||
RECEIVED_MESSAGES_READ = "received_messages.read"
|
RECEIVED_MESSAGES_READ = "received_messages.read"
|
||||||
SYSTEM_LOGS_READ = "system_logs.read"
|
SYSTEM_LOGS_READ = "system_logs.read"
|
||||||
|
SYSTEM_LOGS_CLEAR = "system_logs.clear"
|
||||||
|
|
||||||
USERS_MANAGE = "users.manage"
|
USERS_MANAGE = "users.manage"
|
||||||
|
ROLES_MANAGE = "roles.manage"
|
||||||
SETTINGS_MANAGE = "settings.manage"
|
SETTINGS_MANAGE = "settings.manage"
|
||||||
|
SETTINGS_DATABASE = "settings.database"
|
||||||
DESKTOP_MANAGE = "desktop.manage"
|
DESKTOP_MANAGE = "desktop.manage"
|
||||||
PAYMENTS_MANAGE = "payments.manage"
|
PAYMENTS_MANAGE = "payments.manage"
|
||||||
ORDERS_READ = "orders.read"
|
ORDERS_READ = "orders.read"
|
||||||
|
ORDERS_CREATE = "orders.create"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Data scope
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Without this (and without is_admin), users only see their own data.
|
||||||
|
DATA_SCOPE_ALL = "data.scope_all"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Bundles / aliases expanded on save & when checking permissions
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
ACCOUNTS_WRITE_GRANULAR: tuple[str, ...] = (
|
||||||
|
ACCOUNTS_CREATE,
|
||||||
|
ACCOUNTS_UPDATE,
|
||||||
|
ACCOUNTS_DELETE,
|
||||||
|
ACCOUNTS_START,
|
||||||
|
ACCOUNTS_STOP,
|
||||||
|
ACCOUNTS_COOKIE,
|
||||||
|
)
|
||||||
|
|
||||||
|
LEGACY_BUNDLES: dict[str, tuple[str, ...]] = {
|
||||||
|
ACCOUNTS_WRITE: ACCOUNTS_WRITE_GRANULAR,
|
||||||
|
}
|
||||||
|
|
||||||
ALL_PERMISSIONS: tuple[str, ...] = (
|
ALL_PERMISSIONS: tuple[str, ...] = (
|
||||||
MENU_DASHBOARD,
|
MENU_DASHBOARD,
|
||||||
@@ -48,21 +90,34 @@ ALL_PERMISSIONS: tuple[str, ...] = (
|
|||||||
MENU_DOWNLOAD,
|
MENU_DOWNLOAD,
|
||||||
MENU_HELP,
|
MENU_HELP,
|
||||||
MENU_USERS,
|
MENU_USERS,
|
||||||
|
MENU_ROLES,
|
||||||
MENU_SETTINGS,
|
MENU_SETTINGS,
|
||||||
MENU_DESKTOP_UPDATE,
|
MENU_DESKTOP_UPDATE,
|
||||||
MENU_PAYMENT_SETTINGS,
|
MENU_PAYMENT_SETTINGS,
|
||||||
MENU_PAYMENT_ORDERS,
|
MENU_PAYMENT_ORDERS,
|
||||||
|
ACCOUNTS_CREATE,
|
||||||
|
ACCOUNTS_UPDATE,
|
||||||
|
ACCOUNTS_DELETE,
|
||||||
|
ACCOUNTS_START,
|
||||||
|
ACCOUNTS_STOP,
|
||||||
|
ACCOUNTS_COOKIE,
|
||||||
ACCOUNTS_WRITE,
|
ACCOUNTS_WRITE,
|
||||||
MESSAGES_WRITE,
|
MESSAGES_WRITE,
|
||||||
RULES_WRITE,
|
RULES_WRITE,
|
||||||
|
LINK_CARDS_WRITE,
|
||||||
LOGS_READ,
|
LOGS_READ,
|
||||||
RECEIVED_MESSAGES_READ,
|
RECEIVED_MESSAGES_READ,
|
||||||
SYSTEM_LOGS_READ,
|
SYSTEM_LOGS_READ,
|
||||||
|
SYSTEM_LOGS_CLEAR,
|
||||||
USERS_MANAGE,
|
USERS_MANAGE,
|
||||||
|
ROLES_MANAGE,
|
||||||
SETTINGS_MANAGE,
|
SETTINGS_MANAGE,
|
||||||
|
SETTINGS_DATABASE,
|
||||||
DESKTOP_MANAGE,
|
DESKTOP_MANAGE,
|
||||||
PAYMENTS_MANAGE,
|
PAYMENTS_MANAGE,
|
||||||
ORDERS_READ,
|
ORDERS_READ,
|
||||||
|
ORDERS_CREATE,
|
||||||
|
DATA_SCOPE_ALL,
|
||||||
)
|
)
|
||||||
|
|
||||||
PERMISSION_SET = frozenset(ALL_PERMISSIONS)
|
PERMISSION_SET = frozenset(ALL_PERMISSIONS)
|
||||||
@@ -70,8 +125,10 @@ PERMISSION_SET = frozenset(ALL_PERMISSIONS)
|
|||||||
WRITE_PERMISSIONS = frozenset(
|
WRITE_PERMISSIONS = frozenset(
|
||||||
{
|
{
|
||||||
ACCOUNTS_WRITE,
|
ACCOUNTS_WRITE,
|
||||||
|
*ACCOUNTS_WRITE_GRANULAR,
|
||||||
MESSAGES_WRITE,
|
MESSAGES_WRITE,
|
||||||
RULES_WRITE,
|
RULES_WRITE,
|
||||||
|
LINK_CARDS_WRITE,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -85,22 +142,35 @@ _PERMISSION_META: dict[str, dict[str, str]] = {
|
|||||||
MENU_SYSTEM_LOGS: {"group": "menu", "label": "系统诊断日志"},
|
MENU_SYSTEM_LOGS: {"group": "menu", "label": "系统诊断日志"},
|
||||||
MENU_DOWNLOAD: {"group": "menu", "label": "软件下载"},
|
MENU_DOWNLOAD: {"group": "menu", "label": "软件下载"},
|
||||||
MENU_HELP: {"group": "menu", "label": "帮助中心"},
|
MENU_HELP: {"group": "menu", "label": "帮助中心"},
|
||||||
MENU_USERS: {"group": "menu", "label": "用户与角色"},
|
MENU_USERS: {"group": "menu", "label": "用户管理"},
|
||||||
|
MENU_ROLES: {"group": "menu", "label": "角色设定"},
|
||||||
MENU_SETTINGS: {"group": "menu", "label": "系统设置"},
|
MENU_SETTINGS: {"group": "menu", "label": "系统设置"},
|
||||||
MENU_DESKTOP_UPDATE: {"group": "menu", "label": "桌面端升级"},
|
MENU_DESKTOP_UPDATE: {"group": "menu", "label": "桌面端升级"},
|
||||||
MENU_PAYMENT_SETTINGS: {"group": "menu", "label": "支付配置"},
|
MENU_PAYMENT_SETTINGS: {"group": "menu", "label": "支付配置"},
|
||||||
MENU_PAYMENT_ORDERS: {"group": "menu", "label": "我的订单"},
|
MENU_PAYMENT_ORDERS: {"group": "menu", "label": "我的订单"},
|
||||||
ACCOUNTS_WRITE: {"group": "action", "label": "账号写操作(启动/停止/改凭证/删除)"},
|
ACCOUNTS_CREATE: {"group": "action", "label": "新增抖音账号"},
|
||||||
MESSAGES_WRITE: {"group": "action", "label": "发送私信"},
|
ACCOUNTS_UPDATE: {"group": "action", "label": "编辑账号信息"},
|
||||||
|
ACCOUNTS_DELETE: {"group": "action", "label": "删除账号"},
|
||||||
|
ACCOUNTS_START: {"group": "action", "label": "启动托管 / 批量启动"},
|
||||||
|
ACCOUNTS_STOP: {"group": "action", "label": "停止托管"},
|
||||||
|
ACCOUNTS_COOKIE: {"group": "action", "label": "查看/修改 Cookie 与凭证"},
|
||||||
|
ACCOUNTS_WRITE: {"group": "action", "label": "账号全部写操作(兼容旧版,等同下列细项)"},
|
||||||
|
MESSAGES_WRITE: {"group": "action", "label": "发送私信 / 队列立即发送"},
|
||||||
RULES_WRITE: {"group": "action", "label": "编辑自动回复规则"},
|
RULES_WRITE: {"group": "action", "label": "编辑自动回复规则"},
|
||||||
|
LINK_CARDS_WRITE: {"group": "action", "label": "上传素材 / 生成链接卡片"},
|
||||||
LOGS_READ: {"group": "action", "label": "查看回复日志"},
|
LOGS_READ: {"group": "action", "label": "查看回复日志"},
|
||||||
RECEIVED_MESSAGES_READ: {"group": "action", "label": "查看接收消息日志"},
|
RECEIVED_MESSAGES_READ: {"group": "action", "label": "查看接收消息日志"},
|
||||||
SYSTEM_LOGS_READ: {"group": "action", "label": "查看系统诊断日志"},
|
SYSTEM_LOGS_READ: {"group": "action", "label": "查看系统诊断日志"},
|
||||||
USERS_MANAGE: {"group": "action", "label": "管理用户与角色"},
|
SYSTEM_LOGS_CLEAR: {"group": "action", "label": "清空系统诊断日志"},
|
||||||
|
USERS_MANAGE: {"group": "action", "label": "管理用户"},
|
||||||
|
ROLES_MANAGE: {"group": "action", "label": "管理角色"},
|
||||||
SETTINGS_MANAGE: {"group": "action", "label": "管理系统设置"},
|
SETTINGS_MANAGE: {"group": "action", "label": "管理系统设置"},
|
||||||
|
SETTINGS_DATABASE: {"group": "action", "label": "管理数据库配置与迁移"},
|
||||||
DESKTOP_MANAGE: {"group": "action", "label": "管理桌面端升级"},
|
DESKTOP_MANAGE: {"group": "action", "label": "管理桌面端升级"},
|
||||||
PAYMENTS_MANAGE: {"group": "action", "label": "管理支付配置"},
|
PAYMENTS_MANAGE: {"group": "action", "label": "管理支付配置与全部订单"},
|
||||||
ORDERS_READ: {"group": "action", "label": "查看我的订单"},
|
ORDERS_READ: {"group": "action", "label": "查看我的订单"},
|
||||||
|
ORDERS_CREATE: {"group": "action", "label": "购买额度 / 创建订单"},
|
||||||
|
DATA_SCOPE_ALL: {"group": "data", "label": "查看全部用户数据(全局数据范围)"},
|
||||||
}
|
}
|
||||||
|
|
||||||
OPERATOR_PERMISSIONS: tuple[str, ...] = (
|
OPERATOR_PERMISSIONS: tuple[str, ...] = (
|
||||||
@@ -113,12 +183,14 @@ OPERATOR_PERMISSIONS: tuple[str, ...] = (
|
|||||||
MENU_DOWNLOAD,
|
MENU_DOWNLOAD,
|
||||||
MENU_HELP,
|
MENU_HELP,
|
||||||
MENU_PAYMENT_ORDERS,
|
MENU_PAYMENT_ORDERS,
|
||||||
ACCOUNTS_WRITE,
|
*ACCOUNTS_WRITE_GRANULAR,
|
||||||
MESSAGES_WRITE,
|
MESSAGES_WRITE,
|
||||||
RULES_WRITE,
|
RULES_WRITE,
|
||||||
|
LINK_CARDS_WRITE,
|
||||||
LOGS_READ,
|
LOGS_READ,
|
||||||
RECEIVED_MESSAGES_READ,
|
RECEIVED_MESSAGES_READ,
|
||||||
ORDERS_READ,
|
ORDERS_READ,
|
||||||
|
ORDERS_CREATE,
|
||||||
)
|
)
|
||||||
|
|
||||||
VIEWER_PERMISSIONS: tuple[str, ...] = (
|
VIEWER_PERMISSIONS: tuple[str, ...] = (
|
||||||
@@ -149,14 +221,158 @@ def normalize_permissions(codes: list[str] | tuple[str, ...] | None) -> list[str
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def expand_legacy_bundles(codes: set[str]) -> set[str]:
|
||||||
|
"""Expand legacy bundle codes into granular permissions."""
|
||||||
|
expanded = set(codes)
|
||||||
|
for bundle, parts in LEGACY_BUNDLES.items():
|
||||||
|
if bundle in expanded:
|
||||||
|
expanded.update(parts)
|
||||||
|
return expanded
|
||||||
|
|
||||||
|
|
||||||
|
# Menu → required action. Selecting a menu always grants the action.
|
||||||
|
MENU_REQUIRED_ACTIONS: dict[str, str | tuple[str, ...]] = {
|
||||||
|
MENU_USERS: USERS_MANAGE,
|
||||||
|
MENU_ROLES: ROLES_MANAGE,
|
||||||
|
MENU_SETTINGS: SETTINGS_MANAGE,
|
||||||
|
MENU_DESKTOP_UPDATE: DESKTOP_MANAGE,
|
||||||
|
MENU_PAYMENT_SETTINGS: PAYMENTS_MANAGE,
|
||||||
|
MENU_PAYMENT_ORDERS: (ORDERS_READ, ORDERS_CREATE),
|
||||||
|
MENU_LOGS: LOGS_READ,
|
||||||
|
MENU_RECEIVED_MESSAGES: RECEIVED_MESSAGES_READ,
|
||||||
|
MENU_SYSTEM_LOGS: SYSTEM_LOGS_READ,
|
||||||
|
MENU_ACCOUNTS: (), # page access only; buttons are separate
|
||||||
|
MENU_MESSAGES: (),
|
||||||
|
MENU_RULES: (),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Action → primary menu only.
|
||||||
|
ACTION_PRIMARY_MENU: dict[str, str] = {
|
||||||
|
USERS_MANAGE: MENU_USERS,
|
||||||
|
ROLES_MANAGE: MENU_ROLES,
|
||||||
|
SETTINGS_MANAGE: MENU_SETTINGS,
|
||||||
|
SETTINGS_DATABASE: MENU_SETTINGS,
|
||||||
|
DESKTOP_MANAGE: MENU_DESKTOP_UPDATE,
|
||||||
|
PAYMENTS_MANAGE: MENU_PAYMENT_SETTINGS,
|
||||||
|
ORDERS_READ: MENU_PAYMENT_ORDERS,
|
||||||
|
ORDERS_CREATE: MENU_PAYMENT_ORDERS,
|
||||||
|
LOGS_READ: MENU_LOGS,
|
||||||
|
RECEIVED_MESSAGES_READ: MENU_RECEIVED_MESSAGES,
|
||||||
|
SYSTEM_LOGS_READ: MENU_SYSTEM_LOGS,
|
||||||
|
SYSTEM_LOGS_CLEAR: MENU_SYSTEM_LOGS,
|
||||||
|
ACCOUNTS_CREATE: MENU_ACCOUNTS,
|
||||||
|
ACCOUNTS_UPDATE: MENU_ACCOUNTS,
|
||||||
|
ACCOUNTS_DELETE: MENU_ACCOUNTS,
|
||||||
|
ACCOUNTS_START: MENU_ACCOUNTS,
|
||||||
|
ACCOUNTS_STOP: MENU_ACCOUNTS,
|
||||||
|
ACCOUNTS_COOKIE: MENU_ACCOUNTS,
|
||||||
|
ACCOUNTS_WRITE: MENU_ACCOUNTS,
|
||||||
|
MESSAGES_WRITE: MENU_MESSAGES,
|
||||||
|
RULES_WRITE: MENU_RULES,
|
||||||
|
LINK_CARDS_WRITE: MENU_RULES,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _iter_required_actions(menu: str) -> tuple[str, ...]:
|
||||||
|
raw = MENU_REQUIRED_ACTIONS.get(menu)
|
||||||
|
if raw is None:
|
||||||
|
return ()
|
||||||
|
if isinstance(raw, str):
|
||||||
|
return (raw,)
|
||||||
|
return tuple(raw)
|
||||||
|
|
||||||
|
|
||||||
|
# Catalog / UI pairs (menu → first required action for checkbox hints).
|
||||||
|
MENU_ACTION_PAIRS: tuple[tuple[str, str], ...] = tuple(
|
||||||
|
(menu, actions[0])
|
||||||
|
for menu, actions in (
|
||||||
|
(m, _iter_required_actions(m)) for m in MENU_REQUIRED_ACTIONS
|
||||||
|
)
|
||||||
|
if actions
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def expand_paired_permissions(codes: list[str] | tuple[str, ...] | None) -> list[str]:
|
||||||
|
"""Normalize, expand legacy bundles, and auto-complete menu/action pairs."""
|
||||||
|
selected = expand_legacy_bundles(set(normalize_permissions(codes)))
|
||||||
|
for menu in list(selected):
|
||||||
|
for action in _iter_required_actions(menu):
|
||||||
|
selected.add(action)
|
||||||
|
for action, menu in ACTION_PRIMARY_MENU.items():
|
||||||
|
if action in selected:
|
||||||
|
selected.add(menu)
|
||||||
|
# If every granular account write is present, keep the legacy bundle flag.
|
||||||
|
if all(code in selected for code in ACCOUNTS_WRITE_GRANULAR):
|
||||||
|
selected.add(ACCOUNTS_WRITE)
|
||||||
|
return [code for code in ALL_PERMISSIONS if code in selected]
|
||||||
|
|
||||||
|
|
||||||
|
def permission_implies(held: set[str], needed: str) -> bool:
|
||||||
|
"""Whether a held permission set satisfies ``needed`` (incl. legacy bundles)."""
|
||||||
|
if needed in held:
|
||||||
|
return True
|
||||||
|
for bundle, parts in LEGACY_BUNDLES.items():
|
||||||
|
if needed in parts and bundle in held:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def permission_catalog() -> dict[str, Any]:
|
def permission_catalog() -> dict[str, Any]:
|
||||||
menus = []
|
menus = []
|
||||||
actions = []
|
actions = []
|
||||||
|
data = []
|
||||||
|
meta_by_code: dict[str, dict[str, str]] = {}
|
||||||
for code in ALL_PERMISSIONS:
|
for code in ALL_PERMISSIONS:
|
||||||
meta = _PERMISSION_META[code]
|
meta = _PERMISSION_META[code]
|
||||||
item = {"code": code, "label": meta["label"]}
|
item = {"code": code, "label": meta["label"]}
|
||||||
if meta["group"] == "menu":
|
meta_by_code[code] = meta
|
||||||
|
group = meta["group"]
|
||||||
|
if group == "menu":
|
||||||
menus.append(item)
|
menus.append(item)
|
||||||
|
elif group == "data":
|
||||||
|
data.append(item)
|
||||||
else:
|
else:
|
||||||
actions.append(item)
|
actions.append(item)
|
||||||
return {"menus": menus, "actions": actions}
|
|
||||||
|
# Reverse index: menu → child action codes (preserve ALL_PERMISSIONS order).
|
||||||
|
children_by_menu: dict[str, list[dict[str, str]]] = {m["code"]: [] for m in menus}
|
||||||
|
for code in ALL_PERMISSIONS:
|
||||||
|
if meta_by_code[code]["group"] != "action":
|
||||||
|
continue
|
||||||
|
parent = ACTION_PRIMARY_MENU.get(code)
|
||||||
|
if parent and parent in children_by_menu:
|
||||||
|
children_by_menu[parent].append(
|
||||||
|
{"code": code, "label": meta_by_code[code]["label"]}
|
||||||
|
)
|
||||||
|
|
||||||
|
tree: list[dict[str, Any]] = []
|
||||||
|
for menu in menus:
|
||||||
|
node: dict[str, Any] = {
|
||||||
|
"code": menu["code"],
|
||||||
|
"label": menu["label"],
|
||||||
|
"kind": "menu",
|
||||||
|
"children": children_by_menu.get(menu["code"], []),
|
||||||
|
}
|
||||||
|
tree.append(node)
|
||||||
|
|
||||||
|
if data:
|
||||||
|
tree.append(
|
||||||
|
{
|
||||||
|
"code": "__group.data__",
|
||||||
|
"label": "数据权限",
|
||||||
|
"kind": "group",
|
||||||
|
"children": [
|
||||||
|
{"code": item["code"], "label": item["label"]} for item in data
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"menus": menus,
|
||||||
|
"actions": actions,
|
||||||
|
"data": data,
|
||||||
|
"tree": tree,
|
||||||
|
"pairs": [
|
||||||
|
{"menu": menu, "action": action} for menu, action in MENU_ACTION_PAIRS
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ from sqlalchemy import func, select
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from models.models import Role, User
|
from models.models import Role, User
|
||||||
from .permissions import ALL_PERMISSIONS, normalize_permissions
|
from .permissions import ALL_PERMISSIONS, expand_paired_permissions, normalize_permissions
|
||||||
from .roles import (
|
from .roles import (
|
||||||
ROLE_ADMIN,
|
ROLE_ADMIN,
|
||||||
RoleRecord,
|
RoleRecord,
|
||||||
@@ -48,7 +48,11 @@ def _decode_permissions(raw: str | None) -> list[str]:
|
|||||||
|
|
||||||
|
|
||||||
def role_to_record(row: Role) -> RoleRecord:
|
def role_to_record(row: Role) -> RoleRecord:
|
||||||
perms = list(ALL_PERMISSIONS) if row.is_admin else _decode_permissions(row.permissions)
|
perms = (
|
||||||
|
list(ALL_PERMISSIONS)
|
||||||
|
if row.is_admin
|
||||||
|
else expand_paired_permissions(_decode_permissions(row.permissions))
|
||||||
|
)
|
||||||
return RoleRecord(
|
return RoleRecord(
|
||||||
code=row.code,
|
code=row.code,
|
||||||
label=row.label,
|
label=row.label,
|
||||||
@@ -70,7 +74,7 @@ async def refresh_role_cache(db: AsyncSession) -> list[RoleRecord]:
|
|||||||
|
|
||||||
|
|
||||||
async def seed_builtin_roles(db: AsyncSession) -> None:
|
async def seed_builtin_roles(db: AsyncSession) -> None:
|
||||||
"""Insert missing built-in roles and keep admin permissions complete."""
|
"""Insert missing built-in roles and keep system role permissions in sync."""
|
||||||
seeds = {seed.code: seed for seed in default_role_seeds()}
|
seeds = {seed.code: seed for seed in default_role_seeds()}
|
||||||
result = await db.execute(select(Role))
|
result = await db.execute(select(Role))
|
||||||
existing = {row.code: row for row in result.scalars().all()}
|
existing = {row.code: row for row in result.scalars().all()}
|
||||||
@@ -92,17 +96,35 @@ async def seed_builtin_roles(db: AsyncSession) -> None:
|
|||||||
)
|
)
|
||||||
changed = True
|
changed = True
|
||||||
continue
|
continue
|
||||||
# Keep system flags and admin full permission set in sync.
|
# Keep system flags and built-in permission sets in sync with code.
|
||||||
if not row.is_system:
|
if not row.is_system:
|
||||||
row.is_system = True
|
row.is_system = True
|
||||||
changed = True
|
changed = True
|
||||||
if seed.is_admin and (not row.is_admin or row.permissions != payload):
|
if seed.is_admin:
|
||||||
row.is_admin = True
|
if not row.is_admin or row.permissions != payload or row.label != seed.label:
|
||||||
row.permissions = payload
|
row.is_admin = True
|
||||||
row.label = seed.label
|
row.permissions = payload
|
||||||
changed = True
|
row.label = seed.label
|
||||||
elif not row.label:
|
if seed.description and row.description != seed.description:
|
||||||
row.label = seed.label
|
row.description = seed.description
|
||||||
|
changed = True
|
||||||
|
else:
|
||||||
|
# operator / viewer: resync catalog so new menu/action codes ship.
|
||||||
|
desired = _encode_permissions(expand_paired_permissions(seed.permissions))
|
||||||
|
if row.permissions != desired or row.label != seed.label:
|
||||||
|
row.permissions = desired
|
||||||
|
row.label = seed.label
|
||||||
|
row.is_admin = False
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
# Repair custom roles that have unpaired menu/action selections.
|
||||||
|
for row in existing.values():
|
||||||
|
if row.code in seeds or row.is_admin:
|
||||||
|
continue
|
||||||
|
repaired = expand_paired_permissions(_decode_permissions(row.permissions))
|
||||||
|
encoded = _encode_permissions(repaired)
|
||||||
|
if row.permissions != encoded:
|
||||||
|
row.permissions = encoded
|
||||||
changed = True
|
changed = True
|
||||||
|
|
||||||
if changed:
|
if changed:
|
||||||
|
|||||||
+30
-11
@@ -7,12 +7,15 @@ from typing import Iterable
|
|||||||
|
|
||||||
from .permissions import (
|
from .permissions import (
|
||||||
ALL_PERMISSIONS,
|
ALL_PERMISSIONS,
|
||||||
|
DATA_SCOPE_ALL,
|
||||||
OPERATOR_PERMISSIONS,
|
OPERATOR_PERMISSIONS,
|
||||||
VIEWER_PERMISSIONS,
|
VIEWER_PERMISSIONS,
|
||||||
WRITE_PERMISSIONS,
|
WRITE_PERMISSIONS,
|
||||||
normalize_permissions,
|
expand_legacy_bundles,
|
||||||
|
permission_implies,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
ROLE_ADMIN = "admin"
|
ROLE_ADMIN = "admin"
|
||||||
ROLE_OPERATOR = "operator"
|
ROLE_OPERATOR = "operator"
|
||||||
ROLE_VIEWER = "viewer"
|
ROLE_VIEWER = "viewer"
|
||||||
@@ -44,7 +47,7 @@ def default_role_seeds() -> list[RoleRecord]:
|
|||||||
RoleRecord(
|
RoleRecord(
|
||||||
code=ROLE_ADMIN,
|
code=ROLE_ADMIN,
|
||||||
label=ROLE_LABELS[ROLE_ADMIN],
|
label=ROLE_LABELS[ROLE_ADMIN],
|
||||||
description="拥有全部菜单与操作权限,可管理全局数据",
|
description="拥有全部菜单、按钮与全局数据权限",
|
||||||
is_system=True,
|
is_system=True,
|
||||||
is_admin=True,
|
is_admin=True,
|
||||||
permissions=list(ALL_PERMISSIONS),
|
permissions=list(ALL_PERMISSIONS),
|
||||||
@@ -52,7 +55,7 @@ def default_role_seeds() -> list[RoleRecord]:
|
|||||||
RoleRecord(
|
RoleRecord(
|
||||||
code=ROLE_OPERATOR,
|
code=ROLE_OPERATOR,
|
||||||
label=ROLE_LABELS[ROLE_OPERATOR],
|
label=ROLE_LABELS[ROLE_OPERATOR],
|
||||||
description="管理自己的账号、规则与私信",
|
description="管理自己的账号、规则与私信(仅本人数据)",
|
||||||
is_system=True,
|
is_system=True,
|
||||||
is_admin=False,
|
is_admin=False,
|
||||||
permissions=list(OPERATOR_PERMISSIONS),
|
permissions=list(OPERATOR_PERMISSIONS),
|
||||||
@@ -91,20 +94,27 @@ def role_label(code: str | None) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def is_admin(role: str | None) -> bool:
|
def is_admin(role: str | None) -> bool:
|
||||||
"""True when the role has global data scope (built-in admin)."""
|
"""True for the built-in admin role (global admin flag)."""
|
||||||
record = get_cached_role(role)
|
record = get_cached_role(role)
|
||||||
if record is not None:
|
if record is not None:
|
||||||
return bool(record.is_admin)
|
return bool(record.is_admin)
|
||||||
# Fallback before cache is warm / for unit tests.
|
|
||||||
return str(role or "") == ROLE_ADMIN
|
return str(role or "") == ROLE_ADMIN
|
||||||
|
|
||||||
|
|
||||||
|
def has_global_scope(role: str | None) -> bool:
|
||||||
|
"""True when the role may see all users' data (admin or data.scope_all)."""
|
||||||
|
if is_admin(role):
|
||||||
|
return True
|
||||||
|
return has_permission(role, DATA_SCOPE_ALL)
|
||||||
|
|
||||||
|
|
||||||
def can_write(role: str | None) -> bool:
|
def can_write(role: str | None) -> bool:
|
||||||
record = get_cached_role(role)
|
record = get_cached_role(role)
|
||||||
if record is not None:
|
if record is not None:
|
||||||
if record.is_admin:
|
if record.is_admin:
|
||||||
return True
|
return True
|
||||||
return any(code in WRITE_PERMISSIONS for code in record.permissions)
|
held = expand_legacy_bundles(set(record.permissions))
|
||||||
|
return any(code in WRITE_PERMISSIONS for code in held)
|
||||||
return str(role or "") in (ROLE_ADMIN, ROLE_OPERATOR)
|
return str(role or "") in (ROLE_ADMIN, ROLE_OPERATOR)
|
||||||
|
|
||||||
|
|
||||||
@@ -112,6 +122,10 @@ def can_manage_users(role: str | None) -> bool:
|
|||||||
return has_permission(role, "users.manage")
|
return has_permission(role, "users.manage")
|
||||||
|
|
||||||
|
|
||||||
|
def can_manage_roles(role: str | None) -> bool:
|
||||||
|
return has_permission(role, "roles.manage")
|
||||||
|
|
||||||
|
|
||||||
def has_permission(role: str | None, permission: str) -> bool:
|
def has_permission(role: str | None, permission: str) -> bool:
|
||||||
code = str(permission or "").strip()
|
code = str(permission or "").strip()
|
||||||
if not code:
|
if not code:
|
||||||
@@ -121,13 +135,14 @@ def has_permission(role: str | None, permission: str) -> bool:
|
|||||||
if str(role or "") == ROLE_ADMIN:
|
if str(role or "") == ROLE_ADMIN:
|
||||||
return True
|
return True
|
||||||
if str(role or "") == ROLE_OPERATOR:
|
if str(role or "") == ROLE_OPERATOR:
|
||||||
return code in OPERATOR_PERMISSIONS
|
return permission_implies(expand_legacy_bundles(set(OPERATOR_PERMISSIONS)), code)
|
||||||
if str(role or "") == ROLE_VIEWER:
|
if str(role or "") == ROLE_VIEWER:
|
||||||
return code in VIEWER_PERMISSIONS
|
return permission_implies(set(VIEWER_PERMISSIONS), code)
|
||||||
return False
|
return False
|
||||||
if record.is_admin:
|
if record.is_admin:
|
||||||
return True
|
return True
|
||||||
return code in record.permissions
|
held = expand_legacy_bundles(set(record.permissions))
|
||||||
|
return permission_implies(held, code)
|
||||||
|
|
||||||
|
|
||||||
def permissions_for_role(role: str | None) -> list[str]:
|
def permissions_for_role(role: str | None) -> list[str]:
|
||||||
@@ -136,7 +151,9 @@ def permissions_for_role(role: str | None) -> list[str]:
|
|||||||
if str(role or "") == ROLE_ADMIN:
|
if str(role or "") == ROLE_ADMIN:
|
||||||
return list(ALL_PERMISSIONS)
|
return list(ALL_PERMISSIONS)
|
||||||
if str(role or "") == ROLE_OPERATOR:
|
if str(role or "") == ROLE_OPERATOR:
|
||||||
return list(OPERATOR_PERMISSIONS)
|
from .permissions import expand_paired_permissions
|
||||||
|
|
||||||
|
return expand_paired_permissions(OPERATOR_PERMISSIONS)
|
||||||
if str(role or "") == ROLE_VIEWER:
|
if str(role or "") == ROLE_VIEWER:
|
||||||
return list(VIEWER_PERMISSIONS)
|
return list(VIEWER_PERMISSIONS)
|
||||||
return []
|
return []
|
||||||
@@ -164,4 +181,6 @@ def sanitize_role_permissions(
|
|||||||
) -> list[str]:
|
) -> list[str]:
|
||||||
if force_all:
|
if force_all:
|
||||||
return list(ALL_PERMISSIONS)
|
return list(ALL_PERMISSIONS)
|
||||||
return normalize_permissions(codes)
|
from .permissions import expand_paired_permissions
|
||||||
|
|
||||||
|
return expand_paired_permissions(codes)
|
||||||
|
|||||||
+23
-12
@@ -14,7 +14,7 @@ from .account_limits import (
|
|||||||
normalize_max_accounts,
|
normalize_max_accounts,
|
||||||
)
|
)
|
||||||
from .account_quota import default_stop_worker, sync_user_account_quota
|
from .account_quota import default_stop_worker, sync_user_account_quota
|
||||||
from .dependencies import get_current_user, require_user_manager
|
from .dependencies import get_current_user, require_role_manager, require_user_manager
|
||||||
from .email_service import (
|
from .email_service import (
|
||||||
build_password_reset_link,
|
build_password_reset_link,
|
||||||
build_verification_link,
|
build_verification_link,
|
||||||
@@ -37,6 +37,7 @@ from .role_service import (
|
|||||||
user_permission_payload,
|
user_permission_payload,
|
||||||
)
|
)
|
||||||
from .roles import ROLE_OPERATOR, is_admin
|
from .roles import ROLE_OPERATOR, is_admin
|
||||||
|
from .scopes import ensure_user_manageable, users_for_manager
|
||||||
from .schemas import (
|
from .schemas import (
|
||||||
LoginRequest,
|
LoginRequest,
|
||||||
MessageResponse,
|
MessageResponse,
|
||||||
@@ -393,17 +394,20 @@ async def list_auth_roles(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
_: User = Depends(get_current_user),
|
_: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""Lightweight role list for dropdowns (any logged-in user)."""
|
"""Lightweight role list for dropdowns (any logged-in user).
|
||||||
|
|
||||||
|
Intentionally omits permission arrays to avoid leaking the full ACL map.
|
||||||
|
"""
|
||||||
records = await list_role_records(db)
|
records = await list_role_records(db)
|
||||||
return RolesResponse(
|
return RolesResponse(
|
||||||
roles=[
|
roles=[
|
||||||
RoleInfo(
|
RoleInfo(
|
||||||
value=item.code,
|
value=item.code,
|
||||||
label=item.label,
|
label=item.label,
|
||||||
description=item.description or None,
|
description=None,
|
||||||
is_system=item.is_system,
|
is_system=item.is_system,
|
||||||
is_admin=item.is_admin,
|
is_admin=item.is_admin,
|
||||||
permissions=list(item.permissions),
|
permissions=[],
|
||||||
)
|
)
|
||||||
for item in records
|
for item in records
|
||||||
]
|
]
|
||||||
@@ -417,9 +421,9 @@ roles_router = APIRouter(prefix="/api/roles", tags=["roles"])
|
|||||||
@users_router.get("", response_model=list[UserResponse])
|
@users_router.get("", response_model=list[UserResponse])
|
||||||
async def list_users(
|
async def list_users(
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
_: User = Depends(require_user_manager),
|
current: User = Depends(require_user_manager),
|
||||||
):
|
):
|
||||||
result = await db.execute(select(User).order_by(User.id.asc()))
|
result = await db.execute(users_for_manager(current).order_by(User.id.asc()))
|
||||||
users = result.scalars().all()
|
users = result.scalars().all()
|
||||||
responses = []
|
responses = []
|
||||||
for user in users:
|
for user in users:
|
||||||
@@ -431,13 +435,15 @@ async def list_users(
|
|||||||
async def create_user(
|
async def create_user(
|
||||||
body: UserCreate,
|
body: UserCreate,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
_: User = Depends(require_user_manager),
|
current: User = Depends(require_user_manager),
|
||||||
):
|
):
|
||||||
settings = await load_settings(db)
|
settings = await load_settings(db)
|
||||||
exists = await db.execute(select(User).where(User.username == body.username))
|
exists = await db.execute(select(User).where(User.username == body.username))
|
||||||
if exists.scalar_one_or_none():
|
if exists.scalar_one_or_none():
|
||||||
raise HTTPException(status_code=400, detail="用户名已存在")
|
raise HTTPException(status_code=400, detail="用户名已存在")
|
||||||
role = await ensure_role_assignable(db, body.role)
|
role = await ensure_role_assignable(db, body.role)
|
||||||
|
if is_admin(role) and not is_admin(current.role):
|
||||||
|
raise HTTPException(status_code=403, detail="只有管理员可以分配管理员角色")
|
||||||
email = await _ensure_email_available(db, str(body.email) if body.email else None)
|
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:
|
if settings.email_binding_required and not is_admin(role) and not email:
|
||||||
raise HTTPException(status_code=400, detail="系统已开启「登录必须绑定邮箱」,请填写邮箱")
|
raise HTTPException(status_code=400, detail="系统已开启「登录必须绑定邮箱」,请填写邮箱")
|
||||||
@@ -455,6 +461,7 @@ async def create_user(
|
|||||||
email_verified=email_verified,
|
email_verified=email_verified,
|
||||||
email_verified_at=datetime.utcnow() if email and email_verified else None,
|
email_verified_at=datetime.utcnow() if email and email_verified else None,
|
||||||
max_accounts=normalize_max_accounts(body.max_accounts, role),
|
max_accounts=normalize_max_accounts(body.max_accounts, role),
|
||||||
|
created_by=current.id,
|
||||||
)
|
)
|
||||||
db.add(user)
|
db.add(user)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
@@ -473,6 +480,7 @@ async def update_user(
|
|||||||
user = result.scalar_one_or_none()
|
user = result.scalar_one_or_none()
|
||||||
if not user:
|
if not user:
|
||||||
raise HTTPException(status_code=404, detail="用户不存在")
|
raise HTTPException(status_code=404, detail="用户不存在")
|
||||||
|
ensure_user_manageable(current, user)
|
||||||
settings = await load_settings(db)
|
settings = await load_settings(db)
|
||||||
if user.id == current.id and body.is_active is False:
|
if user.id == current.id and body.is_active is False:
|
||||||
raise HTTPException(status_code=400, detail="不能禁用当前登录账号")
|
raise HTTPException(status_code=400, detail="不能禁用当前登录账号")
|
||||||
@@ -482,6 +490,8 @@ async def update_user(
|
|||||||
await guard_last_admin_change(db, user=user, deactivating=True)
|
await guard_last_admin_change(db, user=user, deactivating=True)
|
||||||
if body.role is not None:
|
if body.role is not None:
|
||||||
new_role = await ensure_role_assignable(db, body.role)
|
new_role = await ensure_role_assignable(db, body.role)
|
||||||
|
if is_admin(new_role) and not is_admin(current.role):
|
||||||
|
raise HTTPException(status_code=403, detail="只有管理员可以分配管理员角色")
|
||||||
await guard_last_admin_change(db, user=user, new_role=new_role)
|
await guard_last_admin_change(db, user=user, new_role=new_role)
|
||||||
|
|
||||||
if body.display_name is not None:
|
if body.display_name is not None:
|
||||||
@@ -541,6 +551,7 @@ async def delete_user(
|
|||||||
user = result.scalar_one_or_none()
|
user = result.scalar_one_or_none()
|
||||||
if not user:
|
if not user:
|
||||||
raise HTTPException(status_code=404, detail="用户不存在")
|
raise HTTPException(status_code=404, detail="用户不存在")
|
||||||
|
ensure_user_manageable(current, user)
|
||||||
await guard_last_admin_change(db, user=user, deleting=True)
|
await guard_last_admin_change(db, user=user, deleting=True)
|
||||||
await db.delete(user)
|
await db.delete(user)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
@@ -550,7 +561,7 @@ async def delete_user(
|
|||||||
@roles_router.get("", response_model=RolesResponse)
|
@roles_router.get("", response_model=RolesResponse)
|
||||||
async def admin_list_roles(
|
async def admin_list_roles(
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
_: User = Depends(require_user_manager),
|
_: User = Depends(require_role_manager),
|
||||||
):
|
):
|
||||||
records = await list_role_records(db)
|
records = await list_role_records(db)
|
||||||
roles = []
|
roles = []
|
||||||
@@ -570,7 +581,7 @@ async def admin_list_roles(
|
|||||||
|
|
||||||
|
|
||||||
@roles_router.get("/catalog", response_model=PermissionCatalogResponse)
|
@roles_router.get("/catalog", response_model=PermissionCatalogResponse)
|
||||||
async def get_permission_catalog(_: User = Depends(require_user_manager)):
|
async def get_permission_catalog(_: User = Depends(require_role_manager)):
|
||||||
return PermissionCatalogResponse(**permission_catalog())
|
return PermissionCatalogResponse(**permission_catalog())
|
||||||
|
|
||||||
|
|
||||||
@@ -578,7 +589,7 @@ async def get_permission_catalog(_: User = Depends(require_user_manager)):
|
|||||||
async def create_custom_role(
|
async def create_custom_role(
|
||||||
body: RoleCreate,
|
body: RoleCreate,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
_: User = Depends(require_user_manager),
|
_: User = Depends(require_role_manager),
|
||||||
):
|
):
|
||||||
record = await create_role(
|
record = await create_role(
|
||||||
db,
|
db,
|
||||||
@@ -603,7 +614,7 @@ async def update_custom_role(
|
|||||||
code: str,
|
code: str,
|
||||||
body: RoleUpdate,
|
body: RoleUpdate,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
_: User = Depends(require_user_manager),
|
_: User = Depends(require_role_manager),
|
||||||
):
|
):
|
||||||
record = await update_role(
|
record = await update_role(
|
||||||
db,
|
db,
|
||||||
@@ -627,7 +638,7 @@ async def update_custom_role(
|
|||||||
async def delete_custom_role(
|
async def delete_custom_role(
|
||||||
code: str,
|
code: str,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
_: User = Depends(require_user_manager),
|
_: User = Depends(require_role_manager),
|
||||||
):
|
):
|
||||||
await delete_role(db, code)
|
await delete_role(db, code)
|
||||||
return {"message": "角色已删除"}
|
return {"message": "角色已删除"}
|
||||||
|
|||||||
@@ -132,3 +132,6 @@ class RoleUpdate(BaseModel):
|
|||||||
class PermissionCatalogResponse(BaseModel):
|
class PermissionCatalogResponse(BaseModel):
|
||||||
menus: list[dict[str, Any]]
|
menus: list[dict[str, Any]]
|
||||||
actions: list[dict[str, Any]]
|
actions: list[dict[str, Any]]
|
||||||
|
data: list[dict[str, Any]] = Field(default_factory=list)
|
||||||
|
tree: list[dict[str, Any]] = Field(default_factory=list)
|
||||||
|
pairs: list[dict[str, Any]] = Field(default_factory=list)
|
||||||
|
|||||||
+57
-14
@@ -5,8 +5,12 @@ from sqlalchemy import or_, select
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from models.models import Account, AutoReplyRule, MessageLog, ReceivedMessageLog, SystemLog, User
|
from models.models import Account, AutoReplyRule, MessageLog, ReceivedMessageLog, SystemLog, User
|
||||||
from .permissions import ACCOUNTS_WRITE, RULES_WRITE
|
from .permissions import (
|
||||||
from .roles import has_permission, is_admin
|
ACCOUNTS_UPDATE,
|
||||||
|
ACCOUNTS_WRITE_GRANULAR,
|
||||||
|
RULES_WRITE,
|
||||||
|
)
|
||||||
|
from .roles import has_global_scope, has_permission, is_admin
|
||||||
|
|
||||||
|
|
||||||
async def get_owned_account(
|
async def get_owned_account(
|
||||||
@@ -15,29 +19,42 @@ async def get_owned_account(
|
|||||||
account_id: int,
|
account_id: int,
|
||||||
*,
|
*,
|
||||||
write: bool = False,
|
write: bool = False,
|
||||||
|
write_permission: str | None = None,
|
||||||
) -> Account:
|
) -> Account:
|
||||||
result = await db.execute(select(Account).where(Account.id == account_id))
|
result = await db.execute(select(Account).where(Account.id == account_id))
|
||||||
account = result.scalar_one_or_none()
|
account = result.scalar_one_or_none()
|
||||||
if not account:
|
if not account:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="账号不存在")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="账号不存在")
|
||||||
if is_admin(user.role):
|
if has_global_scope(user.role):
|
||||||
|
if write:
|
||||||
|
needed = write_permission or ACCOUNTS_UPDATE
|
||||||
|
if not has_permission(user.role, needed):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail=f"缺少权限:{needed}",
|
||||||
|
)
|
||||||
return account
|
return account
|
||||||
if account.owner_id != user.id:
|
if account.owner_id != user.id:
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权访问该账号")
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权访问该账号")
|
||||||
if write and not has_permission(user.role, ACCOUNTS_WRITE):
|
if write:
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="只读用户无法修改")
|
needed = write_permission or ACCOUNTS_UPDATE
|
||||||
|
if not has_permission(user.role, needed):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail=f"缺少权限:{needed}",
|
||||||
|
)
|
||||||
return account
|
return account
|
||||||
|
|
||||||
|
|
||||||
def accounts_for_user(user: User):
|
def accounts_for_user(user: User):
|
||||||
stmt = select(Account)
|
stmt = select(Account)
|
||||||
if not is_admin(user.role):
|
if not has_global_scope(user.role):
|
||||||
stmt = stmt.where(Account.owner_id == user.id)
|
stmt = stmt.where(Account.owner_id == user.id)
|
||||||
return stmt
|
return stmt
|
||||||
|
|
||||||
|
|
||||||
async def owned_account_ids(db: AsyncSession, user: User) -> Optional[set[int]]:
|
async def owned_account_ids(db: AsyncSession, user: User) -> Optional[set[int]]:
|
||||||
if is_admin(user.role):
|
if has_global_scope(user.role):
|
||||||
return None
|
return None
|
||||||
result = await db.execute(select(Account.id).where(Account.owner_id == user.id))
|
result = await db.execute(select(Account.id).where(Account.owner_id == user.id))
|
||||||
return {row[0] for row in result.all()}
|
return {row[0] for row in result.all()}
|
||||||
@@ -47,7 +64,7 @@ def logs_for_user(user: User, account_id: Optional[int] = None):
|
|||||||
stmt = select(MessageLog)
|
stmt = select(MessageLog)
|
||||||
if account_id is not None:
|
if account_id is not None:
|
||||||
stmt = stmt.where(MessageLog.account_id == account_id)
|
stmt = stmt.where(MessageLog.account_id == account_id)
|
||||||
if not is_admin(user.role):
|
if not has_global_scope(user.role):
|
||||||
owned = select(Account.id).where(Account.owner_id == user.id)
|
owned = select(Account.id).where(Account.owner_id == user.id)
|
||||||
stmt = stmt.where(MessageLog.account_id.in_(owned))
|
stmt = stmt.where(MessageLog.account_id.in_(owned))
|
||||||
return stmt
|
return stmt
|
||||||
@@ -57,7 +74,7 @@ def received_logs_for_user(user: User, account_id: Optional[int] = None):
|
|||||||
stmt = select(ReceivedMessageLog)
|
stmt = select(ReceivedMessageLog)
|
||||||
if account_id is not None:
|
if account_id is not None:
|
||||||
stmt = stmt.where(ReceivedMessageLog.account_id == account_id)
|
stmt = stmt.where(ReceivedMessageLog.account_id == account_id)
|
||||||
if not is_admin(user.role):
|
if not has_global_scope(user.role):
|
||||||
owned = select(Account.id).where(Account.owner_id == user.id)
|
owned = select(Account.id).where(Account.owner_id == user.id)
|
||||||
stmt = stmt.where(ReceivedMessageLog.account_id.in_(owned))
|
stmt = stmt.where(ReceivedMessageLog.account_id.in_(owned))
|
||||||
return stmt
|
return stmt
|
||||||
@@ -67,19 +84,23 @@ def rules_for_user(user: User, account_id: Optional[int] = None):
|
|||||||
stmt = select(AutoReplyRule)
|
stmt = select(AutoReplyRule)
|
||||||
if account_id is not None:
|
if account_id is not None:
|
||||||
stmt = stmt.where(AutoReplyRule.account_id == account_id)
|
stmt = stmt.where(AutoReplyRule.account_id == account_id)
|
||||||
if is_admin(user.role):
|
if has_global_scope(user.role):
|
||||||
return stmt
|
return stmt
|
||||||
owned = select(Account.id).where(Account.owner_id == user.id)
|
owned = select(Account.id).where(Account.owner_id == user.id)
|
||||||
return stmt.where(AutoReplyRule.account_id.in_(owned))
|
return stmt.where(AutoReplyRule.account_id.in_(owned))
|
||||||
|
|
||||||
|
|
||||||
async def get_accessible_rule(db: AsyncSession, user: User, rule_id: int, *, write: bool = False) -> AutoReplyRule:
|
async def get_accessible_rule(
|
||||||
|
db: AsyncSession, user: User, rule_id: int, *, write: bool = False
|
||||||
|
) -> AutoReplyRule:
|
||||||
result = await db.execute(select(AutoReplyRule).where(AutoReplyRule.id == rule_id))
|
result = await db.execute(select(AutoReplyRule).where(AutoReplyRule.id == rule_id))
|
||||||
rule = result.scalar_one_or_none()
|
rule = result.scalar_one_or_none()
|
||||||
if not rule:
|
if not rule:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="规则不存在")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="规则不存在")
|
||||||
|
|
||||||
if is_admin(user.role):
|
if has_global_scope(user.role):
|
||||||
|
if write and not has_permission(user.role, RULES_WRITE):
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="缺少权限:rules.write")
|
||||||
return rule
|
return rule
|
||||||
|
|
||||||
if rule.account_id is None:
|
if rule.account_id is None:
|
||||||
@@ -89,7 +110,7 @@ async def get_accessible_rule(db: AsyncSession, user: User, rule_id: int, *, wri
|
|||||||
if rule.owner_id and rule.owner_id != user.id and account.owner_id != user.id:
|
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="无权访问该规则")
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权访问该规则")
|
||||||
if write and not has_permission(user.role, RULES_WRITE):
|
if write and not has_permission(user.role, RULES_WRITE):
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="只读用户无法修改")
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="缺少权限:rules.write")
|
||||||
return rule
|
return rule
|
||||||
|
|
||||||
|
|
||||||
@@ -97,7 +118,7 @@ def system_logs_for_user(user: User, account_id: Optional[int] = None):
|
|||||||
stmt = select(SystemLog)
|
stmt = select(SystemLog)
|
||||||
if account_id is not None:
|
if account_id is not None:
|
||||||
stmt = stmt.where(SystemLog.account_id == account_id)
|
stmt = stmt.where(SystemLog.account_id == account_id)
|
||||||
if not is_admin(user.role):
|
if not has_global_scope(user.role):
|
||||||
owned = select(Account.id).where(Account.owner_id == user.id)
|
owned = select(Account.id).where(Account.owner_id == user.id)
|
||||||
stmt = stmt.where(
|
stmt = stmt.where(
|
||||||
or_(
|
or_(
|
||||||
@@ -106,3 +127,25 @@ def system_logs_for_user(user: User, account_id: Optional[int] = None):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
return stmt
|
return stmt
|
||||||
|
|
||||||
|
|
||||||
|
def users_for_manager(manager: User):
|
||||||
|
"""Admins see all users; others only see themselves and users they created."""
|
||||||
|
stmt = select(User)
|
||||||
|
if is_admin(manager.role):
|
||||||
|
return stmt
|
||||||
|
return stmt.where(or_(User.created_by == manager.id, User.id == manager.id))
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_user_manageable(manager: User, target: User) -> None:
|
||||||
|
"""Raise 403 unless manager may edit/delete target user."""
|
||||||
|
if is_admin(manager.role):
|
||||||
|
return
|
||||||
|
if target.id == manager.id:
|
||||||
|
return
|
||||||
|
if target.created_by == manager.id:
|
||||||
|
return
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="只能管理自己创建的用户",
|
||||||
|
)
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ from models.db_config import (
|
|||||||
from models.db_transfer import inspect_sqlite_source, migrate_sqlite_to_target
|
from models.db_transfer import inspect_sqlite_source, migrate_sqlite_to_target
|
||||||
from models.models import User
|
from models.models import User
|
||||||
from .dependencies import require_permission
|
from .dependencies import require_permission
|
||||||
from .permissions import SETTINGS_MANAGE
|
from .permissions import PAYMENTS_MANAGE, SETTINGS_DATABASE, SETTINGS_MANAGE
|
||||||
from .email_service import send_test_email
|
from .email_service import send_test_email
|
||||||
from .system_settings import (
|
from .system_settings import (
|
||||||
PASSWORD_PLACEHOLDER,
|
PASSWORD_PLACEHOLDER,
|
||||||
@@ -231,7 +231,7 @@ async def update_system_settings(
|
|||||||
@router.get("/payment", response_model=PaymentSettingsResponse)
|
@router.get("/payment", response_model=PaymentSettingsResponse)
|
||||||
async def get_payment_settings(
|
async def get_payment_settings(
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
_: User = Depends(require_permission(SETTINGS_MANAGE)),
|
_: User = Depends(require_permission(PAYMENTS_MANAGE)),
|
||||||
):
|
):
|
||||||
data = await load_settings(db)
|
data = await load_settings(db)
|
||||||
return PaymentSettingsResponse(**settings_to_payment_response(data))
|
return PaymentSettingsResponse(**settings_to_payment_response(data))
|
||||||
@@ -241,7 +241,7 @@ async def get_payment_settings(
|
|||||||
async def update_payment_settings(
|
async def update_payment_settings(
|
||||||
body: PaymentSettingsUpdate,
|
body: PaymentSettingsUpdate,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
_: User = Depends(require_permission(SETTINGS_MANAGE)),
|
_: User = Depends(require_permission(PAYMENTS_MANAGE)),
|
||||||
):
|
):
|
||||||
updates = body.model_dump(exclude_unset=True)
|
updates = body.model_dump(exclude_unset=True)
|
||||||
data = await save_settings(db, updates)
|
data = await save_settings(db, updates)
|
||||||
@@ -277,14 +277,14 @@ async def test_smtp_email(
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/database", response_model=DatabaseSettingsResponse)
|
@router.get("/database", response_model=DatabaseSettingsResponse)
|
||||||
async def get_database_settings(_: User = Depends(require_permission(SETTINGS_MANAGE))):
|
async def get_database_settings(_: User = Depends(require_permission(SETTINGS_DATABASE))):
|
||||||
return DatabaseSettingsResponse(**database_config_to_response())
|
return DatabaseSettingsResponse(**database_config_to_response())
|
||||||
|
|
||||||
|
|
||||||
@router.put("/database", response_model=MessageResponse)
|
@router.put("/database", response_model=MessageResponse)
|
||||||
async def update_database_settings(
|
async def update_database_settings(
|
||||||
body: DatabaseSettingsUpdate,
|
body: DatabaseSettingsUpdate,
|
||||||
_: User = Depends(require_permission(SETTINGS_MANAGE)),
|
_: User = Depends(require_permission(SETTINGS_DATABASE)),
|
||||||
):
|
):
|
||||||
payload = body.model_dump(exclude_unset=True)
|
payload = body.model_dump(exclude_unset=True)
|
||||||
if payload.get("db_password") in (None, "", DB_PASSWORD_PLACEHOLDER):
|
if payload.get("db_password") in (None, "", DB_PASSWORD_PLACEHOLDER):
|
||||||
@@ -303,7 +303,7 @@ async def update_database_settings(
|
|||||||
@router.post("/database/test", response_model=MessageResponse)
|
@router.post("/database/test", response_model=MessageResponse)
|
||||||
async def test_database_settings(
|
async def test_database_settings(
|
||||||
body: DatabaseTestRequest,
|
body: DatabaseTestRequest,
|
||||||
_: User = Depends(require_permission(SETTINGS_MANAGE)),
|
_: User = Depends(require_permission(SETTINGS_DATABASE)),
|
||||||
):
|
):
|
||||||
payload = body.model_dump(exclude_unset=True)
|
payload = body.model_dump(exclude_unset=True)
|
||||||
if payload.get("db_password") in (None, "", DB_PASSWORD_PLACEHOLDER):
|
if payload.get("db_password") in (None, "", DB_PASSWORD_PLACEHOLDER):
|
||||||
@@ -318,7 +318,7 @@ async def test_database_settings(
|
|||||||
@router.get("/database/migrate/preview", response_model=DatabaseMigratePreviewResponse)
|
@router.get("/database/migrate/preview", response_model=DatabaseMigratePreviewResponse)
|
||||||
async def preview_database_migration(
|
async def preview_database_migration(
|
||||||
source_db_path: str | None = None,
|
source_db_path: str | None = None,
|
||||||
_: User = Depends(require_permission(SETTINGS_MANAGE)),
|
_: User = Depends(require_permission(SETTINGS_DATABASE)),
|
||||||
):
|
):
|
||||||
return DatabaseMigratePreviewResponse(**await inspect_sqlite_source(source_db_path))
|
return DatabaseMigratePreviewResponse(**await inspect_sqlite_source(source_db_path))
|
||||||
|
|
||||||
@@ -326,7 +326,7 @@ async def preview_database_migration(
|
|||||||
@router.post("/database/migrate", response_model=DatabaseMigrateResponse)
|
@router.post("/database/migrate", response_model=DatabaseMigrateResponse)
|
||||||
async def migrate_database_data(
|
async def migrate_database_data(
|
||||||
body: DatabaseMigrateRequest,
|
body: DatabaseMigrateRequest,
|
||||||
_: User = Depends(require_permission(SETTINGS_MANAGE)),
|
_: User = Depends(require_permission(SETTINGS_DATABASE)),
|
||||||
):
|
):
|
||||||
payload = body.model_dump(exclude_unset=True)
|
payload = body.model_dump(exclude_unset=True)
|
||||||
clear_target = bool(payload.pop("clear_target", False))
|
clear_target = bool(payload.pop("clear_target", False))
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -12,7 +12,9 @@ from pydantic import BaseModel, Field
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from auth.dependencies import get_current_user, require_write
|
from auth.dependencies import get_current_user, require_link_cards_write
|
||||||
|
from auth.roles import has_permission
|
||||||
|
from auth.permissions import LINK_CARDS_WRITE
|
||||||
from link_cards import (
|
from link_cards import (
|
||||||
absolute_media_url,
|
absolute_media_url,
|
||||||
build_keywords,
|
build_keywords,
|
||||||
@@ -68,8 +70,8 @@ async def _get_owned_card(
|
|||||||
card = (await db.execute(stmt)).scalar_one_or_none()
|
card = (await db.execute(stmt)).scalar_one_or_none()
|
||||||
if not card or card.owner_id != user.id:
|
if not card or card.owner_id != user.id:
|
||||||
raise HTTPException(status_code=404, detail="卡片不存在")
|
raise HTTPException(status_code=404, detail="卡片不存在")
|
||||||
if write and user.role == "viewer":
|
if write and not has_permission(user.role, LINK_CARDS_WRITE):
|
||||||
raise HTTPException(status_code=403, detail="无写入权限")
|
raise HTTPException(status_code=403, detail="缺少权限:link_cards.write")
|
||||||
return card
|
return card
|
||||||
|
|
||||||
|
|
||||||
@@ -93,7 +95,7 @@ def _card_response(card: LinkCardPage, request: Request) -> LinkCardResponse:
|
|||||||
async def upload_link_card_image(
|
async def upload_link_card_image(
|
||||||
request: Request,
|
request: Request,
|
||||||
file: UploadFile = File(...),
|
file: UploadFile = File(...),
|
||||||
user: User = Depends(require_write),
|
user: User = Depends(require_link_cards_write),
|
||||||
):
|
):
|
||||||
if not file.content_type or not file.content_type.startswith("image/"):
|
if not file.content_type or not file.content_type.startswith("image/"):
|
||||||
raise HTTPException(status_code=400, detail="仅支持上传图片文件")
|
raise HTTPException(status_code=400, detail="仅支持上传图片文件")
|
||||||
@@ -137,7 +139,7 @@ async def upsert_link_card(
|
|||||||
body: LinkCardUpsert,
|
body: LinkCardUpsert,
|
||||||
request: Request,
|
request: Request,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
user: User = Depends(require_write),
|
user: User = Depends(require_link_cards_write),
|
||||||
):
|
):
|
||||||
title = body.title.strip()
|
title = body.title.strip()
|
||||||
content = (body.content or "").strip()
|
content = (body.content or "").strip()
|
||||||
|
|||||||
+41
-33
@@ -44,11 +44,15 @@ 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 link_cards_router import router as link_cards_router, UPLOAD_DIR as LINK_CARD_UPLOAD_DIR
|
||||||
from auth.dependencies import (
|
from auth.dependencies import (
|
||||||
get_current_user,
|
get_current_user,
|
||||||
require_accounts_write,
|
require_accounts_cookie,
|
||||||
require_admin,
|
require_accounts_create,
|
||||||
|
require_accounts_delete,
|
||||||
|
require_accounts_start,
|
||||||
|
require_accounts_stop,
|
||||||
|
require_accounts_update,
|
||||||
require_messages_write,
|
require_messages_write,
|
||||||
require_rules_write,
|
require_rules_write,
|
||||||
require_write,
|
require_system_logs_clear,
|
||||||
)
|
)
|
||||||
from auth.account_limits import ensure_can_add_account
|
from auth.account_limits import ensure_can_add_account
|
||||||
from auth.scopes import (
|
from auth.scopes import (
|
||||||
@@ -61,7 +65,7 @@ from auth.scopes import (
|
|||||||
received_logs_for_user,
|
received_logs_for_user,
|
||||||
system_logs_for_user,
|
system_logs_for_user,
|
||||||
)
|
)
|
||||||
from auth.roles import has_permission, is_admin
|
from auth.roles import has_global_scope, has_permission, is_admin
|
||||||
from auth.permissions import LOGS_READ, RECEIVED_MESSAGES_READ, SYSTEM_LOGS_READ
|
from auth.permissions import LOGS_READ, RECEIVED_MESSAGES_READ, SYSTEM_LOGS_READ
|
||||||
from auth.passwords import hash_password
|
from auth.passwords import hash_password
|
||||||
from rpa_engine.batch_start import BatchStartQueue
|
from rpa_engine.batch_start import BatchStartQueue
|
||||||
@@ -1413,7 +1417,7 @@ async def get_account_options(
|
|||||||
Account.reply_cooldown_seconds,
|
Account.reply_cooldown_seconds,
|
||||||
Account.quota_disabled,
|
Account.quota_disabled,
|
||||||
)
|
)
|
||||||
if not is_admin(user.role):
|
if not has_global_scope(user.role):
|
||||||
stmt = stmt.where(Account.owner_id == user.id)
|
stmt = stmt.where(Account.owner_id == user.id)
|
||||||
|
|
||||||
rows = (await db.execute(stmt.order_by(Account.id.asc()))).all()
|
rows = (await db.execute(stmt.order_by(Account.id.asc()))).all()
|
||||||
@@ -1632,9 +1636,9 @@ async def update_account(
|
|||||||
account_id: int,
|
account_id: int,
|
||||||
body: AccountUpdate,
|
body: AccountUpdate,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
user: User = Depends(require_accounts_write),
|
user: User = Depends(require_accounts_update),
|
||||||
):
|
):
|
||||||
account = await get_owned_account(db, user, account_id, write=True)
|
account = await get_owned_account(db, user, account_id, write=True, write_permission="accounts.update")
|
||||||
follow_config_changed = bool(
|
follow_config_changed = bool(
|
||||||
{"follow_welcome_enabled", "follow_welcome_content"}
|
{"follow_welcome_enabled", "follow_welcome_content"}
|
||||||
& set(body.model_fields_set)
|
& set(body.model_fields_set)
|
||||||
@@ -1700,7 +1704,7 @@ async def get_reply_queue_summaries(
|
|||||||
allowed_ids = await owned_account_ids(db, user)
|
allowed_ids = await owned_account_ids(db, user)
|
||||||
worker_entries = list(manager.workers.items())
|
worker_entries = list(manager.workers.items())
|
||||||
else:
|
else:
|
||||||
if is_admin(user.role):
|
if has_global_scope(user.role):
|
||||||
allowed_ids = set(requested_ids)
|
allowed_ids = set(requested_ids)
|
||||||
elif requested_ids:
|
elif requested_ids:
|
||||||
owned_result = await db.execute(
|
owned_result = await db.execute(
|
||||||
@@ -1793,7 +1797,7 @@ async def send_account_queued_reply_now(
|
|||||||
user: User = Depends(require_messages_write),
|
user: User = Depends(require_messages_write),
|
||||||
):
|
):
|
||||||
"""将指定任务原子移入紧急队列,并把它后面的普通任务前移一槽。"""
|
"""将指定任务原子移入紧急队列,并把它后面的普通任务前移一槽。"""
|
||||||
await get_owned_account(db, user, account_id, write=True)
|
await get_owned_account(db, user, account_id, write=True, write_permission="messages.write")
|
||||||
worker = manager.workers.get(account_id)
|
worker = manager.workers.get(account_id)
|
||||||
service = worker._im_service if worker else None
|
service = worker._im_service if worker else None
|
||||||
if not worker or not worker.is_running or not service or not service._running:
|
if not worker or not worker.is_running or not service or not service._running:
|
||||||
@@ -1888,7 +1892,7 @@ async def get_account_cookie(
|
|||||||
account_id: int,
|
account_id: int,
|
||||||
purpose: Optional[str] = None,
|
purpose: Optional[str] = None,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
user: User = Depends(get_current_user),
|
user: User = Depends(require_accounts_cookie),
|
||||||
):
|
):
|
||||||
"""Read a Cookie for management or for legacy desktop login clients.
|
"""Read a Cookie for management or for legacy desktop login clients.
|
||||||
|
|
||||||
@@ -1921,7 +1925,7 @@ async def get_account_cookie(
|
|||||||
async def get_desktop_login_credential(
|
async def get_desktop_login_credential(
|
||||||
account_id: int,
|
account_id: int,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
user: User = Depends(get_current_user),
|
user: User = Depends(require_accounts_cookie),
|
||||||
):
|
):
|
||||||
"""Return credentials only when the selected Douyin identity is verified.
|
"""Return credentials only when the selected Douyin identity is verified.
|
||||||
|
|
||||||
@@ -1945,7 +1949,7 @@ async def update_account_cookie(
|
|||||||
account_id: int,
|
account_id: int,
|
||||||
body: AccountCookieUpdate,
|
body: AccountCookieUpdate,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
user: User = Depends(require_accounts_write),
|
user: User = Depends(require_accounts_cookie),
|
||||||
):
|
):
|
||||||
# Validate first: malformed input must not take a healthy hosted account
|
# Validate first: malformed input must not take a healthy hosted account
|
||||||
# offline. Filesystem and database mutations happen only after the worker
|
# offline. Filesystem and database mutations happen only after the worker
|
||||||
@@ -1960,7 +1964,7 @@ async def update_account_cookie(
|
|||||||
# preparations. Keep the lock until the new Cookie and cleared identity
|
# preparations. Keep the lock until the new Cookie and cleared identity
|
||||||
# are committed so no worker can start in the stop/commit gap.
|
# are committed so no worker can start in the stop/commit gap.
|
||||||
async with manager.preparation_lock(account_id):
|
async with manager.preparation_lock(account_id):
|
||||||
account = await get_owned_account(db, user, account_id, write=True)
|
account = await get_owned_account(db, user, account_id, write=True, write_permission="accounts.cookie")
|
||||||
await _release_db_connection(db)
|
await _release_db_connection(db)
|
||||||
await batch_start_queue.cancel_account(account_id)
|
await batch_start_queue.cancel_account(account_id)
|
||||||
await manager.stop_worker(account_id)
|
await manager.stop_worker(account_id)
|
||||||
@@ -1994,13 +1998,13 @@ async def update_account_cookie(
|
|||||||
async def delete_account_cookie(
|
async def delete_account_cookie(
|
||||||
account_id: int,
|
account_id: int,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
user: User = Depends(require_accounts_write),
|
user: User = Depends(require_accounts_cookie),
|
||||||
):
|
):
|
||||||
# Deleting credentials uses the same preparation lock as starting a
|
# Deleting credentials uses the same preparation lock as starting a
|
||||||
# worker, preventing a new worker from appearing after stop_worker but
|
# worker, preventing a new worker from appearing after stop_worker but
|
||||||
# before the cleared credentials are committed.
|
# before the cleared credentials are committed.
|
||||||
async with manager.preparation_lock(account_id):
|
async with manager.preparation_lock(account_id):
|
||||||
account = await get_owned_account(db, user, account_id, write=True)
|
account = await get_owned_account(db, user, account_id, write=True, write_permission="accounts.cookie")
|
||||||
await _release_db_connection(db)
|
await _release_db_connection(db)
|
||||||
await batch_start_queue.cancel_account(account_id)
|
await batch_start_queue.cancel_account(account_id)
|
||||||
await manager.stop_worker(account_id)
|
await manager.stop_worker(account_id)
|
||||||
@@ -2023,7 +2027,7 @@ async def delete_account_cookie(
|
|||||||
async def create_account(
|
async def create_account(
|
||||||
account_in: AccountCreate,
|
account_in: AccountCreate,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
user: User = Depends(require_accounts_write),
|
user: User = Depends(require_accounts_create),
|
||||||
):
|
):
|
||||||
cookie_data = (account_in.cookie_data or "").strip()
|
cookie_data = (account_in.cookie_data or "").strip()
|
||||||
standard_json_str = None
|
standard_json_str = None
|
||||||
@@ -2061,9 +2065,9 @@ async def create_account(
|
|||||||
async def delete_account(
|
async def delete_account(
|
||||||
account_id: int,
|
account_id: int,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
user: User = Depends(require_accounts_write),
|
user: User = Depends(require_accounts_delete),
|
||||||
):
|
):
|
||||||
await get_owned_account(db, user, account_id, write=True)
|
await get_owned_account(db, user, account_id, write=True, write_permission="accounts.delete")
|
||||||
# 停止运行中的任务
|
# 停止运行中的任务
|
||||||
await _release_db_connection(db)
|
await _release_db_connection(db)
|
||||||
await batch_start_queue.cancel_account(account_id)
|
await batch_start_queue.cancel_account(account_id)
|
||||||
@@ -2079,7 +2083,7 @@ async def delete_account(
|
|||||||
async def validate_account_credential(
|
async def validate_account_credential(
|
||||||
account_id: int,
|
account_id: int,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
user: User = Depends(get_current_user),
|
user: User = Depends(require_accounts_cookie),
|
||||||
):
|
):
|
||||||
account = await get_owned_account(db, user, account_id)
|
account = await get_owned_account(db, user, account_id)
|
||||||
|
|
||||||
@@ -2096,9 +2100,9 @@ async def validate_account_credential(
|
|||||||
async def reset_account_credentials(
|
async def reset_account_credentials(
|
||||||
account_id: int,
|
account_id: int,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
user: User = Depends(require_accounts_write),
|
user: User = Depends(require_accounts_cookie),
|
||||||
):
|
):
|
||||||
await get_owned_account(db, user, account_id, write=True)
|
await get_owned_account(db, user, account_id, write=True, write_permission="accounts.cookie")
|
||||||
await _release_db_connection(db)
|
await _release_db_connection(db)
|
||||||
await batch_start_queue.cancel_account(account_id)
|
await batch_start_queue.cancel_account(account_id)
|
||||||
account = await _reset_account_credentials(account_id, db)
|
account = await _reset_account_credentials(account_id, db)
|
||||||
@@ -2251,9 +2255,11 @@ async def start_account_rpa(
|
|||||||
account_id: int,
|
account_id: int,
|
||||||
body: StartAccountRequest = StartAccountRequest(),
|
body: StartAccountRequest = StartAccountRequest(),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
user: User = Depends(require_accounts_write),
|
user: User = Depends(require_accounts_start),
|
||||||
):
|
):
|
||||||
account = await get_owned_account(db, user, account_id, write=True)
|
account = await get_owned_account(
|
||||||
|
db, user, account_id, write=True, write_permission="accounts.start"
|
||||||
|
)
|
||||||
# Cancelling waits for an in-flight queued start, and the preparation lock
|
# Cancelling waits for an in-flight queued start, and the preparation lock
|
||||||
# waits for whichever start owns this account. Neither may keep a pooled
|
# waits for whichever start owns this account. Neither may keep a pooled
|
||||||
# connection checked out while it waits.
|
# connection checked out while it waits.
|
||||||
@@ -2267,7 +2273,7 @@ async def start_account_rpa(
|
|||||||
async def submit_account_start_batch(
|
async def submit_account_start_batch(
|
||||||
body: BatchStartRequest,
|
body: BatchStartRequest,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
user: User = Depends(require_accounts_write),
|
user: User = Depends(require_accounts_start),
|
||||||
):
|
):
|
||||||
requested_ids = list(
|
requested_ids = list(
|
||||||
dict.fromkeys(int(value) for value in body.account_ids if int(value) > 0)
|
dict.fromkeys(int(value) for value in body.account_ids if int(value) > 0)
|
||||||
@@ -2280,7 +2286,7 @@ async def submit_account_start_batch(
|
|||||||
# The submit path needs only ids and the disabled flag. Do not hydrate
|
# The submit path needs only ids and the disabled flag. Do not hydrate
|
||||||
# every account's large cookie/session/QR columns just to enqueue ids.
|
# every account's large cookie/session/QR columns just to enqueue ids.
|
||||||
candidate_stmt = select(Account.id, Account.quota_disabled)
|
candidate_stmt = select(Account.id, Account.quota_disabled)
|
||||||
if not is_admin(user.role):
|
if not has_global_scope(user.role):
|
||||||
candidate_stmt = candidate_stmt.where(Account.owner_id == user.id)
|
candidate_stmt = candidate_stmt.where(Account.owner_id == user.id)
|
||||||
if not body.all_accounts:
|
if not body.all_accounts:
|
||||||
candidate_stmt = candidate_stmt.where(Account.id.in_(requested_ids))
|
candidate_stmt = candidate_stmt.where(Account.id.in_(requested_ids))
|
||||||
@@ -2331,9 +2337,11 @@ async def get_account_start_batch(
|
|||||||
async def stop_account_rpa(
|
async def stop_account_rpa(
|
||||||
account_id: int,
|
account_id: int,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
user: User = Depends(require_accounts_write),
|
user: User = Depends(require_accounts_stop),
|
||||||
):
|
):
|
||||||
account = await get_owned_account(db, user, account_id, write=True)
|
account = await get_owned_account(
|
||||||
|
db, user, account_id, write=True, write_permission="accounts.stop"
|
||||||
|
)
|
||||||
|
|
||||||
# Cancelling drains an in-flight queued start, which can take as long as
|
# Cancelling drains an in-flight queued start, which can take as long as
|
||||||
# the batch per-account deadline. Do not hold a pooled connection for it.
|
# the batch per-account deadline. Do not hold a pooled connection for it.
|
||||||
@@ -2415,7 +2423,7 @@ async def create_rule(
|
|||||||
rule_in.keyword = ""
|
rule_in.keyword = ""
|
||||||
if rule_in.account_id is None:
|
if rule_in.account_id is None:
|
||||||
raise HTTPException(status_code=400, detail="请选择适用账号,每条规则必须绑定一个托管账号")
|
raise HTTPException(status_code=400, detail="请选择适用账号,每条规则必须绑定一个托管账号")
|
||||||
await get_owned_account(db, user, rule_in.account_id, write=True)
|
await get_owned_account(db, user, rule_in.account_id, write=True, write_permission="rules.write")
|
||||||
|
|
||||||
sort_stmt = select(func.max(AutoReplyRule.sort_order)).where(
|
sort_stmt = select(func.max(AutoReplyRule.sort_order)).where(
|
||||||
AutoReplyRule.account_id == rule_in.account_id
|
AutoReplyRule.account_id == rule_in.account_id
|
||||||
@@ -2451,7 +2459,7 @@ async def update_rule(
|
|||||||
rule_in.keyword = ""
|
rule_in.keyword = ""
|
||||||
if rule_in.account_id is None:
|
if rule_in.account_id is None:
|
||||||
raise HTTPException(status_code=400, detail="请选择适用账号,每条规则必须绑定一个托管账号")
|
raise HTTPException(status_code=400, detail="请选择适用账号,每条规则必须绑定一个托管账号")
|
||||||
await get_owned_account(db, user, rule_in.account_id, write=True)
|
await get_owned_account(db, user, rule_in.account_id, write=True, write_permission="rules.write")
|
||||||
|
|
||||||
rule.account_id = rule_in.account_id
|
rule.account_id = rule_in.account_id
|
||||||
rule.keyword = rule_in.keyword
|
rule.keyword = rule_in.keyword
|
||||||
@@ -2637,7 +2645,7 @@ async def get_system_logs(
|
|||||||
category=category,
|
category=category,
|
||||||
limit=max(1, min(int(limit or 200), 1000)),
|
limit=max(1, min(int(limit or 200), 1000)),
|
||||||
)
|
)
|
||||||
if not is_admin(user.role):
|
if not has_global_scope(user.role):
|
||||||
allowed = await owned_account_ids(db, user)
|
allowed = await owned_account_ids(db, user)
|
||||||
entries = [e for e in entries if e.get("account_id") in allowed]
|
entries = [e for e in entries if e.get("account_id") in allowed]
|
||||||
return [SystemLogResponse(**e) for e in entries]
|
return [SystemLogResponse(**e) for e in entries]
|
||||||
@@ -2646,7 +2654,7 @@ async def get_system_logs(
|
|||||||
@app.delete("/api/system-logs")
|
@app.delete("/api/system-logs")
|
||||||
async def clear_system_logs(
|
async def clear_system_logs(
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
_: User = Depends(require_admin),
|
_: User = Depends(require_system_logs_clear),
|
||||||
):
|
):
|
||||||
"""清空系统诊断日志(内存缓冲区 + 数据库历史)。"""
|
"""清空系统诊断日志(内存缓冲区 + 数据库历史)。"""
|
||||||
system_logger.clear()
|
system_logger.clear()
|
||||||
@@ -2712,7 +2720,7 @@ async def upload_message_image(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
user: User = Depends(require_messages_write),
|
user: User = Depends(require_messages_write),
|
||||||
):
|
):
|
||||||
account = await get_owned_account(db, user, account_id, write=True)
|
account = await get_owned_account(db, user, account_id, write=True, write_permission="messages.write")
|
||||||
if not file.content_type or not file.content_type.startswith("image/"):
|
if not file.content_type or not file.content_type.startswith("image/"):
|
||||||
raise HTTPException(status_code=400, detail="仅支持上传图片文件")
|
raise HTTPException(status_code=400, detail="仅支持上传图片文件")
|
||||||
|
|
||||||
@@ -2803,7 +2811,7 @@ async def send_account_message(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
user: User = Depends(require_messages_write),
|
user: User = Depends(require_messages_write),
|
||||||
):
|
):
|
||||||
account = await get_owned_account(db, user, account_id, write=True)
|
account = await get_owned_account(db, user, account_id, write=True, write_permission="messages.write")
|
||||||
|
|
||||||
content = normalize_outgoing_content(
|
content = normalize_outgoing_content(
|
||||||
content=body.content or "",
|
content=body.content or "",
|
||||||
|
|||||||
@@ -275,6 +275,12 @@ def migrate_users_table(conn) -> None:
|
|||||||
"max_accounts",
|
"max_accounts",
|
||||||
{"default": "ALTER TABLE users ADD COLUMN max_accounts INTEGER DEFAULT 3"},
|
{"default": "ALTER TABLE users ADD COLUMN max_accounts INTEGER DEFAULT 3"},
|
||||||
)
|
)
|
||||||
|
add_column_if_missing(
|
||||||
|
conn,
|
||||||
|
"users",
|
||||||
|
"created_by",
|
||||||
|
{"default": "ALTER TABLE users ADD COLUMN created_by INTEGER"},
|
||||||
|
)
|
||||||
cols = _table_columns(conn, "users")
|
cols = _table_columns(conn, "users")
|
||||||
if not cols:
|
if not cols:
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ class Role(Base):
|
|||||||
"""Assignable role with a fixed permission-code list.
|
"""Assignable role with a fixed permission-code list.
|
||||||
|
|
||||||
``users.role`` stores ``Role.code``. Built-in roles are seeded on startup;
|
``users.role`` stores ``Role.code``. Built-in roles are seeded on startup;
|
||||||
custom roles are owned by admins via the users.manage permission.
|
custom roles are owned by admins via the users.manage / roles.manage permissions.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__tablename__ = "roles"
|
__tablename__ = "roles"
|
||||||
@@ -40,6 +40,13 @@ class User(Base):
|
|||||||
email_verified = Column(Boolean, default=False)
|
email_verified = Column(Boolean, default=False)
|
||||||
email_verified_at = Column(DateTime, nullable=True)
|
email_verified_at = Column(DateTime, nullable=True)
|
||||||
max_accounts = Column(Integer, default=3)
|
max_accounts = Column(Integer, default=3)
|
||||||
|
# User who created this account via「用户管理」; null for self-register / seeded.
|
||||||
|
created_by = Column(
|
||||||
|
Integer,
|
||||||
|
ForeignKey("users.id", ondelete="SET NULL"),
|
||||||
|
nullable=True,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
created_at = Column(DateTime, default=datetime.utcnow)
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ from fastapi.responses import PlainTextResponse
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from auth.dependencies import get_current_user, require_permission
|
from auth.dependencies import get_current_user, require_permission
|
||||||
from auth.permissions import PAYMENTS_MANAGE
|
from auth.permissions import ORDERS_CREATE, ORDERS_READ, PAYMENTS_MANAGE
|
||||||
|
from auth.roles import has_permission
|
||||||
from auth.system_settings import load_settings
|
from auth.system_settings import load_settings
|
||||||
from models.database import get_db
|
from models.database import get_db
|
||||||
from models.models import User
|
from models.models import User
|
||||||
@@ -21,6 +22,18 @@ from .schemas import (
|
|||||||
router = APIRouter(prefix="/api/payments", tags=["payments"])
|
router = APIRouter(prefix="/api/payments", tags=["payments"])
|
||||||
|
|
||||||
|
|
||||||
|
def _require_orders_access(user: User) -> None:
|
||||||
|
if has_permission(user.role, ORDERS_READ) or has_permission(user.role, PAYMENTS_MANAGE):
|
||||||
|
return
|
||||||
|
raise HTTPException(status_code=403, detail="缺少权限:orders.read")
|
||||||
|
|
||||||
|
|
||||||
|
def _require_orders_create(user: User) -> None:
|
||||||
|
if has_permission(user.role, ORDERS_CREATE) or has_permission(user.role, PAYMENTS_MANAGE):
|
||||||
|
return
|
||||||
|
raise HTTPException(status_code=403, detail="缺少权限:orders.create")
|
||||||
|
|
||||||
|
|
||||||
@router.get("/config", response_model=PaymentConfigResponse)
|
@router.get("/config", response_model=PaymentConfigResponse)
|
||||||
async def get_payment_config(
|
async def get_payment_config(
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
@@ -36,6 +49,7 @@ async def create_payment_order(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
user: User = Depends(get_current_user),
|
user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
|
_require_orders_create(user)
|
||||||
order, demo_mode = await service.create_order(db, user, body.slots, body.channel)
|
order, demo_mode = await service.create_order(db, user, body.slots, body.channel)
|
||||||
return PaymentOrderResponse(**service.order_to_dict(order, demo_mode=demo_mode))
|
return PaymentOrderResponse(**service.order_to_dict(order, demo_mode=demo_mode))
|
||||||
|
|
||||||
@@ -49,6 +63,7 @@ async def list_payment_orders(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
user: User = Depends(get_current_user),
|
user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
|
_require_orders_access(user)
|
||||||
if status and status not in service.ORDER_STATUSES:
|
if status and status not in service.ORDER_STATUSES:
|
||||||
raise HTTPException(status_code=400, detail="无效的订单状态")
|
raise HTTPException(status_code=400, detail="无效的订单状态")
|
||||||
data = await service.list_orders(
|
data = await service.list_orders(
|
||||||
@@ -68,6 +83,7 @@ async def get_payment_order(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
user: User = Depends(get_current_user),
|
user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
|
_require_orders_access(user)
|
||||||
settings = await load_settings(db)
|
settings = await load_settings(db)
|
||||||
order = await service.get_user_order(db, user, order_no)
|
order = await service.get_user_order(db, user, order_no)
|
||||||
demo_mode = settings.payment_demo_mode and not settings.payment_channel_available(order.channel)
|
demo_mode = settings.payment_demo_mode and not settings.payment_channel_available(order.channel)
|
||||||
@@ -80,6 +96,7 @@ async def simulate_payment_order(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
user: User = Depends(get_current_user),
|
user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
|
_require_orders_create(user)
|
||||||
settings = await load_settings(db)
|
settings = await load_settings(db)
|
||||||
order = await service.simulate_pay(db, user, order_no)
|
order = await service.simulate_pay(db, user, order_no)
|
||||||
return PaymentOrderResponse(**service.order_to_dict(order, demo_mode=settings.payment_demo_mode))
|
return PaymentOrderResponse(**service.order_to_dict(order, demo_mode=settings.payment_demo_mode))
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ from sqlalchemy import func, select, update
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from auth.account_quota import default_stop_worker, sync_user_account_quota
|
from auth.account_quota import default_stop_worker, sync_user_account_quota
|
||||||
from auth.roles import is_admin
|
from auth.roles import has_global_scope, has_permission, is_admin
|
||||||
|
from auth.permissions import ORDERS_READ, PAYMENTS_MANAGE
|
||||||
from auth.system_settings import SystemSettingsData, load_settings
|
from auth.system_settings import SystemSettingsData, load_settings
|
||||||
from models.models import PaymentOrder, User
|
from models.models import PaymentOrder, User
|
||||||
from . import alipay, wechat
|
from . import alipay, wechat
|
||||||
@@ -347,7 +348,12 @@ async def list_orders(
|
|||||||
page_size = max(1, min(100, page_size))
|
page_size = max(1, min(100, page_size))
|
||||||
|
|
||||||
filters = []
|
filters = []
|
||||||
if not is_admin(current_user.role):
|
# Global order list for built-in admin, payments.manage, or data.scope_all.
|
||||||
|
if not (
|
||||||
|
is_admin(current_user.role)
|
||||||
|
or has_permission(current_user.role, PAYMENTS_MANAGE)
|
||||||
|
or has_global_scope(current_user.role)
|
||||||
|
):
|
||||||
filters.append(PaymentOrder.user_id == current_user.id)
|
filters.append(PaymentOrder.user_id == current_user.id)
|
||||||
if status:
|
if status:
|
||||||
filters.append(PaymentOrder.status == status)
|
filters.append(PaymentOrder.status == status)
|
||||||
|
|||||||
@@ -19,7 +19,13 @@ if str(BACKEND_DIR) not in sys.path:
|
|||||||
|
|
||||||
from models.models import Base, Role, User # noqa: E402
|
from models.models import Base, Role, User # noqa: E402
|
||||||
from auth.passwords import hash_password # noqa: E402
|
from auth.passwords import hash_password # noqa: E402
|
||||||
from auth.permissions import ACCOUNTS_WRITE, ALL_PERMISSIONS, MENU_USERS # noqa: E402
|
from auth.permissions import ( # noqa: E402
|
||||||
|
ACCOUNTS_WRITE,
|
||||||
|
ALL_PERMISSIONS,
|
||||||
|
MENU_USERS,
|
||||||
|
USERS_MANAGE,
|
||||||
|
expand_paired_permissions,
|
||||||
|
)
|
||||||
from auth.role_service import ( # noqa: E402
|
from auth.role_service import ( # noqa: E402
|
||||||
create_role,
|
create_role,
|
||||||
delete_role,
|
delete_role,
|
||||||
@@ -109,8 +115,35 @@ class RolesRbacTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
payload = await auth_router._build_user_response(self.db, user)
|
payload = await auth_router._build_user_response(self.db, user)
|
||||||
self.assertEqual(payload.role_label, "运营")
|
self.assertEqual(payload.role_label, "运营")
|
||||||
self.assertFalse(payload.is_admin)
|
self.assertFalse(payload.is_admin)
|
||||||
self.assertIn(ACCOUNTS_WRITE, payload.permissions)
|
self.assertIn("accounts.create", payload.permissions)
|
||||||
self.assertNotIn(MENU_USERS, payload.permissions)
|
self.assertNotIn(MENU_USERS, payload.permissions)
|
||||||
|
self.assertNotIn("data.scope_all", payload.permissions)
|
||||||
|
|
||||||
|
async def test_legacy_accounts_write_implies_granular(self):
|
||||||
|
created = await create_role(
|
||||||
|
self.db,
|
||||||
|
code="legacy_ops",
|
||||||
|
label="旧版运营",
|
||||||
|
description=None,
|
||||||
|
permissions=[ACCOUNTS_WRITE, "menu.accounts"],
|
||||||
|
)
|
||||||
|
self.assertTrue(has_permission("legacy_ops", "accounts.start"))
|
||||||
|
self.assertTrue(has_permission("legacy_ops", "accounts.cookie"))
|
||||||
|
self.assertIn(ACCOUNTS_WRITE, created.permissions)
|
||||||
|
|
||||||
|
async def test_data_scope_all_for_custom_role(self):
|
||||||
|
from auth.roles import has_global_scope
|
||||||
|
|
||||||
|
await create_role(
|
||||||
|
self.db,
|
||||||
|
code="auditor",
|
||||||
|
label="审计",
|
||||||
|
description=None,
|
||||||
|
permissions=["menu.accounts", "data.scope_all"],
|
||||||
|
)
|
||||||
|
self.assertTrue(has_global_scope("auditor"))
|
||||||
|
self.assertFalse(has_global_scope("operator"))
|
||||||
|
self.assertTrue(has_global_scope("admin"))
|
||||||
|
|
||||||
async def test_last_admin_cannot_be_demoted(self):
|
async def test_last_admin_cannot_be_demoted(self):
|
||||||
admin = User(
|
admin = User(
|
||||||
@@ -128,6 +161,31 @@ class RolesRbacTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
await guard_last_admin_change(self.db, user=admin, new_role="operator")
|
await guard_last_admin_change(self.db, user=admin, new_role="operator")
|
||||||
self.assertEqual(caught.exception.status_code, 400)
|
self.assertEqual(caught.exception.status_code, 400)
|
||||||
|
|
||||||
|
async def test_menu_action_pairs_expand(self):
|
||||||
|
expanded = expand_paired_permissions([MENU_USERS])
|
||||||
|
self.assertIn(MENU_USERS, expanded)
|
||||||
|
self.assertIn(USERS_MANAGE, expanded)
|
||||||
|
created = await create_role(
|
||||||
|
self.db,
|
||||||
|
code="hr_desk",
|
||||||
|
label="人事台",
|
||||||
|
description=None,
|
||||||
|
permissions=[MENU_USERS],
|
||||||
|
)
|
||||||
|
self.assertIn(USERS_MANAGE, created.permissions)
|
||||||
|
self.assertNotIn("menu.roles", created.permissions)
|
||||||
|
self.assertNotIn("roles.manage", created.permissions)
|
||||||
|
|
||||||
|
roles_only = await create_role(
|
||||||
|
self.db,
|
||||||
|
code="role_editor",
|
||||||
|
label="角色编辑",
|
||||||
|
description=None,
|
||||||
|
permissions=["menu.roles"],
|
||||||
|
)
|
||||||
|
self.assertIn("roles.manage", roles_only.permissions)
|
||||||
|
self.assertNotIn(USERS_MANAGE, roles_only.permissions)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
MessageOutlined,
|
MessageOutlined,
|
||||||
BugOutlined,
|
BugOutlined,
|
||||||
TeamOutlined,
|
TeamOutlined,
|
||||||
|
SafetyCertificateOutlined,
|
||||||
LogoutOutlined,
|
LogoutOutlined,
|
||||||
ControlOutlined,
|
ControlOutlined,
|
||||||
PayCircleOutlined,
|
PayCircleOutlined,
|
||||||
@@ -32,6 +33,7 @@ const ICON_MAP = {
|
|||||||
MessageOutlined,
|
MessageOutlined,
|
||||||
BugOutlined,
|
BugOutlined,
|
||||||
TeamOutlined,
|
TeamOutlined,
|
||||||
|
SafetyCertificateOutlined,
|
||||||
ControlOutlined,
|
ControlOutlined,
|
||||||
PayCircleOutlined,
|
PayCircleOutlined,
|
||||||
UnorderedListOutlined,
|
UnorderedListOutlined,
|
||||||
|
|||||||
@@ -21,6 +21,14 @@ const props = defineProps({
|
|||||||
showHeader: {
|
showHeader: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: true
|
default: true
|
||||||
|
},
|
||||||
|
readonly: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
canUploadCards: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -39,10 +47,12 @@ const typeOptions = replyTypeOptions.map((opt) => ({
|
|||||||
const cardUploading = ref({})
|
const cardUploading = ref({})
|
||||||
|
|
||||||
const addReplyItem = () => {
|
const addReplyItem = () => {
|
||||||
|
if (props.readonly) return
|
||||||
replyItems.value = [...replyItems.value, emptyReplyForm()]
|
replyItems.value = [...replyItems.value, emptyReplyForm()]
|
||||||
}
|
}
|
||||||
|
|
||||||
const removeReplyItem = (index) => {
|
const removeReplyItem = (index) => {
|
||||||
|
if (props.readonly) return
|
||||||
if (replyItems.value.length <= 1) {
|
if (replyItems.value.length <= 1) {
|
||||||
message.warning('至少保留一条回复消息')
|
message.warning('至少保留一条回复消息')
|
||||||
return
|
return
|
||||||
@@ -53,6 +63,7 @@ const removeReplyItem = (index) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const updateReplyField = (index, field, value) => {
|
const updateReplyField = (index, field, value) => {
|
||||||
|
if (props.readonly) return
|
||||||
const next = replyItems.value.map((item, i) =>
|
const next = replyItems.value.map((item, i) =>
|
||||||
i === index ? { ...item, [field]: value } : item
|
i === index ? { ...item, [field]: value } : item
|
||||||
)
|
)
|
||||||
@@ -60,6 +71,7 @@ const updateReplyField = (index, field, value) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const updateReplyFields = (index, fields) => {
|
const updateReplyFields = (index, fields) => {
|
||||||
|
if (props.readonly) return
|
||||||
const next = replyItems.value.map((item, i) =>
|
const next = replyItems.value.map((item, i) =>
|
||||||
i === index ? { ...item, ...fields } : item
|
i === index ? { ...item, ...fields } : item
|
||||||
)
|
)
|
||||||
@@ -67,6 +79,11 @@ const updateReplyFields = (index, fields) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const uploadCardImage = async (index, options) => {
|
const uploadCardImage = async (index, options) => {
|
||||||
|
if (props.readonly || !props.canUploadCards) {
|
||||||
|
message.warning('当前账号无卡片上传权限')
|
||||||
|
options?.onError?.(new Error('no permission'))
|
||||||
|
return
|
||||||
|
}
|
||||||
const { file, onSuccess, onError } = options
|
const { file, onSuccess, onError } = options
|
||||||
cardUploading.value = { ...cardUploading.value, [index]: true }
|
cardUploading.value = { ...cardUploading.value, [index]: true }
|
||||||
try {
|
try {
|
||||||
@@ -104,7 +121,13 @@ const copyPageUrl = async (url) => {
|
|||||||
<div class="reply-rule-editor">
|
<div class="reply-rule-editor">
|
||||||
<div v-if="showHeader" class="reply-list-header">
|
<div v-if="showHeader" class="reply-list-header">
|
||||||
<span class="reply-list-title">自动回复消息</span>
|
<span class="reply-list-title">自动回复消息</span>
|
||||||
<a-button type="dashed" size="small" class="reply-add-btn" @click="addReplyItem">
|
<a-button
|
||||||
|
v-if="!readonly"
|
||||||
|
type="dashed"
|
||||||
|
size="small"
|
||||||
|
class="reply-add-btn"
|
||||||
|
@click="addReplyItem"
|
||||||
|
>
|
||||||
<template #icon><PlusOutlined /></template>
|
<template #icon><PlusOutlined /></template>
|
||||||
添加一条消息
|
添加一条消息
|
||||||
</a-button>
|
</a-button>
|
||||||
@@ -114,7 +137,7 @@ const copyPageUrl = async (url) => {
|
|||||||
<div class="reply-item-header">
|
<div class="reply-item-header">
|
||||||
<span>消息 {{ replyIndex + 1 }}</span>
|
<span>消息 {{ replyIndex + 1 }}</span>
|
||||||
<a-button
|
<a-button
|
||||||
v-if="replyItems.length > 1"
|
v-if="!readonly && replyItems.length > 1"
|
||||||
type="text"
|
type="text"
|
||||||
danger
|
danger
|
||||||
size="small"
|
size="small"
|
||||||
@@ -130,6 +153,7 @@ const copyPageUrl = async (url) => {
|
|||||||
:value="reply.reply_type"
|
:value="reply.reply_type"
|
||||||
button-style="solid"
|
button-style="solid"
|
||||||
class="reply-type-group"
|
class="reply-type-group"
|
||||||
|
:disabled="readonly"
|
||||||
@update:value="(v) => updateReplyField(replyIndex, 'reply_type', v)"
|
@update:value="(v) => updateReplyField(replyIndex, 'reply_type', v)"
|
||||||
>
|
>
|
||||||
<a-radio-button v-for="opt in typeOptions" :key="opt.value" :value="opt.value">
|
<a-radio-button v-for="opt in typeOptions" :key="opt.value" :value="opt.value">
|
||||||
@@ -193,6 +217,7 @@ const copyPageUrl = async (url) => {
|
|||||||
<a-form-item label="封面图片" required>
|
<a-form-item label="封面图片" required>
|
||||||
<div class="card-upload-row">
|
<div class="card-upload-row">
|
||||||
<a-upload
|
<a-upload
|
||||||
|
v-if="canUploadCards && !readonly"
|
||||||
name="file"
|
name="file"
|
||||||
list-type="picture-card"
|
list-type="picture-card"
|
||||||
:show-upload-list="false"
|
:show-upload-list="false"
|
||||||
@@ -214,6 +239,15 @@ const copyPageUrl = async (url) => {
|
|||||||
<div>上传封面</div>
|
<div>上传封面</div>
|
||||||
</div>
|
</div>
|
||||||
</a-upload>
|
</a-upload>
|
||||||
|
<div v-else class="card-upload-placeholder">
|
||||||
|
<img
|
||||||
|
v-if="reply.reply_card_cover_url || reply.reply_card_image_path"
|
||||||
|
:src="reply.reply_card_cover_url || reply.reply_card_image_path"
|
||||||
|
alt="封面"
|
||||||
|
class="card-cover-preview"
|
||||||
|
/>
|
||||||
|
<template v-else>无上传权限</template>
|
||||||
|
</div>
|
||||||
<span class="card-upload-tip">上传后自动转为 PNG favicon(32×32)与卡片封面(256×256)</span>
|
<span class="card-upload-tip">上传后自动转为 PNG favicon(32×32)与卡片封面(256×256)</span>
|
||||||
</div>
|
</div>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* Sidebar menu catalog. Visibility is driven by permission codes from /auth/me.
|
* Sidebar menu catalog. Visibility is driven by permission codes from /auth/me.
|
||||||
|
* Admin pages also require paired action permissions (alsoRequires).
|
||||||
*/
|
*/
|
||||||
export const MENU_ITEMS = [
|
export const MENU_ITEMS = [
|
||||||
{
|
{
|
||||||
@@ -36,6 +37,7 @@ export const MENU_ITEMS = [
|
|||||||
name: 'Logs',
|
name: 'Logs',
|
||||||
title: '回复日志面板',
|
title: '回复日志面板',
|
||||||
permission: 'menu.logs',
|
permission: 'menu.logs',
|
||||||
|
alsoRequires: ['logs.read'],
|
||||||
icon: 'FileTextOutlined'
|
icon: 'FileTextOutlined'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -43,6 +45,7 @@ export const MENU_ITEMS = [
|
|||||||
name: 'ReceivedMessages',
|
name: 'ReceivedMessages',
|
||||||
title: '接收消息日志',
|
title: '接收消息日志',
|
||||||
permission: 'menu.received_messages',
|
permission: 'menu.received_messages',
|
||||||
|
alsoRequires: ['received_messages.read'],
|
||||||
icon: 'InboxOutlined'
|
icon: 'InboxOutlined'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -50,6 +53,7 @@ export const MENU_ITEMS = [
|
|||||||
name: 'SystemLogs',
|
name: 'SystemLogs',
|
||||||
title: '系统诊断日志',
|
title: '系统诊断日志',
|
||||||
permission: 'menu.system_logs',
|
permission: 'menu.system_logs',
|
||||||
|
alsoRequires: ['system_logs.read'],
|
||||||
icon: 'BugOutlined'
|
icon: 'BugOutlined'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -69,15 +73,25 @@ export const MENU_ITEMS = [
|
|||||||
{
|
{
|
||||||
path: '/users',
|
path: '/users',
|
||||||
name: 'Users',
|
name: 'Users',
|
||||||
title: '用户与角色',
|
title: '用户管理',
|
||||||
permission: 'menu.users',
|
permission: 'menu.users',
|
||||||
|
alsoRequires: ['users.manage'],
|
||||||
icon: 'TeamOutlined'
|
icon: 'TeamOutlined'
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/roles',
|
||||||
|
name: 'Roles',
|
||||||
|
title: '角色设定',
|
||||||
|
permission: 'menu.roles',
|
||||||
|
alsoRequires: ['roles.manage'],
|
||||||
|
icon: 'SafetyCertificateOutlined'
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/settings',
|
path: '/settings',
|
||||||
name: 'Settings',
|
name: 'Settings',
|
||||||
title: '系统设置',
|
title: '系统设置',
|
||||||
permission: 'menu.settings',
|
permission: 'menu.settings',
|
||||||
|
alsoRequires: ['settings.manage'],
|
||||||
icon: 'ControlOutlined'
|
icon: 'ControlOutlined'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -85,6 +99,7 @@ export const MENU_ITEMS = [
|
|||||||
name: 'DesktopUpdate',
|
name: 'DesktopUpdate',
|
||||||
title: '桌面端升级',
|
title: '桌面端升级',
|
||||||
permission: 'menu.desktop_update',
|
permission: 'menu.desktop_update',
|
||||||
|
alsoRequires: ['desktop.manage'],
|
||||||
icon: 'RocketOutlined'
|
icon: 'RocketOutlined'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -92,6 +107,7 @@ export const MENU_ITEMS = [
|
|||||||
name: 'PaymentSettings',
|
name: 'PaymentSettings',
|
||||||
title: '支付配置',
|
title: '支付配置',
|
||||||
permission: 'menu.payment_settings',
|
permission: 'menu.payment_settings',
|
||||||
|
alsoRequires: ['payments.manage'],
|
||||||
icon: 'PayCircleOutlined'
|
icon: 'PayCircleOutlined'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -99,6 +115,7 @@ export const MENU_ITEMS = [
|
|||||||
name: 'MyPaymentOrders',
|
name: 'MyPaymentOrders',
|
||||||
title: '我的订单',
|
title: '我的订单',
|
||||||
permission: 'menu.payment_orders',
|
permission: 'menu.payment_orders',
|
||||||
|
alsoRequires: ['orders.read'],
|
||||||
icon: 'UnorderedListOutlined'
|
icon: 'UnorderedListOutlined'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -110,7 +127,8 @@ export const HEADER_TITLES = {
|
|||||||
Rules: '策略中心',
|
Rules: '策略中心',
|
||||||
ReceivedMessages: '接收消息日志',
|
ReceivedMessages: '接收消息日志',
|
||||||
SystemLogs: '诊断中心',
|
SystemLogs: '诊断中心',
|
||||||
Users: '权限中心',
|
Users: '用户管理',
|
||||||
|
Roles: '角色设定',
|
||||||
Settings: '系统设置',
|
Settings: '系统设置',
|
||||||
DesktopUpdate: '桌面端升级',
|
DesktopUpdate: '桌面端升级',
|
||||||
PaymentSettings: '支付配置',
|
PaymentSettings: '支付配置',
|
||||||
@@ -120,7 +138,13 @@ export const HEADER_TITLES = {
|
|||||||
Logs: '日志中心'
|
Logs: '日志中心'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function menuAccessible(item, hasPermission) {
|
||||||
|
if (!item || !hasPermission(item.permission)) return false
|
||||||
|
const extra = item.alsoRequires || []
|
||||||
|
return extra.every((code) => hasPermission(code))
|
||||||
|
}
|
||||||
|
|
||||||
export function firstAccessiblePath(hasPermission) {
|
export function firstAccessiblePath(hasPermission) {
|
||||||
const hit = MENU_ITEMS.find((item) => hasPermission(item.permission))
|
const hit = MENU_ITEMS.find((item) => menuAccessible(item, hasPermission))
|
||||||
return hit?.path || '/help'
|
return hit?.path || '/help'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import SystemLogs from '../views/SystemLogs.vue'
|
|||||||
import ReceivedMessages from '../views/ReceivedMessages.vue'
|
import ReceivedMessages from '../views/ReceivedMessages.vue'
|
||||||
import Login from '../views/Login.vue'
|
import Login from '../views/Login.vue'
|
||||||
import Users from '../views/Users.vue'
|
import Users from '../views/Users.vue'
|
||||||
|
import Roles from '../views/Roles.vue'
|
||||||
import Settings from '../views/Settings.vue'
|
import Settings from '../views/Settings.vue'
|
||||||
import DesktopUpdate from '../views/DesktopUpdate.vue'
|
import DesktopUpdate from '../views/DesktopUpdate.vue'
|
||||||
import PaymentSettings from '../views/PaymentSettings.vue'
|
import PaymentSettings from '../views/PaymentSettings.vue'
|
||||||
@@ -15,7 +16,8 @@ import MyPaymentOrders from '../views/MyPaymentOrders.vue'
|
|||||||
import Help from '../views/Help.vue'
|
import Help from '../views/Help.vue'
|
||||||
import Download from '../views/Download.vue'
|
import Download from '../views/Download.vue'
|
||||||
import { useAuthStore } from '../stores/auth'
|
import { useAuthStore } from '../stores/auth'
|
||||||
import { firstAccessiblePath } from '../config/menus'
|
import { firstAccessiblePath, MENU_ITEMS, menuAccessible } from '../config/menus'
|
||||||
|
|
||||||
|
|
||||||
const routes = [
|
const routes = [
|
||||||
{ path: '/login', component: Login, name: 'Login', meta: { public: true } },
|
{ path: '/login', component: Login, name: 'Login', meta: { public: true } },
|
||||||
@@ -37,6 +39,7 @@ const routes = [
|
|||||||
meta: { permission: 'menu.system_logs' }
|
meta: { permission: 'menu.system_logs' }
|
||||||
},
|
},
|
||||||
{ path: '/users', component: Users, name: 'Users', meta: { permission: 'menu.users' } },
|
{ path: '/users', component: Users, name: 'Users', meta: { permission: 'menu.users' } },
|
||||||
|
{ path: '/roles', component: Roles, name: 'Roles', meta: { permission: 'menu.roles' } },
|
||||||
{ path: '/settings', component: Settings, name: 'Settings', meta: { permission: 'menu.settings' } },
|
{ path: '/settings', component: Settings, name: 'Settings', meta: { permission: 'menu.settings' } },
|
||||||
{
|
{
|
||||||
path: '/desktop-update',
|
path: '/desktop-update',
|
||||||
@@ -101,6 +104,11 @@ router.beforeEach(async (to) => {
|
|||||||
return firstAccessiblePath((code) => auth.hasPermission(code))
|
return firstAccessiblePath((code) => auth.hasPermission(code))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const menuItem = MENU_ITEMS.find((item) => item.path === to.path)
|
||||||
|
if (menuItem && !menuAccessible(menuItem, (code) => auth.hasPermission(code))) {
|
||||||
|
return firstAccessiblePath((code) => auth.hasPermission(code))
|
||||||
|
}
|
||||||
|
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import api from '../api'
|
import api from '../api'
|
||||||
import { MENU_ITEMS } from '../config/menus'
|
import { MENU_ITEMS, menuAccessible } from '../config/menus'
|
||||||
|
|
||||||
export const useAuthStore = defineStore('auth', () => {
|
export const useAuthStore = defineStore('auth', () => {
|
||||||
const token = ref(localStorage.getItem('kefu_token') || '')
|
const token = ref(localStorage.getItem('kefu_token') || '')
|
||||||
@@ -24,21 +24,60 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
const hasPermission = (code) => {
|
const hasPermission = (code) => {
|
||||||
if (!code) return true
|
if (!code) return true
|
||||||
if (isAdmin.value) return true
|
if (isAdmin.value) return true
|
||||||
return permissionSet.value.has(code)
|
if (permissionSet.value.has(code)) return true
|
||||||
|
// Legacy accounts.write covers all granular account write buttons.
|
||||||
|
if (
|
||||||
|
code.startsWith('accounts.') &&
|
||||||
|
code !== 'accounts.write' &&
|
||||||
|
permissionSet.value.has('accounts.write')
|
||||||
|
) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
const canWrite = computed(() => hasPermission('accounts.write'))
|
const canWrite = computed(() =>
|
||||||
|
hasPermission('accounts.write') ||
|
||||||
|
hasPermission('accounts.create') ||
|
||||||
|
hasPermission('accounts.update') ||
|
||||||
|
hasPermission('accounts.delete') ||
|
||||||
|
hasPermission('accounts.start') ||
|
||||||
|
hasPermission('accounts.stop') ||
|
||||||
|
hasPermission('accounts.cookie')
|
||||||
|
)
|
||||||
|
const canCreateAccounts = computed(() => hasPermission('accounts.create'))
|
||||||
|
const canUpdateAccounts = computed(() => hasPermission('accounts.update'))
|
||||||
|
const canDeleteAccounts = computed(() => hasPermission('accounts.delete'))
|
||||||
|
const canStartAccounts = computed(() => hasPermission('accounts.start'))
|
||||||
|
const canStopAccounts = computed(() => hasPermission('accounts.stop'))
|
||||||
|
const canManageCookies = computed(() => hasPermission('accounts.cookie'))
|
||||||
const canWriteMessages = computed(() => hasPermission('messages.write'))
|
const canWriteMessages = computed(() => hasPermission('messages.write'))
|
||||||
const canWriteRules = computed(() => hasPermission('rules.write'))
|
const canWriteRules = computed(() => hasPermission('rules.write'))
|
||||||
|
const canWriteLinkCards = computed(() => hasPermission('link_cards.write'))
|
||||||
|
const canClearSystemLogs = computed(() => hasPermission('system_logs.clear'))
|
||||||
const canManageUsers = computed(() => hasPermission('users.manage'))
|
const canManageUsers = computed(() => hasPermission('users.manage'))
|
||||||
const isViewer = computed(() => !canWrite.value && !isAdmin.value)
|
const canManageRoles = computed(() => hasPermission('roles.manage'))
|
||||||
|
const canManagePayments = computed(() => hasPermission('payments.manage'))
|
||||||
|
const canManageSettings = computed(() => hasPermission('settings.manage'))
|
||||||
|
const canManageDatabase = computed(() => hasPermission('settings.database'))
|
||||||
|
const canCreateOrders = computed(() => hasPermission('orders.create'))
|
||||||
|
const hasGlobalDataScope = computed(
|
||||||
|
() => isAdmin.value || hasPermission('data.scope_all')
|
||||||
|
)
|
||||||
|
const isViewer = computed(
|
||||||
|
() =>
|
||||||
|
!canWrite.value &&
|
||||||
|
!canWriteMessages.value &&
|
||||||
|
!canWriteRules.value &&
|
||||||
|
!isAdmin.value
|
||||||
|
)
|
||||||
|
|
||||||
const roleLabel = computed(
|
const roleLabel = computed(
|
||||||
() => user.value?.role_label || user.value?.role || ''
|
() => user.value?.role_label || user.value?.role || ''
|
||||||
)
|
)
|
||||||
|
|
||||||
const visibleMenus = computed(() =>
|
const visibleMenus = computed(() =>
|
||||||
MENU_ITEMS.filter((item) => hasPermission(item.permission)).map((item) => ({
|
MENU_ITEMS.filter((item) => menuAccessible(item, hasPermission)).map((item) => ({
|
||||||
...item,
|
...item,
|
||||||
title: isAdmin.value && item.adminTitle ? item.adminTitle : item.title
|
title: isAdmin.value && item.adminTitle ? item.adminTitle : item.title
|
||||||
}))
|
}))
|
||||||
@@ -115,9 +154,23 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
permissions,
|
permissions,
|
||||||
isAdmin,
|
isAdmin,
|
||||||
canWrite,
|
canWrite,
|
||||||
|
canCreateAccounts,
|
||||||
|
canUpdateAccounts,
|
||||||
|
canDeleteAccounts,
|
||||||
|
canStartAccounts,
|
||||||
|
canStopAccounts,
|
||||||
|
canManageCookies,
|
||||||
canWriteMessages,
|
canWriteMessages,
|
||||||
canWriteRules,
|
canWriteRules,
|
||||||
|
canWriteLinkCards,
|
||||||
|
canClearSystemLogs,
|
||||||
canManageUsers,
|
canManageUsers,
|
||||||
|
canManageRoles,
|
||||||
|
canManagePayments,
|
||||||
|
canManageSettings,
|
||||||
|
canManageDatabase,
|
||||||
|
canCreateOrders,
|
||||||
|
hasGlobalDataScope,
|
||||||
isViewer,
|
isViewer,
|
||||||
roleLabel,
|
roleLabel,
|
||||||
visibleMenus,
|
visibleMenus,
|
||||||
|
|||||||
@@ -395,7 +395,12 @@ const accountQuotaLabel = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const canPurchaseSlots = computed(() => {
|
const canPurchaseSlots = computed(() => {
|
||||||
return paymentConfig.value?.payment_enabled && accountQuota.value.limited && !canAddAccount.value
|
return (
|
||||||
|
auth.canCreateOrders &&
|
||||||
|
paymentConfig.value?.payment_enabled &&
|
||||||
|
accountQuota.value.limited &&
|
||||||
|
!canAddAccount.value
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
const startableAccounts = computed(() =>
|
const startableAccounts = computed(() =>
|
||||||
@@ -857,7 +862,7 @@ const queueActionLabel = (item) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const isQueueSendDisabled = (item) => {
|
const isQueueSendDisabled = (item) => {
|
||||||
if (auth.user?.role === 'viewer') return true
|
if (!auth.canWriteMessages) return true
|
||||||
if (!queueSnapshot.value.running) return true
|
if (!queueSnapshot.value.running) return true
|
||||||
if (queueSendingJobId.value) return true
|
if (queueSendingJobId.value) return true
|
||||||
return item?.status !== 'waiting' || !!item?.expedited
|
return item?.status !== 'waiting' || !!item?.expedited
|
||||||
@@ -1000,8 +1005,8 @@ const goAccountRulesPage = (accountId) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const openAddModal = () => {
|
const openAddModal = () => {
|
||||||
if (!auth.canWrite) {
|
if (!auth.canCreateAccounts) {
|
||||||
message.warning('当前账号为只读角色,不能添加托管账号')
|
message.warning('当前账号无添加账号权限')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!canAddAccount.value) {
|
if (!canAddAccount.value) {
|
||||||
@@ -1599,11 +1604,13 @@ const openEditModal = async (acc) => {
|
|||||||
}
|
}
|
||||||
initUserAgentFields(acc)
|
initUserAgentFields(acc)
|
||||||
try {
|
try {
|
||||||
const res = await api.get(`/accounts/${acc.id}/cookie?purpose=management`)
|
if (auth.canManageCookies) {
|
||||||
editForm.value.cookie_data = res.data.cookie_data
|
const res = await api.get(`/accounts/${acc.id}/cookie?purpose=management`)
|
||||||
? JSON.stringify(JSON.parse(res.data.cookie_data), null, 2)
|
editForm.value.cookie_data = res.data.cookie_data
|
||||||
: ''
|
? JSON.stringify(JSON.parse(res.data.cookie_data), null, 2)
|
||||||
applyCookieResponse(res.data)
|
: ''
|
||||||
|
applyCookieResponse(res.data)
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
message.error('加载 Cookie 失败')
|
message.error('加载 Cookie 失败')
|
||||||
} finally {
|
} finally {
|
||||||
@@ -1798,7 +1805,7 @@ onUnmounted(() => {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
<div v-if="startableAccounts.length > 0" class="batch-toolbar">
|
<div v-if="auth.canStartAccounts && startableAccounts.length > 0" class="batch-toolbar">
|
||||||
<a-checkbox
|
<a-checkbox
|
||||||
:indeterminate="selectedStartableCount > 0 && selectedStartableCount < startableAccounts.length"
|
:indeterminate="selectedStartableCount > 0 && selectedStartableCount < startableAccounts.length"
|
||||||
:checked="startableAccounts.length > 0 && selectedStartableCount === startableAccounts.length"
|
:checked="startableAccounts.length > 0 && selectedStartableCount === startableAccounts.length"
|
||||||
@@ -1823,6 +1830,7 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
<a-space wrap>
|
<a-space wrap>
|
||||||
<a-popconfirm
|
<a-popconfirm
|
||||||
|
v-if="auth.canStartAccounts"
|
||||||
title="将启动所有未启动的账号(含其他分页),确认继续?"
|
title="将启动所有未启动的账号(含其他分页),确认继续?"
|
||||||
ok-text="全部启动"
|
ok-text="全部启动"
|
||||||
cancel-text="取消"
|
cancel-text="取消"
|
||||||
@@ -1857,7 +1865,7 @@ onUnmounted(() => {
|
|||||||
购买额度
|
购买额度
|
||||||
</a-button>
|
</a-button>
|
||||||
<a-button
|
<a-button
|
||||||
v-if="auth.canWrite"
|
v-if="auth.canCreateAccounts"
|
||||||
type="primary"
|
type="primary"
|
||||||
class="gradient-btn"
|
class="gradient-btn"
|
||||||
:disabled="!canAddAccount && !canPurchaseSlots"
|
:disabled="!canAddAccount && !canPurchaseSlots"
|
||||||
@@ -2050,7 +2058,13 @@ onUnmounted(() => {
|
|||||||
<template #icon><MessageOutlined /></template>
|
<template #icon><MessageOutlined /></template>
|
||||||
自动回复
|
自动回复
|
||||||
</a-button>
|
</a-button>
|
||||||
<a-button type="text" size="small" class="action-edit-btn" @click="openEditModal(acc)">
|
<a-button
|
||||||
|
v-if="auth.canUpdateAccounts"
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
class="action-edit-btn"
|
||||||
|
@click="openEditModal(acc)"
|
||||||
|
>
|
||||||
<template #icon><EditOutlined /></template>
|
<template #icon><EditOutlined /></template>
|
||||||
编辑
|
编辑
|
||||||
</a-button>
|
</a-button>
|
||||||
@@ -2061,7 +2075,7 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
<div class="account-actions-primary">
|
<div class="account-actions-primary">
|
||||||
<a-button
|
<a-button
|
||||||
v-if="!acc.quota_disabled && (acc.status === 'offline' || acc.status === 'error' || acc.status === 'starting')"
|
v-if="auth.canStartAccounts && !acc.quota_disabled && (acc.status === 'offline' || acc.status === 'error' || acc.status === 'starting')"
|
||||||
type="primary"
|
type="primary"
|
||||||
ghost
|
ghost
|
||||||
size="small"
|
size="small"
|
||||||
@@ -2073,7 +2087,7 @@ onUnmounted(() => {
|
|||||||
启动托管
|
启动托管
|
||||||
</a-button>
|
</a-button>
|
||||||
<a-button
|
<a-button
|
||||||
v-if="!acc.quota_disabled && acc.status === 'logging_in'"
|
v-if="auth.canStartAccounts && !acc.quota_disabled && acc.status === 'logging_in'"
|
||||||
type="primary"
|
type="primary"
|
||||||
size="small"
|
size="small"
|
||||||
class="action-btn-warn"
|
class="action-btn-warn"
|
||||||
@@ -2083,7 +2097,7 @@ onUnmounted(() => {
|
|||||||
扫码登录
|
扫码登录
|
||||||
</a-button>
|
</a-button>
|
||||||
<a-button
|
<a-button
|
||||||
v-if="!acc.quota_disabled && acc.status === 'online'"
|
v-if="auth.canStopAccounts && !acc.quota_disabled && acc.status === 'online'"
|
||||||
danger
|
danger
|
||||||
ghost
|
ghost
|
||||||
size="small"
|
size="small"
|
||||||
@@ -2093,6 +2107,7 @@ onUnmounted(() => {
|
|||||||
停止托管
|
停止托管
|
||||||
</a-button>
|
</a-button>
|
||||||
<a-popconfirm
|
<a-popconfirm
|
||||||
|
v-if="auth.canDeleteAccounts"
|
||||||
title="确认删除该账号?删除后其所有的自动回复规则和消息日志也将被清除。"
|
title="确认删除该账号?删除后其所有的自动回复规则和消息日志也将被清除。"
|
||||||
ok-text="确认"
|
ok-text="确认"
|
||||||
cancel-text="取消"
|
cancel-text="取消"
|
||||||
@@ -2113,7 +2128,7 @@ onUnmounted(() => {
|
|||||||
<UserOutlined style="font-size: 4rem; color: var(--text-muted); margin-bottom: 16px;" />
|
<UserOutlined style="font-size: 4rem; color: var(--text-muted); margin-bottom: 16px;" />
|
||||||
<h3>暂无托管账号</h3>
|
<h3>暂无托管账号</h3>
|
||||||
<p style="color: var(--text-secondary); margin-bottom: 20px;">添加一个抖音账号,开始自动化回复工作吧</p>
|
<p style="color: var(--text-secondary); margin-bottom: 20px;">添加一个抖音账号,开始自动化回复工作吧</p>
|
||||||
<a-button v-if="auth.canWrite" type="primary" class="gradient-btn" @click="openAddModal">
|
<a-button v-if="auth.canCreateAccounts" type="primary" class="gradient-btn" @click="openAddModal">
|
||||||
<template #icon><PlusOutlined /></template>
|
<template #icon><PlusOutlined /></template>
|
||||||
立即添加
|
立即添加
|
||||||
</a-button>
|
</a-button>
|
||||||
@@ -2168,7 +2183,10 @@ onUnmounted(() => {
|
|||||||
<a-form-item label="手机号 (可选,方便记录备注)">
|
<a-form-item label="手机号 (可选,方便记录备注)">
|
||||||
<a-input v-model:value="addForm.phone" placeholder="请输入绑定的手机号码" />
|
<a-input v-model:value="addForm.phone" placeholder="请输入绑定的手机号码" />
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item label="Cookie 数据 (可选,可直接导入登录态)">
|
<a-form-item
|
||||||
|
v-if="auth.canManageCookies"
|
||||||
|
label="Cookie 数据 (可选,可直接导入登录态)"
|
||||||
|
>
|
||||||
<a-textarea
|
<a-textarea
|
||||||
v-model:value="addForm.cookie_data"
|
v-model:value="addForm.cookie_data"
|
||||||
:rows="10"
|
:rows="10"
|
||||||
@@ -2381,7 +2399,10 @@ onUnmounted(() => {
|
|||||||
启动托管时,浏览器登录与 IM 发送将使用所选设备头;须与 a_bogus 签名一致,修改后请重新启动托管。
|
启动托管时,浏览器登录与 IM 发送将使用所选设备头;须与 a_bogus 签名一致,修改后请重新启动托管。
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<a-form-item label="Cookie 数据(Playwright storage_state JSON)">
|
<a-form-item
|
||||||
|
v-if="auth.canManageCookies"
|
||||||
|
label="Cookie 数据(Playwright storage_state JSON)"
|
||||||
|
>
|
||||||
<a-textarea
|
<a-textarea
|
||||||
v-model:value="editForm.cookie_data"
|
v-model:value="editForm.cookie_data"
|
||||||
:rows="isMobile ? 8 : 12"
|
:rows="isMobile ? 8 : 12"
|
||||||
@@ -2392,6 +2413,7 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
<div class="edit-modal-footer">
|
<div class="edit-modal-footer">
|
||||||
<a-popconfirm
|
<a-popconfirm
|
||||||
|
v-if="auth.canManageCookies"
|
||||||
title="确认清除该账号的 Cookie?清除后需重新扫码登录。"
|
title="确认清除该账号的 Cookie?清除后需重新扫码登录。"
|
||||||
ok-text="确认"
|
ok-text="确认"
|
||||||
cancel-text="取消"
|
cancel-text="取消"
|
||||||
@@ -2399,8 +2421,20 @@ onUnmounted(() => {
|
|||||||
>
|
>
|
||||||
<a-button danger :loading="editSaving">清除 Cookie</a-button>
|
<a-button danger :loading="editSaving">清除 Cookie</a-button>
|
||||||
</a-popconfirm>
|
</a-popconfirm>
|
||||||
<a-button :loading="editSaving" @click="saveAccountInfo">保存账号信息</a-button>
|
<a-button
|
||||||
<a-button type="primary" class="gradient-btn" :loading="editSaving" @click="saveCookie">
|
v-if="auth.canUpdateAccounts"
|
||||||
|
:loading="editSaving"
|
||||||
|
@click="saveAccountInfo"
|
||||||
|
>
|
||||||
|
保存账号信息
|
||||||
|
</a-button>
|
||||||
|
<a-button
|
||||||
|
v-if="auth.canManageCookies"
|
||||||
|
type="primary"
|
||||||
|
class="gradient-btn"
|
||||||
|
:loading="editSaving"
|
||||||
|
@click="saveCookie"
|
||||||
|
>
|
||||||
保存 Cookie
|
保存 Cookie
|
||||||
</a-button>
|
</a-button>
|
||||||
</div>
|
</div>
|
||||||
@@ -2439,17 +2473,30 @@ onUnmounted(() => {
|
|||||||
关闭后该账号不再发送兜底回复,状态与策略中心的「是否启用」开关同步。
|
关闭后该账号不再发送兜底回复,状态与策略中心的「是否启用」开关同步。
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<a-switch v-model:checked="accountDefaultRuleForm.is_active" />
|
<a-switch
|
||||||
|
v-model:checked="accountDefaultRuleForm.is_active"
|
||||||
|
:disabled="!auth.canWriteRules"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ReplyRuleEditor v-model:replies="accountDefaultRuleForm.replies" />
|
<ReplyRuleEditor
|
||||||
|
v-model:replies="accountDefaultRuleForm.replies"
|
||||||
|
:readonly="!auth.canWriteRules"
|
||||||
|
:can-upload-cards="auth.canWriteLinkCards"
|
||||||
|
/>
|
||||||
|
|
||||||
<a-space style="width: 100%; justify-content: flex-end; margin-top: 8px;">
|
<a-space style="width: 100%; justify-content: flex-end; margin-top: 8px;">
|
||||||
<a-button @click="goAccountRulesPage(rulesModalAccount.id)">
|
<a-button @click="goAccountRulesPage(rulesModalAccount.id)">
|
||||||
<template #icon><SettingOutlined /></template>
|
<template #icon><SettingOutlined /></template>
|
||||||
管理全部规则
|
管理全部规则
|
||||||
</a-button>
|
</a-button>
|
||||||
<a-button type="primary" class="gradient-btn" :loading="rulesModalSaving" @click="saveAccountDefaultRule">
|
<a-button
|
||||||
|
v-if="auth.canWriteRules"
|
||||||
|
type="primary"
|
||||||
|
class="gradient-btn"
|
||||||
|
:loading="rulesModalSaving"
|
||||||
|
@click="saveAccountDefaultRule"
|
||||||
|
>
|
||||||
保存兜底回复
|
保存兜底回复
|
||||||
</a-button>
|
</a-button>
|
||||||
</a-space>
|
</a-space>
|
||||||
@@ -2491,10 +2538,10 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<a-alert
|
<a-alert
|
||||||
v-if="auth.user?.role === 'viewer'"
|
v-if="!auth.canWriteMessages"
|
||||||
type="info"
|
type="info"
|
||||||
show-icon
|
show-icon
|
||||||
message="当前账号为只读权限,可查看队列详情,但不能执行立即发送。"
|
message="当前账号无消息发送权限,可查看队列详情,但不能执行立即发送。"
|
||||||
class="reply-queue-alert reply-queue-readonly-alert"
|
class="reply-queue-alert reply-queue-readonly-alert"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -98,20 +98,20 @@ onUnmounted(() => {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="banner-side">
|
<div class="banner-side">
|
||||||
<div v-if="auth.canWrite" class="banner-actions">
|
<div v-if="auth.hasPermission('menu.accounts')" class="banner-actions">
|
||||||
<router-link to="/accounts">
|
<router-link to="/accounts">
|
||||||
<a-button size="large">
|
<a-button size="large">
|
||||||
<template #icon><UserOutlined /></template>
|
<template #icon><UserOutlined /></template>
|
||||||
{{ auth.isAdmin ? '账号管理' : `我的账号(${stats.myAccounts})` }}
|
{{ auth.hasGlobalDataScope ? '账号管理' : `我的账号(${stats.myAccounts})` }}
|
||||||
</a-button>
|
</a-button>
|
||||||
</router-link>
|
</router-link>
|
||||||
<router-link
|
<router-link
|
||||||
v-if="auth.canWrite"
|
v-if="auth.canCreateAccounts"
|
||||||
:to="{ path: '/accounts', query: { action: 'add' } }"
|
:to="{ path: '/accounts', query: { action: 'add' } }"
|
||||||
>
|
>
|
||||||
<a-button type="primary" size="large" class="gradient-btn">
|
<a-button type="primary" size="large" class="gradient-btn">
|
||||||
<template #icon><PlusOutlined /></template>
|
<template #icon><PlusOutlined /></template>
|
||||||
{{ auth.isAdmin ? '添加账号' : '添加自己的账号' }}
|
{{ auth.hasGlobalDataScope ? '添加账号' : '添加自己的账号' }}
|
||||||
</a-button>
|
</a-button>
|
||||||
</router-link>
|
</router-link>
|
||||||
</div>
|
</div>
|
||||||
@@ -231,8 +231,8 @@ onUnmounted(() => {
|
|||||||
<div class="quick-actions-grid" style="margin-top: 20px;">
|
<div class="quick-actions-grid" style="margin-top: 20px;">
|
||||||
<router-link to="/accounts" class="quick-action-card">
|
<router-link to="/accounts" class="quick-action-card">
|
||||||
<UserOutlined class="action-icon text-gradient" />
|
<UserOutlined class="action-icon text-gradient" />
|
||||||
<span>{{ auth.isAdmin ? '账号管理' : '我的账号' }}</span>
|
<span>{{ auth.hasGlobalDataScope ? '账号管理' : '我的账号' }}</span>
|
||||||
<p v-if="auth.isAdmin">管理全平台账号配置与运行状态</p>
|
<p v-if="auth.hasGlobalDataScope">管理全平台账号配置与运行状态</p>
|
||||||
<p v-else>仅查看和管理自己添加的账号,当前 {{ stats.myAccounts }} 个</p>
|
<p v-else>仅查看和管理自己添加的账号,当前 {{ stats.myAccounts }} 个</p>
|
||||||
</router-link>
|
</router-link>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,798 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { message, Modal } from 'ant-design-vue'
|
||||||
|
import {
|
||||||
|
PlusOutlined,
|
||||||
|
SafetyCertificateOutlined,
|
||||||
|
DeleteOutlined
|
||||||
|
} from '@ant-design/icons-vue'
|
||||||
|
import api from '../api'
|
||||||
|
import { useIsMobile } from '../composables/useIsMobile'
|
||||||
|
|
||||||
|
const isMobile = useIsMobile()
|
||||||
|
const roleRecords = ref([])
|
||||||
|
const permissionCatalog = ref({ menus: [], actions: [], data: [], tree: [], pairs: [] })
|
||||||
|
const rolesLoading = ref(false)
|
||||||
|
const roleModalVisible = ref(false)
|
||||||
|
const roleModalTitle = ref('新建角色')
|
||||||
|
const editingRoleCode = ref(null)
|
||||||
|
const roleSaving = ref(false)
|
||||||
|
const roleForm = ref({
|
||||||
|
code: '',
|
||||||
|
label: '',
|
||||||
|
description: '',
|
||||||
|
permissions: []
|
||||||
|
})
|
||||||
|
|
||||||
|
const rolePermissionsReadonly = computed(() => editingRoleCode.value === 'admin')
|
||||||
|
|
||||||
|
/** Build parent/child tree when API omits ``tree`` (older backends). */
|
||||||
|
const buildPermissionTreeFallback = (catalog) => {
|
||||||
|
const menus = catalog?.menus || []
|
||||||
|
const actions = catalog?.actions || []
|
||||||
|
const data = catalog?.data || []
|
||||||
|
const pairs = catalog?.pairs || []
|
||||||
|
const actionToMenu = {}
|
||||||
|
for (const pair of pairs) {
|
||||||
|
if (pair?.action && pair?.menu) actionToMenu[pair.action] = pair.menu
|
||||||
|
}
|
||||||
|
// Heuristic: accounts.* → menu.accounts, messages.* → menu.messages, etc.
|
||||||
|
const guessParent = (code) => {
|
||||||
|
if (actionToMenu[code]) return actionToMenu[code]
|
||||||
|
if (code.startsWith('accounts.')) return 'menu.accounts'
|
||||||
|
if (code.startsWith('messages.')) return 'menu.messages'
|
||||||
|
if (code.startsWith('rules.') || code === 'link_cards.write') return 'menu.rules'
|
||||||
|
if (code.startsWith('logs.')) return 'menu.logs'
|
||||||
|
if (code.startsWith('received_messages.')) return 'menu.received_messages'
|
||||||
|
if (code.startsWith('system_logs.')) return 'menu.system_logs'
|
||||||
|
if (code === 'users.manage') return 'menu.users'
|
||||||
|
if (code === 'roles.manage') return 'menu.roles'
|
||||||
|
if (code.startsWith('settings.')) return 'menu.settings'
|
||||||
|
if (code === 'desktop.manage') return 'menu.desktop_update'
|
||||||
|
if (code === 'payments.manage') return 'menu.payment_settings'
|
||||||
|
if (code.startsWith('orders.')) return 'menu.payment_orders'
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const childrenByMenu = Object.fromEntries(menus.map((m) => [m.code, []]))
|
||||||
|
for (const action of actions) {
|
||||||
|
const parent = guessParent(action.code)
|
||||||
|
if (parent && childrenByMenu[parent]) {
|
||||||
|
childrenByMenu[parent].push({ code: action.code, label: action.label })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const tree = menus.map((menu) => ({
|
||||||
|
code: menu.code,
|
||||||
|
label: menu.label,
|
||||||
|
kind: 'menu',
|
||||||
|
children: childrenByMenu[menu.code] || []
|
||||||
|
}))
|
||||||
|
if (data.length) {
|
||||||
|
tree.push({
|
||||||
|
code: '__group.data__',
|
||||||
|
label: '数据权限',
|
||||||
|
kind: 'group',
|
||||||
|
children: data.map((item) => ({ code: item.code, label: item.label }))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return tree
|
||||||
|
}
|
||||||
|
|
||||||
|
const permissionTree = computed(() => {
|
||||||
|
const catalog = permissionCatalog.value || {}
|
||||||
|
if (Array.isArray(catalog.tree) && catalog.tree.length) return catalog.tree
|
||||||
|
return buildPermissionTreeFallback(catalog)
|
||||||
|
})
|
||||||
|
|
||||||
|
const selectedPermissionSet = computed(
|
||||||
|
() => new Set(roleForm.value.permissions || [])
|
||||||
|
)
|
||||||
|
|
||||||
|
const childCodesOf = (node) => (node.children || []).map((c) => c.code)
|
||||||
|
|
||||||
|
const applyPermissionPairs = (codes) => {
|
||||||
|
const selected = new Set(codes || [])
|
||||||
|
const pairs = permissionCatalog.value.pairs || []
|
||||||
|
for (const pair of pairs) {
|
||||||
|
if (selected.has(pair.menu)) selected.add(pair.action)
|
||||||
|
}
|
||||||
|
const actionPrimary = {}
|
||||||
|
for (const pair of pairs) {
|
||||||
|
if (!actionPrimary[pair.action]) actionPrimary[pair.action] = pair.menu
|
||||||
|
}
|
||||||
|
// Also map from tree: any selected child implies its menu parent.
|
||||||
|
for (const node of permissionTree.value) {
|
||||||
|
if (node.kind === 'menu') {
|
||||||
|
for (const child of node.children || []) {
|
||||||
|
if (selected.has(child.code)) selected.add(node.code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const [action, menu] of Object.entries(actionPrimary)) {
|
||||||
|
if (selected.has(action)) selected.add(menu)
|
||||||
|
}
|
||||||
|
return [...selected]
|
||||||
|
}
|
||||||
|
|
||||||
|
const isChecked = (code) => selectedPermissionSet.value.has(code)
|
||||||
|
|
||||||
|
const isParentChecked = (node) => {
|
||||||
|
if (node.kind === 'group') {
|
||||||
|
const kids = childCodesOf(node)
|
||||||
|
return kids.length > 0 && kids.every((code) => isChecked(code))
|
||||||
|
}
|
||||||
|
return isChecked(node.code)
|
||||||
|
}
|
||||||
|
|
||||||
|
const isParentIndeterminate = (node) => {
|
||||||
|
const kids = childCodesOf(node)
|
||||||
|
if (!kids.length) return false
|
||||||
|
const checkedCount = kids.filter((code) => isChecked(code)).length
|
||||||
|
if (node.kind === 'group') {
|
||||||
|
return checkedCount > 0 && checkedCount < kids.length
|
||||||
|
}
|
||||||
|
// Menu parent: indeterminate when some (not all) children checked,
|
||||||
|
// or when menu is checked but children are mixed.
|
||||||
|
if (checkedCount === 0) return false
|
||||||
|
if (checkedCount === kids.length && isChecked(node.code)) return false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const setPermissions = (codes) => {
|
||||||
|
roleForm.value.permissions = applyPermissionPairs(codes)
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleParent = (node, checked) => {
|
||||||
|
const current = new Set(roleForm.value.permissions || [])
|
||||||
|
const kids = childCodesOf(node)
|
||||||
|
if (node.kind === 'group') {
|
||||||
|
for (const code of kids) {
|
||||||
|
if (checked) current.add(code)
|
||||||
|
else current.delete(code)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (checked) {
|
||||||
|
current.add(node.code)
|
||||||
|
} else {
|
||||||
|
current.delete(node.code)
|
||||||
|
for (const code of kids) current.delete(code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setPermissions([...current])
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleChild = (parent, childCode, checked) => {
|
||||||
|
const current = new Set(roleForm.value.permissions || [])
|
||||||
|
if (checked) {
|
||||||
|
current.add(childCode)
|
||||||
|
if (parent.kind === 'menu') current.add(parent.code)
|
||||||
|
} else {
|
||||||
|
current.delete(childCode)
|
||||||
|
}
|
||||||
|
setPermissions([...current])
|
||||||
|
}
|
||||||
|
|
||||||
|
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)) {
|
||||||
|
return detail
|
||||||
|
.map((item) => {
|
||||||
|
if (typeof item === 'string') return item
|
||||||
|
return item?.msg || item?.message || JSON.stringify(item)
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(';') || fallback
|
||||||
|
}
|
||||||
|
if (typeof detail === 'object') {
|
||||||
|
return detail.msg || detail.message || JSON.stringify(detail)
|
||||||
|
}
|
||||||
|
return String(detail)
|
||||||
|
}
|
||||||
|
|
||||||
|
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: [],
|
||||||
|
data: [],
|
||||||
|
tree: [],
|
||||||
|
pairs: []
|
||||||
|
}
|
||||||
|
} 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 fetchRoleRecords()
|
||||||
|
} 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 fetchRoleRecords()
|
||||||
|
} catch (error) {
|
||||||
|
message.error(formatApiError(error, '删除角色失败'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchRoleRecords()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="roles-page">
|
||||||
|
<div class="page-header glass-card">
|
||||||
|
<div class="page-header-main">
|
||||||
|
<h2 class="page-title">
|
||||||
|
<SafetyCertificateOutlined class="page-title-icon" />
|
||||||
|
角色设定
|
||||||
|
</h2>
|
||||||
|
<p class="subtitle">按菜单父子层级配置权限:父级进页面,子级控按钮与数据范围</p>
|
||||||
|
</div>
|
||||||
|
<a-button type="primary" class="gradient-btn add-btn" @click="openAddRole">
|
||||||
|
<template #icon><PlusOutlined /></template>
|
||||||
|
新建角色
|
||||||
|
</a-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div 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="110">
|
||||||
|
<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" class="edit-btn" @click="openEditRole(record)">
|
||||||
|
{{ record.is_admin ? '查看' : '编辑' }}
|
||||||
|
</a-button>
|
||||||
|
<a-button
|
||||||
|
v-if="!record.is_system"
|
||||||
|
type="text"
|
||||||
|
danger
|
||||||
|
@click="handleDeleteRole(record)"
|
||||||
|
>
|
||||||
|
<template #icon><DeleteOutlined /></template>
|
||||||
|
删除
|
||||||
|
</a-button>
|
||||||
|
</a-space>
|
||||||
|
</template>
|
||||||
|
</a-table-column>
|
||||||
|
</a-table>
|
||||||
|
|
||||||
|
<div v-else class="role-card-list">
|
||||||
|
<div v-for="record in roleRecords" :key="record.value" class="role-card glass-card">
|
||||||
|
<div class="role-card-head">
|
||||||
|
<div>
|
||||||
|
<div class="role-card-name">{{ record.label }}</div>
|
||||||
|
<div class="role-card-code">{{ 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="role-card-meta">
|
||||||
|
<div class="role-card-row">
|
||||||
|
<span class="role-card-label">权限</span>
|
||||||
|
<span>{{ (record.permissions || []).length }} 项</span>
|
||||||
|
</div>
|
||||||
|
<div class="role-card-row">
|
||||||
|
<span class="role-card-label">用户</span>
|
||||||
|
<span>{{ record.user_count ?? 0 }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="record.description" class="role-card-desc">
|
||||||
|
{{ record.description }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="role-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="roleModalVisible"
|
||||||
|
:title="roleModalTitle"
|
||||||
|
:width="isMobile ? 'calc(100vw - 32px)' : 760"
|
||||||
|
:confirm-loading="roleSaving"
|
||||||
|
@ok="handleSaveRole"
|
||||||
|
ok-text="保存"
|
||||||
|
cancel-text="取消"
|
||||||
|
>
|
||||||
|
<a-form layout="vertical" class="role-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
|
||||||
|
type="info"
|
||||||
|
show-icon
|
||||||
|
message="父级为菜单入口,子级为页面内按钮/操作;勾选子级会自动勾选父级菜单。取消父级会清除其下全部子权限。"
|
||||||
|
style="margin-bottom: 16px;"
|
||||||
|
/>
|
||||||
|
<a-alert
|
||||||
|
v-if="rolePermissionsReadonly"
|
||||||
|
type="info"
|
||||||
|
show-icon
|
||||||
|
message="管理员角色固定拥有全部权限,不可取消勾选"
|
||||||
|
style="margin-bottom: 16px;"
|
||||||
|
/>
|
||||||
|
<a-form-item label="权限配置">
|
||||||
|
<div class="perm-tree" :class="{ 'perm-tree-readonly': rolePermissionsReadonly }">
|
||||||
|
<div
|
||||||
|
v-for="node in permissionTree"
|
||||||
|
:key="node.code"
|
||||||
|
class="perm-tree-node"
|
||||||
|
:class="{ 'perm-tree-node-group': node.kind === 'group' }"
|
||||||
|
>
|
||||||
|
<label class="perm-tree-parent">
|
||||||
|
<a-checkbox
|
||||||
|
:checked="isParentChecked(node)"
|
||||||
|
:indeterminate="isParentIndeterminate(node)"
|
||||||
|
:disabled="rolePermissionsReadonly"
|
||||||
|
@change="(e) => toggleParent(node, e.target.checked)"
|
||||||
|
>
|
||||||
|
<span class="perm-tree-parent-label">{{ node.label }}</span>
|
||||||
|
<span v-if="node.kind === 'menu' && !(node.children || []).length" class="perm-tree-tag">仅菜单</span>
|
||||||
|
<span v-else-if="node.kind === 'group'" class="perm-tree-tag">数据范围</span>
|
||||||
|
</a-checkbox>
|
||||||
|
</label>
|
||||||
|
<div v-if="(node.children || []).length" class="perm-tree-children">
|
||||||
|
<label
|
||||||
|
v-for="child in node.children"
|
||||||
|
:key="child.code"
|
||||||
|
class="perm-tree-child"
|
||||||
|
>
|
||||||
|
<a-checkbox
|
||||||
|
:checked="isChecked(child.code)"
|
||||||
|
:disabled="rolePermissionsReadonly"
|
||||||
|
@change="(e) => toggleChild(node, child.code, e.target.checked)"
|
||||||
|
>
|
||||||
|
{{ child.label }}
|
||||||
|
</a-checkbox>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div v-if="node.kind === 'group'" class="field-hint perm-tree-hint">
|
||||||
|
勾选「查看全部用户数据」后可跨用户查看账号/规则/日志;未勾选则仅本人数据。内置管理员始终拥有全局范围。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</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>自定义角色创建后,可在「用户管理」中分配给用户</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.roles-page {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 24px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header-main {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title-icon {
|
||||||
|
color: #c084fc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gradient-btn {
|
||||||
|
background: linear-gradient(135deg, var(--primary-color) 0%, var(--accent-pink) 100%) !important;
|
||||||
|
border: none !important;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.roles-panel {
|
||||||
|
padding: 16px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.roles-panel:hover,
|
||||||
|
.role-help:hover {
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.roles-page :deep(.ant-table) {
|
||||||
|
background: transparent !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.roles-page :deep(.ant-table-thead > tr > th) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.roles-page :deep(.ant-table-tbody > tr > td) {
|
||||||
|
background: transparent !important;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.08) !important;
|
||||||
|
color: #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.roles-page :deep(.ant-table-tbody > tr:hover > td) {
|
||||||
|
background: rgba(170, 59, 255, 0.08) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.roles-page :deep(.ant-tag) {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.perm-tree {
|
||||||
|
max-height: min(58vh, 520px);
|
||||||
|
overflow: auto;
|
||||||
|
padding: 4px 2px 8px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.perm-tree-node {
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
.perm-tree-node-group {
|
||||||
|
border-color: rgba(96, 165, 250, 0.28);
|
||||||
|
background: rgba(59, 130, 246, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.perm-tree-parent {
|
||||||
|
display: block;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.perm-tree-parent :deep(.ant-checkbox-wrapper) {
|
||||||
|
color: #f3f4f6 !important;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-left: 0 !important;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.perm-tree-parent-label {
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.perm-tree-tag {
|
||||||
|
display: inline-block;
|
||||||
|
margin-left: 4px;
|
||||||
|
padding: 1px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.perm-tree-children {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||||
|
gap: 8px 12px;
|
||||||
|
margin: 10px 0 0 28px;
|
||||||
|
padding-top: 10px;
|
||||||
|
border-top: 1px dashed rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.perm-tree-child {
|
||||||
|
display: block;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.perm-tree-child :deep(.ant-checkbox-wrapper) {
|
||||||
|
color: #e5e7eb !important;
|
||||||
|
margin-left: 0 !important;
|
||||||
|
line-height: 1.45;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.perm-tree-hint {
|
||||||
|
margin: 10px 0 0 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.perm-tree-readonly {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-btn {
|
||||||
|
color: #d8b4fe !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-btn:hover {
|
||||||
|
color: #f3e8ff !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-muted {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-hint {
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-form :deep(.ant-form-item-label > label) {
|
||||||
|
color: var(--text-secondary) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-help {
|
||||||
|
margin-top: 24px;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-list {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.8;
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-card-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-card {
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: hsla(230, 20%, 12%, 0.85);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-card:hover {
|
||||||
|
transform: none;
|
||||||
|
border-color: rgba(192, 132, 252, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-card-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-card-name {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-card-code {
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-card-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 0;
|
||||||
|
border-top: 1px solid var(--border-light);
|
||||||
|
border-bottom: 1px solid var(--border-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-card-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 0.88rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-card-label {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-card-desc {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-card-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 4px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.page-header {
|
||||||
|
padding: 16px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-btn {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-help {
|
||||||
|
margin-top: 16px;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -714,7 +714,11 @@ watch(
|
|||||||
<a-input v-model:value="ruleForm.keyword" placeholder="当对方发来的信息包含此词语时触发回复..." />
|
<a-input v-model:value="ruleForm.keyword" placeholder="当对方发来的信息包含此词语时触发回复..." />
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
|
|
||||||
<ReplyRuleEditor v-model:replies="ruleForm.replies" />
|
<ReplyRuleEditor
|
||||||
|
v-model:replies="ruleForm.replies"
|
||||||
|
:readonly="!auth.canWriteRules"
|
||||||
|
:can-upload-cards="auth.canWriteLinkCards"
|
||||||
|
/>
|
||||||
</a-form>
|
</a-form>
|
||||||
</a-modal>
|
</a-modal>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { ref, onMounted } from 'vue'
|
|||||||
|
|
||||||
import { message, Modal } from 'ant-design-vue'
|
import { message, Modal } from 'ant-design-vue'
|
||||||
|
|
||||||
|
import { useAuthStore } from '../stores/auth'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
|
||||||
SaveOutlined,
|
SaveOutlined,
|
||||||
@@ -24,6 +26,8 @@ import api from '../api'
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const auth = useAuthStore()
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
@@ -589,7 +593,9 @@ const handleTestEmail = async () => {
|
|||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
fetchSettings()
|
fetchSettings()
|
||||||
fetchDatabaseSettings()
|
if (auth.canManageDatabase) {
|
||||||
|
fetchDatabaseSettings()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
@@ -978,7 +984,7 @@ onMounted(() => {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
<div class="glass-card panel db-panel">
|
<div v-if="auth.canManageDatabase" class="glass-card panel db-panel">
|
||||||
|
|
||||||
<div class="db-panel-header">
|
<div class="db-panel-header">
|
||||||
|
|
||||||
|
|||||||
@@ -293,7 +293,7 @@ onMounted(() => {
|
|||||||
刷新
|
刷新
|
||||||
</a-button>
|
</a-button>
|
||||||
|
|
||||||
<a-button v-if="auth.isAdmin" danger @click="clearLogs">
|
<a-button v-if="auth.canClearSystemLogs" danger @click="clearLogs">
|
||||||
<template #icon><DeleteOutlined /></template>
|
<template #icon><DeleteOutlined /></template>
|
||||||
清空
|
清空
|
||||||
</a-button>
|
</a-button>
|
||||||
|
|||||||
+15
-399
@@ -54,26 +54,11 @@ const formatApiError = (error, fallback = '操作失败') => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
const activeTab = ref('users')
|
|
||||||
const users = ref([])
|
const users = ref([])
|
||||||
const roles = ref([])
|
|
||||||
const roleRecords = ref([])
|
|
||||||
const permissionCatalog = ref({ menus: [], actions: [] })
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const rolesLoading = ref(false)
|
|
||||||
const modalVisible = ref(false)
|
const modalVisible = ref(false)
|
||||||
const modalTitle = ref('新增用户')
|
const modalTitle = ref('新增用户')
|
||||||
const editingId = ref(null)
|
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({
|
const userForm = ref({
|
||||||
username: '',
|
username: '',
|
||||||
@@ -96,6 +81,11 @@ const roleOptions = ref([
|
|||||||
{ value: 'viewer', label: '只读', is_admin: false }
|
{ value: 'viewer', label: '只读', is_admin: false }
|
||||||
])
|
])
|
||||||
|
|
||||||
|
const assignableRoleOptions = computed(() => {
|
||||||
|
if (auth.isAdmin) return roleOptions.value
|
||||||
|
return roleOptions.value.filter((r) => !r.is_admin)
|
||||||
|
})
|
||||||
|
|
||||||
const isAdminRole = computed(() => {
|
const isAdminRole = computed(() => {
|
||||||
const hit = roleOptions.value.find((r) => r.value === userForm.value.role)
|
const hit = roleOptions.value.find((r) => r.value === userForm.value.role)
|
||||||
return !!(hit?.is_admin || userForm.value.role === 'admin')
|
return !!(hit?.is_admin || userForm.value.role === 'admin')
|
||||||
@@ -103,16 +93,11 @@ const isAdminRole = computed(() => {
|
|||||||
const emailRequiredForRole = computed(
|
const emailRequiredForRole = computed(
|
||||||
() => emailBindingRequired.value && !isAdminRole.value
|
() => emailBindingRequired.value && !isAdminRole.value
|
||||||
)
|
)
|
||||||
const rolePermissionsReadonly = computed(
|
|
||||||
() => editingRoleCode.value === 'admin'
|
|
||||||
)
|
|
||||||
|
|
||||||
const hasEmail = computed(() => !!userForm.value.email?.trim())
|
const hasEmail = computed(() => !!userForm.value.email?.trim())
|
||||||
|
|
||||||
const getRoleLabel = (role) =>
|
const getRoleLabel = (role) =>
|
||||||
roleOptions.value.find((r) => r.value === role)?.label ||
|
roleOptions.value.find((r) => r.value === role)?.label || role
|
||||||
roleRecords.value.find((r) => r.value === role)?.label ||
|
|
||||||
role
|
|
||||||
|
|
||||||
const filteredUsers = computed(() => {
|
const filteredUsers = computed(() => {
|
||||||
const keyword = searchKeyword.value.trim().toLowerCase()
|
const keyword = searchKeyword.value.trim().toLowerCase()
|
||||||
@@ -165,9 +150,9 @@ const fetchUsers = async () => {
|
|||||||
const fetchRoles = async () => {
|
const fetchRoles = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await api.get('/auth/roles')
|
const res = await api.get('/auth/roles')
|
||||||
roles.value = res.data.roles || []
|
const list = res.data.roles || []
|
||||||
if (roles.value.length) {
|
if (list.length) {
|
||||||
roleOptions.value = roles.value.map((r) => ({
|
roleOptions.value = list.map((r) => ({
|
||||||
value: r.value,
|
value: r.value,
|
||||||
label: r.label,
|
label: r.label,
|
||||||
is_admin: !!r.is_admin
|
is_admin: !!r.is_admin
|
||||||
@@ -178,120 +163,6 @@ const fetchRoles = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 () => {
|
const fetchDefaultMaxAccounts = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await api.get('/settings')
|
const res = await api.get('/settings')
|
||||||
@@ -468,12 +339,6 @@ const isUnlimitedQuota = (record) =>
|
|||||||
!!(record.is_admin || record.role === 'admin' ||
|
!!(record.is_admin || record.role === 'admin' ||
|
||||||
roleOptions.value.find((r) => r.value === record.role)?.is_admin)
|
roleOptions.value.find((r) => r.value === record.role)?.is_admin)
|
||||||
|
|
||||||
watch(activeTab, (tab) => {
|
|
||||||
if (tab === 'roles' && !roleRecords.value.length) {
|
|
||||||
fetchRoleRecords()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const resolveAccountLimit = (record) => {
|
const resolveAccountLimit = (record) => {
|
||||||
if (isUnlimitedQuota(record)) return null
|
if (isUnlimitedQuota(record)) return null
|
||||||
const raw = record.max_accounts
|
const raw = record.max_accounts
|
||||||
@@ -521,36 +386,16 @@ onMounted(() => {
|
|||||||
<div class="page-header-main">
|
<div class="page-header-main">
|
||||||
<h2 class="page-title">
|
<h2 class="page-title">
|
||||||
<TeamOutlined class="page-title-icon" />
|
<TeamOutlined class="page-title-icon" />
|
||||||
用户与角色管理
|
用户管理
|
||||||
</h2>
|
</h2>
|
||||||
<p class="subtitle">创建用户、自定义角色,并勾选菜单与操作权限</p>
|
<p class="subtitle">创建与管理后台用户并分配角色;非管理员仅能查看自己创建的用户</p>
|
||||||
</div>
|
</div>
|
||||||
<a-button
|
<a-button type="primary" class="gradient-btn add-user-btn" @click="openAdd">
|
||||||
v-if="activeTab === 'users'"
|
|
||||||
type="primary"
|
|
||||||
class="gradient-btn add-user-btn"
|
|
||||||
@click="openAdd"
|
|
||||||
>
|
|
||||||
<template #icon><PlusOutlined /></template>
|
<template #icon><PlusOutlined /></template>
|
||||||
新增用户
|
新增用户
|
||||||
</a-button>
|
</a-button>
|
||||||
<a-button
|
|
||||||
v-else
|
|
||||||
type="primary"
|
|
||||||
class="gradient-btn add-user-btn"
|
|
||||||
@click="openAddRole"
|
|
||||||
>
|
|
||||||
<template #icon><PlusOutlined /></template>
|
|
||||||
新建角色
|
|
||||||
</a-button>
|
|
||||||
</div>
|
</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">
|
<div class="users-toolbar glass-card">
|
||||||
<a-input
|
<a-input
|
||||||
v-model:value="searchKeyword"
|
v-model:value="searchKeyword"
|
||||||
@@ -567,7 +412,6 @@ onMounted(() => {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 桌面端:表格 -->
|
|
||||||
<div v-if="!isMobile" class="glass-card table-card">
|
<div v-if="!isMobile" class="glass-card table-card">
|
||||||
<a-table
|
<a-table
|
||||||
:data-source="paginatedUsers"
|
:data-source="paginatedUsers"
|
||||||
@@ -618,7 +462,7 @@ onMounted(() => {
|
|||||||
<a-table-column title="操作" key="action" width="180px">
|
<a-table-column title="操作" key="action" width="180px">
|
||||||
<template #default="{ record }">
|
<template #default="{ record }">
|
||||||
<a-space>
|
<a-space>
|
||||||
<a-button type="text" style="color: #c084fc;" @click="openEdit(record)">
|
<a-button type="text" class="edit-btn" @click="openEdit(record)">
|
||||||
<template #icon><EditOutlined /></template>
|
<template #icon><EditOutlined /></template>
|
||||||
编辑
|
编辑
|
||||||
</a-button>
|
</a-button>
|
||||||
@@ -637,7 +481,6 @@ onMounted(() => {
|
|||||||
</a-table>
|
</a-table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 手机端:卡片列表 -->
|
|
||||||
<div v-else class="users-mobile-list">
|
<div v-else class="users-mobile-list">
|
||||||
<a-spin :spinning="loading">
|
<a-spin :spinning="loading">
|
||||||
<div v-if="paginatedUsers.length" class="user-card-list">
|
<div v-if="paginatedUsers.length" class="user-card-list">
|
||||||
@@ -720,96 +563,6 @@ onMounted(() => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</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
|
<a-modal
|
||||||
v-model:visible="modalVisible"
|
v-model:visible="modalVisible"
|
||||||
@@ -860,7 +613,7 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item label="角色">
|
<a-form-item label="角色">
|
||||||
<a-select v-model:value="userForm.role" :options="roleOptions" />
|
<a-select v-model:value="userForm.role" :options="assignableRoleOptions" />
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item v-if="!isAdminRole" label="可添加抖音账号数">
|
<a-form-item v-if="!isAdminRole" label="可添加抖音账号数">
|
||||||
<a-input-number
|
<a-input-number
|
||||||
@@ -879,84 +632,6 @@ onMounted(() => {
|
|||||||
</a-form-item>
|
</a-form-item>
|
||||||
</a-form>
|
</a-form>
|
||||||
</a-modal>
|
</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>自定义角色可在「角色设定」中新建,并分配给用户</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -1000,48 +675,11 @@ onMounted(() => {
|
|||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.users-tabs {
|
|
||||||
padding: 8px 16px 0;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.users-tabs:hover,
|
|
||||||
.users-toolbar:hover,
|
.users-toolbar:hover,
|
||||||
.table-card:hover,
|
.table-card:hover {
|
||||||
.roles-panel:hover,
|
|
||||||
.role-help:hover {
|
|
||||||
transform: none;
|
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 {
|
.users-toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -1234,18 +872,6 @@ onMounted(() => {
|
|||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.role-help {
|
|
||||||
margin-top: 24px;
|
|
||||||
padding: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.role-list {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
line-height: 1.8;
|
|
||||||
margin: 0;
|
|
||||||
padding-left: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-form :deep(.ant-form-item-label > label) {
|
.user-form :deep(.ant-form-item-label > label) {
|
||||||
color: var(--text-secondary) !important;
|
color: var(--text-secondary) !important;
|
||||||
}
|
}
|
||||||
@@ -1278,15 +904,5 @@ onMounted(() => {
|
|||||||
max-width: none;
|
max-width: none;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.role-help {
|
|
||||||
margin-top: 16px !important;
|
|
||||||
padding: 16px !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.role-list {
|
|
||||||
padding-left: 18px;
|
|
||||||
font-size: 0.88rem;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user