diff --git a/docs/README.md b/docs/README.md index 1ba13ff..6011c3c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,6 +1,6 @@ # 项目文档索引 -更新时间:2026-11-06 +更新时间:2026-11-08 本文只列当前仍有维护价值、能直接用于开发或排查的文档。已被代码实现取代的 一次性计划、事故经过和中间抓包记录已删除;需要追查历史时使用 Git 历史。 @@ -9,6 +9,7 @@ - [自动化与资料收集](自动化与资料收集.md):账号巡检、CK 自动重登、资料刷新、订单读取、文件存储的运行规则。 - [账号全生命周期](设计-账号全生命周期.md):斗鱼账号状态、CK、事件流和自动化边界。 +- [微博账号池、分配与个人工作台](微博/微博账号池与个人工作台-2026-11-08.md):微博 CK、账号归属、客服可见范围、分配管理和个人工作台。 - [虎牙外部 CK 协作台账](设计-虎牙外部CK协作台账.md):外部订单、CK、业务状态、核账、归档和权限规则。 - [虎牙 Cookie 路线状态](虎牙/HUYA_COOKIE路线状态.md):当前 Cookie 字段来源、已完成链路和已知边界。 - [虎牙浏览器兑换](虎牙/HUYA_BROWSER_EXCHANGE.md):人工兑换页面、扩展注入和目标页说明。 diff --git a/docs/微博/微博账号池与个人工作台-2026-11-08.md b/docs/微博/微博账号池与个人工作台-2026-11-08.md index 17df392..82c9934 100644 --- a/docs/微博/微博账号池与个人工作台-2026-11-08.md +++ b/docs/微博/微博账号池与个人工作台-2026-11-08.md @@ -1,10 +1,10 @@ --- type: 设计 -description: 微博外部 CK 账号池与客服个人工作台的拆分设计,覆盖数据归属、权限、接口和 UI。 +description: 微博外部 CK 账号池、归属分配与客服个人工作台设计,覆盖数据范围、权限、接口和 UI。 tags: [微博, CK, 账号池, 工作台, 权限, 协作] generated: by: ai/codex - at: 2026-11-08T16:20:00+08:00 + at: 2026-11-08T20:00:00+08:00 status: implemented --- @@ -15,6 +15,7 @@ status: implemented 把原来的「账号与绑定」拆成两个入口: - **账号管理**:全局微博账号池,负责 CK 导入、生命周期、标签、批量操作和资料维护。 +- **分配管理**:管理员和运营把全局账号分配给客服,支持批量分配、取消分配和按客服查看。 - **微博工作台**:每个用户自己的账号子集,只展示自己导入的账号,用于绑定、充值、兑换和日常操作。 工作台采用斗鱼模式:**账号实体仍然全局唯一,工作台只保存用户与账号的映射**。同一个全局账号同一时间只能被一个用户加入工作台;当前使用人移出后,其他人才可以加入。 @@ -23,6 +24,18 @@ status: implemented `weibo_accounts` 继续作为全局账号池,不允许因为工作台拆出第二份 CK。 +账号池新增归属字段: + +```text +weibo_accounts.assigned_to -> users.id +``` + +- `assigned_to` 为空表示未分配。 +- 管理员和运营可查看全部账号并修改归属。 +- 客服的账号管理、标签、工作台候选、绑定、充值、兑换、复制 CK 等操作全部只允许访问 `assigned_to = 当前用户` 的账号。 +- 同一账号改分配给新客服时,旧客服工作台中的映射会自动清除,避免账号被旧工作台继续占用。 +- 取消分配时清除该账号的全部工作台映射。 + 新增: ```text @@ -41,6 +54,7 @@ UNIQUE(account_id) -- 同一账号同时只能属于一个工作台 ## 3. 权限 - `weibo:view`:查看全局账号管理。 +- `weibo:assign`:查看全部账号、进入分配管理、批量分配/取消分配客服归属。 - `weibo:workbench`:访问个人微博工作台。 - `weibo:import`:导入外部 CK。 - `weibo:manage`:编辑账号标签/备注、启停账号、批量标签。 @@ -49,20 +63,23 @@ UNIQUE(account_id) -- 同一账号同时只能属于一个工作台 - `weibo:recharge`:创建充值订单。 - `weibo:exchange`:执行兑换。 -客服和运营默认拥有个人工作台及日常操作权限;删除账号仍由 `weibo:delete` 控制,客服默认没有。 +运营默认拥有全量查看和分配权限;客服默认没有 `weibo:assign`,只能查看和使用分配给自己的账号。删除账号仍由 `weibo:delete` 控制,客服默认没有。 ## 4. 接口 | 方法 | 路径 | 权限 | 说明 | | --- | --- | --- | --- | -| GET | `/api/weibo/accounts` | `weibo:view` | 全局账号池分页列表 | +| GET | `/api/weibo/accounts` | `weibo:view` | 按当前用户范围返回账号分页列表 | +| GET | `/api/weibo/accounts/ids` | `weibo:view` | 返回当前筛选下全部可见账号 ID,用于跨页分配 | | GET | `/api/weibo/workbench/accounts` | `weibo:workbench` | 当前用户工作台账号分页列表 | | GET | `/api/weibo/workbench/pool` | `weibo:workbench` | 可加入当前工作台且尚未加入的账号 | | POST | `/api/weibo/workbench/accounts` | `weibo:workbench` | 批量加入工作台;已被其他用户占用时返回 409 | | POST | `/api/weibo/workbench/accounts/remove` | `weibo:workbench` | 批量移出工作台,不影响账号池和其他用户 | | POST | `/api/weibo/accounts/batch-tag` | `weibo:manage` | 账号管理页批量设置标签 | +| POST | `/api/weibo/accounts/batch-assign` | `weibo:assign` | 批量分配给客服;`assigned_to=null` 表示取消分配 | +| GET | `/api/weibo/assignments/summary` | `weibo:assign` | 分配统计和客服名下账号数 | -工作台接口始终按 `current.id` 过滤,不接受客户端传入用户 ID。 +工作台接口始终按 `current.id` 过滤,不接受客户端传入用户 ID。客服访问账号相关接口时,服务端还会强制叠加 `assigned_to = current.id`,不能依赖前端隐藏入口。 ## 5. UI @@ -71,6 +88,7 @@ UNIQUE(account_id) -- 同一账号同时只能属于一个工作台 ```text 微博 -> 账号管理 + -> 分配管理(仅 weibo:assign) -> 微博工作台 -> 外部单协作 ``` @@ -78,8 +96,17 @@ UNIQUE(account_id) -- 同一账号同时只能属于一个工作台 账号管理页: - 标题「微博账号管理」。 -- 保留全量账号列表、生命周期、批量刷新、批量标签、启停和删除。 -- 用于维护账号池,不作为客服的日常入口。 +- 管理员和运营看到全量账号、归属客服列、生命周期、批量刷新、批量标签、启停和删除。 +- 客服只看到分配给自己的账号,并保留绑定、充值、兑换、标签、CK 操作等日常能力。 +- 用于维护账号池和客服归属,不作为客服个人工作台的选择状态。 + +分配管理页: + +- 参考斗鱼分配管理:顶部统计、左侧客服列表、右侧未分配/已分配账号页签。 +- 支持按搜索、标签、生命周期筛选。 +- 支持跨页勾选后批量分配或取消分配。 +- 支持把当前筛选下的全部未分配账号一次性分配给选中客服。 +- 客服列表展示每个客服当前名下账号数。 工作台页: @@ -94,9 +121,9 @@ UNIQUE(account_id) -- 同一账号同时只能属于一个工作台 ## 6. 数据隔离边界 - 工作台列表和增删接口都按当前登录用户过滤。 -- 账号池候选排除所有已被其他用户占用的账号。 +- 客服的账号池候选只包含分配给自己的账号,并排除所有已被其他用户工作台占用的账号。 - 加入/移出只操作 `weibo_workbench_accounts`,不修改账号归属或 CK。 -- 账号池仍由全局权限控制;客服可以看到账号管理入口,但不能删除账号。 +- 账号池全量查看由 `weibo:assign` 控制;客服可以看到账号管理入口,但列表和所有详情/操作接口只返回分配给自己的账号。 - 外部单协作继续引用全局账号 ID,不受个人工作台选择影响。 ## 7. CK 重复导入 @@ -105,5 +132,7 @@ UNIQUE(account_id) -- 同一账号同时只能属于一个工作台 - 默认重复导入:不覆盖现有 CK,返回 `skipped=1`。 - 勾选「重复账号时更新 CK」:覆盖现有账号 CK 和最新资料,但**不新建账号**。 +- 客服不能更新已分配给其他客服的账号 CK,也不能通过重复导入改变账号归属。 +- 客服导入新账号时,账号会自动分配给当前客服。 - 若账号已被其他人的工作台占用,其他用户不能更新 CK,必须由当前使用人操作。 - 无论是否更新,全局账号记录始终只有一条。 diff --git a/tests/test_migrations.py b/tests/test_migrations.py index eb6a16a..741e9ae 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_0073" +HEAD_REVISION = "20261108_0074" # 迁移新增、但不声明在模型里的复合查询索引。 EXTRA_INDEXES = ( diff --git a/tests/test_weibo_accounts.py b/tests/test_weibo_accounts.py index ddcb906..ac85aeb 100644 --- a/tests/test_weibo_accounts.py +++ b/tests/test_weibo_accounts.py @@ -23,6 +23,8 @@ 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, + assignments_summary, + batch_assign_accounts, batch_tag_accounts, copy_account_ck, create_bind_qrcodes, @@ -30,6 +32,7 @@ from web.backend.routers.weibo import ( enable_account, get_account, import_cookies, + list_account_ids, list_account_tags, list_accounts, list_workbench_accounts, @@ -43,6 +46,7 @@ from web.backend.schemas import ( WeiboAccountBatchTagRequest, WeiboAccountDisableRequest, WeiboAccountUpdate, + WeiboBatchAssignRequest, WeiboCookieImport, WeiboExchangeRequest, WeiboPaymentStatusRequest, @@ -241,11 +245,13 @@ def test_occupied_workbench_account_cannot_be_updated_by_another_user(db, curren ) db.add_all([owner, other]) db.commit() - add_workbench_accounts( - WeiboWorkbenchAccountsRequest(account_ids=[imported.id]), - db=db, - current=owner, + db.add( + WeiboWorkbenchAccount( + user_id=owner.id, + account_id=imported.id, + ) ) + db.commit() result = import_cookies( WeiboCookieImport( @@ -328,6 +334,9 @@ def test_copy_account_ck_uses_export_permission_defaults(db, current_user): ) db.add(support) db.commit() + account = db.query(WeiboAccount).filter(WeiboAccount.id == account_out.id).one() + account.assigned_to = support.id + db.commit() support_result = copy_account_ck(account_out.id, db=db, current=support) assert support_result.ck_text == "SCF=a; SUB=b" @@ -406,6 +415,9 @@ def test_weibo_workbench_is_isolated_per_user(db, current_user): ) db.add_all([user_a, user_b]) db.commit() + first.assigned_to = user_a.id + second.assigned_to = user_b.id + db.commit() add_workbench_accounts( WeiboWorkbenchAccountsRequest(account_ids=[first.id]), @@ -413,14 +425,14 @@ def test_weibo_workbench_is_isolated_per_user(db, current_user): current=user_a, ) pool_before = list_workbench_pool(page=1, page_size=20, db=db, current=user_a) - assert [item.uid for item in pool_before["items"]] == [second.uid] + assert pool_before["items"] == [] with pytest.raises(HTTPException) as exc_info: add_workbench_accounts( WeiboWorkbenchAccountsRequest(account_ids=[first.id]), db=db, current=user_b, ) - assert exc_info.value.status_code == 409 + assert exc_info.value.status_code == 403 add_workbench_accounts( WeiboWorkbenchAccountsRequest(account_ids=[second.id]), db=db, @@ -458,7 +470,10 @@ def test_weibo_workbench_is_isolated_per_user(db, current_user): == 1 ) pool_b = list_workbench_pool(page=1, page_size=20, db=db, current=user_b) - assert [item.uid for item in pool_b["items"]] == [first.uid] + assert pool_b["items"] == [] + + pool_a = list_workbench_pool(page=1, page_size=20, db=db, current=user_a) + assert [item.uid for item in pool_a["items"]] == [first.uid] def test_batch_tag_updates_selected_accounts(db, current_user): @@ -522,6 +537,233 @@ def test_list_accounts_returns_parallel_page_shape(db, current_user): } +def test_weibo_account_scope_for_support_and_operator(db): + support_a = User( + username="weibo-scope-a", + password_hash="hash", + role="support", + is_active=True, + ) + support_b = User( + username="weibo-scope-b", + password_hash="hash", + role="support", + is_active=True, + ) + operator = User( + username="weibo-scope-operator", + password_hash="hash", + role="operation", + is_active=True, + ) + db.add_all([support_a, support_b, operator]) + db.commit() + own = WeiboAccount( + uid="4014000301", + nickname="自己账号", + cookie="SCF=a; SUB=own", + tag="自己标签", + assigned_to=support_a.id, + ) + other = WeiboAccount( + uid="4014000302", + nickname="他人账号", + cookie="SCF=b; SUB=other", + tag="他人标签", + assigned_to=support_b.id, + ) + db.add_all([own, other]) + db.commit() + + own_page = list_accounts(page=1, page_size=20, db=db, current=support_a) + assert own_page["total"] == 1 + own_dump = own_page["items"][0].model_dump() + assert own_dump["uid"] == own.uid + assert own_dump["assigned_to"] == support_a.id + assert own_dump["assigned_username"] == support_a.username + assert list_account_ids(db=db, current=support_a)["account_ids"] == [own.id] + assert list_account_tags(db=db, current=support_a)["tags"] == ["自己标签"] + + with pytest.raises(HTTPException) as exc_info: + get_account(other.id, db=db, current=support_a) + assert exc_info.value.status_code == 404 + + own.tag = "原标签" + db.commit() + with pytest.raises(HTTPException) as exc_info: + batch_tag_accounts( + WeiboAccountBatchTagRequest(ids=[own.id, other.id], tag="越权"), + db=db, + current=support_a, + ) + assert exc_info.value.status_code == 404 + db.refresh(own) + db.refresh(other) + assert own.tag == "原标签" + assert other.tag == "他人标签" + + operator_page = list_accounts(page=1, page_size=20, db=db, current=operator) + assert operator_page["total"] == 2 + assert set(list_account_tags(db=db, current=operator)["tags"]) == { + "他人标签", + "原标签", + } + + preview = assignments_summary(db=db, current=operator) + assert preview["total_count"] == 2 + assert preview["assigned_count"] == 2 + assert preview["unassigned_count"] == 0 + counts = {item["id"]: item["assigned_count"] for item in preview["support_users"]} + assert counts == {support_a.id: 1, support_b.id: 1} + + with pytest.raises(HTTPException) as exc_info: + batch_assign_accounts( + WeiboBatchAssignRequest(account_ids=[own.id], assigned_to=support_b.id), + db=db, + current=support_a, + ) + assert exc_info.value.status_code == 403 + + +def test_weibo_batch_assign_cleans_previous_workbench(db): + operator = User( + username="weibo-assign-operator", + password_hash="hash", + role="operation", + is_active=True, + ) + old_support = User( + username="weibo-assign-old", + password_hash="hash", + role="support", + is_active=True, + ) + new_support = User( + username="weibo-assign-new", + password_hash="hash", + role="support", + is_active=True, + ) + db.add_all([operator, old_support, new_support]) + db.commit() + account = WeiboAccount( + uid="4014000311", + nickname="待改分配", + cookie="SCF=a; SUB=b", + assigned_to=old_support.id, + ) + db.add(account) + db.commit() + add_workbench_accounts( + WeiboWorkbenchAccountsRequest(account_ids=[account.id]), + db=db, + current=old_support, + ) + + result = batch_assign_accounts( + WeiboBatchAssignRequest( + account_ids=[account.id], + assigned_to=new_support.id, + ), + db=db, + current=operator, + ) + + assert result["updated"] == 1 + db.refresh(account) + assert account.assigned_to == new_support.id + assert ( + db.query(WeiboWorkbenchAccount) + .filter(WeiboWorkbenchAccount.account_id == account.id) + .count() + == 0 + ) + add_workbench_accounts( + WeiboWorkbenchAccountsRequest(account_ids=[account.id]), + db=db, + current=new_support, + ) + audit = ( + db.query(AuditLog) + .filter(AuditLog.action == "weibo:account:assign") + .order_by(AuditLog.id.desc()) + .first() + ) + assert audit is not None + assert str(account.id) in audit.detail + assert "SCF=a" not in audit.detail + + +def test_support_cannot_update_another_users_account_by_reimport(db): + support_a = User( + username="weibo-reimport-a", + password_hash="hash", + role="support", + is_active=True, + ) + support_b = User( + username="weibo-reimport-b", + password_hash="hash", + role="support", + is_active=True, + ) + db.add_all([support_a, support_b]) + db.commit() + account = WeiboAccount( + uid="4014000321", + nickname="他人 CK", + cookie="SCF=old; SUB=owner", + assigned_to=support_a.id, + ) + db.add(account) + db.commit() + StubWeiboClient.snapshot = WeiboHomeSnapshot( + uid=account.uid, + nickname="他人 CK", + is_bound=False, + role_name="", + can_rebind=True, + rebind_remain_days=0, + weidou_balance=100, + bind_url=BIND_URL, + ) + + denied = import_cookies( + WeiboCookieImport( + text="SCF=new; SUB=attacker", + update_existing=True, + ), + db=db, + current=support_b, + ) + + assert denied.count == 0 + assert len(denied.errors) == 1 + assert "已分配给其他客服" in denied.errors[0].message + db.refresh(account) + assert account.cookie == "SCF=old; SUB=owner" + assert account.assigned_to == support_a.id + + StubWeiboClient.snapshot = WeiboHomeSnapshot( + uid="4014000322", + nickname="新账号", + is_bound=False, + role_name="", + can_rebind=True, + rebind_remain_days=0, + weidou_balance=100, + bind_url=BIND_URL, + ) + imported = import_cookies( + WeiboCookieImport(text="SCF=new; SUB=own"), + db=db, + current=support_b, + ) + assert imported.count == 1 + new_account = db.query(WeiboAccount).filter(WeiboAccount.uid == "4014000322").one() + assert new_account.assigned_to == support_b.id + + def test_account_crud_and_lifecycle(db, current_user): account_out = import_cookies( WeiboCookieImport(text="SCF=a; SUB=b", tag="初始"), diff --git a/web/backend/migrations/versions/20261108_0074_weibo_account_assignee.py b/web/backend/migrations/versions/20261108_0074_weibo_account_assignee.py new file mode 100644 index 0000000..853626e --- /dev/null +++ b/web/backend/migrations/versions/20261108_0074_weibo_account_assignee.py @@ -0,0 +1,59 @@ +"""增加微博账号归属客服。""" + +from __future__ import annotations + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "20261108_0074" +down_revision: str | None = "20261108_0073" +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_accounts"): + return + columns = {column["name"] for column in inspector.get_columns("weibo_accounts")} + if "assigned_to" not in columns: + op.add_column( + "weibo_accounts", + sa.Column("assigned_to", sa.Integer(), nullable=True), + ) + op.create_foreign_key( + "fk_weibo_accounts_assigned_to_users", + "weibo_accounts", + "users", + ["assigned_to"], + ["id"], + ondelete="SET NULL", + ) + indexes = {index["name"] for index in inspector.get_indexes("weibo_accounts")} + if "ix_weibo_accounts_assigned_to" not in indexes: + op.create_index( + "ix_weibo_accounts_assigned_to", + "weibo_accounts", + ["assigned_to"], + ) + + +def downgrade() -> None: + inspector = sa.inspect(op.get_bind()) + if not inspector.has_table("weibo_accounts"): + return + foreign_keys = {fk["name"] for fk in inspector.get_foreign_keys("weibo_accounts")} + if "fk_weibo_accounts_assigned_to_users" in foreign_keys: + op.drop_constraint( + "fk_weibo_accounts_assigned_to_users", + "weibo_accounts", + type_="foreignkey", + ) + indexes = {index["name"] for index in inspector.get_indexes("weibo_accounts")} + if "ix_weibo_accounts_assigned_to" in indexes: + op.drop_index("ix_weibo_accounts_assigned_to", table_name="weibo_accounts") + columns = {column["name"] for column in inspector.get_columns("weibo_accounts")} + if "assigned_to" in columns: + op.drop_column("weibo_accounts", "assigned_to") diff --git a/web/backend/models.py b/web/backend/models.py index dadd82e..271e7f7 100644 --- a/web/backend/models.py +++ b/web/backend/models.py @@ -934,6 +934,11 @@ class WeiboAccount(Base): String(256), nullable=False, default="", server_default="" ) activity: Mapped[str] = mapped_column(String(64), default="") + assigned_to: Mapped[int | None] = mapped_column( + ForeignKey("users.id", ondelete="SET NULL"), + nullable=True, + index=True, + ) is_enabled: Mapped[bool] = mapped_column( Boolean, nullable=False, default=True, server_default="1", index=True @@ -960,6 +965,11 @@ class WeiboAccount(Base): UTCDateTime, default=_utcnow, onupdate=_utcnow ) + assigned_user: Mapped[User | None] = relationship( + "User", + foreign_keys=[assigned_to], + ) + class WeiboWorkbenchAccount(Base): """用户个人微博工作台中的账号选择。""" diff --git a/web/backend/permissions.py b/web/backend/permissions.py index fd688af..c8b3962 100644 --- a/web/backend/permissions.py +++ b/web/backend/permissions.py @@ -44,6 +44,7 @@ PERMISSIONS = { # 微博微豆 "weibo:view": "查看微博账号", "weibo:workbench": "使用个人微博工作台", + "weibo:assign": "查看全部微博账号并分配客服", "weibo:import": "导入/刷新微博 CK", "weibo:manage": "编辑、停用和启用微博账号", "weibo:account:ck_export": "复制微博外部 CK 账号的 CK(写剪贴板)", @@ -116,6 +117,7 @@ ROLE_PERMISSIONS = { "yyb:history", "weibo:view", "weibo:workbench", + "weibo:assign", "weibo:import", "weibo:manage", "weibo:account:ck_export", diff --git a/web/backend/routers/weibo.py b/web/backend/routers/weibo.py index 9308074..2bd984e 100644 --- a/web/backend/routers/weibo.py +++ b/web/backend/routers/weibo.py @@ -6,9 +6,9 @@ import os from fastapi import APIRouter, Depends, HTTPException, Query from loguru import logger -from sqlalchemy import or_ +from sqlalchemy import func, or_ from sqlalchemy.exc import IntegrityError -from sqlalchemy.orm import Session, defer +from sqlalchemy.orm import Session, defer, joinedload from core.weibo import WeiboAuthError, WeiboError, WeiboExchangeUncertainError @@ -22,6 +22,7 @@ from ..schemas import ( WeiboAccountDisableRequest, WeiboAccountOut, WeiboAccountUpdate, + WeiboBatchAssignRequest, WeiboBindQrOut, WeiboBindSchemeOut, WeiboCookieImport, @@ -80,15 +81,38 @@ def _int_or_none(value: object) -> int | None: return None -def _get_account(db: Session, account_id: int) -> WeiboAccount: - account = db.query(WeiboAccount).filter(WeiboAccount.id == account_id).first() +def _can_view_all_weibo_accounts(user: User) -> bool: + return user_has_permission(user, "weibo:assign") + + +def _visible_weibo_accounts_query(query, current: User): + if _can_view_all_weibo_accounts(current): + return query + return query.filter(WeiboAccount.assigned_to == current.id) + + +def _get_account( + db: Session, + account_id: int, + current: User | None = None, +) -> WeiboAccount: + query = db.query(WeiboAccount).filter(WeiboAccount.id == account_id) + if current is not None: + query = _visible_weibo_accounts_query(query, current) + account = query.first() if account is None: raise HTTPException(status_code=404, detail="微博账号不存在") return account def _account_out(account: WeiboAccount) -> WeiboAccountOut: - return WeiboAccountOut.model_validate(account) + return WeiboAccountOut.model_validate(account).model_copy( + update={ + "assigned_username": ( + account.assigned_user.username if account.assigned_user else "" + ) + } + ) def _apply_account_filters( @@ -98,6 +122,8 @@ def _apply_account_filters( tag: str, bind_status: str, enabled: bool | None, + assign_status: str = "", + assigned_user_id: int | None = None, ): search_text = _query_str(search) if search_text: @@ -114,6 +140,14 @@ def _apply_account_filters( tag_value = _query_str(tag) if tag_value: query = query.filter(WeiboAccount.tag == tag_value) + assign_value = _query_str(assign_status) + if assign_value == "unassigned": + query = query.filter(WeiboAccount.assigned_to.is_(None)) + elif assign_value == "assigned": + query = query.filter(WeiboAccount.assigned_to.isnot(None)) + target_user_id = _int_or_none(assigned_user_id) + if target_user_id is not None: + query = query.filter(WeiboAccount.assigned_to == target_user_id) if isinstance(enabled, bool): query = query.filter(WeiboAccount.is_enabled.is_(enabled)) bind_value = _query_str(bind_status) @@ -132,7 +166,10 @@ def _apply_account_filters( def _paginated_accounts(query, *, page: int, page_size: int) -> dict: total = query.order_by(None).count() rows = ( - query.options(defer(WeiboAccount.cookie)) + query.options( + joinedload(WeiboAccount.assigned_user), + defer(WeiboAccount.cookie), + ) .order_by(WeiboAccount.id.desc()) .offset((page - 1) * page_size) .limit(page_size) @@ -200,6 +237,8 @@ def list_accounts( tag: str = Query(""), bind_status: str = Query(""), enabled: bool | None = Query(None), + assign_status: str = Query(""), + assigned_user_id: int | None = Query(None), page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=200), db: Session = Depends(get_db), @@ -209,11 +248,13 @@ def list_accounts( _require_weibo_perm(current, "weibo:view") query = _apply_account_filters( - db.query(WeiboAccount), + _visible_weibo_accounts_query(db.query(WeiboAccount), current), search=search, tag=tag, bind_status=bind_status, enabled=enabled, + assign_status=assign_status, + assigned_user_id=assigned_user_id, ) return _paginated_accounts(query, page=page, page_size=page_size) @@ -240,6 +281,8 @@ def list_workbench_accounts( ) .filter(WeiboWorkbenchAccount.user_id == current.id) ) + if not _can_view_all_weibo_accounts(current): + query = query.filter(WeiboAccount.assigned_to == current.id) query = _apply_account_filters( query, search=search, @@ -263,8 +306,11 @@ def list_workbench_pool( _require_weibo_perm(current, "weibo:workbench") occupied = db.query(WeiboWorkbenchAccount.account_id) + pool_query = db.query(WeiboAccount).filter(WeiboAccount.id.not_in(occupied)) + if not _can_view_all_weibo_accounts(current): + pool_query = pool_query.filter(WeiboAccount.assigned_to == current.id) query = _apply_account_filters( - db.query(WeiboAccount).filter(WeiboAccount.id.not_in(occupied)), + pool_query, search=search, tag=tag, bind_status="", @@ -281,7 +327,7 @@ def list_workbench_tags( """返回当前用户工作台中已有的标签。""" _require_weibo_perm(current, "weibo:workbench") - rows = ( + tag_query = ( db.query(WeiboAccount.tag) .join( WeiboWorkbenchAccount, @@ -291,10 +337,10 @@ def list_workbench_tags( WeiboWorkbenchAccount.user_id == current.id, WeiboAccount.tag != "", ) - .distinct() - .order_by(WeiboAccount.tag.asc()) - .all() ) + if not _can_view_all_weibo_accounts(current): + tag_query = tag_query.filter(WeiboAccount.assigned_to == current.id) + rows = tag_query.distinct().order_by(WeiboAccount.tag.asc()).all() return {"tags": [tag for (tag,) in rows if tag]} @@ -321,6 +367,24 @@ def add_workbench_accounts( missing = sorted(set(account_ids) - existing_ids) if missing: raise HTTPException(status_code=404, detail=f"账号不存在: {missing}") + if not _can_view_all_weibo_accounts(current): + unavailable = { + account_id + for (account_id,) in db.query(WeiboAccount.id) + .filter( + WeiboAccount.id.in_(account_ids), + or_( + WeiboAccount.assigned_to.is_(None), + WeiboAccount.assigned_to != current.id, + ), + ) + .all() + } + if unavailable: + raise HTTPException( + status_code=403, + detail="只能使用分配给自己的微博账号", + ) taken = { account_id for (account_id,) in db.query(WeiboWorkbenchAccount.account_id) @@ -410,7 +474,11 @@ def batch_tag_accounts( 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() + accounts = ( + _visible_weibo_accounts_query(db.query(WeiboAccount), current) + .filter(WeiboAccount.id.in_(account_ids)) + .all() + ) actual_ids = {account.id for account in accounts} missing = sorted(set(account_ids) - actual_ids) if missing: @@ -434,6 +502,109 @@ def batch_tag_accounts( return {"updated": len(accounts), "tag": tag, "success": True} +@router.post("/accounts/batch-assign") +def batch_assign_accounts( + req: WeiboBatchAssignRequest, + db: Session = Depends(get_db), + current: User = Depends(get_current_user), +): + """批量分配微博账号给客服,或取消分配。""" + + _require_weibo_perm(current, "weibo:assign") + account_ids = sorted(set(req.account_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}") + target = None + if req.assigned_to is not None: + target = ( + db.query(User) + .filter( + User.id == req.assigned_to, + User.role == "support", + User.is_active.is_(True), + ) + .first() + ) + if target is None: + raise HTTPException(status_code=404, detail="目标客服不存在或已停用") + previous_assignments = {account.id: account.assigned_to for account in accounts} + if target is None: + db.query(WeiboWorkbenchAccount).filter( + WeiboWorkbenchAccount.account_id.in_(actual_ids) + ).delete(synchronize_session=False) + else: + db.query(WeiboWorkbenchAccount).filter( + WeiboWorkbenchAccount.account_id.in_(actual_ids), + WeiboWorkbenchAccount.user_id != target.id, + ).delete(synchronize_session=False) + for account in accounts: + account.assigned_to = target.id if target else None + db.commit() + target_name = target.username if target else "" + _record_audit_safely( + db, + current, + action="weibo:account:assign", + target=f"weibo_accounts:{len(account_ids)}", + detail={ + "account_ids": account_ids, + "assigned_to": target.id if target else None, + "assigned_username": target_name, + "previous_assignments": previous_assignments, + }, + ) + return { + "updated": len(accounts), + "assigned_to": target.id if target else None, + "assigned_username": target_name, + "success": True, + } + + +@router.get("/assignments/summary") +def assignments_summary( + db: Session = Depends(get_db), + current: User = Depends(get_current_user), +): + """微博账号分配概览。""" + + _require_weibo_perm(current, "weibo:assign") + rows = ( + db.query(User.id, User.username, func.count(WeiboAccount.id)) + .outerjoin(WeiboAccount, WeiboAccount.assigned_to == User.id) + .filter( + User.role == "support", + User.is_active.is_(True), + ) + .group_by(User.id, User.username) + .order_by(User.username.asc()) + .all() + ) + users = [ + { + "id": user_id, + "username": username, + "assigned_count": assigned_count, + } + for user_id, username, assigned_count in rows + ] + total_count = db.query(WeiboAccount).count() + unassigned_count = ( + db.query(WeiboAccount).filter(WeiboAccount.assigned_to.is_(None)).count() + ) + return { + "total_count": total_count, + "assigned_count": total_count - unassigned_count, + "unassigned_count": unassigned_count, + "support_users": users, + } + + @router.get("/accounts/tags") def list_account_tags( db: Session = Depends(get_db), @@ -442,9 +613,9 @@ def list_account_tags( """返回全局账号池已有的标签。""" _require_weibo_perm(current, "weibo:view") + tag_query = _visible_weibo_accounts_query(db.query(WeiboAccount.tag), current) rows = ( - db.query(WeiboAccount.tag) - .filter(WeiboAccount.tag != "") + tag_query.filter(WeiboAccount.tag != "") .distinct() .order_by(WeiboAccount.tag.asc()) .all() @@ -452,6 +623,38 @@ def list_account_tags( return {"tags": [tag for (tag,) in rows if tag]} +@router.get("/accounts/ids") +def list_account_ids( + search: str = Query(""), + tag: str = Query(""), + bind_status: str = Query(""), + enabled: bool | None = Query(None), + assign_status: str = Query(""), + assigned_user_id: int | None = Query(None), + db: Session = Depends(get_db), + current: User = Depends(get_current_user), +): + """返回当前用户可见且符合筛选条件的全部微博账号 ID。""" + + _require_weibo_perm(current, "weibo:view") + query = _apply_account_filters( + _visible_weibo_accounts_query(db.query(WeiboAccount), current), + search=search, + tag=tag, + bind_status=bind_status, + enabled=enabled, + assign_status=assign_status, + assigned_user_id=assigned_user_id, + ) + account_ids = [ + account_id + for (account_id,) in query.with_entities(WeiboAccount.id) + .order_by(WeiboAccount.id.desc()) + .all() + ] + return {"account_ids": account_ids, "total": len(account_ids)} + + @router.get("/accounts/{account_id}", response_model=WeiboAccountOut) def get_account( account_id: int, @@ -461,7 +664,7 @@ def get_account( """查看一个微博账号的本地资料。""" _require_weibo_perm(current, "weibo:view") - return _account_out(_get_account(db, account_id)) + return _account_out(_get_account(db, account_id, current)) @router.put("/accounts/{account_id}", response_model=WeiboAccountOut) @@ -474,7 +677,7 @@ def update_account( """更新账号的人工标签和备注。""" _require_weibo_perm(current, "weibo:manage") - account = _get_account(db, account_id) + account = _get_account(db, account_id, current) payload = req.model_dump(exclude_unset=True) changes = update_weibo_account_metadata( db, @@ -503,7 +706,7 @@ def disable_account( """停用账号,保留 CK 和历史资料。""" _require_weibo_perm(current, "weibo:manage") - account = _get_account(db, account_id) + account = _get_account(db, account_id, current) disable_weibo_account(db, account, reason=req.reason) _record_audit_safely( db, @@ -528,7 +731,7 @@ def enable_account( """重新检测 CK 与支付风险,通过后启用账号。""" _require_weibo_perm(current, "weibo:manage") - account = _get_account(db, account_id) + account = _get_account(db, account_id, current) _refresh_or_http(account, db) enable_weibo_account(db, account) _record_audit_safely( @@ -557,10 +760,21 @@ def import_cookies( WEIBO_DEFAULT_ACTIVITY, req.update_existing, current.id, + can_update_all=_can_view_all_weibo_accounts(current), ) typed_errors = [ WeiboImportError(line=item.line, message=item.message) for item in errors ] + if accounts and not _can_view_all_weibo_accounts(current): + changed = False + for account in accounts: + if account.assigned_to != current.id: + account.assigned_to = current.id + changed = True + if changed: + db.commit() + for account in accounts: + db.refresh(account) if accounts: _record_audit_safely( db, @@ -598,7 +812,7 @@ def refresh_account( """立即刷新一个微博账号的 CK 状态和资料。""" _require_weibo_perm(current, "weibo:view") - account = _get_account(db, account_id) + account = _get_account(db, account_id, current) _ensure_enabled(account) _refresh_or_http(account, db) _record_audit_safely( @@ -620,7 +834,7 @@ def copy_account_ck( """复制已导入的微博 CK 明文;只走单独权限并保留审计。""" _require_weibo_perm(current, "weibo:account:ck_export") - account = _get_account(db, account_id) + account = _get_account(db, account_id, current) ck_text = (account.cookie or "").strip() if not ck_text: raise HTTPException(status_code=409, detail="该账号没有保存 CK") @@ -649,7 +863,7 @@ def create_bind_qrcodes( _require_weibo_perm(current, "weibo:bind") selected_channel = channel.strip() if isinstance(channel, str) else "" - account = _get_account(db, account_id) + account = _get_account(db, account_id, current) _ensure_enabled(account) snapshot = _refresh_or_http(account, db) if snapshot.is_bound and not snapshot.can_rebind: @@ -726,7 +940,7 @@ def list_mall_goods( """手动查询微豆商城商品列表。""" _require_weibo_perm(current, "weibo:view") - account = _get_account(db, account_id) + account = _get_account(db, account_id, current) _ensure_enabled(account) try: data = query_weibo_mall( @@ -779,7 +993,7 @@ def exchange_goods( _require_weibo_perm(current, "weibo:exchange") if not req.confirm: raise HTTPException(status_code=400, detail="请先确认兑换商品") - account = _get_account(db, account_id) + account = _get_account(db, account_id, current) _ensure_enabled(account) try: result = exchange_weibo_goods( @@ -837,7 +1051,7 @@ def list_exchange_records( """手动查询微豆兑换记录。""" _require_weibo_perm(current, "weibo:view") - account = _get_account(db, account_id) + account = _get_account(db, account_id, current) _ensure_enabled(account) try: data = query_weibo_exchange_records( @@ -886,7 +1100,7 @@ def list_point_history( """手动查询微豆获取和充值明细。""" _require_weibo_perm(current, "weibo:view") - account = _get_account(db, account_id) + account = _get_account(db, account_id, current) _ensure_enabled(account) try: data = query_weibo_point_history( @@ -936,7 +1150,7 @@ def create_recharge_order( """手动创建助威充值订单并返回支付二维码。""" _require_weibo_perm(current, "weibo:recharge") - account = _get_account(db, account_id) + account = _get_account(db, account_id, current) _ensure_enabled(account) try: order = create_weibo_recharge_order( @@ -984,7 +1198,7 @@ def get_recharge_status( """手动查询支付状态;支付动作仍由用户在微信/支付宝完成。""" _require_weibo_perm(current, "weibo:recharge") - account = _get_account(db, account_id) + account = _get_account(db, account_id, current) _ensure_enabled(account) try: status = query_weibo_payment_status(db, account, pay_id=req.pay_id) @@ -1007,7 +1221,7 @@ def delete_account( """删除一个微博账号。""" _require_weibo_perm(current, "weibo:delete") - account = _get_account(db, account_id) + account = _get_account(db, account_id, current) uid = account.uid db.delete(account) db.commit() diff --git a/web/backend/schemas.py b/web/backend/schemas.py index a0d7a1d..9178cf7 100644 --- a/web/backend/schemas.py +++ b/web/backend/schemas.py @@ -483,6 +483,8 @@ class WeiboAccountOut(BaseModel): tag: str = "" remark: str = "" activity: str = "" + assigned_to: int | None = None + assigned_username: str = "" is_enabled: bool = True disabled_at: datetime | None = None disabled_reason: str = "" @@ -515,6 +517,11 @@ class WeiboWorkbenchAccountsRequest(BaseModel): account_ids: list[int] = Field(default_factory=list) +class WeiboBatchAssignRequest(BaseModel): + account_ids: list[int] = Field(min_length=1) + assigned_to: int | None = None + + class WeiboAccountDisableRequest(BaseModel): reason: str = Field("", max_length=256) diff --git a/web/backend/services/weibo_service.py b/web/backend/services/weibo_service.py index c5f8f0a..26cf590 100644 --- a/web/backend/services/weibo_service.py +++ b/web/backend/services/weibo_service.py @@ -63,6 +63,7 @@ def import_weibo_cookies( activity: str = "", update_existing: bool = False, current_user_id: int | None = None, + can_update_all: bool = False, ) -> tuple[list[WeiboAccount], list[WeiboImportIssue], int]: """逐行验证并导入微博 CK。 @@ -98,6 +99,18 @@ def import_weibo_cookies( db.query(WeiboAccount).filter(WeiboAccount.uid == snapshot.uid).first() ) if account is not None and update_existing and current_user_id is not None: + if ( + not can_update_all + and account.assigned_to is not None + and account.assigned_to != current_user_id + ): + errors.append( + WeiboImportIssue( + line=line_no, + message="该账号已分配给其他客服,不能更新 CK", + ) + ) + continue owner_id = ( db.query(WeiboWorkbenchAccount.user_id) .filter(WeiboWorkbenchAccount.account_id == account.id) diff --git a/web/frontend/src/App.tsx b/web/frontend/src/App.tsx index 9400bf7..7e3e778 100644 --- a/web/frontend/src/App.tsx +++ b/web/frontend/src/App.tsx @@ -32,6 +32,7 @@ const HuyaElitePage = lazy(() => import('./pages/HuyaElitePage')); const HuyaCollabPage = lazy(() => import('./pages/HuyaCollabPage')); const HuyaBrowserExchangePage = lazy(() => import('./pages/HuyaBrowserExchangePage')); const WeiboAccountsPage = lazy(() => import('./pages/WeiboAccountsPage')); +const WeiboAssignmentsPage = lazy(() => import('./pages/WeiboAssignmentsPage')); const WeiboCollabPage = lazy(() => import('./pages/WeiboCollabPage')); const YybRechargePage = lazy(() => import('./pages/YybRechargePage')); const AuditLogsPage = lazy(() => import('./pages/AuditLogsPage')); @@ -108,6 +109,7 @@ function AppContent() { )} /> )} /> )} /> + )} /> )} /> )} /> )} /> diff --git a/web/frontend/src/api/types.ts b/web/frontend/src/api/types.ts index 74c6500..68c08c8 100644 --- a/web/frontend/src/api/types.ts +++ b/web/frontend/src/api/types.ts @@ -754,6 +754,8 @@ export interface WeiboAccountItem { nickname: string; tag: string; remark: string; + assigned_to: number | null; + assigned_username: string; is_enabled: boolean; disabled_at: string | null; disabled_reason: string; diff --git a/web/frontend/src/api/weibo.ts b/web/frontend/src/api/weibo.ts index 8a46e1c..fa345d4 100644 --- a/web/frontend/src/api/weibo.ts +++ b/web/frontend/src/api/weibo.ts @@ -1,5 +1,6 @@ import api from './client'; import type { + AssignmentsSummary, MessageResponse, PaginatedResponse, WeiboAccountItem, @@ -22,6 +23,8 @@ export const weiboApi = { tag?: string; bind_status?: string; enabled?: boolean; + assign_status?: string; + assigned_user_id?: number; page: number; page_size: number; }) => @@ -29,6 +32,18 @@ export const weiboApi = { '/weibo/accounts', { params }, ), + listAccountIds: (params: { + search?: string; + tag?: string; + bind_status?: string; + enabled?: boolean; + assign_status?: string; + assigned_user_id?: number; + }) => + api.get<{ account_ids: number[]; total: number }, { account_ids: number[]; total: number }>( + '/weibo/accounts/ids', + { params }, + ), getAccount: (accountId: number) => api.get(`/weibo/accounts/${accountId}`), importCookies: (text: string, tag = '', updateExisting = false) => @@ -69,6 +84,13 @@ export const weiboApi = { '/weibo/accounts/batch-tag', { ids, tag }, ), + batchAssign: (accountIds: number[], assignedTo: number | null) => + api.post('/weibo/accounts/batch-assign', { + account_ids: accountIds, + assigned_to: assignedTo, + }), + assignmentsSummary: () => + api.get('/weibo/assignments/summary'), accountTags: () => api.get<{ tags: string[] }, { tags: string[] }>('/weibo/accounts/tags'), workbenchTags: () => diff --git a/web/frontend/src/layouts/MainLayout.tsx b/web/frontend/src/layouts/MainLayout.tsx index 14643fd..b1f70c6 100644 --- a/web/frontend/src/layouts/MainLayout.tsx +++ b/web/frontend/src/layouts/MainLayout.tsx @@ -128,6 +128,9 @@ export default function MainLayout({ onLogout }: { onLogout?: () => void }) { if (can('weibo:view')) { weiboItems.push({ key: '/weibo/accounts', label: '账号管理', icon: }); } + if (can('weibo:assign')) { + weiboItems.push({ key: '/weibo/assignments', label: '分配管理', icon: }); + } if (can('weibo:workbench')) { weiboItems.push({ key: '/weibo/workbench', label: '微博工作台', icon: }); } diff --git a/web/frontend/src/pages/WeiboAccountsPage.tsx b/web/frontend/src/pages/WeiboAccountsPage.tsx index fbc6822..90e78e3 100644 --- a/web/frontend/src/pages/WeiboAccountsPage.tsx +++ b/web/frontend/src/pages/WeiboAccountsPage.tsx @@ -132,6 +132,7 @@ export default function WeiboAccountsPage({ const canRecharge = can('weibo:recharge'); const canExchange = can('weibo:exchange'); const canDelete = can('weibo:delete'); + const canAssign = can('weibo:assign'); const [items, setItems] = useState([]); const [total, setTotal] = useState(0); @@ -860,6 +861,23 @@ export default function WeiboAccountsPage({ - ), }, + ...(canAssign + ? [ + { + title: '归属客服', + dataIndex: 'assigned_username', + width: 120, + align: 'center' as const, + onHeaderCell: centerHeader, + render: (value: string) => + value ? ( + {value} + ) : ( + 未分配 + ), + }, + ] + : []), { title: 'CK', key: 'ck', @@ -1237,7 +1255,9 @@ export default function WeiboAccountsPage({ {isWorkbench ? '当前账号独立使用的微博账号、角色绑定、充值与兑换' - : '全局账号池、账号生命周期、批量操作、微豆资料和角色绑定'} + : canAssign + ? '全局账号池、归属分配、账号生命周期、批量操作和角色绑定' + : '仅显示分配给你的微博账号,可进行绑定、充值与兑换'} @@ -1484,16 +1504,30 @@ export default function WeiboAccountsPage({ > {featureAccount ? ( -
- - {featureAccount.nickname || featureAccount.uid} - +
- - UID {featureAccount.uid} + + {featureAccount.nickname || featureAccount.uid} +
+ + UID {featureAccount.uid} + +
-
+ +
= { + valid: { label: '有效', color: 'success' }, + invalid: { label: '失效', color: 'error' }, + error: { label: '查询失败', color: 'warning' }, +}; + +function renderBindStatus(account: WeiboAccountItem) { + if (account.is_bound === true && account.can_rebind === true) { + return 可换绑; + } + if (account.is_bound === true) { + const days = account.rebind_remain_days ?? 0; + return {days > 0 ? `${days} 天后可换绑` : '已绑定'}; + } + if (account.is_bound === false) return 未绑定; + return 未查询; +} + +export default function WeiboAssignmentsPage() { + const { token } = theme.useToken(); + const [summary, setSummary] = useState(null); + const [supportUsers, setSupportUsers] = useState([]); + const [selectedUser, setSelectedUser] = useState(null); + const [accounts, setAccounts] = useState([]); + const [totalCount, setTotalCount] = useState(0); + const [tableLoading, setTableLoading] = useState(false); + const [refreshing, setRefreshing] = useState(false); + const [assigning, setAssigning] = useState(false); + const [selectedRowKeys, setSelectedRowKeys] = useState([]); + const [knownTags, setKnownTags] = useState([]); + const [searchText, setSearchText] = useState(''); + const [filterTag, setFilterTag] = useState(); + const [enabledFilter, setEnabledFilter] = useState(''); + const [activeTab, setActiveTab] = useState<'unassigned' | 'assigned'>('unassigned'); + const [currentPage, setCurrentPage] = useState(1); + const [pageSize, setPageSize] = useState(() => { + const saved = localStorage.getItem(STORAGE_KEY_PAGE_SIZE); + return saved ? Number(saved) || 15 : 15; + }); + + const loadSummary = useCallback(async (silent = false) => { + try { + const data = await weiboApi.assignmentsSummary(); + setSummary(data); + setSupportUsers(data.support_users); + return data.support_users; + } catch (error) { + if (!silent) message.error(getErrorMessage(error)); + return []; + } + }, []); + + const loadTags = useCallback(async () => { + try { + const result = await weiboApi.accountTags(); + setKnownTags(result.tags); + } catch { + // 标签字典失败不影响分配列表。 + } + }, []); + + const loadAccounts = useCallback( + async (silent = false) => { + if (activeTab === 'assigned' && !selectedUser) return; + if (!silent) setTableLoading(true); + try { + const data: PaginatedResponse = await weiboApi.listAccounts({ + page: currentPage, + page_size: pageSize, + search: searchText.trim() || undefined, + tag: filterTag, + enabled: + enabledFilter === 'enabled' + ? true + : enabledFilter === 'disabled' + ? false + : undefined, + assign_status: activeTab === 'unassigned' ? 'unassigned' : undefined, + assigned_user_id: + activeTab === 'assigned' ? selectedUser?.id : undefined, + }); + setAccounts(data.items); + setTotalCount(data.total); + } catch (error) { + if (!silent) message.error(getErrorMessage(error)); + } finally { + if (!silent) setTableLoading(false); + } + }, + [ + activeTab, + currentPage, + enabledFilter, + filterTag, + pageSize, + searchText, + selectedUser, + ], + ); + + useEffect(() => { + const initialize = async () => { + void loadTags(); + const users = await loadSummary(); + if (users.length > 0) { + const savedId = localStorage.getItem(STORAGE_KEY_SELECTED_USER); + const savedUser = savedId + ? users.find((user) => user.id === Number(savedId)) + : undefined; + setSelectedUser(savedUser || users[0]); + } + }; + void initialize(); + }, [loadSummary, loadTags]); + + useEffect(() => { + void loadAccounts(); + }, [loadAccounts]); + + const refreshData = async (silent = false) => { + await Promise.all([loadSummary(silent), loadAccounts(silent)]); + }; + + const handleRefresh = async () => { + setRefreshing(true); + try { + await refreshData(); + } finally { + setRefreshing(false); + } + }; + + const handleSelectUser = (user: SupportUserItem) => { + setSelectedUser(user); + setActiveTab('unassigned'); + setSelectedRowKeys([]); + setCurrentPage(1); + localStorage.setItem(STORAGE_KEY_SELECTED_USER, String(user.id)); + }; + + const handlePageChange = (page: number, size: number) => { + setCurrentPage(page); + if (size !== pageSize) { + setPageSize(size); + localStorage.setItem(STORAGE_KEY_PAGE_SIZE, String(size)); + setCurrentPage(1); + } + }; + + const handleBatchAssign = async () => { + if (!selectedUser || selectedRowKeys.length === 0) { + message.warning('请先选择客服和微博账号'); + return; + } + setAssigning(true); + try { + const result = await weiboApi.batchAssign(selectedRowKeys, selectedUser.id); + message.success( + result.message || `已将 ${selectedRowKeys.length} 个账号分配给 ${selectedUser.username}`, + ); + setSelectedRowKeys([]); + await refreshData(); + } catch (error) { + message.error(getErrorMessage(error)); + } finally { + setAssigning(false); + } + }; + + const handleAssignAllFiltered = async () => { + if (!selectedUser) { + message.warning('请先选择客服'); + return; + } + setAssigning(true); + try { + const result = await weiboApi.listAccountIds({ + search: searchText.trim() || undefined, + tag: filterTag, + enabled: + enabledFilter === 'enabled' + ? true + : enabledFilter === 'disabled' + ? false + : undefined, + assign_status: 'unassigned', + }); + if (result.account_ids.length === 0) { + message.info('当前筛选下没有未分配账号'); + return; + } + await weiboApi.batchAssign(result.account_ids, selectedUser.id); + message.success( + `已将 ${result.account_ids.length} 个账号分配给 ${selectedUser.username}`, + ); + setSelectedRowKeys([]); + await refreshData(); + } catch (error) { + message.error(getErrorMessage(error)); + } finally { + setAssigning(false); + } + }; + + const handleBatchUnassign = async () => { + if (selectedRowKeys.length === 0) { + message.warning('请先选择微博账号'); + return; + } + setAssigning(true); + try { + await weiboApi.batchAssign(selectedRowKeys, null); + message.success(`已取消 ${selectedRowKeys.length} 个账号的分配`); + setSelectedRowKeys([]); + await refreshData(); + } catch (error) { + message.error(getErrorMessage(error)); + } finally { + setAssigning(false); + } + }; + + const selectedUserCount = + supportUsers.find((user) => user.id === selectedUser?.id)?.assigned_count ?? + selectedUser?.assigned_count ?? + 0; + const unassignedCount = summary?.unassigned_count ?? 0; + const assignedCount = summary?.assigned_count ?? 0; + const accountCount = summary?.total_count ?? assignedCount + unassignedCount; + + const columns: TableProps['columns'] = [ + { + title: '序号', + key: 'index', + width: 58, + align: 'center', + render: (_, __, index) => (currentPage - 1) * pageSize + index + 1, + }, + { + title: '微博账号', + key: 'account', + width: 190, + align: 'center', + render: (_, account) => ( + + {account.nickname || account.uid} + + UID {account.uid} + + + ), + }, + { + title: '标签', + dataIndex: 'tag', + width: 100, + align: 'center', + render: (tag: string) => + tag ? {tag} : -, + }, + { + title: 'CK 状态', + dataIndex: 'ck_check_status', + width: 100, + align: 'center', + render: (status: string) => { + const state = CK_STATUS[status] || { label: '未检测', color: 'default' }; + return {state.label}; + }, + }, + { + title: '角色绑定', + key: 'bind', + width: 120, + align: 'center', + render: (_, account) => renderBindStatus(account), + }, + { + title: '微豆', + dataIndex: 'weidou_balance', + width: 90, + align: 'center', + render: (value: number | null) => + value == null ? - : value.toLocaleString(), + }, + { + title: '生命周期', + dataIndex: 'is_enabled', + width: 90, + align: 'center', + render: (enabled: boolean) => ( + + {enabled ? '已启用' : '已停用'} + + ), + }, + { + title: '更新时间', + dataIndex: 'updated_at', + width: 155, + align: 'center', + render: (value: string | null) => + value ? formatTime(value) : -, + }, + ]; + + return ( +
+
+
+

微博分配管理

+ 管理员和运营查看全部账号;客服只能看到分配给自己的账号。 +
+ +
+ + + + + } /> + + + + + } + /> + + + + + } + /> + + + + + } + /> + + + + + + + +
+ {supportUsers.map((user) => { + const isSelected = selectedUser?.id === user.id; + return ( +
handleSelectUser(user)} + style={{ + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + padding: '10px 14px', + borderRadius: 6, + cursor: 'pointer', + border: `1px solid ${isSelected ? token.colorPrimary : token.colorBorderSecondary}`, + background: isSelected ? token.colorPrimaryBg : token.colorBgContainer, + }} + > + + + {user.username} + + + + +
+ ); + })} + {supportUsers.length === 0 && ( + + 暂无客服用户 + + )} +
+
+ + + + + 已选中客服: + + {selectedUser.username}(已分配 {selectedUserCount} 个) + + + ) : ( + 请选择左侧客服进行操作 + ) + } + style={{ flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0 }} + styles={{ + body: { + flex: 1, + display: 'flex', + flexDirection: 'column', + minHeight: 0, + padding: 0, + }, + }} + > +
+ { + setSearchText(event.target.value); + setCurrentPage(1); + }} + style={{ width: 230 }} + size="small" + /> + { + setEnabledFilter(value); + setCurrentPage(1); + }} + style={{ width: 120 }} + size="small" + options={[ + { value: '', label: '全部状态' }, + { value: 'enabled', label: '已启用' }, + { value: 'disabled', label: '已停用' }, + ]} + /> +
+ + { + setActiveTab(key as 'unassigned' | 'assigned'); + setSelectedRowKeys([]); + setCurrentPage(1); + }} + style={{ padding: '0 16px', flexShrink: 0, marginBottom: 0 }} + items={[ + { key: 'unassigned', label: `未分配 (${unassignedCount})` }, + { + key: 'assigned', + label: selectedUser + ? `已分配给 ${selectedUser.username} (${selectedUserCount})` + : '已分配账号', + disabled: !selectedUser, + }, + ]} + /> + +
+ {activeTab === 'unassigned' && selectedUser && ( + <> + + + + + + )} + {activeTab === 'assigned' && selectedUser && ( + + )} +
+ + + columns={columns} + dataSource={accounts} + rowKey="id" + size="small" + loading={tableLoading} + pagination={{ + current: currentPage, + pageSize, + total: totalCount, + size: 'small', + showSizeChanger: true, + showTotal: (total) => `共 ${total} 条`, + onChange: handlePageChange, + }} + rowSelection={{ + selectedRowKeys, + preserveSelectedRowKeys: true, + onChange: (keys) => setSelectedRowKeys(keys.map(Number)), + }} + scroll={{ x: 940, y: 'calc(100vh - 500px)' }} + /> +
+ +
+
+ ); +}