feat(weibo): 增加账号分配与客服数据隔离
This commit is contained in:
+2
-1
@@ -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):人工兑换页面、扩展注入和目标页说明。
|
||||
|
||||
@@ -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,必须由当前使用人操作。
|
||||
- 无论是否更新,全局账号记录始终只有一条。
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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="初始"),
|
||||
|
||||
@@ -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")
|
||||
@@ -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):
|
||||
"""用户个人微博工作台中的账号选择。"""
|
||||
|
||||
@@ -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",
|
||||
|
||||
+243
-29
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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() {
|
||||
<Route path="huya/collab" element={lazyRoute(<HuyaCollabPage />)} />
|
||||
<Route path="huya/browser-exchange" element={lazyRoute(<HuyaBrowserExchangePage />)} />
|
||||
<Route path="weibo/accounts" element={lazyRoute(<WeiboAccountsPage mode="management" />)} />
|
||||
<Route path="weibo/assignments" element={lazyRoute(<WeiboAssignmentsPage />)} />
|
||||
<Route path="weibo/workbench" element={lazyRoute(<WeiboAccountsPage mode="workbench" />)} />
|
||||
<Route path="weibo/collab" element={lazyRoute(<WeiboCollabPage />)} />
|
||||
<Route path="yyb/recharge" element={lazyRoute(<YybRechargePage />)} />
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<WeiboAccountItem, WeiboAccountItem>(`/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<MessageResponse, MessageResponse>('/weibo/accounts/batch-assign', {
|
||||
account_ids: accountIds,
|
||||
assigned_to: assignedTo,
|
||||
}),
|
||||
assignmentsSummary: () =>
|
||||
api.get<AssignmentsSummary, AssignmentsSummary>('/weibo/assignments/summary'),
|
||||
accountTags: () =>
|
||||
api.get<{ tags: string[] }, { tags: string[] }>('/weibo/accounts/tags'),
|
||||
workbenchTags: () =>
|
||||
|
||||
@@ -128,6 +128,9 @@ export default function MainLayout({ onLogout }: { onLogout?: () => void }) {
|
||||
if (can('weibo:view')) {
|
||||
weiboItems.push({ key: '/weibo/accounts', label: '账号管理', icon: <UserOutlined /> });
|
||||
}
|
||||
if (can('weibo:assign')) {
|
||||
weiboItems.push({ key: '/weibo/assignments', label: '分配管理', icon: <SwapOutlined /> });
|
||||
}
|
||||
if (can('weibo:workbench')) {
|
||||
weiboItems.push({ key: '/weibo/workbench', label: '微博工作台', icon: <DesktopOutlined /> });
|
||||
}
|
||||
|
||||
@@ -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<WeiboAccountItem[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
@@ -860,6 +861,23 @@ export default function WeiboAccountsPage({
|
||||
<Text type="secondary">-</Text>
|
||||
),
|
||||
},
|
||||
...(canAssign
|
||||
? [
|
||||
{
|
||||
title: '归属客服',
|
||||
dataIndex: 'assigned_username',
|
||||
width: 120,
|
||||
align: 'center' as const,
|
||||
onHeaderCell: centerHeader,
|
||||
render: (value: string) =>
|
||||
value ? (
|
||||
<Tag color="cyan">{value}</Tag>
|
||||
) : (
|
||||
<Text type="secondary">未分配</Text>
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
title: 'CK',
|
||||
key: 'ck',
|
||||
@@ -1237,7 +1255,9 @@ export default function WeiboAccountsPage({
|
||||
<Text type="secondary">
|
||||
{isWorkbench
|
||||
? '当前账号独立使用的微博账号、角色绑定、充值与兑换'
|
||||
: '全局账号池、账号生命周期、批量操作、微豆资料和角色绑定'}
|
||||
: canAssign
|
||||
? '全局账号池、归属分配、账号生命周期、批量操作和角色绑定'
|
||||
: '仅显示分配给你的微博账号,可进行绑定、充值与兑换'}
|
||||
</Text>
|
||||
</div>
|
||||
<Space wrap>
|
||||
@@ -1484,16 +1504,30 @@ export default function WeiboAccountsPage({
|
||||
>
|
||||
{featureAccount ? (
|
||||
<Space orientation="vertical" size={12} style={{ width: '100%' }}>
|
||||
<div>
|
||||
<Text strong style={{ fontSize: 16 }}>
|
||||
{featureAccount.nickname || featureAccount.uid}
|
||||
</Text>
|
||||
<Space
|
||||
align="start"
|
||||
style={{ width: '100%', justifyContent: 'space-between' }}
|
||||
>
|
||||
<div>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
UID {featureAccount.uid}
|
||||
<Text strong style={{ fontSize: 16 }}>
|
||||
{featureAccount.nickname || featureAccount.uid}
|
||||
</Text>
|
||||
<div>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
UID {featureAccount.uid}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<ReloadOutlined />}
|
||||
loading={refreshingId === featureAccount.id}
|
||||
disabled={!featureAccount.is_enabled}
|
||||
onClick={() => void handleRefresh(featureAccount)}
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
</Space>
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
|
||||
@@ -0,0 +1,625 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
Input,
|
||||
Popconfirm,
|
||||
Row,
|
||||
Select,
|
||||
Space,
|
||||
Statistic,
|
||||
Table,
|
||||
Tabs,
|
||||
Tag,
|
||||
Tooltip,
|
||||
Typography,
|
||||
theme,
|
||||
} from 'antd';
|
||||
import type { TableProps } from 'antd';
|
||||
import {
|
||||
CheckCircleOutlined,
|
||||
ClearOutlined,
|
||||
ReloadOutlined,
|
||||
SwapOutlined,
|
||||
TeamOutlined,
|
||||
UserOutlined,
|
||||
UsergroupAddOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import {
|
||||
weiboApi,
|
||||
type AssignmentsSummary,
|
||||
type PaginatedResponse,
|
||||
type SupportUserItem,
|
||||
type WeiboAccountItem,
|
||||
} from '../api/modules';
|
||||
import { getErrorMessage } from '../utils/error';
|
||||
import { message } from '../utils/antdMessage';
|
||||
import { formatTime } from '../utils/time';
|
||||
|
||||
const { Text } = Typography;
|
||||
const STORAGE_KEY_SELECTED_USER = 'weibo_assignments_selected_user_id';
|
||||
const STORAGE_KEY_PAGE_SIZE = 'weibo_assignment_page_size';
|
||||
|
||||
const CK_STATUS: Record<string, { label: string; color: string }> = {
|
||||
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 <Tag color="processing">可换绑</Tag>;
|
||||
}
|
||||
if (account.is_bound === true) {
|
||||
const days = account.rebind_remain_days ?? 0;
|
||||
return <Tag color="success">{days > 0 ? `${days} 天后可换绑` : '已绑定'}</Tag>;
|
||||
}
|
||||
if (account.is_bound === false) return <Tag color="warning">未绑定</Tag>;
|
||||
return <Tag>未查询</Tag>;
|
||||
}
|
||||
|
||||
export default function WeiboAssignmentsPage() {
|
||||
const { token } = theme.useToken();
|
||||
const [summary, setSummary] = useState<AssignmentsSummary | null>(null);
|
||||
const [supportUsers, setSupportUsers] = useState<SupportUserItem[]>([]);
|
||||
const [selectedUser, setSelectedUser] = useState<SupportUserItem | null>(null);
|
||||
const [accounts, setAccounts] = useState<WeiboAccountItem[]>([]);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
const [tableLoading, setTableLoading] = useState(false);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [assigning, setAssigning] = useState(false);
|
||||
const [selectedRowKeys, setSelectedRowKeys] = useState<number[]>([]);
|
||||
const [knownTags, setKnownTags] = useState<string[]>([]);
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [filterTag, setFilterTag] = useState<string | undefined>();
|
||||
const [enabledFilter, setEnabledFilter] = useState<string>('');
|
||||
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<WeiboAccountItem> = 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<WeiboAccountItem>['columns'] = [
|
||||
{
|
||||
title: '序号',
|
||||
key: 'index',
|
||||
width: 58,
|
||||
align: 'center',
|
||||
render: (_, __, index) => (currentPage - 1) * pageSize + index + 1,
|
||||
},
|
||||
{
|
||||
title: '微博账号',
|
||||
key: 'account',
|
||||
width: 190,
|
||||
align: 'center',
|
||||
render: (_, account) => (
|
||||
<Space orientation="vertical" size={0}>
|
||||
<Text strong>{account.nickname || account.uid}</Text>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
UID {account.uid}
|
||||
</Text>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '标签',
|
||||
dataIndex: 'tag',
|
||||
width: 100,
|
||||
align: 'center',
|
||||
render: (tag: string) =>
|
||||
tag ? <Tag color="blue">{tag}</Tag> : <Text type="secondary">-</Text>,
|
||||
},
|
||||
{
|
||||
title: 'CK 状态',
|
||||
dataIndex: 'ck_check_status',
|
||||
width: 100,
|
||||
align: 'center',
|
||||
render: (status: string) => {
|
||||
const state = CK_STATUS[status] || { label: '未检测', color: 'default' };
|
||||
return <Tag color={state.color}>{state.label}</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
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 ? <Text type="secondary">-</Text> : value.toLocaleString(),
|
||||
},
|
||||
{
|
||||
title: '生命周期',
|
||||
dataIndex: 'is_enabled',
|
||||
width: 90,
|
||||
align: 'center',
|
||||
render: (enabled: boolean) => (
|
||||
<Tag color={enabled ? 'success' : 'default'}>
|
||||
{enabled ? '已启用' : '已停用'}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '更新时间',
|
||||
dataIndex: 'updated_at',
|
||||
width: 155,
|
||||
align: 'center',
|
||||
render: (value: string | null) =>
|
||||
value ? formatTime(value) : <Text type="secondary">-</Text>,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<h2 style={{ margin: 0 }}>微博分配管理</h2>
|
||||
<Text type="secondary">管理员和运营查看全部账号;客服只能看到分配给自己的账号。</Text>
|
||||
</div>
|
||||
<Button icon={<ReloadOutlined />} loading={refreshing} onClick={handleRefresh}>
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Row gutter={12} style={{ flexShrink: 0 }}>
|
||||
<Col xs={12} lg={6}>
|
||||
<Card size="small">
|
||||
<Statistic title="微博账号" value={accountCount} prefix={<TeamOutlined />} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} lg={6}>
|
||||
<Card size="small">
|
||||
<Statistic
|
||||
title="已分配"
|
||||
value={assignedCount}
|
||||
styles={{ content: { color: token.colorSuccess } }}
|
||||
prefix={<CheckCircleOutlined />}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} lg={6}>
|
||||
<Card size="small">
|
||||
<Statistic
|
||||
title="未分配"
|
||||
value={unassignedCount}
|
||||
styles={{ content: { color: token.colorError } }}
|
||||
prefix={<SwapOutlined />}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} lg={6}>
|
||||
<Card size="small">
|
||||
<Statistic
|
||||
title="客服人数"
|
||||
value={supportUsers.length}
|
||||
prefix={<UsergroupAddOutlined />}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={12} style={{ flex: 1, minHeight: 0 }}>
|
||||
<Col xs={24} lg={8} style={{ height: '100%', display: 'flex', minHeight: 0 }}>
|
||||
<Card
|
||||
title="客服列表"
|
||||
size="small"
|
||||
style={{ flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0 }}
|
||||
styles={{ body: { flex: 1, overflow: 'auto', padding: 8 } }}
|
||||
>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{supportUsers.map((user) => {
|
||||
const isSelected = selectedUser?.id === user.id;
|
||||
return (
|
||||
<div
|
||||
key={user.id}
|
||||
onClick={() => 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,
|
||||
}}
|
||||
>
|
||||
<Space>
|
||||
<UserOutlined style={{ color: token.colorPrimary }} />
|
||||
<Text strong>{user.username}</Text>
|
||||
</Space>
|
||||
<Tooltip title={`已分配 ${user.assigned_count} 个账号`}>
|
||||
<Badge
|
||||
count={user.assigned_count}
|
||||
overflowCount={999999}
|
||||
showZero
|
||||
style={{ backgroundColor: token.colorPrimary }}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{supportUsers.length === 0 && (
|
||||
<Text
|
||||
type="secondary"
|
||||
style={{ textAlign: 'center', padding: 20, display: 'block' }}
|
||||
>
|
||||
暂无客服用户
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col xs={24} lg={16} style={{ height: '100%', display: 'flex', minHeight: 0 }}>
|
||||
<Card
|
||||
size="small"
|
||||
title={
|
||||
selectedUser ? (
|
||||
<Space wrap>
|
||||
<span>已选中客服:</span>
|
||||
<Tag color="blue" style={{ fontSize: 14, padding: '2px 10px' }}>
|
||||
{selectedUser.username}(已分配 {selectedUserCount} 个)
|
||||
</Tag>
|
||||
</Space>
|
||||
) : (
|
||||
<Text type="secondary">请选择左侧客服进行操作</Text>
|
||||
)
|
||||
}
|
||||
style={{ flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0 }}
|
||||
styles={{
|
||||
body: {
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
minHeight: 0,
|
||||
padding: 0,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
borderBottom: `1px solid ${token.colorBorderSecondary}`,
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: 8,
|
||||
alignItems: 'center',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Input.Search
|
||||
allowClear
|
||||
placeholder="搜索 UID、昵称、角色或备注"
|
||||
value={searchText}
|
||||
onChange={(event) => {
|
||||
setSearchText(event.target.value);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
style={{ width: 230 }}
|
||||
size="small"
|
||||
/>
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="按标签筛选"
|
||||
value={filterTag}
|
||||
onChange={(value) => {
|
||||
setFilterTag(value);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
style={{ width: 140 }}
|
||||
size="small"
|
||||
options={knownTags.map((tag) => ({ value: tag, label: tag }))}
|
||||
/>
|
||||
<Select
|
||||
value={enabledFilter}
|
||||
onChange={(value) => {
|
||||
setEnabledFilter(value);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
style={{ width: 120 }}
|
||||
size="small"
|
||||
options={[
|
||||
{ value: '', label: '全部状态' },
|
||||
{ value: 'enabled', label: '已启用' },
|
||||
{ value: 'disabled', label: '已停用' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Tabs
|
||||
size="small"
|
||||
activeKey={activeTab}
|
||||
onChange={(key) => {
|
||||
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,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<div
|
||||
style={{
|
||||
padding: '0 16px 8px',
|
||||
display: 'flex',
|
||||
gap: 8,
|
||||
flexWrap: 'wrap',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{activeTab === 'unassigned' && selectedUser && (
|
||||
<>
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
icon={<SwapOutlined />}
|
||||
loading={assigning}
|
||||
disabled={selectedRowKeys.length === 0}
|
||||
onClick={handleBatchAssign}
|
||||
>
|
||||
分配选中 ({selectedRowKeys.length}) 给 {selectedUser.username}
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title={`把当前筛选下 ${totalCount} 个未分配账号全部分给 ${selectedUser.username}?`}
|
||||
okText="确认分配"
|
||||
cancelText="取消"
|
||||
disabled={totalCount === 0}
|
||||
onConfirm={handleAssignAllFiltered}
|
||||
>
|
||||
<Button size="small" loading={assigning} disabled={totalCount === 0}>
|
||||
分配当前筛选全部
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</>
|
||||
)}
|
||||
{activeTab === 'assigned' && selectedUser && (
|
||||
<Button
|
||||
danger
|
||||
size="small"
|
||||
icon={<ClearOutlined />}
|
||||
loading={assigning}
|
||||
disabled={selectedRowKeys.length === 0}
|
||||
onClick={handleBatchUnassign}
|
||||
>
|
||||
取消分配选中 ({selectedRowKeys.length})
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Table<WeiboAccountItem>
|
||||
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)' }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user