fix: 修复proxy_service和account_service中不当的顶层import
- proxy_service.py: 将requests和WhitelistManager改为函数内延迟import, 避免启动时加载不需要的依赖;移除未使用的get_exit_ip_via_proxy导入 - account_service.py: 移除未使用的func、joinedload、AuditLog、 user_has_permission顶层导入
This commit is contained in:
+17
-42
@@ -5,12 +5,16 @@ from pathlib import Path
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker, declarative_base
|
||||
|
||||
DB_PATH = Path(__file__).parent.parent.parent / "data" / "web.db"
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
DB_PATH = PROJECT_ROOT / "data" / "web.db"
|
||||
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
DATABASE_URL = os.getenv("DATABASE_URL", f"sqlite:///{DB_PATH}")
|
||||
|
||||
connect_args = {"check_same_thread": False} if DATABASE_URL.startswith("sqlite") else {}
|
||||
|
||||
engine = create_engine(
|
||||
f"sqlite:///{DB_PATH}",
|
||||
connect_args={"check_same_thread": False},
|
||||
DATABASE_URL,
|
||||
connect_args=connect_args,
|
||||
echo=False,
|
||||
)
|
||||
|
||||
@@ -28,49 +32,20 @@ def get_db():
|
||||
|
||||
|
||||
def init_db():
|
||||
"""建表 + 写入初始数据。"""
|
||||
Base.metadata.create_all(bind=engine)
|
||||
_migrate()
|
||||
"""执行数据库迁移 + 写入初始数据。"""
|
||||
run_migrations()
|
||||
_seed()
|
||||
|
||||
|
||||
def _migrate():
|
||||
"""数据库迁移:为已有表添加新列。"""
|
||||
from sqlalchemy import text
|
||||
with engine.connect() as conn:
|
||||
# 检查 accounts.tag 列是否存在
|
||||
result = conn.execute(text("PRAGMA table_info(accounts)"))
|
||||
columns = [row[1] for row in result]
|
||||
if 'tag' not in columns:
|
||||
conn.execute(text("ALTER TABLE accounts ADD COLUMN tag VARCHAR(64) DEFAULT ''"))
|
||||
conn.commit()
|
||||
def run_migrations():
|
||||
"""运行 Alembic 迁移到最新版本。"""
|
||||
from alembic import command
|
||||
from alembic.config import Config
|
||||
|
||||
# 检查 users.custom_permissions 列是否存在
|
||||
result = conn.execute(text("PRAGMA table_info(users)"))
|
||||
columns = [row[1] for row in result]
|
||||
if 'custom_permissions' not in columns:
|
||||
conn.execute(text("ALTER TABLE users ADD COLUMN custom_permissions JSON DEFAULT NULL"))
|
||||
conn.commit()
|
||||
|
||||
# 检查 accounts.email_imap_ssl 列是否存在
|
||||
result = conn.execute(text("PRAGMA table_info(accounts)"))
|
||||
columns = [row[1] for row in result]
|
||||
if 'email_imap_ssl' not in columns:
|
||||
# 旧数据端口993的默认True(SSL),其他端口默认False
|
||||
conn.execute(text("ALTER TABLE accounts ADD COLUMN email_imap_ssl BOOLEAN DEFAULT 1"))
|
||||
conn.commit()
|
||||
# 修正旧数据中使用 111.229.206.54 的账号:端口改为143,SSL改为False
|
||||
conn.execute(text(
|
||||
"UPDATE accounts SET email_imap_port = 143, email_imap_ssl = 0 "
|
||||
"WHERE email_imap_server = '111.229.206.54'"
|
||||
))
|
||||
conn.commit()
|
||||
# 修正旧数据中使用 mail.bdhg.xyz 的账号:993端口不通,改用143非SSL
|
||||
conn.execute(text(
|
||||
"UPDATE accounts SET email_imap_port = 143, email_imap_ssl = 0 "
|
||||
"WHERE email_imap_server = 'mail.bdhg.xyz'"
|
||||
))
|
||||
conn.commit()
|
||||
config = Config(str(PROJECT_ROOT / "alembic.ini"))
|
||||
config.set_main_option("script_location", str(PROJECT_ROOT / "web" / "backend" / "migrations"))
|
||||
config.set_main_option("sqlalchemy.url", DATABASE_URL)
|
||||
command.upgrade(config, "head")
|
||||
|
||||
|
||||
def _seed():
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
# 数据库迁移
|
||||
|
||||
本目录由 Alembic 管理 Web 后台数据库结构。
|
||||
|
||||
常用命令:
|
||||
|
||||
```bash
|
||||
uv run alembic upgrade head
|
||||
uv run alembic revision -m "描述"
|
||||
```
|
||||
|
||||
应用启动时会自动执行 `upgrade head`,本地开发通常不需要手动运行迁移命令。
|
||||
@@ -0,0 +1,57 @@
|
||||
"""Alembic 迁移环境。"""
|
||||
|
||||
from logging.config import fileConfig
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
from alembic import context
|
||||
from sqlalchemy import engine_from_config, pool
|
||||
|
||||
ROOT_DIR = Path(__file__).resolve().parents[3]
|
||||
if str(ROOT_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT_DIR))
|
||||
|
||||
from web.backend.database import Base, DATABASE_URL # noqa: E402
|
||||
from web.backend import models # noqa: F401,E402
|
||||
|
||||
config = context.config
|
||||
config.set_main_option("sqlalchemy.url", DATABASE_URL)
|
||||
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
target_metadata = Base.metadata
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
"""离线模式生成 SQL。"""
|
||||
context.configure(
|
||||
url=DATABASE_URL,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
"""在线模式直接执行迁移。"""
|
||||
connectable = engine_from_config(
|
||||
config.get_section(config.config_ini_section, {}),
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
|
||||
with connectable.connect() as connection:
|
||||
context.configure(connection=connection, target_metadata=target_metadata)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
@@ -0,0 +1,25 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
revision: str = ${repr(up_revision)}
|
||||
down_revision: Union[str, None] = ${repr(down_revision)}
|
||||
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
${downgrades if downgrades else "pass"}
|
||||
@@ -0,0 +1,160 @@
|
||||
"""初始化 Web 后台数据库结构
|
||||
|
||||
Revision ID: 20260623_0001
|
||||
Revises:
|
||||
Create Date: 2026-06-23
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = "20260623_0001"
|
||||
down_revision: Union[str, None] = None
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def _has_table(bind, table_name: str) -> bool:
|
||||
return sa.inspect(bind).has_table(table_name)
|
||||
|
||||
|
||||
def _columns(bind, table_name: str) -> set[str]:
|
||||
if not _has_table(bind, table_name):
|
||||
return set()
|
||||
return {column["name"] for column in sa.inspect(bind).get_columns(table_name)}
|
||||
|
||||
|
||||
def _indexes(bind, table_name: str) -> set[str]:
|
||||
if not _has_table(bind, table_name):
|
||||
return set()
|
||||
return {index["name"] for index in sa.inspect(bind).get_indexes(table_name)}
|
||||
|
||||
|
||||
def _add_column_if_missing(bind, table_name: str, column: sa.Column) -> bool:
|
||||
if column.name in _columns(bind, table_name):
|
||||
return False
|
||||
op.add_column(table_name, column)
|
||||
return True
|
||||
|
||||
|
||||
def _create_index_if_missing(bind, name: str, table_name: str, columns: list[str], unique: bool = False) -> None:
|
||||
if name not in _indexes(bind, table_name):
|
||||
op.create_index(name, table_name, columns, unique=unique)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
|
||||
if not _has_table(bind, "users"):
|
||||
op.create_table(
|
||||
"users",
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("username", sa.String(length=64), nullable=False),
|
||||
sa.Column("password_hash", sa.String(length=256), nullable=False),
|
||||
sa.Column("role", sa.String(length=32), nullable=False),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=True),
|
||||
sa.Column("remark", sa.String(length=256), nullable=True),
|
||||
sa.Column("custom_permissions", sa.JSON(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=True),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
else:
|
||||
_add_column_if_missing(bind, "users", sa.Column("custom_permissions", sa.JSON(), nullable=True))
|
||||
_create_index_if_missing(bind, "ix_users_username", "users", ["username"], unique=True)
|
||||
|
||||
if not _has_table(bind, "accounts"):
|
||||
op.create_table(
|
||||
"accounts",
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("username", sa.String(length=128), nullable=False),
|
||||
sa.Column("password", sa.String(length=256), nullable=False),
|
||||
sa.Column("email", sa.String(length=128), nullable=False),
|
||||
sa.Column("email_password", sa.String(length=256), nullable=False),
|
||||
sa.Column("email_imap_server", sa.String(length=128), nullable=True),
|
||||
sa.Column("email_imap_port", sa.Integer(), nullable=True),
|
||||
sa.Column("email_imap_ssl", sa.Boolean(), nullable=True),
|
||||
sa.Column("assigned_to", sa.Integer(), nullable=True),
|
||||
sa.Column("tag", sa.String(length=64), nullable=True),
|
||||
sa.Column("remark", sa.String(length=256), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=True),
|
||||
sa.ForeignKeyConstraint(["assigned_to"], ["users.id"]),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
else:
|
||||
_add_column_if_missing(bind, "accounts", sa.Column("tag", sa.String(length=64), server_default=""))
|
||||
added_ssl = _add_column_if_missing(bind, "accounts", sa.Column("email_imap_ssl", sa.Boolean(), server_default=sa.text("1")))
|
||||
if added_ssl:
|
||||
op.execute(
|
||||
"UPDATE accounts SET email_imap_port = 143, email_imap_ssl = 0 "
|
||||
"WHERE email_imap_server = '111.229.206.54'"
|
||||
)
|
||||
op.execute(
|
||||
"UPDATE accounts SET email_imap_port = 143, email_imap_ssl = 0 "
|
||||
"WHERE email_imap_server = 'mail.bdhg.xyz'"
|
||||
)
|
||||
_create_index_if_missing(bind, "ix_accounts_assigned_to", "accounts", ["assigned_to"])
|
||||
|
||||
if not _has_table(bind, "proxy_config"):
|
||||
op.create_table(
|
||||
"proxy_config",
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("enabled", sa.Boolean(), nullable=True),
|
||||
sa.Column("api_url", sa.String(length=512), nullable=True),
|
||||
sa.Column("http", sa.String(length=256), nullable=True),
|
||||
sa.Column("https", sa.String(length=256), nullable=True),
|
||||
sa.Column("whitelist_enabled", sa.Boolean(), nullable=True),
|
||||
sa.Column("whitelist_uid", sa.String(length=64), nullable=True),
|
||||
sa.Column("whitelist_ukey", sa.String(length=128), nullable=True),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
|
||||
if not _has_table(bind, "login_tasks"):
|
||||
op.create_table(
|
||||
"login_tasks",
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("batch_id", sa.String(length=64), nullable=False),
|
||||
sa.Column("account_id", sa.Integer(), nullable=False),
|
||||
sa.Column("status", sa.String(length=32), nullable=True),
|
||||
sa.Column("cookie", sa.Text(), nullable=True),
|
||||
sa.Column("message", sa.String(length=512), nullable=True),
|
||||
sa.Column("created_by", sa.Integer(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("finished_at", sa.DateTime(), nullable=True),
|
||||
sa.ForeignKeyConstraint(["account_id"], ["accounts.id"]),
|
||||
sa.ForeignKeyConstraint(["created_by"], ["users.id"]),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
_create_index_if_missing(bind, "ix_login_tasks_batch_id", "login_tasks", ["batch_id"])
|
||||
|
||||
if not _has_table(bind, "audit_logs"):
|
||||
op.create_table(
|
||||
"audit_logs",
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("user_id", sa.Integer(), nullable=True),
|
||||
sa.Column("username", sa.String(length=64), nullable=True),
|
||||
sa.Column("action", sa.String(length=128), nullable=False),
|
||||
sa.Column("target", sa.String(length=256), nullable=True),
|
||||
sa.Column("detail", sa.Text(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=True),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
if _has_table(bind, "audit_logs"):
|
||||
op.drop_table("audit_logs")
|
||||
if _has_table(bind, "login_tasks"):
|
||||
op.drop_index("ix_login_tasks_batch_id", table_name="login_tasks")
|
||||
op.drop_table("login_tasks")
|
||||
if _has_table(bind, "proxy_config"):
|
||||
op.drop_table("proxy_config")
|
||||
if _has_table(bind, "accounts"):
|
||||
op.drop_index("ix_accounts_assigned_to", table_name="accounts")
|
||||
op.drop_table("accounts")
|
||||
if _has_table(bind, "users"):
|
||||
op.drop_index("ix_users_username", table_name="users")
|
||||
op.drop_table("users")
|
||||
@@ -3,11 +3,9 @@
|
||||
import csv
|
||||
import re
|
||||
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..models import Account, AuditLog, LoginTask
|
||||
from ..permissions import user_has_permission
|
||||
from ..models import Account, LoginTask
|
||||
|
||||
|
||||
EMAIL_PATTERN = re.compile(r'^[^\s@|]+@[^\s@|]+\.[^\s@|]+$')
|
||||
|
||||
@@ -6,11 +6,9 @@ import threading
|
||||
import uuid
|
||||
from typing import Optional
|
||||
|
||||
import requests as req_lib
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.douyu.proxy import resolve_working_proxy, verify_proxy_url, parse_proxy_response
|
||||
from core.douyu.whitelist import WhitelistManager, get_exit_ip_via_proxy
|
||||
from ..models import ProxyConfig as ProxyConfigModel, AuditLog
|
||||
|
||||
|
||||
@@ -146,6 +144,8 @@ class ProxyService:
|
||||
loop: asyncio.AbstractEventLoop,
|
||||
):
|
||||
"""在线程中执行白名单测试。"""
|
||||
import requests as req_lib
|
||||
from core.douyu.whitelist import WhitelistManager
|
||||
|
||||
def push(level, message):
|
||||
asyncio.run_coroutine_threadsafe(
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import api from './client';
|
||||
import type {
|
||||
AccountItem,
|
||||
AssignmentsSummary,
|
||||
MessageCountResponse,
|
||||
MessageDeletedResponse,
|
||||
MessageResponse,
|
||||
} from './types';
|
||||
|
||||
export const accountApi = {
|
||||
list: (params?: { assigned_only?: boolean; tag?: string; has_cookie?: boolean }) =>
|
||||
api.get<AccountItem[], AccountItem[]>('/accounts', { params }),
|
||||
import: (text: string) => api.post<MessageCountResponse, MessageCountResponse>('/accounts/import', { text }),
|
||||
assign: (id: number, assigned_to: number | null) =>
|
||||
api.put<MessageResponse, MessageResponse>(`/accounts/${id}/assign`, { assigned_to }),
|
||||
batchAssign: (account_ids: number[], assigned_to: number | null) =>
|
||||
api.post<MessageCountResponse, MessageCountResponse>('/accounts/batch-assign', { account_ids, assigned_to }),
|
||||
assignmentsSummary: () =>
|
||||
api.get<AssignmentsSummary, AssignmentsSummary>('/accounts/assignments/summary'),
|
||||
setTag: (id: number, tag: string) =>
|
||||
api.put<MessageResponse, MessageResponse>(`/accounts/${id}/tag`, { tag }),
|
||||
batchTag: (account_ids: number[], tag: string) =>
|
||||
api.put<MessageCountResponse, MessageCountResponse>('/accounts/batch-tag', { account_ids, tag }),
|
||||
listTags: () => api.get<string[], string[]>('/accounts/tags/list'),
|
||||
delete: (id: number) => api.delete<MessageResponse, MessageResponse>(`/accounts/${id}`),
|
||||
batchDelete: (account_ids: number[]) =>
|
||||
api.delete<MessageDeletedResponse, MessageDeletedResponse>('/accounts/batch/delete', { params: { account_ids: account_ids.join(',') } }),
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
import api from './client';
|
||||
import type { CurrentUser, LoginResult, MessageResponse } from './types';
|
||||
|
||||
export const authApi = {
|
||||
login: (username: string, password: string) =>
|
||||
api.post<LoginResult, LoginResult>('/auth/login', { username, password }),
|
||||
|
||||
me: () => api.get<CurrentUser, CurrentUser>('/auth/me'),
|
||||
|
||||
logout: () => api.post<MessageResponse, MessageResponse>('/auth/logout'),
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
import axios from 'axios';
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: '/api',
|
||||
timeout: 30000,
|
||||
withCredentials: true, // 携带 httpOnly cookie
|
||||
});
|
||||
|
||||
// 响应拦截:统一错误处理
|
||||
api.interceptors.response.use(
|
||||
(response) => response.data,
|
||||
(error) => {
|
||||
if (error.response?.status === 401) {
|
||||
localStorage.removeItem('user');
|
||||
window.location.href = '/login';
|
||||
}
|
||||
const msg = error.response?.data?.detail || error.message || '请求失败';
|
||||
return Promise.reject(new Error(msg));
|
||||
}
|
||||
);
|
||||
|
||||
export default api;
|
||||
@@ -0,0 +1,8 @@
|
||||
import api from './client';
|
||||
import type { CookieItem, MessageResponse } from './types';
|
||||
|
||||
export const cookieApi = {
|
||||
list: () => api.get<CookieItem[], CookieItem[]>('/cookies'),
|
||||
exportCsv: (format?: string) => api.get<Blob, Blob>('/cookies/export', { responseType: 'blob', params: format ? { format } : {} }),
|
||||
delete: (id: number) => api.delete<MessageResponse, MessageResponse>(`/cookies/${id}`),
|
||||
};
|
||||
@@ -1,22 +1 @@
|
||||
import axios from 'axios';
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: '/api',
|
||||
timeout: 30000,
|
||||
withCredentials: true, // 携带 httpOnly cookie
|
||||
});
|
||||
|
||||
// 响应拦截:统一错误处理
|
||||
api.interceptors.response.use(
|
||||
(response) => response.data,
|
||||
(error) => {
|
||||
if (error.response?.status === 401) {
|
||||
localStorage.removeItem('user');
|
||||
window.location.href = '/login';
|
||||
}
|
||||
const msg = error.response?.data?.detail || error.message || '请求失败';
|
||||
return Promise.reject(new Error(msg));
|
||||
}
|
||||
);
|
||||
|
||||
export default api;
|
||||
export { default } from './client';
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import api from './client';
|
||||
import type { BatchLoginResult, LoginTaskItem, MessageDeletedResponse, MessageResponse } from './types';
|
||||
|
||||
export const loginApi = {
|
||||
createBatch: (account_ids: number[], max_geetest_retries?: number, concurrency?: number, max_proxy_retries?: number) =>
|
||||
api.post<BatchLoginResult, BatchLoginResult>('/login/batch', { account_ids, max_geetest_retries, concurrency, max_proxy_retries }),
|
||||
listTasks: (batch_id?: string) =>
|
||||
api.get<LoginTaskItem[], LoginTaskItem[]>('/login/tasks', { params: batch_id ? { batch_id } : {} }),
|
||||
stop: (batch_id: string) => api.post<MessageResponse, MessageResponse>(`/login/stop/${batch_id}`),
|
||||
deleteTask: (id: number) => api.delete<MessageResponse, MessageResponse>(`/login/tasks/${id}`),
|
||||
deleteTasks: (ids: number[]) => api.delete<MessageDeletedResponse, MessageDeletedResponse>(`/login/tasks`, { params: { task_ids: ids.join(',') } }),
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
import api from './client';
|
||||
import type { HttpLogClearResult, HttpLogListResult } from './types';
|
||||
|
||||
export const logApi = {
|
||||
listHttp: (params?: { limit?: number; offset?: number; category?: string; level?: string; keyword?: string }) =>
|
||||
api.get<HttpLogListResult, HttpLogListResult>('/logs/http', { params }),
|
||||
clearHttp: () => api.delete<HttpLogClearResult, HttpLogClearResult>('/logs/http'),
|
||||
};
|
||||
@@ -1,228 +1,8 @@
|
||||
import api from './index';
|
||||
|
||||
// ==================== 通用响应类型 ====================
|
||||
|
||||
interface MessageResponse {
|
||||
message: string;
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
interface MessageCountResponse extends MessageResponse {
|
||||
count: number;
|
||||
}
|
||||
|
||||
interface MessageDeletedResponse extends MessageResponse {
|
||||
deleted: number;
|
||||
}
|
||||
|
||||
// ==================== Auth ====================
|
||||
|
||||
export interface LoginResult {
|
||||
access_token: string;
|
||||
token_type: string;
|
||||
role: string;
|
||||
username: string;
|
||||
permissions: string[];
|
||||
}
|
||||
|
||||
export interface CurrentUser {
|
||||
id: number;
|
||||
username: string;
|
||||
role: string;
|
||||
role_label: string;
|
||||
is_active: boolean;
|
||||
remark: string | null;
|
||||
permissions: string[];
|
||||
}
|
||||
|
||||
export interface UserInfo {
|
||||
id: number;
|
||||
username: string;
|
||||
role: string;
|
||||
is_active: boolean;
|
||||
remark: string;
|
||||
permissions: string[];
|
||||
custom_permissions?: string[] | null;
|
||||
}
|
||||
|
||||
// ==================== Account ====================
|
||||
|
||||
export interface AccountItem {
|
||||
id: number;
|
||||
username: string;
|
||||
password?: string | null;
|
||||
email?: string | null;
|
||||
email_password?: string | null;
|
||||
tag: string;
|
||||
assigned_to: number | null;
|
||||
assigned_username: string | null;
|
||||
remark: string;
|
||||
created_at: string | null;
|
||||
}
|
||||
|
||||
export interface AssignmentsSummary {
|
||||
support_users: SupportUserItem[];
|
||||
unassigned_count: number;
|
||||
}
|
||||
|
||||
export interface SupportUserItem {
|
||||
id: number;
|
||||
username: string;
|
||||
assigned_count: number;
|
||||
}
|
||||
|
||||
// ==================== Login Task ====================
|
||||
|
||||
export interface LoginTaskItem {
|
||||
id: number;
|
||||
batch_id: string;
|
||||
account_id: number;
|
||||
account_username: string;
|
||||
status: string;
|
||||
cookie: string;
|
||||
message: string;
|
||||
created_by: number;
|
||||
created_at: string | null;
|
||||
finished_at: string | null;
|
||||
}
|
||||
|
||||
export interface BatchLoginResult {
|
||||
batch_id: string;
|
||||
count: number;
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
// ==================== Cookie ====================
|
||||
|
||||
export interface CookieItem {
|
||||
id: number;
|
||||
batch_id: string;
|
||||
account_id: number;
|
||||
account_username: string;
|
||||
assigned_to: number | null;
|
||||
assigned_username: string | null;
|
||||
created_at: string | null;
|
||||
cookie: string;
|
||||
cookie_preview: string;
|
||||
}
|
||||
|
||||
// ==================== Proxy ====================
|
||||
|
||||
export interface ProxyConfig {
|
||||
enabled: boolean;
|
||||
api_url: string;
|
||||
http: string;
|
||||
https: string;
|
||||
whitelist_enabled: boolean;
|
||||
whitelist_uid: string;
|
||||
whitelist_ukey: string;
|
||||
}
|
||||
|
||||
export interface ProxyTestResult {
|
||||
test_id: string;
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
// ==================== Log ====================
|
||||
|
||||
export interface HttpLogEntry {
|
||||
timestamp: string;
|
||||
ts: number;
|
||||
category: string;
|
||||
tag: string;
|
||||
method: string;
|
||||
url: string;
|
||||
proxy: string | null;
|
||||
request: { headers: Record<string, string>; body: string };
|
||||
response: { status_code: number | null; headers: Record<string, string>; body: string };
|
||||
duration_ms: number | null;
|
||||
error: string | null;
|
||||
level: string;
|
||||
}
|
||||
|
||||
export interface HttpLogListResult {
|
||||
items: HttpLogEntry[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface HttpLogClearResult {
|
||||
success: boolean;
|
||||
cleared: number;
|
||||
}
|
||||
|
||||
// ==================== Permissions ====================
|
||||
|
||||
export interface PermissionsListResult {
|
||||
permissions: Record<string, string>;
|
||||
role_permissions: Record<string, string[]>;
|
||||
}
|
||||
|
||||
// ==================== API 定义 ====================
|
||||
|
||||
export const authApi = {
|
||||
login: (username: string, password: string) =>
|
||||
api.post<any, LoginResult>('/auth/login', { username, password }),
|
||||
|
||||
me: () => api.get<any, CurrentUser>('/auth/me'),
|
||||
|
||||
logout: () => api.post<any, MessageResponse>('/auth/logout'),
|
||||
};
|
||||
|
||||
export const userApi = {
|
||||
list: () => api.get<any, UserInfo[]>('/users'),
|
||||
create: (data: { username: string; password: string; role: string; remark?: string }) =>
|
||||
api.post<any, UserInfo>('/users', data),
|
||||
update: (id: number, data: { password?: string; role?: string; is_active?: boolean; remark?: string; custom_permissions?: string[] | null }) =>
|
||||
api.put<any, UserInfo>(`/users/${id}`, data),
|
||||
delete: (id: number) => api.delete<any, MessageResponse>(`/users/${id}`),
|
||||
listPermissions: () => api.get<any, PermissionsListResult>('/users/permissions/list'),
|
||||
};
|
||||
|
||||
export const accountApi = {
|
||||
list: (params?: { assigned_only?: boolean; tag?: string; has_cookie?: boolean }) =>
|
||||
api.get<any, AccountItem[]>('/accounts', { params }),
|
||||
import: (text: string) => api.post<any, MessageCountResponse>('/accounts/import', { text }),
|
||||
assign: (id: number, assigned_to: number | null) =>
|
||||
api.put<any, MessageResponse>(`/accounts/${id}/assign`, { assigned_to }),
|
||||
batchAssign: (account_ids: number[], assigned_to: number | null) =>
|
||||
api.post<any, MessageCountResponse>('/accounts/batch-assign', { account_ids, assigned_to }),
|
||||
assignmentsSummary: () =>
|
||||
api.get<any, AssignmentsSummary>('/accounts/assignments/summary'),
|
||||
setTag: (id: number, tag: string) =>
|
||||
api.put<any, MessageResponse>(`/accounts/${id}/tag`, { tag }),
|
||||
batchTag: (account_ids: number[], tag: string) =>
|
||||
api.put<any, MessageCountResponse>('/accounts/batch-tag', { account_ids, tag }),
|
||||
listTags: () => api.get<any, string[]>('/accounts/tags/list'),
|
||||
delete: (id: number) => api.delete<any, MessageResponse>(`/accounts/${id}`),
|
||||
batchDelete: (account_ids: number[]) =>
|
||||
api.delete<any, MessageDeletedResponse>('/accounts/batch/delete', { params: { account_ids: account_ids.join(',') } }),
|
||||
};
|
||||
|
||||
export const loginApi = {
|
||||
createBatch: (account_ids: number[], max_geetest_retries?: number, concurrency?: number, max_proxy_retries?: number) =>
|
||||
api.post<any, BatchLoginResult>('/login/batch', { account_ids, max_geetest_retries, concurrency, max_proxy_retries }),
|
||||
listTasks: (batch_id?: string) =>
|
||||
api.get<any, LoginTaskItem[]>('/login/tasks', { params: batch_id ? { batch_id } : {} }),
|
||||
stop: (batch_id: string) => api.post<any, MessageResponse>(`/login/stop/${batch_id}`),
|
||||
deleteTask: (id: number) => api.delete<any, MessageResponse>(`/login/tasks/${id}`),
|
||||
deleteTasks: (ids: number[]) => api.delete<any, MessageDeletedResponse>(`/login/tasks`, { params: { task_ids: ids.join(',') } }),
|
||||
};
|
||||
|
||||
export const cookieApi = {
|
||||
list: () => api.get<any, CookieItem[]>('/cookies'),
|
||||
exportCsv: (format?: string) => api.get('/cookies/export', { responseType: 'blob', params: format ? { format } : {} }),
|
||||
delete: (id: number) => api.delete<any, MessageResponse>(`/cookies/${id}`),
|
||||
};
|
||||
|
||||
export const proxyApi = {
|
||||
get: () => api.get<any, ProxyConfig>('/proxy'),
|
||||
update: (data: ProxyConfig) => api.put<any, ProxyConfig>('/proxy', data),
|
||||
test: () => api.post<any, ProxyTestResult>('/proxy/test'),
|
||||
testWhitelist: () => api.post<any, ProxyTestResult>('/proxy/whitelist/test'),
|
||||
};
|
||||
|
||||
export const logApi = {
|
||||
listHttp: (params?: { limit?: number; offset?: number; category?: string; level?: string; keyword?: string }) =>
|
||||
api.get<any, HttpLogListResult>('/logs/http', { params }),
|
||||
clearHttp: () => api.delete<any, HttpLogClearResult>('/logs/http'),
|
||||
};
|
||||
export * from './types';
|
||||
export { accountApi } from './accounts';
|
||||
export { authApi } from './auth';
|
||||
export { cookieApi } from './cookies';
|
||||
export { logApi } from './logs';
|
||||
export { loginApi } from './login';
|
||||
export { proxyApi } from './proxy';
|
||||
export { userApi } from './users';
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import api from './client';
|
||||
import type { ProxyConfig, ProxyTestResult } from './types';
|
||||
|
||||
export const proxyApi = {
|
||||
get: () => api.get<ProxyConfig, ProxyConfig>('/proxy'),
|
||||
update: (data: ProxyConfig) => api.put<ProxyConfig, ProxyConfig>('/proxy', data),
|
||||
test: () => api.post<ProxyTestResult, ProxyTestResult>('/proxy/test'),
|
||||
testWhitelist: () => api.post<ProxyTestResult, ProxyTestResult>('/proxy/whitelist/test'),
|
||||
};
|
||||
@@ -0,0 +1,156 @@
|
||||
// ==================== 通用响应类型 ====================
|
||||
|
||||
export interface MessageResponse {
|
||||
message: string;
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
export interface MessageCountResponse extends MessageResponse {
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface MessageDeletedResponse extends MessageResponse {
|
||||
deleted: number;
|
||||
}
|
||||
|
||||
// ==================== Auth ====================
|
||||
|
||||
export interface LoginResult {
|
||||
access_token: string;
|
||||
token_type: string;
|
||||
role: string;
|
||||
username: string;
|
||||
permissions: string[];
|
||||
}
|
||||
|
||||
export interface CurrentUser {
|
||||
id: number;
|
||||
username: string;
|
||||
role: string;
|
||||
role_label: string;
|
||||
is_active: boolean;
|
||||
remark: string | null;
|
||||
permissions: string[];
|
||||
}
|
||||
|
||||
export interface UserInfo {
|
||||
id: number;
|
||||
username: string;
|
||||
role: string;
|
||||
is_active: boolean;
|
||||
remark: string;
|
||||
permissions: string[];
|
||||
custom_permissions?: string[] | null;
|
||||
}
|
||||
|
||||
// ==================== Account ====================
|
||||
|
||||
export interface AccountItem {
|
||||
id: number;
|
||||
username: string;
|
||||
password?: string | null;
|
||||
email?: string | null;
|
||||
email_password?: string | null;
|
||||
tag: string;
|
||||
assigned_to: number | null;
|
||||
assigned_username: string | null;
|
||||
remark: string;
|
||||
created_at: string | null;
|
||||
}
|
||||
|
||||
export interface AssignmentsSummary {
|
||||
support_users: SupportUserItem[];
|
||||
unassigned_count: number;
|
||||
}
|
||||
|
||||
export interface SupportUserItem {
|
||||
id: number;
|
||||
username: string;
|
||||
assigned_count: number;
|
||||
}
|
||||
|
||||
// ==================== Login Task ====================
|
||||
|
||||
export interface LoginTaskItem {
|
||||
id: number;
|
||||
batch_id: string;
|
||||
account_id: number;
|
||||
account_username: string;
|
||||
status: string;
|
||||
cookie: string;
|
||||
message: string;
|
||||
created_by: number;
|
||||
created_at: string | null;
|
||||
finished_at: string | null;
|
||||
}
|
||||
|
||||
export interface BatchLoginResult {
|
||||
batch_id: string;
|
||||
count: number;
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
// ==================== Cookie ====================
|
||||
|
||||
export interface CookieItem {
|
||||
id: number;
|
||||
batch_id: string;
|
||||
account_id: number;
|
||||
account_username: string;
|
||||
assigned_to: number | null;
|
||||
assigned_username: string | null;
|
||||
created_at: string | null;
|
||||
cookie: string;
|
||||
cookie_preview: string;
|
||||
}
|
||||
|
||||
// ==================== Proxy ====================
|
||||
|
||||
export interface ProxyConfig {
|
||||
enabled: boolean;
|
||||
api_url: string;
|
||||
http: string;
|
||||
https: string;
|
||||
whitelist_enabled: boolean;
|
||||
whitelist_uid: string;
|
||||
whitelist_ukey: string;
|
||||
}
|
||||
|
||||
export interface ProxyTestResult {
|
||||
test_id: string;
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
// ==================== Log ====================
|
||||
|
||||
export interface HttpLogEntry {
|
||||
timestamp: string;
|
||||
ts: number;
|
||||
category: string;
|
||||
tag: string;
|
||||
method: string;
|
||||
url: string;
|
||||
proxy: string | null;
|
||||
request: { headers: Record<string, string>; body: string };
|
||||
response: { status_code: number | null; headers: Record<string, string>; body: string };
|
||||
duration_ms: number | null;
|
||||
error: string | null;
|
||||
level: string;
|
||||
}
|
||||
|
||||
export interface HttpLogListResult {
|
||||
items: HttpLogEntry[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface HttpLogClearResult {
|
||||
success: boolean;
|
||||
cleared: number;
|
||||
}
|
||||
|
||||
// ==================== Permissions ====================
|
||||
|
||||
export interface PermissionsListResult {
|
||||
permissions: Record<string, string>;
|
||||
role_permissions: Record<string, string[]>;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import api from './client';
|
||||
import type { MessageResponse, PermissionsListResult, UserInfo } from './types';
|
||||
|
||||
export const userApi = {
|
||||
list: () => api.get<UserInfo[], UserInfo[]>('/users'),
|
||||
create: (data: { username: string; password: string; role: string; remark?: string }) =>
|
||||
api.post<UserInfo, UserInfo>('/users', data),
|
||||
update: (id: number, data: { password?: string; role?: string; is_active?: boolean; remark?: string; custom_permissions?: string[] | null }) =>
|
||||
api.put<UserInfo, UserInfo>(`/users/${id}`, data),
|
||||
delete: (id: number) => api.delete<MessageResponse, MessageResponse>(`/users/${id}`),
|
||||
listPermissions: () => api.get<PermissionsListResult, PermissionsListResult>('/users/permissions/list'),
|
||||
};
|
||||
@@ -0,0 +1,112 @@
|
||||
import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react';
|
||||
import { Card, Spin, Tag, theme } from 'antd';
|
||||
import { DownOutlined, UpOutlined } from '@ant-design/icons';
|
||||
import type { RealtimeLog } from '../hooks/useWebSocketLogs';
|
||||
|
||||
interface RealtimeLogPanelProps {
|
||||
logs: RealtimeLog[];
|
||||
connected?: boolean;
|
||||
title?: string;
|
||||
emptyText?: ReactNode;
|
||||
height?: number | string;
|
||||
mode?: 'inline' | 'card';
|
||||
collapsible?: boolean;
|
||||
defaultVisible?: boolean;
|
||||
spinWhenEmpty?: boolean;
|
||||
style?: CSSProperties;
|
||||
bodyStyle?: CSSProperties;
|
||||
}
|
||||
|
||||
export default function RealtimeLogPanel({
|
||||
logs,
|
||||
connected = false,
|
||||
title = '实时日志',
|
||||
emptyText = '暂无日志',
|
||||
height = '20vh',
|
||||
mode = 'inline',
|
||||
collapsible = false,
|
||||
defaultVisible = true,
|
||||
spinWhenEmpty = false,
|
||||
style,
|
||||
bodyStyle,
|
||||
}: RealtimeLogPanelProps) {
|
||||
const { token } = theme.useToken();
|
||||
const [visible, setVisible] = useState(defaultVisible);
|
||||
const endRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
endRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
}, [logs, visible]);
|
||||
|
||||
const logColors: Record<string, string> = {
|
||||
error: token.colorError,
|
||||
success: token.colorSuccess,
|
||||
warning: token.colorWarning,
|
||||
info: token.colorText,
|
||||
};
|
||||
|
||||
const logBody = (
|
||||
<div
|
||||
style={{
|
||||
height,
|
||||
overflow: 'auto',
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 12,
|
||||
padding: mode === 'card' ? '8px 16px' : 4,
|
||||
backgroundColor: mode === 'card' ? undefined : token.colorBgLayout,
|
||||
borderRadius: mode === 'card' ? undefined : 4,
|
||||
...bodyStyle,
|
||||
}}
|
||||
>
|
||||
{logs.length === 0 ? (
|
||||
spinWhenEmpty && connected ? (
|
||||
<Spin spinning size="small" />
|
||||
) : (
|
||||
<span style={{ color: token.colorTextTertiary }}>{emptyText}</span>
|
||||
)
|
||||
) : (
|
||||
logs.map((log, index) => (
|
||||
<div
|
||||
key={`${index}-${log.message}`}
|
||||
style={{ color: logColors[log.level] || token.colorText, lineHeight: '20px' }}
|
||||
>
|
||||
{log.message}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
<div ref={endRef} />
|
||||
</div>
|
||||
);
|
||||
|
||||
if (mode === 'card') {
|
||||
return (
|
||||
<Card
|
||||
title={title}
|
||||
size="small"
|
||||
style={{ flex: 1, overflow: 'hidden', ...style }}
|
||||
styles={{ body: { height: '100%', overflow: 'hidden', padding: 0 } }}
|
||||
>
|
||||
{logBody}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ flexShrink: 0, borderTop: `1px solid ${token.colorBorderSecondary}`, ...style }}>
|
||||
<div
|
||||
style={{ display: 'flex', alignItems: 'center', cursor: collapsible ? 'pointer' : 'default', padding: '4px 0', userSelect: 'none' }}
|
||||
onClick={() => collapsible && setVisible((value) => !value)}
|
||||
>
|
||||
<span style={{ fontWeight: 500, fontSize: 13 }}>{title}</span>
|
||||
{collapsible && (
|
||||
visible ? <UpOutlined style={{ marginLeft: 6, fontSize: 10 }} /> : <DownOutlined style={{ marginLeft: 6, fontSize: 10 }} />
|
||||
)}
|
||||
{logs.length > 0 && <span style={{ marginLeft: 8, fontSize: 12, color: token.colorTextTertiary }}>{logs.length} 条</span>}
|
||||
{connected && <Tag color="processing" style={{ marginLeft: 8 }}>连接中</Tag>}
|
||||
</div>
|
||||
{visible && logBody}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { getUser, type AuthUser } from '../store/auth';
|
||||
|
||||
export function usePermissions(userOverride?: AuthUser | null) {
|
||||
const user = userOverride === undefined ? getUser() : userOverride;
|
||||
const permissions = useMemo(() => user?.permissions ?? [], [user]);
|
||||
|
||||
const permissionSet = useMemo(() => new Set(permissions), [permissions]);
|
||||
|
||||
const can = useCallback((permission: string) => permissionSet.has(permission), [permissionSet]);
|
||||
|
||||
const canAny = useCallback(
|
||||
(items: string[]) => items.some((permission) => permissionSet.has(permission)),
|
||||
[permissionSet],
|
||||
);
|
||||
|
||||
return {
|
||||
user,
|
||||
can,
|
||||
canAny,
|
||||
permissions,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
export interface RealtimeLog {
|
||||
level: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface ConnectOptions {
|
||||
clear?: boolean;
|
||||
onClose?: () => void;
|
||||
onError?: () => void;
|
||||
onResult?: () => void;
|
||||
}
|
||||
|
||||
function toWebSocketUrl(pathOrUrl: string): string {
|
||||
if (pathOrUrl.startsWith('ws://') || pathOrUrl.startsWith('wss://')) {
|
||||
return pathOrUrl;
|
||||
}
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
const path = pathOrUrl.startsWith('/') ? pathOrUrl : `/${pathOrUrl}`;
|
||||
return `${protocol}://${window.location.host}${path}`;
|
||||
}
|
||||
|
||||
export function useWebSocketLogs() {
|
||||
const [logs, setLogs] = useState<RealtimeLog[]>([]);
|
||||
const [connected, setConnected] = useState(false);
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const callbacksRef = useRef<ConnectOptions>({});
|
||||
const suppressCloseRef = useRef(false);
|
||||
|
||||
const clearLogs = useCallback(() => {
|
||||
setLogs([]);
|
||||
}, []);
|
||||
|
||||
const close = useCallback((notify = false) => {
|
||||
if (!wsRef.current) {
|
||||
setConnected(false);
|
||||
return;
|
||||
}
|
||||
suppressCloseRef.current = !notify;
|
||||
wsRef.current.close();
|
||||
wsRef.current = null;
|
||||
setConnected(false);
|
||||
}, []);
|
||||
|
||||
const connect = useCallback((pathOrUrl: string, options: ConnectOptions = {}) => {
|
||||
close(false);
|
||||
suppressCloseRef.current = false;
|
||||
callbacksRef.current = options;
|
||||
if (options.clear ?? true) {
|
||||
setLogs([]);
|
||||
}
|
||||
|
||||
const ws = new WebSocket(toWebSocketUrl(pathOrUrl));
|
||||
wsRef.current = ws;
|
||||
setConnected(true);
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data) as RealtimeLog;
|
||||
if (msg.level === 'heartbeat') return;
|
||||
if (msg.level === 'result') {
|
||||
callbacksRef.current.onResult?.();
|
||||
return;
|
||||
}
|
||||
setLogs((prev) => [...prev, msg]);
|
||||
} catch {
|
||||
setLogs((prev) => [...prev, { level: 'info', message: String(event.data) }]);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
const isCurrent = wsRef.current === ws;
|
||||
const shouldNotify = isCurrent && !suppressCloseRef.current;
|
||||
if (isCurrent) {
|
||||
wsRef.current = null;
|
||||
setConnected(false);
|
||||
suppressCloseRef.current = false;
|
||||
}
|
||||
if (shouldNotify) {
|
||||
callbacksRef.current.onClose?.();
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
setConnected(false);
|
||||
callbacksRef.current.onError?.();
|
||||
};
|
||||
}, [close]);
|
||||
|
||||
useEffect(() => () => {
|
||||
close(false);
|
||||
}, [close]);
|
||||
|
||||
return {
|
||||
logs,
|
||||
connected,
|
||||
clearLogs,
|
||||
connect,
|
||||
close,
|
||||
};
|
||||
}
|
||||
@@ -7,9 +7,10 @@ import {
|
||||
SunOutlined, MoonOutlined, DesktopOutlined, FileTextOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useNavigate, useLocation, Outlet } from 'react-router-dom';
|
||||
import { getUser, clearAuth, hasPerm, type AuthUser } from '../store/auth';
|
||||
import { getUser, clearAuth, type AuthUser } from '../store/auth';
|
||||
import { authApi } from '../api/modules';
|
||||
import { useTheme, type ThemeMode } from '../store/theme';
|
||||
import { usePermissions } from '../hooks/usePermissions';
|
||||
|
||||
const { Sider, Content } = Layout;
|
||||
const { Text } = Typography;
|
||||
@@ -32,6 +33,7 @@ export default function MainLayout({ onLogout }: { onLogout?: () => void }) {
|
||||
const [user] = useState<AuthUser | null>(getUser());
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const { mode, isDark, setMode } = useTheme();
|
||||
const { can, canAny } = usePermissions(user);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) navigate('/login');
|
||||
@@ -45,37 +47,37 @@ export default function MainLayout({ onLogout }: { onLogout?: () => void }) {
|
||||
menuItems.push({ key: '/', label: '概览', icon: <DashboardOutlined /> });
|
||||
|
||||
// 账号管理
|
||||
if (hasPerm(user, 'account:view_all') || hasPerm(user, 'account:view_assigned')) {
|
||||
if (canAny(['account:view_all', 'account:view_assigned'])) {
|
||||
menuItems.push({ key: '/accounts', label: '账号管理', icon: <UserOutlined /> });
|
||||
}
|
||||
|
||||
// 分配管理
|
||||
if (hasPerm(user, 'account:assign')) {
|
||||
if (can('account:assign')) {
|
||||
menuItems.push({ key: '/assignments', label: '分配管理', icon: <SwapOutlined /> });
|
||||
}
|
||||
|
||||
// 登录任务
|
||||
if (hasPerm(user, 'login:batch') || hasPerm(user, 'login:view_all')) {
|
||||
if (canAny(['login:batch', 'login:view_all'])) {
|
||||
menuItems.push({ key: '/login-tasks', label: '登录任务', icon: <ApiOutlined /> });
|
||||
}
|
||||
|
||||
// Cookie 管理
|
||||
if (hasPerm(user, 'cookie:view')) {
|
||||
if (can('cookie:view')) {
|
||||
menuItems.push({ key: '/cookies', label: 'Cookie 管理', icon: <KeyOutlined /> });
|
||||
}
|
||||
|
||||
// 代理配置
|
||||
if (hasPerm(user, 'proxy:manage')) {
|
||||
if (can('proxy:manage')) {
|
||||
menuItems.push({ key: '/proxy', label: '代理配置', icon: <CloudServerOutlined /> });
|
||||
}
|
||||
|
||||
// 请求日志(运营和管理员可见)
|
||||
if (hasPerm(user, 'audit:view') || hasPerm(user, 'login:batch')) {
|
||||
if (canAny(['audit:view', 'login:batch'])) {
|
||||
menuItems.push({ key: '/http-logs', label: '请求日志', icon: <FileTextOutlined /> });
|
||||
}
|
||||
|
||||
// 用户管理
|
||||
if (hasPerm(user, 'user:view')) {
|
||||
if (can('user:view')) {
|
||||
menuItems.push({ key: '/users', label: '用户管理', icon: <TeamOutlined /> });
|
||||
}
|
||||
|
||||
@@ -90,7 +92,9 @@ export default function MainLayout({ onLogout }: { onLogout?: () => void }) {
|
||||
onOk: async () => {
|
||||
try {
|
||||
await authApi.logout();
|
||||
} catch {}
|
||||
} catch {
|
||||
// 忽略服务端登出失败,仍继续清理本地登录态。
|
||||
}
|
||||
clearAuth();
|
||||
onLogout?.();
|
||||
navigate('/login', { replace: true });
|
||||
|
||||
@@ -3,9 +3,10 @@ import {
|
||||
Table, Button, Modal, Input, Select, message, Popconfirm, Typography, Tag, Space,
|
||||
Row, Col, Card, Statistic,
|
||||
} from 'antd';
|
||||
import type { TableProps } from 'antd';
|
||||
import { ImportOutlined, DeleteOutlined, TagOutlined, FilterOutlined } from '@ant-design/icons';
|
||||
import { accountApi, userApi, type AccountItem, type UserInfo } from '../api/modules';
|
||||
import { getUser, hasPerm } from '../store/auth';
|
||||
import { usePermissions } from '../hooks/usePermissions';
|
||||
import { getErrorMessage } from '../utils/error';
|
||||
|
||||
const { TextArea } = Input;
|
||||
@@ -27,12 +28,12 @@ export default function AccountsPage() {
|
||||
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
|
||||
const [batchTagInput, setBatchTagInput] = useState('');
|
||||
const [batchTagVisible, setBatchTagVisible] = useState(false);
|
||||
const user = getUser();
|
||||
const { can } = usePermissions();
|
||||
|
||||
const canViewAll = hasPerm(user, 'account:view_all');
|
||||
const canImport = hasPerm(user, 'account:import');
|
||||
const canAssign = hasPerm(user, 'account:assign');
|
||||
const canDelete = hasPerm(user, 'account:delete');
|
||||
const canViewAll = can('account:view_all');
|
||||
const canImport = can('account:import');
|
||||
const canAssign = can('account:assign');
|
||||
const canDelete = can('account:delete');
|
||||
|
||||
const loadAccounts = async () => {
|
||||
setLoading(true);
|
||||
@@ -52,14 +53,18 @@ export default function AccountsPage() {
|
||||
try {
|
||||
const data = await userApi.list();
|
||||
setUsers(data.filter((u) => u.role === 'support'));
|
||||
} catch {}
|
||||
} catch {
|
||||
// 忽略客服列表加载失败,账号列表仍可继续使用。
|
||||
}
|
||||
};
|
||||
|
||||
const loadTags = async () => {
|
||||
try {
|
||||
const data = await accountApi.listTags();
|
||||
setTags(data);
|
||||
} catch {}
|
||||
} catch {
|
||||
// 忽略标签加载失败,页面会退化为无标签筛选。
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -167,7 +172,7 @@ export default function AccountsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
const columns: TableProps<AccountItem>['columns'] = [
|
||||
{ title: 'ID', dataIndex: 'id', width: 60 },
|
||||
{ title: '用户名', dataIndex: 'username' },
|
||||
{
|
||||
|
||||
@@ -30,7 +30,7 @@ export default function AssignmentsPage() {
|
||||
try {
|
||||
const data = await accountApi.assignmentsSummary();
|
||||
setSupportUsers(data.support_users);
|
||||
return data.support_users as SupportUser[];
|
||||
return data.support_users;
|
||||
} catch (e: unknown) {
|
||||
message.error(getErrorMessage(e));
|
||||
return [];
|
||||
@@ -54,7 +54,7 @@ export default function AssignmentsPage() {
|
||||
const users = await loadSummary();
|
||||
if (users.length > 0) {
|
||||
const savedId = localStorage.getItem(STORAGE_KEY_SELECTED_USER);
|
||||
const savedUser = savedId ? users.find((u: SupportUserItemItem) => u.id === Number(savedId)) : null;
|
||||
const savedUser = savedId ? users.find((u: SupportUserItem) => u.id === Number(savedId)) : null;
|
||||
setSelectedUser(savedUser || users[0]);
|
||||
}
|
||||
};
|
||||
@@ -394,4 +394,4 @@ export default function AssignmentsPage() {
|
||||
</Row>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useEffect, useState } from 'react';
|
||||
import { Table, Button, Card, Row, Col, Statistic, message, Tag, Popconfirm, Space, Typography, Input, theme, Dropdown } from 'antd';
|
||||
import { DownloadOutlined, DeleteOutlined, CopyOutlined, SearchOutlined } from '@ant-design/icons';
|
||||
import { cookieApi, type CookieItem } from '../api/modules';
|
||||
import { getUser, hasPerm } from '../store/auth';
|
||||
import { usePermissions } from '../hooks/usePermissions';
|
||||
import { formatTime } from '../utils/time';
|
||||
import { getErrorMessage } from '../utils/error';
|
||||
|
||||
@@ -14,10 +14,10 @@ export default function CookiePage() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const user = getUser();
|
||||
const { can } = usePermissions();
|
||||
|
||||
const canView = hasPerm(user, 'cookie:view');
|
||||
const canExport = hasPerm(user, 'cookie:export');
|
||||
const canView = can('cookie:view');
|
||||
const canExport = can('cookie:export');
|
||||
|
||||
const loadCookies = async () => {
|
||||
setLoading(true);
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
ReloadOutlined, DeleteOutlined, SearchOutlined, EyeOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { logApi, type HttpLogEntry } from '../api/modules';
|
||||
import { hasPerm, getUser } from '../store/auth';
|
||||
import { usePermissions } from '../hooks/usePermissions';
|
||||
import { getErrorMessage } from '../utils/error';
|
||||
|
||||
const { Text, Paragraph } = Typography;
|
||||
@@ -35,7 +35,7 @@ export default function HttpLogsPage() {
|
||||
const [level, setLevel] = useState<string | undefined>(undefined);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [detailEntry, setDetailEntry] = useState<HttpLogEntry | null>(null);
|
||||
const user = getUser();
|
||||
const { canAny } = usePermissions();
|
||||
|
||||
const fetchLogs = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -78,7 +78,7 @@ export default function HttpLogsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const canManage = user && (hasPerm(user, 'audit:view') || hasPerm(user, 'login:batch'));
|
||||
const canManage = canAny(['audit:view', 'login:batch']);
|
||||
|
||||
const columns = [
|
||||
{
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { useEffect, useState, useRef, useMemo, useCallback } from 'react';
|
||||
import { useEffect, useState, useMemo, useCallback } from 'react';
|
||||
import {
|
||||
Table, Button, Select, message, Tag, Space, Spin, InputNumber, Tooltip, Popconfirm, theme,
|
||||
Table, Button, Select, message, Tag, Space, InputNumber, Tooltip, Popconfirm, theme,
|
||||
} from 'antd';
|
||||
import { PlayCircleOutlined, StopOutlined, FilterOutlined, ThunderboltOutlined, ReloadOutlined, DownOutlined, UpOutlined, DeleteOutlined, SwapOutlined } from '@ant-design/icons';
|
||||
import { PlayCircleOutlined, StopOutlined, FilterOutlined, ThunderboltOutlined, ReloadOutlined, DeleteOutlined, SwapOutlined } from '@ant-design/icons';
|
||||
import { accountApi, loginApi, type AccountItem, type LoginTaskItem } from '../api/modules';
|
||||
import { getUser, hasPerm } from '../store/auth';
|
||||
import RealtimeLogPanel from '../components/RealtimeLogPanel';
|
||||
import { usePermissions } from '../hooks/usePermissions';
|
||||
import { useWebSocketLogs } from '../hooks/useWebSocketLogs';
|
||||
import { formatTime } from '../utils/time';
|
||||
import { getErrorMessage } from '../utils/error';
|
||||
|
||||
@@ -35,20 +37,16 @@ export default function LoginTasksPage() {
|
||||
const [tasks, setTasks] = useState<LoginTaskItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [batchId, setBatchId] = useState<string | null>(null);
|
||||
const [logs, setLogs] = useState<{ level: string; message: string }[]>([]);
|
||||
const [wsConnected, setWsConnected] = useState(false);
|
||||
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
||||
const [concurrency, setConcurrency] = useState(3);
|
||||
const [maxProxyRetries, setMaxProxyRetries] = useState(10);
|
||||
const [logVisible, setLogVisible] = useState(true);
|
||||
const [selectedRowKeys, setSelectedRowKeys] = useState<number[]>([]);
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const logEndRef = useRef<HTMLDivElement | null>(null);
|
||||
const user = getUser();
|
||||
const { logs, connected: wsConnected, connect: connectLogs } = useWebSocketLogs();
|
||||
const { can } = usePermissions();
|
||||
|
||||
const { token } = theme.useToken();
|
||||
|
||||
const canBatch = hasPerm(user, 'login:batch');
|
||||
const canBatch = can('login:batch');
|
||||
|
||||
// 从账号中提取所有标签
|
||||
const allTags = useMemo(() => {
|
||||
@@ -113,20 +111,15 @@ export default function LoginTasksPage() {
|
||||
try {
|
||||
const data = await loginApi.listTasks(batchId || undefined);
|
||||
setTasks(data);
|
||||
} catch {}
|
||||
} catch {
|
||||
// 忽略轮询失败,下一次定时刷新会继续尝试。
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([loadAccounts(), loadTasks()]);
|
||||
}, []);
|
||||
|
||||
// 日志自动滚动到底部
|
||||
useEffect(() => {
|
||||
if (logVisible && logEndRef.current) {
|
||||
logEndRef.current.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
}, [logs, logVisible]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(loadTasks, 3000);
|
||||
return () => clearInterval(timer);
|
||||
@@ -139,31 +132,15 @@ export default function LoginTasksPage() {
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setLogs([]);
|
||||
try {
|
||||
const result = await loginApi.createBatch(accountIds, 5, concurrency, maxProxyRetries);
|
||||
setBatchId(result.batch_id);
|
||||
message.success(`已创建登录任务,共 ${result.count} 个账号`);
|
||||
|
||||
// 连接 WebSocket
|
||||
const wsUrl = `${window.location.protocol === 'https:' ? 'wss' : 'ws'}://${window.location.host}/api/login/ws/login/${result.batch_id}`;
|
||||
const ws = new WebSocket(wsUrl);
|
||||
wsRef.current = ws;
|
||||
setWsConnected(true);
|
||||
ws.onmessage = (event) => {
|
||||
const msg = JSON.parse(event.data);
|
||||
if (msg.level === 'heartbeat') return;
|
||||
if (msg.level === 'result') return;
|
||||
setLogs((prev) => [...prev, msg]);
|
||||
};
|
||||
ws.onclose = () => {
|
||||
wsRef.current = null;
|
||||
setWsConnected(false);
|
||||
setBatchId(null);
|
||||
};
|
||||
ws.onerror = () => {
|
||||
setWsConnected(false);
|
||||
};
|
||||
connectLogs(`/api/login/ws/login/${result.batch_id}`, {
|
||||
onClose: () => setBatchId(null),
|
||||
onResult: () => setBatchId(null),
|
||||
});
|
||||
} catch (e: unknown) {
|
||||
message.error(getErrorMessage(e));
|
||||
} finally {
|
||||
@@ -432,50 +409,13 @@ export default function LoginTasksPage() {
|
||||
</div>
|
||||
|
||||
{/* 实时日志 - 底部可折叠 */}
|
||||
<div style={{ flexShrink: 0, borderTop: `1px solid ${token.colorBorderSecondary}`, marginTop: 4 }}>
|
||||
<div
|
||||
style={{ display: 'flex', alignItems: 'center', cursor: 'pointer', padding: '4px 0', userSelect: 'none' }}
|
||||
onClick={() => setLogVisible((v) => !v)}
|
||||
>
|
||||
<span style={{ fontWeight: 500, fontSize: 13 }}>实时日志</span>
|
||||
{logVisible ? <UpOutlined style={{ marginLeft: 6, fontSize: 10 }} /> : <DownOutlined style={{ marginLeft: 6, fontSize: 10 }} />}
|
||||
{logs.length > 0 && <span style={{ marginLeft: 8, fontSize: 12, color: token.colorTextTertiary }}>{logs.length} 条</span>}
|
||||
{wsConnected && <Tag color="processing" style={{ marginLeft: 8 }}>连接中</Tag>}
|
||||
</div>
|
||||
{logVisible && (
|
||||
<div
|
||||
style={{
|
||||
height: '20vh',
|
||||
overflow: 'auto',
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 12,
|
||||
padding: 4,
|
||||
backgroundColor: token.colorBgLayout,
|
||||
borderRadius: 4,
|
||||
}}
|
||||
>
|
||||
{logs.length === 0 ? (
|
||||
<Spin spinning={wsConnected} size="small" />
|
||||
) : (
|
||||
logs.map((log, i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
color:
|
||||
log.level === 'error' ? token.colorError :
|
||||
log.level === 'success' ? token.colorSuccess :
|
||||
log.level === 'warning' ? token.colorWarning :
|
||||
token.colorText,
|
||||
}}
|
||||
>
|
||||
{log.message}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
<div ref={logEndRef} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<RealtimeLogPanel
|
||||
logs={logs}
|
||||
connected={wsConnected}
|
||||
collapsible
|
||||
spinWhenEmpty
|
||||
style={{ marginTop: 4 }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,19 +1,17 @@
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { Form, Input, Switch, Button, Card, message, Row, Col, theme } from 'antd';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Form, Input, Switch, Button, Card, message, Row, Col } from 'antd';
|
||||
import { proxyApi, type ProxyConfig } from '../api/modules';
|
||||
import RealtimeLogPanel from '../components/RealtimeLogPanel';
|
||||
import { useWebSocketLogs } from '../hooks/useWebSocketLogs';
|
||||
import { getErrorMessage } from '../utils/error';
|
||||
|
||||
const WS_BASE = `${window.location.protocol === 'https:' ? 'wss' : 'ws'}://${window.location.host}`;
|
||||
|
||||
export default function ProxyPage() {
|
||||
const { token } = theme.useToken();
|
||||
const [form] = Form.useForm();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [testingWl, setTestingWl] = useState(false);
|
||||
const [configLoaded, setConfigLoaded] = useState(false);
|
||||
const [logs, setLogs] = useState<{ level: string; message: string }[]>([]);
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const { logs, connect: connectLogs, close: closeLogs } = useWebSocketLogs();
|
||||
|
||||
const loadConfig = async () => {
|
||||
try {
|
||||
@@ -37,30 +35,21 @@ export default function ProxyPage() {
|
||||
useEffect(() => {
|
||||
loadConfig();
|
||||
return () => {
|
||||
wsRef.current?.close();
|
||||
closeLogs();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const appendLog = (level: string, msg: string) => {
|
||||
setLogs((prev) => [...prev, { level, message: msg }]);
|
||||
};
|
||||
|
||||
const connectWs = (testId: string) => {
|
||||
wsRef.current?.close();
|
||||
setLogs([]);
|
||||
const ws = new WebSocket(`${WS_BASE}/api/proxy/ws/test/${testId}`);
|
||||
wsRef.current = ws;
|
||||
ws.onmessage = (event) => {
|
||||
const msg = JSON.parse(event.data);
|
||||
if (msg.level === 'heartbeat') return;
|
||||
if (msg.level === 'result') return;
|
||||
appendLog(msg.level, msg.message);
|
||||
};
|
||||
ws.onclose = () => {
|
||||
wsRef.current = null;
|
||||
setTesting(false);
|
||||
setTestingWl(false);
|
||||
};
|
||||
connectLogs(`/api/proxy/ws/test/${testId}`, {
|
||||
onClose: () => {
|
||||
setTesting(false);
|
||||
setTestingWl(false);
|
||||
},
|
||||
onResult: () => {
|
||||
setTesting(false);
|
||||
setTestingWl(false);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
@@ -78,7 +67,6 @@ export default function ProxyPage() {
|
||||
|
||||
const handleTestProxy = async () => {
|
||||
setTesting(true);
|
||||
setLogs([]);
|
||||
try {
|
||||
const result = await proxyApi.test();
|
||||
if (result.test_id) connectWs(result.test_id);
|
||||
@@ -90,7 +78,6 @@ export default function ProxyPage() {
|
||||
|
||||
const handleTestWhitelist = async () => {
|
||||
setTestingWl(true);
|
||||
setLogs([]);
|
||||
try {
|
||||
const result = await proxyApi.testWhitelist();
|
||||
if (result.test_id) connectWs(result.test_id);
|
||||
@@ -101,13 +88,6 @@ export default function ProxyPage() {
|
||||
};
|
||||
|
||||
|
||||
const logColors: Record<string, string> = {
|
||||
error: token.colorError,
|
||||
success: token.colorSuccess,
|
||||
warning: token.colorWarning,
|
||||
info: token.colorText,
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', gap: 8 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexShrink: 0 }}>
|
||||
@@ -166,22 +146,15 @@ export default function ProxyPage() {
|
||||
</Row>
|
||||
</Form>
|
||||
|
||||
<Card
|
||||
<RealtimeLogPanel
|
||||
mode="card"
|
||||
logs={logs}
|
||||
title="实时日志"
|
||||
size="small"
|
||||
style={{ flex: 1, overflow: 'hidden' }}
|
||||
styles={{ body: { height: '100%', overflow: 'auto', fontFamily: 'monospace', fontSize: 12, padding: '8px 16px' } }}
|
||||
>
|
||||
{logs.length === 0 ? (
|
||||
<span style={{ color: token.colorTextTertiary }}>点击"测试代理"或"测试白名单"查看日志</span>
|
||||
) : (
|
||||
logs.map((log, i) => (
|
||||
<div key={i} style={{ color: logColors[log.level] || token.colorText, lineHeight: '20px' }}>
|
||||
{log.message}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</Card>
|
||||
emptyText={'点击"测试代理"或"测试白名单"查看日志'}
|
||||
height="100%"
|
||||
bodyStyle={{ minHeight: 160 }}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
} from 'antd';
|
||||
import { PlusOutlined, EditOutlined, DeleteOutlined, SafetyOutlined } from '@ant-design/icons';
|
||||
import { userApi, type UserInfo } from '../api/modules';
|
||||
import { getUser, hasPerm } from '../store/auth';
|
||||
import { usePermissions } from '../hooks/usePermissions';
|
||||
import { getErrorMessage } from '../utils/error';
|
||||
|
||||
const ROLE_OPTIONS = [
|
||||
@@ -50,8 +50,9 @@ export default function UsersPage() {
|
||||
const [selectedPerms, setSelectedPerms] = useState<string[]>([]);
|
||||
const [useCustom, setUseCustom] = useState(false);
|
||||
const [permLoading, setPermLoading] = useState(false);
|
||||
const { can } = usePermissions();
|
||||
|
||||
const canAssignPerm = hasPerm(getUser(), 'user:assign_permissions');
|
||||
const canAssignPerm = can('user:assign_permissions');
|
||||
|
||||
const loadUsers = async () => {
|
||||
setLoading(true);
|
||||
|
||||
Reference in New Issue
Block a user