diff --git a/docs/微博/微博账号池与个人工作台-2026-11-08.md b/docs/微博/微博账号池与个人工作台-2026-11-08.md new file mode 100644 index 0000000..a7a3ff1 --- /dev/null +++ b/docs/微博/微博账号池与个人工作台-2026-11-08.md @@ -0,0 +1,97 @@ +--- +type: 设计 +description: 微博外部 CK 账号池与客服个人工作台的拆分设计,覆盖数据归属、权限、接口和 UI。 +tags: [微博, CK, 账号池, 工作台, 权限, 协作] +generated: + by: ai/codex + at: 2026-11-08T16:20:00+08:00 +status: implemented +--- + +# 微博账号池与个人工作台 + +## 1. 目标 + +把原来的「账号与绑定」拆成两个入口: + +- **账号管理**:全局微博账号池,负责 CK 导入、生命周期、标签、批量操作和资料维护。 +- **微博工作台**:每个用户自己的账号子集,只展示自己导入的账号,用于绑定、充值、兑换和日常操作。 + +工作台采用斗鱼模式:**账号实体仍然全局唯一,工作台只保存用户与账号的映射**。同一个账号可以被多人分别加入自己的工作台,工作台增删互不影响;底层 CK 刷新后,所有引用该账号的工作台看到同一份最新资料。 + +## 2. 数据模型 + +`weibo_accounts` 继续作为全局账号池,不允许因为工作台拆出第二份 CK。 + +新增: + +```text +weibo_workbench_accounts + id + user_id -> users.id + account_id -> weibo_accounts.id + created_at + +UNIQUE(user_id, account_id) +``` + +删除账号或用户时,对应工作台映射级联删除,不保留孤儿记录。 + +## 3. 权限 + +- `weibo:view`:查看全局账号管理。 +- `weibo:workbench`:访问个人微博工作台。 +- `weibo:import`:导入外部 CK。 +- `weibo:manage`:编辑账号标签/备注、启停账号、批量标签。 +- `weibo:account:ck_export`:复制账号 CK。 +- `weibo:bind`:生成绑定二维码。 +- `weibo:recharge`:创建充值订单。 +- `weibo:exchange`:执行兑换。 + +客服和运营默认拥有个人工作台及日常操作权限;删除账号仍由 `weibo:delete` 控制,客服默认没有。 + +## 4. 接口 + +| 方法 | 路径 | 权限 | 说明 | +| --- | --- | --- | --- | +| GET | `/api/weibo/accounts` | `weibo:view` | 全局账号池分页列表 | +| GET | `/api/weibo/workbench/accounts` | `weibo:workbench` | 当前用户工作台账号分页列表 | +| GET | `/api/weibo/workbench/pool` | `weibo:workbench` | 可加入当前工作台且尚未加入的账号 | +| POST | `/api/weibo/workbench/accounts` | `weibo:workbench` | 批量加入工作台,幂等 | +| POST | `/api/weibo/workbench/accounts/remove` | `weibo:workbench` | 批量移出工作台,不影响账号池和其他用户 | +| POST | `/api/weibo/accounts/batch-tag` | `weibo:manage` | 账号管理页批量设置标签 | + +工作台接口始终按 `current.id` 过滤,不接受客户端传入用户 ID。 + +## 5. UI + +菜单: + +```text +微博 + -> 账号管理 + -> 微博工作台 + -> 外部单协作 +``` + +账号管理页: + +- 标题「微博账号管理」。 +- 保留全量账号列表、生命周期、批量刷新、批量标签、启停和删除。 +- 用于维护账号池,不作为客服的日常入口。 + +工作台页: + +- 标题「微博工作台」。 +- 复用账号能力,但列表只显示当前用户工作台映射。 +- 「导入账号」从账号池选择账号加入。 +- 「导入外部 CK」会先写入全局账号池,再自动加入当前工作台。 +- 支持单个/批量移出工作台,不删除账号池数据。 +- 绑定二维码、充值、兑换、功能查询继续复用现有账号接口。 + +## 6. 数据隔离边界 + +- 工作台列表、账号池候选和增删接口都按当前登录用户过滤。 +- 加入/移出只操作 `weibo_workbench_accounts`,不修改账号归属或 CK。 +- 账号池仍由全局权限控制;客服可以看到账号管理入口,但不能删除账号。 +- 外部单协作继续引用全局账号 ID,不受个人工作台选择影响。 diff --git a/tests/test_migrations.py b/tests/test_migrations.py index dd122bc..e31c44b 100644 --- a/tests/test_migrations.py +++ b/tests/test_migrations.py @@ -26,7 +26,7 @@ TESTS_DIR = Path(__file__).resolve().parent PROJECT_ROOT = TESTS_DIR.parent VERSIONS_DIR = PROJECT_ROOT / "web" / "backend" / "migrations" / "versions" -HEAD_REVISION = "20261108_0071" +HEAD_REVISION = "20261108_0072" # 迁移新增、但不声明在模型里的复合查询索引。 EXTRA_INDEXES = ( diff --git a/tests/test_weibo_accounts.py b/tests/test_weibo_accounts.py index d82d5f3..6850d4f 100644 --- a/tests/test_weibo_accounts.py +++ b/tests/test_weibo_accounts.py @@ -18,10 +18,12 @@ from core.weibo import ( WeiboRechargeError, WeiboRechargeOrder, ) -from web.backend.models import AuditLog, User, WeiboAccount +from web.backend.models import AuditLog, User, WeiboAccount, WeiboWorkbenchAccount from web.backend.permissions import ROLE_PERMISSIONS from web.backend.routers import weibo as weibo_router from web.backend.routers.weibo import ( + add_workbench_accounts, + batch_tag_accounts, copy_account_ck, create_bind_qrcodes, disable_account, @@ -29,16 +31,21 @@ from web.backend.routers.weibo import ( get_account, import_cookies, list_accounts, + list_workbench_accounts, + list_workbench_pool, refresh_account, + remove_workbench_accounts, update_account, ) from web.backend.schemas import ( + WeiboAccountBatchTagRequest, WeiboAccountDisableRequest, WeiboAccountUpdate, WeiboCookieImport, WeiboExchangeRequest, WeiboPaymentStatusRequest, WeiboRechargeRequest, + WeiboWorkbenchAccountsRequest, ) from web.backend.services import weibo_service @@ -291,6 +298,7 @@ def test_support_role_has_weibo_operation_permissions_without_delete(): assert { "weibo:view", + "weibo:workbench", "weibo:import", "weibo:manage", "weibo:account:ck_export", @@ -304,6 +312,103 @@ def test_support_role_has_weibo_operation_permissions_without_delete(): assert "weibo:collab:manage" not in support +def test_weibo_workbench_is_isolated_per_user(db, current_user): + first = WeiboAccount( + uid="4014000101", + nickname="工作台账号A", + cookie="SCF=a; SUB=first", + tag="A", + ) + second = WeiboAccount( + uid="4014000102", + nickname="工作台账号B", + cookie="SCF=b; SUB=second", + tag="B", + ) + db.add_all([first, second]) + db.commit() + user_a = User( + username="weibo-workbench-a", + password_hash="hash", + role="support", + is_active=True, + ) + user_b = User( + username="weibo-workbench-b", + password_hash="hash", + role="support", + is_active=True, + ) + db.add_all([user_a, user_b]) + db.commit() + + add_workbench_accounts( + WeiboWorkbenchAccountsRequest(account_ids=[first.id]), + db=db, + current=user_a, + ) + add_workbench_accounts( + WeiboWorkbenchAccountsRequest(account_ids=[second.id]), + db=db, + current=user_b, + ) + + page_a = list_workbench_accounts(page=1, page_size=20, db=db, current=user_a) + page_b = list_workbench_accounts(page=1, page_size=20, db=db, current=user_b) + assert [item.uid for item in page_a["items"]] == [first.uid] + assert [item.uid for item in page_b["items"]] == [second.uid] + assert db.query(WeiboWorkbenchAccount).count() == 2 + + pool_a = list_workbench_pool(page=1, page_size=20, db=db, current=user_a) + assert [item.uid for item in pool_a["items"]] == [second.uid] + + remove_workbench_accounts( + WeiboWorkbenchAccountsRequest(account_ids=[first.id]), + db=db, + current=user_a, + ) + assert ( + list_workbench_accounts(page=1, page_size=20, db=db, current=user_a)["total"] + == 0 + ) + assert ( + list_workbench_accounts(page=1, page_size=20, db=db, current=user_b)["total"] + == 1 + ) + + +def test_batch_tag_updates_selected_accounts(db, current_user): + first = WeiboAccount( + uid="4014000201", + nickname="批量标签A", + cookie="SCF=a; SUB=first", + tag="旧A", + ) + second = WeiboAccount( + uid="4014000202", + nickname="批量标签B", + cookie="SCF=b; SUB=second", + tag="旧B", + ) + db.add_all([first, second]) + db.commit() + + result = batch_tag_accounts( + WeiboAccountBatchTagRequest(ids=[first.id, second.id], tag="客服组"), + db=db, + current=current_user, + ) + + assert result["updated"] == 2 + tags = { + account.id: account.tag + for account in db.query(WeiboAccount) + .filter(WeiboAccount.id.in_([first.id, second.id])) + .all() + } + assert tags == {first.id: "客服组", second.id: "客服组"} + + def test_list_accounts_returns_parallel_page_shape(db, current_user): assert ( import_cookies( diff --git a/web/backend/migrations/versions/20261108_0072_weibo_workbench.py b/web/backend/migrations/versions/20261108_0072_weibo_workbench.py new file mode 100644 index 0000000..cb23400 --- /dev/null +++ b/web/backend/migrations/versions/20261108_0072_weibo_workbench.py @@ -0,0 +1,64 @@ +"""增加微博个人工作台账号映射。""" + +from __future__ import annotations + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "20261108_0072" +down_revision: str | None = "20261108_0071" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + inspector = sa.inspect(op.get_bind()) + if not inspector.has_table("weibo_workbench_accounts"): + op.create_table( + "weibo_workbench_accounts", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.Column("account_id", sa.Integer(), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint( + ["account_id"], + ["weibo_accounts.id"], + ondelete="CASCADE", + ), + sa.ForeignKeyConstraint( + ["user_id"], + ["users.id"], + ondelete="CASCADE", + ), + sa.UniqueConstraint( + "user_id", + "account_id", + name="uq_weibo_workbench_account", + ), + ) + inspector = sa.inspect(op.get_bind()) + + existing_indexes = { + index["name"] for index in inspector.get_indexes("weibo_workbench_accounts") + } + if "ix_weibo_workbench_accounts_user_id" not in existing_indexes: + op.create_index( + "ix_weibo_workbench_accounts_user_id", + "weibo_workbench_accounts", + ["user_id"], + ) + if "ix_weibo_workbench_accounts_account_id" not in existing_indexes: + op.create_index( + "ix_weibo_workbench_accounts_account_id", + "weibo_workbench_accounts", + ["account_id"], + ) + + +def downgrade() -> None: + inspector = sa.inspect(op.get_bind()) + if not inspector.has_table("weibo_workbench_accounts"): + return + op.drop_table("weibo_workbench_accounts") diff --git a/web/backend/models.py b/web/backend/models.py index c0c2162..593562b 100644 --- a/web/backend/models.py +++ b/web/backend/models.py @@ -961,6 +961,31 @@ class WeiboAccount(Base): ) +class WeiboWorkbenchAccount(Base): + """用户个人微博工作台中的账号选择。""" + + __tablename__ = "weibo_workbench_accounts" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + user_id: Mapped[int] = mapped_column( + ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True + ) + account_id: Mapped[int] = mapped_column( + ForeignKey("weibo_accounts.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + created_at: Mapped[datetime] = mapped_column(UTCDateTime, default=_utcnow) + + __table_args__ = ( + UniqueConstraint( + "user_id", + "account_id", + name="uq_weibo_workbench_account", + ), + ) + + class WeiboCollabShop(Base): """微博外部单协作的店铺字典。""" diff --git a/web/backend/permissions.py b/web/backend/permissions.py index 4f36d0f..fd688af 100644 --- a/web/backend/permissions.py +++ b/web/backend/permissions.py @@ -43,6 +43,7 @@ PERMISSIONS = { "yyb:history": "查看应用宝充值历史", # 微博微豆 "weibo:view": "查看微博账号", + "weibo:workbench": "使用个人微博工作台", "weibo:import": "导入/刷新微博 CK", "weibo:manage": "编辑、停用和启用微博账号", "weibo:account:ck_export": "复制微博外部 CK 账号的 CK(写剪贴板)", @@ -114,6 +115,7 @@ ROLE_PERMISSIONS = { "yyb:manage", "yyb:history", "weibo:view", + "weibo:workbench", "weibo:import", "weibo:manage", "weibo:account:ck_export", @@ -160,6 +162,7 @@ ROLE_PERMISSIONS = { "yyb:session", "yyb:history", "weibo:view", + "weibo:workbench", "weibo:import", "weibo:manage", "weibo:account:ck_export", diff --git a/web/backend/routers/weibo.py b/web/backend/routers/weibo.py index 83c4750..85259ed 100644 --- a/web/backend/routers/weibo.py +++ b/web/backend/routers/weibo.py @@ -13,9 +13,10 @@ from core.weibo import WeiboAuthError, WeiboError, WeiboExchangeUncertainError from ..database import get_db from ..deps import get_current_user -from ..models import User, WeiboAccount +from ..models import User, WeiboAccount, WeiboWorkbenchAccount from ..permissions import user_has_permission from ..schemas import ( + WeiboAccountBatchTagRequest, WeiboAccountCkOut, WeiboAccountDisableRequest, WeiboAccountOut, @@ -37,6 +38,7 @@ from ..schemas import ( WeiboPointRecordItem, WeiboRechargeOrderOut, WeiboRechargeRequest, + WeiboWorkbenchAccountsRequest, ) from ..services.audit_service import record_audit from ..services.weibo_service import ( @@ -88,6 +90,57 @@ def _account_out(account: WeiboAccount) -> WeiboAccountOut: return WeiboAccountOut.model_validate(account) +def _apply_account_filters( + query, + *, + search: str, + bind_status: str, + enabled: bool | None, +): + search_text = _query_str(search) + if search_text: + pattern = f"%{search_text}%" + query = query.filter( + or_( + WeiboAccount.uid.ilike(pattern), + WeiboAccount.nickname.ilike(pattern), + WeiboAccount.game_role_name.ilike(pattern), + WeiboAccount.tag.ilike(pattern), + WeiboAccount.remark.ilike(pattern), + ) + ) + if isinstance(enabled, bool): + query = query.filter(WeiboAccount.is_enabled.is_(enabled)) + bind_value = _query_str(bind_status) + if bind_value == "unbound": + query = query.filter(WeiboAccount.is_bound.is_(False)) + elif bind_value == "bound": + query = query.filter(WeiboAccount.is_bound.is_(True)) + elif bind_value == "rebindable": + query = query.filter( + WeiboAccount.is_bound.is_(True), + WeiboAccount.can_rebind.is_(True), + ) + return query + + +def _paginated_accounts(query, *, page: int, page_size: int) -> dict: + total = query.order_by(None).count() + rows = ( + query.options(defer(WeiboAccount.cookie)) + .order_by(WeiboAccount.id.desc()) + .offset((page - 1) * page_size) + .limit(page_size) + .all() + ) + return { + "items": [_account_out(account) for account in rows], + "total": total, + "page": page, + "page_size": page_size, + } + + def _ensure_enabled(account: WeiboAccount) -> None: if not account.is_enabled: raise HTTPException(status_code=409, detail="微博账号已停用,请先启用") @@ -149,46 +202,181 @@ def list_accounts( """查看已导入的微博账号。""" _require_weibo_perm(current, "weibo:view") - query = db.query(WeiboAccount) - search_text = _query_str(search) - if search_text: - pattern = f"%{search_text}%" - query = query.filter( - or_( - WeiboAccount.uid.ilike(pattern), - WeiboAccount.nickname.ilike(pattern), - WeiboAccount.game_role_name.ilike(pattern), - WeiboAccount.tag.ilike(pattern), - WeiboAccount.remark.ilike(pattern), - ) - ) - if isinstance(enabled, bool): - query = query.filter(WeiboAccount.is_enabled.is_(enabled)) - bind_value = _query_str(bind_status) - if bind_value == "unbound": - query = query.filter(WeiboAccount.is_bound.is_(False)) - elif bind_value == "bound": - query = query.filter(WeiboAccount.is_bound.is_(True)) - elif bind_value == "rebindable": - query = query.filter( - WeiboAccount.is_bound.is_(True), - WeiboAccount.can_rebind.is_(True), - ) - - total = query.count() - rows = ( - query.options(defer(WeiboAccount.cookie)) - .order_by(WeiboAccount.id.desc()) - .offset((page - 1) * page_size) - .limit(page_size) - .all() + query = _apply_account_filters( + db.query(WeiboAccount), + search=search, + bind_status=bind_status, + enabled=enabled, ) - return { - "items": [_account_out(account) for account in rows], - "total": total, - "page": page, - "page_size": page_size, + return _paginated_accounts(query, page=page, page_size=page_size) + + +@router.get("/workbench/accounts") +def list_workbench_accounts( + search: str = Query(""), + bind_status: str = Query(""), + enabled: bool | None = Query(None), + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=200), + db: Session = Depends(get_db), + current: User = Depends(get_current_user), +): + """查看当前用户个人工作台中的微博账号。""" + + _require_weibo_perm(current, "weibo:workbench") + query = ( + db.query(WeiboAccount) + .join( + WeiboWorkbenchAccount, + WeiboWorkbenchAccount.account_id == WeiboAccount.id, + ) + .filter(WeiboWorkbenchAccount.user_id == current.id) + ) + query = _apply_account_filters( + query, + search=search, + bind_status=bind_status, + enabled=enabled, + ) + return _paginated_accounts(query, page=page, page_size=page_size) + + +@router.get("/workbench/pool") +def list_workbench_pool( + search: str = Query(""), + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=200), + db: Session = Depends(get_db), + current: User = Depends(get_current_user), +): + """查看可加入当前用户工作台、且尚未加入的账号池账号。""" + + _require_weibo_perm(current, "weibo:workbench") + selected = db.query(WeiboWorkbenchAccount.account_id).filter( + WeiboWorkbenchAccount.user_id == current.id + ) + query = _apply_account_filters( + db.query(WeiboAccount).filter(WeiboAccount.id.not_in(selected)), + search=search, + bind_status="", + enabled=True, + ) + return _paginated_accounts(query, page=page, page_size=page_size) + + +@router.post("/workbench/accounts") +def add_workbench_accounts( + req: WeiboWorkbenchAccountsRequest, + db: Session = Depends(get_db), + current: User = Depends(get_current_user), +): + """把账号池账号加入当前用户的工作台。""" + + _require_weibo_perm(current, "weibo:workbench") + account_ids = sorted( + {account_id for account_id in req.account_ids if account_id > 0} + ) + if not account_ids: + raise HTTPException(status_code=400, detail="请先选择账号") + existing_ids = { + account_id + for (account_id,) in db.query(WeiboAccount.id) + .filter(WeiboAccount.id.in_(account_ids)) + .all() } + missing = sorted(set(account_ids) - existing_ids) + if missing: + raise HTTPException(status_code=404, detail=f"账号不存在: {missing}") + selected_ids = { + account_id + for (account_id,) in db.query(WeiboWorkbenchAccount.account_id) + .filter( + WeiboWorkbenchAccount.user_id == current.id, + WeiboWorkbenchAccount.account_id.in_(account_ids), + ) + .all() + } + db.add_all( + [ + WeiboWorkbenchAccount(user_id=current.id, account_id=account_id) + for account_id in account_ids + if account_id not in selected_ids + ] + ) + db.commit() + _record_audit_safely( + db, + current, + action="weibo:workbench:add", + target=f"weibo_user:{current.id}", + detail={"account_ids": account_ids}, + ) + return {"account_ids": account_ids, "success": True} + + +@router.post("/workbench/accounts/remove") +def remove_workbench_accounts( + req: WeiboWorkbenchAccountsRequest, + db: Session = Depends(get_db), + current: User = Depends(get_current_user), +): + """从当前用户工作台移出账号,不影响账号池和其他用户。""" + + _require_weibo_perm(current, "weibo:workbench") + account_ids = sorted( + {account_id for account_id in req.account_ids if account_id > 0} + ) + if not account_ids: + raise HTTPException(status_code=400, detail="请先选择账号") + db.query(WeiboWorkbenchAccount).filter( + WeiboWorkbenchAccount.user_id == current.id, + WeiboWorkbenchAccount.account_id.in_(account_ids), + ).delete(synchronize_session=False) + db.commit() + _record_audit_safely( + db, + current, + action="weibo:workbench:remove", + target=f"weibo_user:{current.id}", + detail={"account_ids": account_ids}, + ) + return {"account_ids": account_ids, "success": True} + + +@router.post("/accounts/batch-tag") +def batch_tag_accounts( + req: WeiboAccountBatchTagRequest, + db: Session = Depends(get_db), + current: User = Depends(get_current_user), +): + """批量设置微博账号标签。""" + + _require_weibo_perm(current, "weibo:manage") + account_ids = sorted(set(req.ids)) + if not account_ids: + raise HTTPException(status_code=400, detail="请先选择账号") + accounts = db.query(WeiboAccount).filter(WeiboAccount.id.in_(account_ids)).all() + actual_ids = {account.id for account in accounts} + missing = sorted(set(account_ids) - actual_ids) + if missing: + raise HTTPException(status_code=404, detail=f"账号不存在: {missing}") + tag = req.tag.strip() + previous = {account.id: account.tag or "" for account in accounts} + for account in accounts: + account.tag = tag + db.commit() + _record_audit_safely( + db, + current, + action="weibo:account:batch_tag", + target=f"weibo_accounts:{len(account_ids)}", + detail={ + "account_ids": account_ids, + "tag": tag, + "previous_tags": previous, + }, + ) + return {"updated": len(accounts), "tag": tag, "success": True} @router.get("/accounts/{account_id}", response_model=WeiboAccountOut) diff --git a/web/backend/schemas.py b/web/backend/schemas.py index 2a583fa..2e0c13b 100644 --- a/web/backend/schemas.py +++ b/web/backend/schemas.py @@ -505,6 +505,15 @@ class WeiboAccountUpdate(BaseModel): remark: str | None = Field(None, max_length=256) +class WeiboAccountBatchTagRequest(BaseModel): + ids: list[int] = Field(min_length=1) + tag: str = Field("", max_length=64) + + +class WeiboWorkbenchAccountsRequest(BaseModel): + account_ids: list[int] = Field(default_factory=list) + + class WeiboAccountDisableRequest(BaseModel): reason: str = Field("", max_length=256) diff --git a/web/frontend/src/App.tsx b/web/frontend/src/App.tsx index 326e57c..9400bf7 100644 --- a/web/frontend/src/App.tsx +++ b/web/frontend/src/App.tsx @@ -107,7 +107,8 @@ function AppContent() { )} /> )} /> )} /> - )} /> + )} /> + )} /> )} /> )} /> )} /> diff --git a/web/frontend/src/api/types.ts b/web/frontend/src/api/types.ts index b0392d6..74c6500 100644 --- a/web/frontend/src/api/types.ts +++ b/web/frontend/src/api/types.ts @@ -807,6 +807,17 @@ export interface WeiboAccountCkResult { ck_text: string; } +export interface WeiboWorkbenchMutationResult { + account_ids: number[]; + success: boolean; +} + +export interface WeiboBatchTagResult { + updated: number; + tag: string; + success: boolean; +} + export interface WeiboGoodsItem { id: number; goods_name: string; diff --git a/web/frontend/src/api/weibo.ts b/web/frontend/src/api/weibo.ts index 795ab3f..5a334bc 100644 --- a/web/frontend/src/api/weibo.ts +++ b/web/frontend/src/api/weibo.ts @@ -4,6 +4,7 @@ import type { PaginatedResponse, WeiboAccountItem, WeiboAccountCkResult, + WeiboBatchTagResult, WeiboBindQrResult, WeiboExchangeRecordsResult, WeiboExchangeResult, @@ -12,6 +13,7 @@ import type { WeiboPointHistoryResult, WeiboRechargeOrderResult, WeiboImportResult, + WeiboWorkbenchMutationResult, } from './types'; export const weiboApi = { @@ -60,6 +62,41 @@ export const weiboApi = { api.post( `/weibo/accounts/${accountId}/ck/copy`, ), + batchTagAccounts: (ids: number[], tag: string) => + api.post( + '/weibo/accounts/batch-tag', + { ids, tag }, + ), + listWorkbenchAccounts: (params: { + search?: string; + bind_status?: string; + enabled?: boolean; + page: number; + page_size: number; + }) => + api.get, PaginatedResponse>( + '/weibo/workbench/accounts', + { params }, + ), + listWorkbenchPool: (params: { + search?: string; + page: number; + page_size: number; + }) => + api.get, PaginatedResponse>( + '/weibo/workbench/pool', + { params }, + ), + addWorkbenchAccounts: (accountIds: number[]) => + api.post( + '/weibo/workbench/accounts', + { account_ids: accountIds }, + ), + removeWorkbenchAccounts: (accountIds: number[]) => + api.post( + '/weibo/workbench/accounts/remove', + { account_ids: accountIds }, + ), bindQrcodes: (accountId: number, channel: 'wx' | 'qq') => api.post( `/weibo/accounts/${accountId}/bind-qrcodes`, diff --git a/web/frontend/src/layouts/MainLayout.tsx b/web/frontend/src/layouts/MainLayout.tsx index 626ced4..14643fd 100644 --- a/web/frontend/src/layouts/MainLayout.tsx +++ b/web/frontend/src/layouts/MainLayout.tsx @@ -8,7 +8,6 @@ import { SunOutlined, MoonOutlined, DesktopOutlined, GiftOutlined, ApartmentOutlined, MobileOutlined, ShoppingCartOutlined, BookOutlined, TrophyOutlined, ShopOutlined, GlobalOutlined, SafetyCertificateOutlined, FileSearchOutlined, RobotOutlined, FileExcelOutlined, - MessageOutlined, } from '@ant-design/icons'; import { useNavigate, useLocation, Outlet } from 'react-router-dom'; import { getUser, clearAuth, type AuthUser } from '../store/auth'; @@ -127,7 +126,10 @@ export default function MainLayout({ onLogout }: { onLogout?: () => void }) { } if (can('weibo:view')) { - weiboItems.push({ key: '/weibo/accounts', label: '账号与绑定', icon: }); + weiboItems.push({ key: '/weibo/accounts', label: '账号管理', icon: }); + } + if (can('weibo:workbench')) { + weiboItems.push({ key: '/weibo/workbench', label: '微博工作台', icon: }); } if (can('weibo:collab:view')) { weiboItems.push({ key: '/weibo/collab', label: '外部单协作', icon: }); diff --git a/web/frontend/src/pages/WeiboAccountsPage.tsx b/web/frontend/src/pages/WeiboAccountsPage.tsx index 6925f60..746eda7 100644 --- a/web/frontend/src/pages/WeiboAccountsPage.tsx +++ b/web/frontend/src/pages/WeiboAccountsPage.tsx @@ -1,9 +1,11 @@ -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { Alert, + AutoComplete, Button, Descriptions, Drawer, + Empty, Form, Input, InputNumber, @@ -69,6 +71,39 @@ const PAYMENT_STATUS_LABELS: Record = { PAY_STATUS_CLOSED: '订单关闭', }; +const WEIBO_MALL_CACHE_KEY = 'weibo_workbench_mall_cache_v1'; + +function readMallCache(accountId: number): WeiboMallListResult | null { + try { + const raw = localStorage.getItem(WEIBO_MALL_CACHE_KEY); + if (!raw) return null; + const cache = JSON.parse(raw) as Record< + string, + { value?: WeiboMallListResult } + >; + const value = cache[String(accountId)]?.value; + if (!value || !Array.isArray(value.goods)) return null; + return value; + } catch { + return null; + } +} + +function writeMallCache(accountId: number, value: WeiboMallListResult) { + try { + const raw = localStorage.getItem(WEIBO_MALL_CACHE_KEY); + const cache = raw + ? (JSON.parse(raw) as Record) + : ({} as Record); + cache[String(accountId)] = { value, cached_at: Date.now() }; + localStorage.setItem(WEIBO_MALL_CACHE_KEY, JSON.stringify(cache)); + } catch { + // 浏览器禁用存储或配额满时只放弃缓存,不影响正常刷新商品。 + } +} + +type WeiboAccountsPageMode = 'management' | 'workbench'; + function bindStatus(account: WeiboAccountItem) { if (account.is_bound === true && account.can_rebind === true) { return 可换绑; @@ -81,9 +116,14 @@ function bindStatus(account: WeiboAccountItem) { return 未查询; } -export default function WeiboAccountsPage() { +export default function WeiboAccountsPage({ + mode = 'management', +}: { + mode?: WeiboAccountsPageMode; +}) { const { token } = theme.useToken(); const { can } = usePermissions(); + const isWorkbench = mode === 'workbench'; const canImport = can('weibo:import'); const canManage = can('weibo:manage'); const canExportCk = can('weibo:account:ck_export'); @@ -105,6 +145,18 @@ export default function WeiboAccountsPage() { const [mutatingId, setMutatingId] = useState(null); const [selectedIds, setSelectedIds] = useState([]); const [batchBusy, setBatchBusy] = useState(false); + const [batchTagOpen, setBatchTagOpen] = useState(false); + const [batchTagValue, setBatchTagValue] = useState(''); + const [batchTagging, setBatchTagging] = useState(false); + + const [poolOpen, setPoolOpen] = useState(false); + const [poolLoading, setPoolLoading] = useState(false); + const [poolItems, setPoolItems] = useState([]); + const [poolTotal, setPoolTotal] = useState(0); + const [poolPage, setPoolPage] = useState(1); + const [poolPageSize, setPoolPageSize] = useState(10); + const [poolSearch, setPoolSearch] = useState(''); + const [poolSelectedIds, setPoolSelectedIds] = useState([]); const [importOpen, setImportOpen] = useState(false); const [importing, setImporting] = useState(false); @@ -121,6 +173,7 @@ export default function WeiboAccountsPage() { const [qrResult, setQrResult] = useState(null); const [qrChannel, setQrChannel] = useState<'wx' | 'qq'>('wx'); const [qrError, setQrError] = useState(''); + const [workbenchGoodsId, setWorkbenchGoodsId] = useState(null); const [featureOpen, setFeatureOpen] = useState(false); const [featureAccount, setFeatureAccount] = useState(null); @@ -144,7 +197,7 @@ export default function WeiboAccountsPage() { const loadAccounts = useCallback(async () => { setLoading(true); try { - const data: PaginatedResponse = await weiboApi.listAccounts({ + const params = { search: search.trim() || undefined, bind_status: bindFilter || undefined, enabled: @@ -155,7 +208,10 @@ export default function WeiboAccountsPage() { : undefined, page, page_size: pageSize, - }); + }; + const data: PaginatedResponse = isWorkbench + ? await weiboApi.listWorkbenchAccounts(params) + : await weiboApi.listAccounts(params); setItems(data.items); setTotal(data.total); setSelectedIds((current) => @@ -166,7 +222,7 @@ export default function WeiboAccountsPage() { } finally { setLoading(false); } - }, [bindFilter, enabledFilter, page, pageSize, search]); + }, [bindFilter, enabledFilter, isWorkbench, page, pageSize, search]); useEffect(() => { void loadAccounts(); @@ -178,8 +234,24 @@ export default function WeiboAccountsPage() { try { const result = await weiboApi.importCookies(values.text, values.tag || ''); setImportErrors(result.errors); + let workbenchError = ''; + if (isWorkbench && result.accounts.length > 0) { + try { + await weiboApi.addWorkbenchAccounts( + result.accounts.map((account) => account.id), + ); + } catch (error) { + workbenchError = getErrorMessage(error); + } + } if (result.count > 0) { - message.success(result.message); + if (workbenchError) { + message.warning( + `${result.message},但加入当前工作台失败:${workbenchError}`, + ); + } else { + message.success(result.message); + } await loadAccounts(); } if (result.errors.length === 0 && result.count > 0) { @@ -195,6 +267,98 @@ export default function WeiboAccountsPage() { } }; + const loadWorkbenchPool = async ( + nextPage = poolPage, + nextPageSize = poolPageSize, + nextSearch = poolSearch, + ) => { + setPoolLoading(true); + try { + const data = await weiboApi.listWorkbenchPool({ + search: nextSearch.trim() || undefined, + page: nextPage, + page_size: nextPageSize, + }); + setPoolItems(data.items); + setPoolTotal(data.total); + setPoolPage(data.page); + setPoolPageSize(data.page_size); + setPoolSelectedIds((current) => + current.filter((id) => data.items.some((item) => item.id === id)), + ); + } catch (error) { + message.error(getErrorMessage(error)); + } finally { + setPoolLoading(false); + } + }; + + const openWorkbenchImport = () => { + setPoolSearch(''); + setPoolSelectedIds([]); + setPoolOpen(true); + void loadWorkbenchPool(1, poolPageSize, ''); + }; + + const handleImportToWorkbench = async () => { + if (poolSelectedIds.length === 0) { + message.warning('请先选择账号'); + return; + } + setPoolLoading(true); + try { + await weiboApi.addWorkbenchAccounts(poolSelectedIds); + setPoolOpen(false); + setPoolSelectedIds([]); + await loadAccounts(); + message.success(`已导入 ${poolSelectedIds.length} 个账号到工作台`); + } catch (error) { + message.error(getErrorMessage(error)); + } finally { + setPoolLoading(false); + } + }; + + const handleRemoveWorkbench = async (ids = selectedIds) => { + if (ids.length === 0) return; + setBatchBusy(true); + try { + await weiboApi.removeWorkbenchAccounts(ids); + setSelectedIds((current) => + current.filter((accountId) => !ids.includes(accountId)), + ); + if (featureAccount && ids.includes(featureAccount.id)) { + setFeatureAccount(null); + } + await loadAccounts(); + message.success(`已从工作台移出 ${ids.length} 个账号`); + } catch (error) { + message.error(getErrorMessage(error)); + } finally { + setBatchBusy(false); + } + }; + + const handleBatchTag = async () => { + if (selectedIds.length === 0) return; + setBatchTagging(true); + try { + const result = await weiboApi.batchTagAccounts( + selectedIds, + batchTagValue.trim(), + ); + setBatchTagOpen(false); + setBatchTagValue(''); + setSelectedIds([]); + await loadAccounts(); + message.success(`已为 ${result.updated} 个账号设置标签`); + } catch (error) { + message.error(getErrorMessage(error)); + } finally { + setBatchTagging(false); + } + }; + const handleRefresh = async (account: WeiboAccountItem) => { if (!account.is_enabled) { message.warning('账号已停用,请先启用'); @@ -258,6 +422,24 @@ export default function WeiboAccountsPage() { } }; + const generateInlineBind = async ( + account: WeiboAccountItem, + channel: 'wx' | 'qq', + ) => { + setQrAccount(account); + setQrChannel(channel); + setQrResult(null); + setQrError(''); + setQrLoading(true); + try { + setQrResult(await weiboApi.bindQrcodes(account.id, channel)); + } catch (error) { + setQrError(getErrorMessage(error)); + } finally { + setQrLoading(false); + } + }; + const handleQrChannelChange = (value: 'wx' | 'qq') => { setQrChannel(value); setQrResult(null); @@ -438,7 +620,9 @@ export default function WeiboAccountsPage() { const loadMall = async (account: WeiboAccountItem) => { setMallLoading(true); try { - setMallResult(await weiboApi.mallList(account.id)); + const result = await weiboApi.mallList(account.id); + setMallResult(result); + writeMallCache(account.id, result); } catch (error) { message.error(getErrorMessage(error)); } finally { @@ -515,6 +699,28 @@ export default function WeiboAccountsPage() { void loadMall(account); }; + const selectWorkbenchAccount = (account: WeiboAccountItem) => { + if (featureAccount?.id !== account.id) { + setMallResult(null); + setExchangeResult(null); + setPointResult(null); + setRechargeOrder(null); + setPaymentStatus(null); + setRechargeAmount(1); + setWorkbenchGoodsId(null); + setQrResult(null); + setQrError(''); + setFeatureTab('goods'); + const cachedMall = readMallCache(account.id); + if (cachedMall) { + setMallResult(cachedMall); + } else { + void loadMall(account); + } + } + setFeatureAccount(account); + }; + const switchFeatureTab = (key: string) => { setFeatureTab(key); if (!featureAccount) return; @@ -569,21 +775,30 @@ export default function WeiboAccountsPage() { await loadPointHistory(featureAccount); }; + const centerHeader = () => ({ style: { textAlign: 'center' as const } }); + const columns: TableProps['columns'] = [ { title: '序号', key: 'index', - width: 72, + width: isWorkbench ? 44 : 56, align: 'center', fixed: 'left', + onHeaderCell: centerHeader, render: (_, __, index) => (page - 1) * pageSize + index + 1, }, { title: '微博账号', key: 'account', - width: 220, + width: isWorkbench ? 190 : 220, + align: 'center', + onHeaderCell: centerHeader, render: (_, account) => ( - + {account.nickname || account.uid} UID {account.uid} @@ -601,13 +816,17 @@ export default function WeiboAccountsPage() { { title: '标签', dataIndex: 'tag', - width: 120, + width: isWorkbench ? 90 : 120, + align: 'center', + onHeaderCell: centerHeader, render: (value: string) => value || -, }, { title: 'CK', key: 'ck', - width: 120, + width: isWorkbench ? 90 : 120, + align: 'center', + onHeaderCell: centerHeader, render: (_, account) => { const state = CK_STATUS[account.ck_check_status] || { label: '未检测', @@ -623,7 +842,9 @@ export default function WeiboAccountsPage() { { title: '启用状态', dataIndex: 'is_enabled', - width: 100, + width: isWorkbench ? 90 : 100, + align: 'center', + onHeaderCell: centerHeader, render: (value: boolean, account) => ( ( - + {bindStatus(account)} {account.game_role_name && ( @@ -656,15 +883,18 @@ export default function WeiboAccountsPage() { { title: '微豆', dataIndex: 'weidou_balance', - width: 100, - align: 'right', + width: isWorkbench ? 80 : 100, + align: 'center', + onHeaderCell: centerHeader, render: (value: number | null) => value == null ? - : value.toLocaleString(), }, { title: '最近检查', dataIndex: 'ck_checked_at', - width: 170, + width: isWorkbench ? 160 : 170, + align: 'center', + onHeaderCell: centerHeader, render: (value: string | null) => value ? formatTime(value) : -, }, @@ -673,6 +903,8 @@ export default function WeiboAccountsPage() { key: 'actions', width: 540, fixed: 'right', + align: 'center', + onHeaderCell: centerHeader, render: (_, account) => { const cannotBind = account.is_bound === true && account.can_rebind !== true; @@ -709,10 +941,14 @@ export default function WeiboAccountsPage() { {canExportCk && ( )} - {canManage && + {!isWorkbench && + canManage && (account.is_enabled ? ( ))} - {canDelete && ( + {!isWorkbench && canDelete && ( } /> )} + {isWorkbench && ( + void handleRemoveWorkbench([account.id])} + > + + + )} ); }, }, ]; + const visibleColumns = isWorkbench + ? columns.filter((column) => column.key !== 'actions') + : columns; + const mallColumns: TableProps['columns'] = [ { title: '商品', @@ -910,6 +1164,24 @@ export default function WeiboAccountsPage() { const activeQrScheme = qrResult?.schemes.find( (item) => item.channel === qrChannel, ); + const workbenchSelectedGoods = mallResult?.goods.find( + (goods) => goods.id === workbenchGoodsId, + ); + const workbenchExchangeDisabled = + !canExchange || + !featureAccount?.is_enabled || + !workbenchSelectedGoods || + workbenchSelectedGoods.is_sold_out || + workbenchSelectedGoods.remain_count === 0 || + workbenchSelectedGoods.price > + (mallResult?.weidou_balance ?? featureAccount?.weidou_balance ?? 0); + const tagOptions = useMemo( + () => + [...new Set(items.map((item) => item.tag.trim()).filter(Boolean))].map( + (tag) => ({ value: tag }), + ), + [items], + ); return ( @@ -924,9 +1196,13 @@ export default function WeiboAccountsPage() { >
- 微博外部 CK 账号 + {isWorkbench ? '微博工作台' : '微博账号管理'} - 外部 CK 导入、账号生命周期、微豆资料和角色绑定 + + {isWorkbench + ? '当前账号独立使用的微博账号、角色绑定、充值与兑换' + : '全局账号池、账号生命周期、批量操作、微豆资料和角色绑定'} +
} onClick={() => void loadAccounts()}> 刷新列表 + {isWorkbench && ( + + )} - {canManage && ( + {!isWorkbench && canManage && ( + + )} + {!isWorkbench && canManage && ( )} - {canManage && ( + {!isWorkbench && canManage && ( + + )} @@ -1061,35 +1371,397 @@ export default function WeiboAccountsPage() {
+
+ + rowKey="id" + loading={loading} + columns={visibleColumns} + dataSource={items} + rowSelection={{ + selectedRowKeys: selectedIds, + onChange: (keys) => setSelectedIds(keys.map(Number)), + }} + scroll={{ x: isWorkbench ? 980 : 1450 }} + onRow={(account) => ({ + onClick: () => { + if (isWorkbench) selectWorkbenchAccount(account); + }, + style: { + cursor: isWorkbench ? 'pointer' : undefined, + background: + isWorkbench && featureAccount?.id === account.id + ? token.colorPrimaryBg + : undefined, + }, + })} + pagination={{ + current: page, + pageSize, + total, + showSizeChanger: true, + showTotal: (value) => `共 ${value} 个账号`, + onChange: (nextPage, nextSize) => { + setPage(nextPage); + setPageSize(nextSize); + }, + }} + /> +
+ + {isWorkbench && ( +
+ {featureAccount ? ( + +
+ + {featureAccount.nickname || featureAccount.uid} + +
+ + UID {featureAccount.uid} + +
+
+
+ 微信 / QQ 绑定 + + + + + {qrLoading && } + {qrError && ( + + )} + {activeQrScheme?.qrcode_image && ( +
+ {`${activeQrScheme.label}绑定二维码`} +
+ )} +
+
+ + 兑换商品 + + + + showSearch + allowClear + optionFilterProp="label" + value={workbenchGoodsId ?? undefined} + onChange={(value) => setWorkbenchGoodsId(value ?? null)} + loading={mallLoading} + placeholder="选择要兑换的商品" + style={{ width: '100%', marginTop: 8 }} + options={(mallResult?.goods || []).map((goods) => ({ + value: goods.id, + label: `${goods.goods_name} · ${goods.price.toLocaleString()} 微豆${ + goods.is_sold_out ? ' · 售罄' : '' + }`, + disabled: + goods.is_sold_out || + goods.remain_count === 0 || + goods.price > + (mallResult?.weidou_balance ?? + featureAccount.weidou_balance ?? + 0), + }))} + /> + { + if (workbenchSelectedGoods) { + void handleExchange(workbenchSelectedGoods); + } + }} + > + + +
+ {canRecharge && ( +
+ 助威充值 + + + + setRechargeAmount(Number(value) || 1) + } + style={{ width: 110 }} + /> + + + + + {rechargeOrder && ( + +
+ +
+ + {rechargeOrder.amount_yuan} 元 · 预计{' '} + {rechargeOrder.single_reward.toLocaleString()} 微豆 + + + {paymentStatus && ( + + {PAYMENT_STATUS_LABELS[paymentStatus.status] || + paymentStatus.status} + + )} +
+ )} +
+ )} + void handleRemoveWorkbench([featureAccount.id])} + > + + +
+ ) : ( + + )} +
+ )} +
+ + void handleBatchTag()} + onCancel={() => setBatchTagOpen(false)} + destroyOnHidden + > + + + + + + void handleImportToWorkbench()} + onCancel={() => setPoolOpen(false)} + destroyOnHidden + > + setPoolSearch(event.target.value)} + onSearch={(value) => void loadWorkbenchPool(1, poolPageSize, value)} + style={{ marginBottom: 12 }} + /> rowKey="id" - loading={loading} - columns={columns} - dataSource={items} + size="small" + loading={poolLoading} + dataSource={poolItems} rowSelection={{ - selectedRowKeys: selectedIds, - onChange: (keys) => setSelectedIds(keys.map(Number)), + selectedRowKeys: poolSelectedIds, + onChange: (keys) => setPoolSelectedIds(keys.map(Number)), }} - scroll={{ x: 1450 }} - pagination={{ - current: page, - pageSize, - total, - showSizeChanger: true, - showTotal: (value) => `共 ${value} 个账号`, - onChange: (nextPage, nextSize) => { - setPage(nextPage); - setPageSize(nextSize); + columns={[ + { + title: '微博账号', + render: (_, account) => ( + + {account.nickname || account.uid} + + UID {account.uid} + + + ), }, + { + title: '标签', + dataIndex: 'tag', + width: 120, + render: (value: string) => value || '-', + }, + { + title: 'CK', + dataIndex: 'ck_check_status', + width: 110, + render: (value: string) => { + const state = CK_STATUS[value] || { + label: '未检测', + color: 'default', + }; + return {state.label}; + }, + }, + ]} + pagination={{ + current: poolPage, + pageSize: poolPageSize, + total: poolTotal, + showSizeChanger: true, + showTotal: (value) => `共 ${value} 个可导入账号`, + onChange: (nextPage, nextSize) => + void loadWorkbenchPool(nextPage, nextSize, poolSearch), }} /> - +