功能: 按用户同步斗鱼工作台

This commit is contained in:
yml2213
2026-08-13 15:47:07 +08:00
parent 090698e6b2
commit cf3fe68de3
14 changed files with 503 additions and 44 deletions
@@ -0,0 +1,68 @@
"""持久化斗鱼工作台账号归属和任务手册范围
Revision ID: 20260813_0024
Revises: 20260813_0023
Create Date: 2026-08-13
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "20260813_0024"
down_revision: Union[str, None] = "20260813_0023"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def _columns(bind, table_name: str) -> set[str]:
return {column["name"] for column in sa.inspect(bind).get_columns(table_name)}
def upgrade() -> None:
bind = op.get_bind()
if "handbook_scope" not in _columns(bind, "douyu_tasks"):
op.add_column(
"douyu_tasks",
sa.Column("handbook_scope", sa.String(length=16), nullable=False, server_default="legacy"),
)
op.create_index("ix_douyu_tasks_handbook_scope", "douyu_tasks", ["handbook_scope"])
if not sa.inspect(bind).has_table("douyu_workbench_accounts"):
op.create_table(
"douyu_workbench_accounts",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=False),
sa.Column("handbook_scope", sa.String(length=16), nullable=False),
sa.Column("account_id", sa.Integer(), sa.ForeignKey("accounts.id"), nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=True),
sa.UniqueConstraint("user_id", "handbook_scope", "account_id", name="uq_douyu_workbench_account"),
)
op.create_index("ix_douyu_workbench_accounts_user_id", "douyu_workbench_accounts", ["user_id"])
op.create_index("ix_douyu_workbench_accounts_handbook_scope", "douyu_workbench_accounts", ["handbook_scope"])
op.create_index("ix_douyu_workbench_accounts_account_id", "douyu_workbench_accounts", ["account_id"])
if not sa.inspect(bind).has_table("douyu_workbenches"):
op.create_table(
"douyu_workbenches",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=False),
sa.Column("handbook_scope", sa.String(length=16), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=True),
sa.UniqueConstraint("user_id", "handbook_scope", name="uq_douyu_workbench"),
)
op.create_index("ix_douyu_workbenches_user_id", "douyu_workbenches", ["user_id"])
op.create_index("ix_douyu_workbenches_handbook_scope", "douyu_workbenches", ["handbook_scope"])
def downgrade() -> None:
bind = op.get_bind()
if sa.inspect(bind).has_table("douyu_workbenches"):
op.drop_table("douyu_workbenches")
if sa.inspect(bind).has_table("douyu_workbench_accounts"):
op.drop_table("douyu_workbench_accounts")
if "handbook_scope" in _columns(bind, "douyu_tasks"):
op.drop_index("ix_douyu_tasks_handbook_scope", table_name="douyu_tasks")
op.drop_column("douyu_tasks", "handbook_scope")
@@ -0,0 +1,39 @@
"""补齐工作台空状态同步表
Revision ID: 20260813_0025
Revises: 20260813_0024
Create Date: 2026-08-13
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "20260813_0025"
down_revision: Union[str, None] = "20260813_0024"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
bind = op.get_bind()
if sa.inspect(bind).has_table("douyu_workbenches"):
return
op.create_table(
"douyu_workbenches",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=False),
sa.Column("handbook_scope", sa.String(length=16), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=True),
sa.UniqueConstraint("user_id", "handbook_scope", name="uq_douyu_workbench"),
)
op.create_index("ix_douyu_workbenches_user_id", "douyu_workbenches", ["user_id"])
op.create_index("ix_douyu_workbenches_handbook_scope", "douyu_workbenches", ["handbook_scope"])
def downgrade() -> None:
bind = op.get_bind()
if sa.inspect(bind).has_table("douyu_workbenches"):
op.drop_table("douyu_workbenches")
+32 -1
View File
@@ -2,7 +2,7 @@
from datetime import datetime, timezone
from sqlalchemy import (
Column, Integer, String, Boolean, Text, DateTime, ForeignKey, JSON,
Column, Integer, String, Boolean, Text, DateTime, ForeignKey, JSON, UniqueConstraint,
)
from sqlalchemy.orm import relationship
from .database import Base
@@ -117,6 +117,8 @@ class DouyuTask(Base):
batch_id = Column(String(64), nullable=False, index=True)
account_id = Column(Integer, ForeignKey("accounts.id"), nullable=False)
task_type = Column(String(64), nullable=False, index=True)
# 任务归属工作台;避免同一账号的精英/电竞/小店任务在前端串行展示或串弹二维码。
handbook_scope = Column(String(16), nullable=False, default="legacy", index=True)
status = Column(String(32), default="pending")
message = Column(String(512), default="")
result = Column(JSON, nullable=True)
@@ -127,6 +129,35 @@ class DouyuTask(Base):
account = relationship("Account", back_populates="douyu_tasks")
class DouyuWorkbenchAccount(Base):
"""用户在指定斗鱼工作台中启用的账号,跨浏览器同步。"""
__tablename__ = "douyu_workbench_accounts"
id = Column(Integer, primary_key=True, autoincrement=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
handbook_scope = Column(String(16), nullable=False, index=True)
account_id = Column(Integer, ForeignKey("accounts.id"), nullable=False, index=True)
created_at = Column(DateTime, default=_utcnow)
__table_args__ = (
UniqueConstraint("user_id", "handbook_scope", "account_id", name="uq_douyu_workbench_account"),
)
class DouyuWorkbench(Base):
"""工作台配置哨兵,令空账号集合也能跨浏览器同步。"""
__tablename__ = "douyu_workbenches"
id = Column(Integer, primary_key=True, autoincrement=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
handbook_scope = Column(String(16), nullable=False, index=True)
updated_at = Column(DateTime, default=_utcnow, onupdate=_utcnow)
__table_args__ = (
UniqueConstraint("user_id", "handbook_scope", name="uq_douyu_workbench"),
)
class YybRechargeTask(Base):
"""应用宝和平精英点券充值任务。
+4 -1
View File
@@ -5,7 +5,7 @@ from sqlalchemy import func, or_
from sqlalchemy.orm import Session, defer, joinedload
from ..database import get_db
from ..models import User, Account, AuditLog, LoginTask, DouyuTask
from ..models import User, Account, AuditLog, LoginTask, DouyuTask, DouyuWorkbenchAccount
from ..schemas import AccountBulkSelection, AccountBulkTag, AccountImport, AccountAssign, AccountTag, AccountOut, BatchAssign
from ..deps import get_current_user, require_permission
from ..permissions import user_has_permission
@@ -405,6 +405,7 @@ def batch_delete_accounts(
# 先删除关联的登录任务
db.query(LoginTask).filter(LoginTask.account_id.in_(ids)).delete(synchronize_session=False)
db.query(DouyuTask).filter(DouyuTask.account_id.in_(ids)).delete(synchronize_session=False)
db.query(DouyuWorkbenchAccount).filter(DouyuWorkbenchAccount.account_id.in_(ids)).delete(synchronize_session=False)
# 删除账号
deleted = db.query(Account).filter(Account.id.in_(ids)).delete(synchronize_session=False)
@@ -430,6 +431,7 @@ def batch_delete_accounts_selection(
db.query(LoginTask).filter(LoginTask.account_id.in_(ids)).delete(synchronize_session=False)
db.query(DouyuTask).filter(DouyuTask.account_id.in_(ids)).delete(synchronize_session=False)
db.query(DouyuWorkbenchAccount).filter(DouyuWorkbenchAccount.account_id.in_(ids)).delete(synchronize_session=False)
deleted = (
_visible_accounts_query(db, current)
.filter(Account.id.in_(ids))
@@ -457,6 +459,7 @@ def delete_account(
# 先删除关联的登录任务,避免外键约束失败
db.query(LoginTask).filter(LoginTask.account_id == account_id).delete(synchronize_session=False)
db.query(DouyuTask).filter(DouyuTask.account_id == account_id).delete(synchronize_session=False)
db.query(DouyuWorkbenchAccount).filter(DouyuWorkbenchAccount.account_id == account_id).delete(synchronize_session=False)
db.add(AuditLog(user_id=current.id, username=current.username,
action="account:delete", target=acc.username))
+73 -1
View File
@@ -12,7 +12,7 @@ from sqlalchemy.orm import Session, joinedload
from ..database import SessionLocal, get_db
from ..deps import authenticate_websocket, get_current_user, require_permission
from ..models import Account, DouyuConfig, DouyuEsportsGoodsSnapshot, DouyuGoodsSnapshot, DouyuTask, DouyuXpdGoodsSnapshot, User
from ..models import Account, DouyuConfig, DouyuEsportsGoodsSnapshot, DouyuGoodsSnapshot, DouyuTask, DouyuWorkbench, DouyuWorkbenchAccount, DouyuXpdGoodsSnapshot, User
from ..permissions import user_has_permission
from ..schemas import (
DouyuConfigOut,
@@ -21,11 +21,13 @@ from ..schemas import (
DouyuTaskAccountOut,
DouyuTaskBatchRequest,
DouyuTaskOut,
DouyuWorkbenchAccountsUpdate,
DouyuXpdGoodsOut,
)
from ..services.douyu_runner import DouyuBatchRunner, douyu_batch_registry
from ..services.douyu_service import (
DOUYU_CONFIG_FIELDS,
DOUYU_HANDBOOK_TASK_TYPES,
SUPPORTED_DOUYU_TASK_TYPES,
apply_douyu_config_defaults,
cleanup_orphan_douyu_tasks,
@@ -242,6 +244,67 @@ def list_task_account_ids(
return {"account_ids": account_ids, "total": len(account_ids)}
@router.get("/workbench-accounts")
def list_workbench_accounts(
handbook_scope: str = Query(..., pattern="^(elite|esports|peace)$"),
db: Session = Depends(get_db),
current: User = Depends(get_current_user),
):
"""返回当前用户在指定工作台启用的账号,供不同浏览器同步。"""
rows = (
db.query(DouyuWorkbenchAccount.account_id)
.join(Account, Account.id == DouyuWorkbenchAccount.account_id)
.filter(
DouyuWorkbenchAccount.user_id == current.id,
DouyuWorkbenchAccount.handbook_scope == handbook_scope,
)
)
if not _can_view_all(current):
rows = rows.filter(Account.assigned_to == current.id)
account_ids = [account_id for account_id, in rows.order_by(DouyuWorkbenchAccount.id.asc()).all()]
configured = db.query(DouyuWorkbench.id).filter(
DouyuWorkbench.user_id == current.id,
DouyuWorkbench.handbook_scope == handbook_scope,
).first() is not None
return {"account_ids": account_ids, "configured": configured}
@router.put("/workbench-accounts")
def update_workbench_accounts(
req: DouyuWorkbenchAccountsUpdate,
db: Session = Depends(get_db),
current: User = Depends(require_permission("douyu:task")),
):
"""用当前完整账号集合覆盖一个工作台,作为跨浏览器的同步状态。"""
account_ids = sorted(set(req.account_ids))
if any(account_id < 1 for account_id in account_ids):
raise HTTPException(status_code=400, detail="无效的账号ID")
if account_ids:
_require_task_account_access(db, current, account_ids)
workbench = db.query(DouyuWorkbench).filter(
DouyuWorkbench.user_id == current.id,
DouyuWorkbench.handbook_scope == req.handbook_scope,
).first()
if workbench is None:
db.add(DouyuWorkbench(user_id=current.id, handbook_scope=req.handbook_scope))
else:
workbench.updated_at = datetime.now(timezone.utc)
db.query(DouyuWorkbenchAccount).filter(
DouyuWorkbenchAccount.user_id == current.id,
DouyuWorkbenchAccount.handbook_scope == req.handbook_scope,
).delete(synchronize_session=False)
db.add_all([
DouyuWorkbenchAccount(
user_id=current.id,
handbook_scope=req.handbook_scope,
account_id=account_id,
)
for account_id in account_ids
])
db.commit()
return {"account_ids": account_ids, "success": True}
@router.get("/config", response_model=DouyuConfigOut)
def get_config(
db: Session = Depends(get_db),
@@ -324,6 +387,7 @@ async def create_task_batch(
db,
req.account_ids,
req.task_type,
req.handbook_scope,
current.id,
req.payload,
)
@@ -355,6 +419,7 @@ async def create_task_batch(
@router.get("/tasks")
def list_tasks(
batch_id: str | None = None,
handbook_scope: str | None = Query(None, pattern="^(elite|esports|peace)$"),
include_detail: bool = Query(False, description="是否返回完整任务结果(默认否,轮询请保持 false)"),
page: int | None = Query(None, ge=1),
page_size: int = Query(100, ge=1, le=500),
@@ -365,6 +430,13 @@ def list_tasks(
query = _visible_tasks_query(db, current)
if batch_id:
query = query.filter(DouyuTask.batch_id == batch_id)
if handbook_scope:
# 旧任务没有归属字段,按历史任务类型继续展示,但绝不会触发自动二维码弹窗。
query = query.filter(or_(
DouyuTask.handbook_scope == handbook_scope,
(DouyuTask.handbook_scope == "legacy")
& DouyuTask.task_type.in_(DOUYU_HANDBOOK_TASK_TYPES[handbook_scope]),
))
total = None
if page is not None:
total = query.enable_eagerloads(False).order_by(None).count()
+8
View File
@@ -642,10 +642,16 @@ class DouyuConfigUpdate(BaseModel):
class DouyuTaskBatchRequest(BaseModel):
account_ids: list[int]
task_type: str
handbook_scope: str = Field(..., pattern="^(elite|esports|peace)$")
concurrency: int = Field(3, ge=1, le=10)
payload: dict[str, Any] = Field(default_factory=dict)
class DouyuWorkbenchAccountsUpdate(BaseModel):
handbook_scope: str = Field(..., pattern="^(elite|esports|peace)$")
account_ids: list[int] = Field(default_factory=list)
class DouyuTaskOut(BaseModel):
id: int
batch_id: str
@@ -654,6 +660,7 @@ class DouyuTaskOut(BaseModel):
account_uid: str = ""
account_nickname: str = ""
task_type: str
handbook_scope: str = "legacy"
status: str
message: str = ""
result: Optional[dict[str, Any]] = None
@@ -673,6 +680,7 @@ class DouyuTaskOut(BaseModel):
"account_uid": self.account_uid,
"account_nickname": self.account_nickname,
"task_type": self.task_type,
"handbook_scope": self.handbook_scope,
"status": self.status,
"message": self.message,
"result": self.result,
+28
View File
@@ -48,6 +48,27 @@ SUPPORTED_DOUYU_TASK_TYPES = { "get_bind_qr": "获取绑定二维码",
"exchange_xpd_goods": "兑换小店商品",
}
DOUYU_HANDBOOK_SCOPES = {"elite", "esports", "peace"}
DOUYU_HANDBOOK_TASK_TYPES = {
"elite": {
"get_bind_qr", "confirm_bind", "create_elite_qr", "create_gold_qr", "donate_elite_gift",
"query_points", "exchange_goods", "query_game_name", "query_change_bind_time",
"query_limited_goods", "query_gold_balance", "refresh_goods", "query_exchange_records",
"prefetch_csrf_token",
},
"esports": {
"prepare_esports_bind", "get_esports_bind_qr", "query_esports_game_name", "confirm_esports_bind",
"create_esports_qr", "query_esports_points", "query_gold_balance", "query_change_bind_time",
"query_limited_goods", "refresh_esports_goods", "exchange_esports_goods", "create_gold_qr",
"donate_esports_chicken_gift", "donate_esports_firework_gift",
},
"peace": {
"get_xpd_bind_qr", "query_xpd_bind_info", "confirm_xpd_bind", "query_xpd_role",
"refresh_xpd_goods", "query_xpd_balance", "query_xpd_fragments",
"query_xpd_purchase_records", "exchange_xpd_goods",
},
}
DOUYU_CONFIG_DEFAULTS = { "manual_id": "G4KA4Qnz4LDp7",
"rid": "9263298",
@@ -171,12 +192,17 @@ def create_douyu_planned_tasks(
db: Session,
account_ids: list[int],
task_type: str,
handbook_scope: str,
created_by: int,
payload: dict | None = None,
) -> tuple[str, int]:
"""创建斗鱼任务记录,等待后台执行器消费。"""
if task_type not in SUPPORTED_DOUYU_TASK_TYPES:
raise ValueError("不支持的任务类型")
if handbook_scope not in DOUYU_HANDBOOK_SCOPES:
raise ValueError("无效的工作台")
if task_type not in DOUYU_HANDBOOK_TASK_TYPES[handbook_scope]:
raise ValueError("该任务不属于当前工作台")
accounts = visible_douyu_task_accounts(db, account_ids)
if task_type in {"refresh_goods", "refresh_esports_goods", "refresh_xpd_goods"} and accounts:
@@ -190,6 +216,7 @@ def create_douyu_planned_tasks(
batch_id=batch_id,
account_id=account.id,
task_type=task_type,
handbook_scope=handbook_scope,
status="planned",
message="任务已创建,等待执行",
result={"payload": payload} if payload else None,
@@ -333,6 +360,7 @@ def douyu_task_payload(task: DouyuTask, *, include_detail: bool = False) -> dict
"account_uid": (account.uid if account else "") or "",
"account_nickname": (account.nickname if account else "") or "",
"task_type": task.task_type or "",
"handbook_scope": task.handbook_scope or "legacy",
"status": task.status or "",
"message": task.message or "",
"result": sanitize_douyu_task_result(
+12 -2
View File
@@ -27,8 +27,18 @@ export const douyuApi = {
listPeaceGoods: () => api.get<DouyuXpdGoodsItem[], DouyuXpdGoodsItem[]>('/douyu/xpd-goods'),
createTasks: (data: DouyuTaskBatchRequest) =>
api.post<DouyuTaskBatchResult, DouyuTaskBatchResult>('/douyu/tasks/batch', data),
listTasks: (batchId?: string) =>
api.get<DouyuTaskItem[], DouyuTaskItem[]>('/douyu/tasks', { params: batchId ? { batch_id: batchId } : {} }),
listTasks: (handbookScope: 'elite' | 'esports' | 'peace', batchId?: string) =>
api.get<DouyuTaskItem[], DouyuTaskItem[]>('/douyu/tasks', {
params: { handbook_scope: handbookScope, ...(batchId ? { batch_id: batchId } : {}) },
}),
listWorkbenchAccounts: (handbookScope: 'elite' | 'esports' | 'peace') =>
api.get<{ account_ids: number[]; configured: boolean }, { account_ids: number[]; configured: boolean }>('/douyu/workbench-accounts', {
params: { handbook_scope: handbookScope },
}),
updateWorkbenchAccounts: (handbookScope: 'elite' | 'esports' | 'peace', accountIds: number[]) =>
api.put<{ account_ids: number[]; success: boolean }, { account_ids: number[]; success: boolean }>(
'/douyu/workbench-accounts', { handbook_scope: handbookScope, account_ids: accountIds },
),
listTasksPaged: (params: PageParams & { batch_id?: string; include_detail?: boolean }) =>
api.get<PaginatedResponse<DouyuTaskItem>, PaginatedResponse<DouyuTaskItem>>('/douyu/tasks', { params }),
getTask: (taskId: number) => api.get<DouyuTaskItem, DouyuTaskItem>(`/douyu/tasks/${taskId}`),
+2
View File
@@ -347,6 +347,7 @@ export interface DouyuConfig {
export interface DouyuTaskBatchRequest {
account_ids: number[];
task_type: string;
handbook_scope: 'elite' | 'esports' | 'peace';
concurrency?: number;
payload?: Record<string, unknown>;
}
@@ -365,6 +366,7 @@ export interface DouyuTaskItem {
account_uid: string;
account_nickname: string;
task_type: string;
handbook_scope: 'elite' | 'esports' | 'peace' | 'legacy';
status: string;
message: string;
result: Record<string, unknown> | null;
+80 -35
View File
@@ -358,12 +358,9 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
const { can } = usePermissions();
const { token } = theme.useToken();
const [accounts, setAccounts] = useState<DouyuTaskAccountItem[]>([]);
// 工作台已导入账号 IDlocalStorage 持久化,默认空白,用户手动导入/移除)
const [workbenchIds, setWorkbenchIds] = useState<number[]>(() => {
const raw = localStorage.getItem(DOUYU_WORKBENCH_IDS_STORAGE_KEY(handbook));
if (!raw) return [];
return raw.split(',').map(Number).filter(Number.isInteger).filter((id) => id >= 1);
});
// 工作台账号归属由服务端按用户保存;localStorage 仅用于首次升级迁移旧浏览器数据。
const [workbenchIds, setWorkbenchIds] = useState<number[]>([]);
const [workbenchReady, setWorkbenchReady] = useState(false);
const [selectedIds, setSelectedIds] = useState<number[]>([]);
const [goods, setGoods] = useState<DouyuGoodsItem[]>([]);
const [tasks, setTasks] = useState<DouyuTaskItem[]>([]);
@@ -421,6 +418,7 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
const [confirmFailCountdown, setConfirmFailCountdown] = useState(0);
const [exchangePreview, setExchangePreview] = useState<{ task: DouyuTaskItem; url: string } | null>(null);
const [xpdPurchaseRecordsAccountId, setXpdPurchaseRecordsAccountId] = useState<number | null>(null);
const locallyStartedBatchIds = useRef<Set<string>>(new Set());
const autoOpenQrReady = useRef(false);
const autoOpenEsportsBindReady = useRef(false);
const autoOpenPayReady = useRef(false);
@@ -594,7 +592,7 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
isPeaceHandbook
? douyuApi.listPeaceGoods()
: (isEsportsHandbook ? douyuApi.listEsportsGoods() : douyuApi.listGoods()),
douyuApi.listTasks(),
douyuApi.listTasks(handbook),
canConfig ? douyuApi.getConfig() : Promise.resolve(null),
douyuApi.taskTypes(),
]);
@@ -608,14 +606,9 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
if (goodsResult.status === 'fulfilled') setGoods(goodsResult.value);
if (taskResult.status === 'fulfilled') {
setTasks(taskResult.value);
// 首次加载时屏蔽历史二维码自动弹窗;后续刷新不能把新任务一并标记掉
// 首次加载只压制历史的复制/失败提示。二维码只会因当前页面创建的批次自动打开。
if (!autoOpenQrReady.current) {
for (const task of taskResult.value) {
if (hasBindQrcode(task)) autoOpenedQrTaskIds.current.add(task.id);
if (task.task_type === 'prepare_esports_bind' && task.result?.esports_bind_dialog === true) {
autoOpenedEsportsBindTaskIds.current.add(task.id);
}
if (hasPaymentQrcode(task)) autoOpenedPayTaskIds.current.add(task.id);
// 历史兑换成功任务不触发自动复制,避免页面加载时打扰
if (exchangeTaskSucceeded(task)) autoCopiedExchangeTaskIds.current.add(task.id);
// 历史确认绑定失败任务不重复弹提示(只提示一次)
@@ -637,13 +630,13 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
} finally {
setLoading(false);
}
}, [accountPage, accountPageSize, accountSearch, canConfig, isEsportsHandbook, isPeaceHandbook, workbenchIds]);
}, [accountPage, accountPageSize, accountSearch, canConfig, handbook, isEsportsHandbook, isPeaceHandbook, workbenchIds]);
const loadTasks = useCallback(async () => {
if (tasksLoadingRef.current) return;
tasksLoadingRef.current = true;
try {
const data = await douyuApi.listTasks();
const data = await douyuApi.listTasks(handbook);
setTasks(data);
if (!data.some((task) => ['pending', 'running', 'planned'].includes(task.status))) {
// 批次已结束时清理 busy 标记,避免 onResult 丢失导致永久锁住
@@ -654,22 +647,43 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
} finally {
tasksLoadingRef.current = false;
}
}, []);
}, [handbook]);
useEffect(() => { loadData(); }, [loadData]);
useEffect(() => {
let cancelled = false;
void douyuApi.listWorkbenchAccounts(handbook)
.then(async ({ account_ids, configured }) => {
if (cancelled) return;
if (configured) {
setWorkbenchIds(account_ids);
localStorage.removeItem(DOUYU_WORKBENCH_IDS_STORAGE_KEY(handbook));
return;
}
// 仅在服务端尚未保存时迁移当前浏览器的旧工作台列表,避免覆盖其他设备的配置。
const legacy = (localStorage.getItem(DOUYU_WORKBENCH_IDS_STORAGE_KEY(handbook)) || '')
.split(',').map(Number).filter(Number.isInteger).filter((id) => id >= 1);
if (legacy.length === 0) return;
const saved = await douyuApi.updateWorkbenchAccounts(handbook, legacy);
if (!cancelled) {
setWorkbenchIds(saved.account_ids);
localStorage.removeItem(DOUYU_WORKBENCH_IDS_STORAGE_KEY(handbook));
}
})
.catch((error) => {
if (!cancelled) message.error(`加载工作台账号失败: ${getErrorMessage(error)}`);
})
.finally(() => {
if (!cancelled) setWorkbenchReady(true);
});
return () => { cancelled = true; };
}, [handbook]);
useEffect(() => {
localStorage.setItem(DOUYU_LAYOUT_MODE_STORAGE_KEY, layoutMode);
}, [layoutMode]);
// 工作台账号集合持久化:移除账号后刷新不再出现
useEffect(() => {
localStorage.setItem(
DOUYU_WORKBENCH_IDS_STORAGE_KEY(handbook),
workbenchIds.length ? workbenchIds.join(',') : '',
);
}, [handbook, workbenchIds]);
// 监听账号表格区域高度变化,动态计算 scroll.y 实现表体内部滚动
useEffect(() => {
const node = accountTableAreaRef.current;
@@ -770,7 +784,11 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
useEffect(() => {
if (!autoOpenQrReady.current) return;
const candidates = visibleTasks
.filter((task) => hasBindQrcode(task) && !autoOpenedQrTaskIds.current.has(task.id))
.filter((task) => (
locallyStartedBatchIds.current.has(task.batch_id)
&& hasBindQrcode(task)
&& !autoOpenedQrTaskIds.current.has(task.id)
))
.sort((a, b) => b.id - a.id);
if (candidates.length === 0) return;
// 全部加入标签栏但不抢焦点;仅在当前无 active 标签时激活最新(id 最大)的
@@ -783,7 +801,8 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
if (!autoOpenEsportsBindReady.current || esportsBindTask) return;
const nextTask = visibleTasks
.filter((task) => (
task.task_type === 'prepare_esports_bind'
locallyStartedBatchIds.current.has(task.batch_id)
&& task.task_type === 'prepare_esports_bind'
&& task.result?.esports_bind_dialog === true
&& !autoOpenedEsportsBindTaskIds.current.has(task.id)
))
@@ -795,7 +814,12 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
useEffect(() => {
if (!autoOpenPayReady.current || payTask) return;
const nextPayTask = visibleTasks
.filter((task) => hasPaymentQrcode(task) && !paymentArrived(task) && !autoOpenedPayTaskIds.current.has(task.id))
.filter((task) => (
locallyStartedBatchIds.current.has(task.batch_id)
&& hasPaymentQrcode(task)
&& !paymentArrived(task)
&& !autoOpenedPayTaskIds.current.has(task.id)
))
.sort((a, b) => b.id - a.id)[0];
if (nextPayTask) openPayTask(nextPayTask);
}, [openPayTask, payTask, visibleTasks]);
@@ -956,9 +980,12 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
const result = await douyuApi.createTasks({
account_ids: ids,
task_type: taskType,
handbook_scope: handbook,
concurrency,
payload,
});
// 只有本页面创建的批次才允许主动弹二维码,防止其他工作台/浏览器的任务打扰当前操作。
locallyStartedBatchIds.current.add(result.batch_id);
setRunningBatchIds((prev) => new Set(prev).add(result.batch_id));
logs.connectBatch(result.batch_id, `/api/douyu/ws/${result.batch_id}`, {
clear: true,
@@ -972,6 +999,7 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
},
onTask: (rawTask) => {
const task = rawTask as unknown as DouyuTaskItem;
if (task.handbook_scope !== handbook) return;
// 已有任务原位更新保持排序;新任务插入头部(与后端 id desc 一致)
setTasks((prev) => {
if (prev.some((t) => t.id === task.id)) {
@@ -1039,9 +1067,21 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
}, [importSearch, importTag]);
useEffect(() => { setImportSelectedIds([]); }, [importPage]);
const importAccounts = (ids: number[]) => {
const saveWorkbenchIds = async (nextIds: number[]) => {
const saved = await douyuApi.updateWorkbenchAccounts(handbook, nextIds);
setWorkbenchIds(saved.account_ids);
return saved.account_ids;
};
const importAccounts = async (ids: number[]) => {
const toImport = importPool.filter((a) => ids.includes(a.id));
setWorkbenchIds((prev) => [...new Set([...prev, ...toImport.map((a) => a.id)])]);
const nextIds = [...new Set([...workbenchIds, ...toImport.map((a) => a.id)])];
try {
await saveWorkbenchIds(nextIds);
} catch (error) {
message.error(getErrorMessage(error));
return;
}
setAccounts((prev) => {
const existing = new Set(prev.map((a) => a.id));
return [...prev, ...toImport.filter((a) => !existing.has(a.id))];
@@ -1063,7 +1103,7 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
message.info('当前筛选条件下没有可新增的账号');
return;
}
setWorkbenchIds((prev) => [...new Set([...prev, ...imported])]);
await saveWorkbenchIds([...new Set([...workbenchIds, ...imported])]);
setImportSelectedIds([]);
setImportOpen(false);
message.success(`已一键导入 ${imported.length} 个账号`);
@@ -1074,9 +1114,14 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
}
};
const removeSelected = () => {
const removeSelected = async () => {
const sel = new Set(selectedIds);
setWorkbenchIds((prev) => prev.filter((id) => !sel.has(id)));
try {
await saveWorkbenchIds(workbenchIds.filter((id) => !sel.has(id)));
} catch (error) {
message.error(getErrorMessage(error));
return;
}
setAccounts((prev) => prev.filter((a) => !sel.has(a.id)));
setSelectedIds([]);
message.success(`已移出 ${sel.size} 个账号`);
@@ -1873,7 +1918,7 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
rowKey="id"
size="small"
className="douyu-task-record-table"
loading={loading}
loading={loading || !workbenchReady}
locale={{ emptyText: '工作台暂无账号,点击右上角「导入账号」添加' }}
rowSelection={{
selectedRowKeys: selectedIds,
@@ -2099,13 +2144,13 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
/>
</Space>
<Space wrap style={{ width: '100%', justifyContent: 'flex-start' }}>
<Button onClick={() => importAccounts(importPool.map((a) => a.id))}>
<Button onClick={() => void importAccounts(importPool.map((a) => a.id))}>
({importPool.length})
</Button>
<Button loading={importAllLoading} onClick={() => void importAllAccounts()}>
</Button>
<Button type="primary" onClick={() => importAccounts(importSelectedIds)} disabled={importSelectedIds.length === 0}>
<Button type="primary" onClick={() => void importAccounts(importSelectedIds)} disabled={importSelectedIds.length === 0}>
({importSelectedIds.length})
</Button>
</Space>