feat(weibo): 增加强制分配权限与账号详情
This commit is contained in:
@@ -58,6 +58,9 @@ UNIQUE(account_id, exclusive_slot)
|
||||
|
||||
- `weibo:view`:查看全局账号管理。
|
||||
- `weibo:assign`:查看全部账号、进入分配管理、批量分配/取消分配客服归属。
|
||||
- `weibo:assign:force`:查看「全部账号」页签,把勾选账号或指定标签下的全部账号
|
||||
强制转给客服。默认仅超级管理员拥有;运营默认关闭,可在用户管理的权限弹窗中
|
||||
单独打开。
|
||||
- `weibo:workbench`:访问个人微博工作台。
|
||||
- `weibo:import`:导入外部 CK。
|
||||
- `weibo:manage`:编辑账号标签/备注、启停账号、批量标签。
|
||||
@@ -80,6 +83,7 @@ UNIQUE(account_id, exclusive_slot)
|
||||
| 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` 表示取消分配 |
|
||||
| POST | `/api/weibo/assignments/force` | `weibo:assign:force` | 按账号 ID 或标签强制改归属,`expected_count` 不一致时返回 409 |
|
||||
| GET | `/api/weibo/assignments/summary` | `weibo:assign` | 分配统计和客服名下账号数 |
|
||||
|
||||
工作台接口始终按 `current.id` 过滤,不接受客户端传入用户 ID。客服访问账号相关接口时,服务端还会强制叠加 `assigned_to = current.id`,不能依赖前端隐藏入口。
|
||||
@@ -102,6 +106,9 @@ UNIQUE(account_id, exclusive_slot)
|
||||
- 管理员和运营看到全量账号、归属客服列、生命周期、批量刷新、批量标签、启停和删除。
|
||||
- 客服只看到分配给自己的账号,并保留绑定、充值、兑换、标签、CK 操作等日常能力。
|
||||
- 用于维护账号池和客服归属,不作为客服个人工作台的选择状态。
|
||||
- 操作列「详情」打开账号详情抽屉:展示导入时间、更新时间、归属、CK 与绑定状态、
|
||||
微豆余额和备注;「微豆收支」「兑换记录」按微博实时接口分页查询。
|
||||
停用账号仍可查看本地资料,但不能实时查询收支和兑换明细。
|
||||
|
||||
分配管理页:
|
||||
|
||||
@@ -109,6 +116,9 @@ UNIQUE(account_id, exclusive_slot)
|
||||
- 支持按搜索、标签、生命周期筛选。
|
||||
- 支持跨页勾选后批量分配或取消分配。
|
||||
- 支持把当前筛选下的全部未分配账号一次性分配给选中客服。
|
||||
- 拥有 `weibo:assign:force` 时显示「全部账号」页签:可查看当前归属客服,
|
||||
跨页勾选后强制改归属,或把某个统一标签下的全部账号强制转给目标客服。
|
||||
标签批量操作先读取数量再二次确认,服务端按 `expected_count` 防止筛选结果变化。
|
||||
- 客服列表展示每个客服当前名下账号数。
|
||||
|
||||
工作台页:
|
||||
|
||||
@@ -31,6 +31,7 @@ from web.backend.routers.weibo import (
|
||||
create_bind_qrcodes,
|
||||
disable_account,
|
||||
enable_account,
|
||||
force_assign_accounts,
|
||||
get_account,
|
||||
import_cookies,
|
||||
import_workbench_accounts_by_text,
|
||||
@@ -52,6 +53,7 @@ from web.backend.schemas import (
|
||||
WeiboBatchAssignRequest,
|
||||
WeiboCookieImport,
|
||||
WeiboExchangeRequest,
|
||||
WeiboForceAssignRequest,
|
||||
WeiboPaymentStatusRequest,
|
||||
WeiboRechargeRequest,
|
||||
WeiboWorkbenchAccountsRequest,
|
||||
@@ -999,6 +1001,11 @@ def test_weibo_batch_assign_cleans_previous_workbench(db):
|
||||
)
|
||||
db.add_all([operator, old_support, new_support, admin])
|
||||
db.commit()
|
||||
operator.custom_permissions = [
|
||||
*ROLE_PERMISSIONS["operation"],
|
||||
"weibo:assign:force",
|
||||
]
|
||||
db.commit()
|
||||
account = WeiboAccount(
|
||||
uid="4014000311",
|
||||
nickname="待改分配",
|
||||
@@ -1021,10 +1028,11 @@ def test_weibo_batch_assign_cleans_previous_workbench(db):
|
||||
)
|
||||
db.commit()
|
||||
|
||||
result = batch_assign_accounts(
|
||||
WeiboBatchAssignRequest(
|
||||
result = force_assign_accounts(
|
||||
WeiboForceAssignRequest(
|
||||
account_ids=[account.id],
|
||||
assigned_to=new_support.id,
|
||||
expected_count=1,
|
||||
),
|
||||
db=db,
|
||||
current=operator,
|
||||
@@ -1054,7 +1062,7 @@ def test_weibo_batch_assign_cleans_previous_workbench(db):
|
||||
)
|
||||
audit = (
|
||||
db.query(AuditLog)
|
||||
.filter(AuditLog.action == "weibo:account:assign")
|
||||
.filter(AuditLog.action == "weibo:account:force_assign")
|
||||
.order_by(AuditLog.id.desc())
|
||||
.first()
|
||||
)
|
||||
@@ -1063,6 +1071,148 @@ def test_weibo_batch_assign_cleans_previous_workbench(db):
|
||||
assert "SCF=a" not in audit.detail
|
||||
|
||||
|
||||
def test_weibo_force_assign_requires_dedicated_permission(db):
|
||||
old_support = User(
|
||||
username="weibo-force-old-support",
|
||||
password_hash="hash",
|
||||
role="support",
|
||||
is_active=True,
|
||||
)
|
||||
support = User(
|
||||
username="weibo-force-support",
|
||||
password_hash="hash",
|
||||
role="support",
|
||||
is_active=True,
|
||||
)
|
||||
operator = User(
|
||||
username="weibo-force-operator",
|
||||
password_hash="hash",
|
||||
role="operation",
|
||||
is_active=True,
|
||||
)
|
||||
db.add_all([old_support, support, operator])
|
||||
db.commit()
|
||||
account = WeiboAccount(
|
||||
uid="4014000312",
|
||||
nickname="强制分配权限",
|
||||
cookie="SCF=a; SUB=force-permission",
|
||||
assigned_to=old_support.id,
|
||||
)
|
||||
db.add(account)
|
||||
db.commit()
|
||||
|
||||
assert "weibo:assign:force" not in ROLE_PERMISSIONS["operation"]
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
batch_assign_accounts(
|
||||
WeiboBatchAssignRequest(
|
||||
account_ids=[account.id],
|
||||
assigned_to=support.id,
|
||||
),
|
||||
db=db,
|
||||
current=operator,
|
||||
)
|
||||
assert exc_info.value.status_code == 403
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
force_assign_accounts(
|
||||
WeiboForceAssignRequest(
|
||||
account_ids=[account.id],
|
||||
assigned_to=support.id,
|
||||
expected_count=1,
|
||||
),
|
||||
db=db,
|
||||
current=operator,
|
||||
)
|
||||
assert exc_info.value.status_code == 403
|
||||
|
||||
operator.custom_permissions = [
|
||||
*ROLE_PERMISSIONS["operation"],
|
||||
"weibo:assign:force",
|
||||
]
|
||||
db.commit()
|
||||
result = force_assign_accounts(
|
||||
WeiboForceAssignRequest(
|
||||
account_ids=[account.id],
|
||||
assigned_to=support.id,
|
||||
expected_count=1,
|
||||
),
|
||||
db=db,
|
||||
current=operator,
|
||||
)
|
||||
|
||||
assert result["updated"] == 1
|
||||
db.refresh(account)
|
||||
assert account.assigned_to == support.id
|
||||
|
||||
|
||||
def test_weibo_force_assign_by_tag_is_count_guarded(db, current_user):
|
||||
support = User(
|
||||
username="weibo-force-tag-support",
|
||||
password_hash="hash",
|
||||
role="support",
|
||||
is_active=True,
|
||||
)
|
||||
db.add(support)
|
||||
db.commit()
|
||||
tagged = [
|
||||
WeiboAccount(
|
||||
uid=f"40140004{index:02d}",
|
||||
nickname=f"标签账号{index}",
|
||||
cookie=f"SCF=a; SUB=tag-{index}",
|
||||
tag="强制组",
|
||||
)
|
||||
for index in range(2)
|
||||
]
|
||||
other = WeiboAccount(
|
||||
uid="4014000499",
|
||||
nickname="其他标签",
|
||||
cookie="SCF=a; SUB=other-tag",
|
||||
tag="其他组",
|
||||
)
|
||||
db.add_all([*tagged, other])
|
||||
db.commit()
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
force_assign_accounts(
|
||||
WeiboForceAssignRequest(
|
||||
tag="强制组",
|
||||
assigned_to=support.id,
|
||||
expected_count=1,
|
||||
),
|
||||
db=db,
|
||||
current=current_user,
|
||||
)
|
||||
assert exc_info.value.status_code == 409
|
||||
|
||||
result = force_assign_accounts(
|
||||
WeiboForceAssignRequest(
|
||||
tag="强制组",
|
||||
assigned_to=support.id,
|
||||
expected_count=2,
|
||||
),
|
||||
db=db,
|
||||
current=current_user,
|
||||
)
|
||||
|
||||
assert result["source"] == "tag"
|
||||
assert result["updated"] == 2
|
||||
assigned = db.query(WeiboAccount).filter(
|
||||
WeiboAccount.id.in_([account.id for account in tagged])
|
||||
)
|
||||
assert {account.assigned_to for account in assigned} == {support.id}
|
||||
db.refresh(other)
|
||||
assert other.assigned_to is None
|
||||
audit = (
|
||||
db.query(AuditLog)
|
||||
.filter(AuditLog.action == "weibo:account:force_assign")
|
||||
.order_by(AuditLog.id.desc())
|
||||
.first()
|
||||
)
|
||||
assert audit is not None
|
||||
assert '"source":"tag"' in audit.detail
|
||||
assert '"tag":"强制组"' in audit.detail
|
||||
|
||||
|
||||
def test_support_cannot_update_another_users_account_by_reimport(db):
|
||||
support_a = User(
|
||||
username="weibo-reimport-a",
|
||||
|
||||
@@ -45,6 +45,7 @@ PERMISSIONS = {
|
||||
"weibo:view": "查看微博账号",
|
||||
"weibo:workbench": "使用个人微博工作台",
|
||||
"weibo:assign": "查看全部微博账号并分配客服",
|
||||
"weibo:assign:force": "查看全部微博账号并强制改分配",
|
||||
"weibo:import": "导入/刷新微博 CK",
|
||||
"weibo:manage": "编辑、停用和启用微博账号",
|
||||
"weibo:account:ck_export": "复制微博外部 CK 账号的 CK(写剪贴板)",
|
||||
|
||||
+115
-20
@@ -31,6 +31,7 @@ from ..schemas import (
|
||||
WeiboExchangeRecordItem,
|
||||
WeiboExchangeRecordsOut,
|
||||
WeiboExchangeRequest,
|
||||
WeiboForceAssignRequest,
|
||||
WeiboGoodsOut,
|
||||
WeiboImportDuplicateOut,
|
||||
WeiboImportError,
|
||||
@@ -99,6 +100,32 @@ def _visible_weibo_accounts_query(query, current: User):
|
||||
return query.filter(WeiboAccount.assigned_to == current.id)
|
||||
|
||||
|
||||
def _active_support_target(db: Session, user_id: int) -> User | None:
|
||||
return (
|
||||
db.query(User)
|
||||
.filter(
|
||||
User.id == user_id,
|
||||
User.role == "support",
|
||||
User.is_active.is_(True),
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
def _replace_exclusive_workbench_assignments(
|
||||
db: Session,
|
||||
account_ids: set[int],
|
||||
target: User | None,
|
||||
) -> None:
|
||||
query = db.query(WeiboWorkbenchAccount).filter(
|
||||
WeiboWorkbenchAccount.account_id.in_(account_ids),
|
||||
WeiboWorkbenchAccount.exclusive_slot.is_not(None),
|
||||
)
|
||||
if target is not None:
|
||||
query = query.filter(WeiboWorkbenchAccount.user_id != target.id)
|
||||
query.delete(synchronize_session=False)
|
||||
|
||||
|
||||
def _get_account(
|
||||
db: Session,
|
||||
account_id: int,
|
||||
@@ -844,29 +871,23 @@ def batch_assign_accounts(
|
||||
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()
|
||||
)
|
||||
target = _active_support_target(db, req.assigned_to)
|
||||
if target is None:
|
||||
raise HTTPException(status_code=404, detail="目标客服不存在或已停用")
|
||||
cross_owner_accounts = [
|
||||
account
|
||||
for account in accounts
|
||||
if account.assigned_to is not None and account.assigned_to != target.id
|
||||
]
|
||||
if cross_owner_accounts and not user_has_permission(
|
||||
current, "weibo:assign:force"
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
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),
|
||||
WeiboWorkbenchAccount.exclusive_slot.is_not(None),
|
||||
).delete(synchronize_session=False)
|
||||
else:
|
||||
db.query(WeiboWorkbenchAccount).filter(
|
||||
WeiboWorkbenchAccount.account_id.in_(actual_ids),
|
||||
WeiboWorkbenchAccount.user_id != target.id,
|
||||
WeiboWorkbenchAccount.exclusive_slot.is_not(None),
|
||||
).delete(synchronize_session=False)
|
||||
_replace_exclusive_workbench_assignments(db, actual_ids, target)
|
||||
for account in accounts:
|
||||
account.assigned_to = target.id if target else None
|
||||
db.commit()
|
||||
@@ -891,6 +912,80 @@ def batch_assign_accounts(
|
||||
}
|
||||
|
||||
|
||||
@router.post("/assignments/force")
|
||||
def force_assign_accounts(
|
||||
req: WeiboForceAssignRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current: User = Depends(get_current_user),
|
||||
):
|
||||
"""把勾选账号或某个标签下的全部账号强制转给指定客服。"""
|
||||
|
||||
_require_weibo_perm(current, "weibo:assign:force")
|
||||
target = _active_support_target(db, req.assigned_to)
|
||||
if target is None:
|
||||
raise HTTPException(status_code=404, detail="目标客服不存在或已停用")
|
||||
|
||||
tag = req.tag.strip()
|
||||
if tag:
|
||||
accounts = (
|
||||
db.query(WeiboAccount)
|
||||
.filter(WeiboAccount.tag == tag)
|
||||
.order_by(WeiboAccount.id.asc())
|
||||
.all()
|
||||
)
|
||||
source = "tag"
|
||||
account_ids: list[int] = []
|
||||
else:
|
||||
account_ids = sorted(set(req.account_ids))
|
||||
if len(account_ids) != req.expected_count:
|
||||
raise HTTPException(status_code=409, detail="账号数量已变化,请刷新后重试")
|
||||
accounts = (
|
||||
db.query(WeiboAccount)
|
||||
.filter(WeiboAccount.id.in_(account_ids))
|
||||
.order_by(WeiboAccount.id.asc())
|
||||
.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}")
|
||||
source = "selection"
|
||||
|
||||
if len(accounts) != req.expected_count:
|
||||
raise HTTPException(status_code=409, detail="账号数量已变化,请刷新后重试")
|
||||
|
||||
actual_ids = {account.id for account in accounts}
|
||||
previous_assignments = {account.id: account.assigned_to for account in accounts}
|
||||
_replace_exclusive_workbench_assignments(db, actual_ids, target)
|
||||
for account in accounts:
|
||||
account.assigned_to = target.id
|
||||
db.commit()
|
||||
|
||||
assigned_ids = sorted(actual_ids)
|
||||
_record_audit_safely(
|
||||
db,
|
||||
current,
|
||||
action="weibo:account:force_assign",
|
||||
target=f"weibo_accounts:{len(assigned_ids)}",
|
||||
detail={
|
||||
"source": source,
|
||||
"tag": tag,
|
||||
"account_ids": assigned_ids,
|
||||
"assigned_to": target.id,
|
||||
"assigned_username": target.username,
|
||||
"previous_assignments": previous_assignments,
|
||||
},
|
||||
)
|
||||
return {
|
||||
"updated": len(assigned_ids),
|
||||
"assigned_to": target.id,
|
||||
"assigned_username": target.username,
|
||||
"source": source,
|
||||
"tag": tag,
|
||||
"success": True,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/assignments/summary")
|
||||
def assignments_summary(
|
||||
db: Session = Depends(get_db),
|
||||
|
||||
+18
-1
@@ -3,7 +3,7 @@
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_serializer
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_serializer, model_validator
|
||||
|
||||
from .huya_defaults import (
|
||||
HUYA_DEFAULT_BIND_ACT_ID,
|
||||
@@ -571,6 +571,23 @@ class WeiboBatchAssignRequest(BaseModel):
|
||||
assigned_to: int | None = None
|
||||
|
||||
|
||||
class WeiboForceAssignRequest(BaseModel):
|
||||
"""超级管理员或获授权用户强制改归属;按 ID 或标签二选一。"""
|
||||
|
||||
assigned_to: int = Field(..., gt=0)
|
||||
account_ids: list[int] = Field(default_factory=list)
|
||||
tag: str = Field("", max_length=64)
|
||||
expected_count: int = Field(..., ge=1)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _validate_scope(self):
|
||||
has_ids = bool(self.account_ids)
|
||||
has_tag = bool(self.tag.strip())
|
||||
if has_ids == has_tag:
|
||||
raise ValueError("account_ids 和 tag 必须且只能填写一项")
|
||||
return self
|
||||
|
||||
|
||||
class WeiboAccountDisableRequest(BaseModel):
|
||||
reason: str = Field("", max_length=256)
|
||||
|
||||
|
||||
@@ -865,6 +865,15 @@ export interface WeiboBatchTagResult {
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
export interface WeiboForceAssignResult {
|
||||
updated: number;
|
||||
assigned_to: number;
|
||||
assigned_username: string;
|
||||
source: 'selection' | 'tag';
|
||||
tag: string;
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
export interface WeiboGoodsItem {
|
||||
id: number;
|
||||
goods_name: string;
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
WeiboBindQrResult,
|
||||
WeiboExchangeRecordsResult,
|
||||
WeiboExchangeResult,
|
||||
WeiboForceAssignResult,
|
||||
WeiboMallListResult,
|
||||
WeiboPaymentStatusResult,
|
||||
WeiboPointHistoryResult,
|
||||
@@ -91,6 +92,21 @@ export const weiboApi = {
|
||||
account_ids: accountIds,
|
||||
assigned_to: assignedTo,
|
||||
}),
|
||||
forceAssign: (
|
||||
assignedTo: number,
|
||||
expectedCount: number,
|
||||
scope: { accountIds: number[] } | { tag: string },
|
||||
) =>
|
||||
api.post<WeiboForceAssignResult, WeiboForceAssignResult>(
|
||||
'/weibo/assignments/force',
|
||||
{
|
||||
assigned_to: assignedTo,
|
||||
expected_count: expectedCount,
|
||||
...('accountIds' in scope
|
||||
? { account_ids: scope.accountIds }
|
||||
: { tag: scope.tag }),
|
||||
},
|
||||
),
|
||||
assignmentsSummary: () =>
|
||||
api.get<AssignmentsSummary, AssignmentsSummary>('/weibo/assignments/summary'),
|
||||
accountTags: () =>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import {
|
||||
Table, Button, Modal, Form, Input, Select, Tag, Popconfirm, Space,
|
||||
Checkbox, Divider, Alert,
|
||||
Checkbox, Divider, Alert, Switch, Typography,
|
||||
} from 'antd';
|
||||
import type { TableProps } from 'antd';
|
||||
import { message } from '../utils/antdMessage';
|
||||
@@ -22,6 +22,9 @@ const ROLE_COLORS: Record<string, string> = {
|
||||
support: 'green',
|
||||
};
|
||||
|
||||
const { Text } = Typography;
|
||||
const WEIBO_FORCE_ASSIGN_PERMISSION = 'weibo:assign:force';
|
||||
|
||||
// 权限分组定义
|
||||
const PERMISSION_GROUPS = [
|
||||
{ label: '用户管理', prefix: 'user:' },
|
||||
@@ -225,6 +228,19 @@ export default function UsersPage() {
|
||||
setSelectedPerms(rolePerms);
|
||||
};
|
||||
|
||||
const handleWeiboForceAssignToggle = (checked: boolean) => {
|
||||
setUseCustom(true);
|
||||
setSelectedPerms((current) => {
|
||||
const next = new Set(current);
|
||||
if (checked) {
|
||||
next.add(WEIBO_FORCE_ASSIGN_PERMISSION);
|
||||
} else {
|
||||
next.delete(WEIBO_FORCE_ASSIGN_PERMISSION);
|
||||
}
|
||||
return Array.from(next);
|
||||
});
|
||||
};
|
||||
|
||||
const columns: TableProps<UserInfo>['columns'] = [
|
||||
{ title: 'ID', dataIndex: 'id', width: 60 },
|
||||
{ title: '用户名', dataIndex: 'username', width: 128, ellipsis: true },
|
||||
@@ -443,6 +459,29 @@ export default function UsersPage() {
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<Divider style={{ margin: '8px 0 12px' }}>微博高级分配</Divider>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
gap: 12,
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
<Switch
|
||||
checked={selectedPerms.includes(WEIBO_FORCE_ASSIGN_PERMISSION)}
|
||||
onChange={handleWeiboForceAssignToggle}
|
||||
/>
|
||||
<div>
|
||||
<Text strong>全部账号与强制分配</Text>
|
||||
<div>
|
||||
<Text type="secondary">
|
||||
打开后可查看“全部账号”页签,并把勾选账号或指定标签的账号强制转给客服。
|
||||
该权限仅超级管理员默认拥有,运营默认关闭。
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Checkbox.Group
|
||||
value={selectedPerms}
|
||||
onChange={(vals) => setSelectedPerms(vals as string[])}
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
CopyOutlined,
|
||||
DownloadOutlined,
|
||||
EditOutlined,
|
||||
EyeOutlined,
|
||||
ImportOutlined,
|
||||
QrcodeOutlined,
|
||||
ReloadOutlined,
|
||||
@@ -287,6 +288,21 @@ export default function WeiboAccountsPage({
|
||||
(entry: BatchRechargeEntry) => Promise<void>
|
||||
>(async () => {});
|
||||
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
const [detailAccount, setDetailAccount] = useState<WeiboAccountItem | null>(
|
||||
null,
|
||||
);
|
||||
const [detailTab, setDetailTab] = useState('basic');
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
const [detailExchangeLoading, setDetailExchangeLoading] = useState(false);
|
||||
const [detailExchangeError, setDetailExchangeError] = useState('');
|
||||
const [detailExchangeResult, setDetailExchangeResult] =
|
||||
useState<WeiboExchangeRecordsResult | null>(null);
|
||||
const [detailPointLoading, setDetailPointLoading] = useState(false);
|
||||
const [detailPointError, setDetailPointError] = useState('');
|
||||
const [detailPointResult, setDetailPointResult] =
|
||||
useState<WeiboPointHistoryResult | null>(null);
|
||||
|
||||
const loadAccounts = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
@@ -940,6 +956,71 @@ export default function WeiboAccountsPage({
|
||||
}
|
||||
};
|
||||
|
||||
const loadDetailExchangeRecords = async (
|
||||
account: WeiboAccountItem,
|
||||
page = 1,
|
||||
) => {
|
||||
setDetailExchangeLoading(true);
|
||||
setDetailExchangeError('');
|
||||
try {
|
||||
setDetailExchangeResult(
|
||||
await weiboApi.exchangeRecords(account.id, page, 20),
|
||||
);
|
||||
} catch (error) {
|
||||
setDetailExchangeError(getErrorMessage(error));
|
||||
} finally {
|
||||
setDetailExchangeLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadDetailPointHistory = async (
|
||||
account: WeiboAccountItem,
|
||||
page = 1,
|
||||
) => {
|
||||
setDetailPointLoading(true);
|
||||
setDetailPointError('');
|
||||
try {
|
||||
setDetailPointResult(await weiboApi.pointHistory(account.id, page, 20));
|
||||
} catch (error) {
|
||||
setDetailPointError(getErrorMessage(error));
|
||||
} finally {
|
||||
setDetailPointLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openAccountDetail = async (account: WeiboAccountItem) => {
|
||||
setDetailAccount(account);
|
||||
setDetailOpen(true);
|
||||
setDetailTab('basic');
|
||||
setDetailExchangeResult(null);
|
||||
setDetailExchangeError('');
|
||||
setDetailPointResult(null);
|
||||
setDetailPointError('');
|
||||
setDetailLoading(true);
|
||||
try {
|
||||
const updated = await weiboApi.getAccount(account.id);
|
||||
setDetailAccount(updated);
|
||||
setItems((current) =>
|
||||
current.map((item) => (item.id === updated.id ? updated : item)),
|
||||
);
|
||||
} catch (error) {
|
||||
message.error(getErrorMessage(error));
|
||||
} finally {
|
||||
setDetailLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const switchDetailTab = (key: string) => {
|
||||
setDetailTab(key);
|
||||
if (!detailAccount || !detailAccount.is_enabled) return;
|
||||
if (key === 'exchange' && !detailExchangeResult) {
|
||||
void loadDetailExchangeRecords(detailAccount);
|
||||
}
|
||||
if (key === 'points' && !detailPointResult) {
|
||||
void loadDetailPointHistory(detailAccount);
|
||||
}
|
||||
};
|
||||
|
||||
const openFeatures = (account: WeiboAccountItem) => {
|
||||
if (!account.is_enabled) {
|
||||
message.warning('账号已停用,请先启用');
|
||||
@@ -1439,7 +1520,7 @@ export default function WeiboAccountsPage({
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 540,
|
||||
width: 610,
|
||||
fixed: 'right',
|
||||
align: 'center',
|
||||
onHeaderCell: centerHeader,
|
||||
@@ -1476,6 +1557,15 @@ export default function WeiboAccountsPage({
|
||||
绑定码
|
||||
</Button>
|
||||
</Tooltip>
|
||||
{!isWorkbench && (
|
||||
<Button
|
||||
size="small"
|
||||
icon={<EyeOutlined />}
|
||||
onClick={() => void openAccountDetail(account)}
|
||||
>
|
||||
详情
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="small"
|
||||
icon={<AppstoreOutlined />}
|
||||
@@ -1672,7 +1762,12 @@ export default function WeiboAccountsPage({
|
||||
align: 'right',
|
||||
render: (value: number | null) => value ?? '-',
|
||||
},
|
||||
{ title: '兑换时间', dataIndex: 'exchange_time', width: 170 },
|
||||
{
|
||||
title: '兑换时间',
|
||||
dataIndex: 'exchange_time',
|
||||
width: 170,
|
||||
render: (value: string) => value || '-',
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'reward_status',
|
||||
@@ -1696,8 +1791,18 @@ export default function WeiboAccountsPage({
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{ title: '到账时间', dataIndex: 'create_time', width: 170 },
|
||||
{ title: '过期时间', dataIndex: 'expire_time', width: 170 },
|
||||
{
|
||||
title: '到账时间',
|
||||
dataIndex: 'create_time',
|
||||
width: 170,
|
||||
render: (value: string) => value || '-',
|
||||
},
|
||||
{
|
||||
title: '过期时间',
|
||||
dataIndex: 'expire_time',
|
||||
width: 170,
|
||||
render: (value: string) => value || '-',
|
||||
},
|
||||
{ title: '订单号', dataIndex: 'source_id' },
|
||||
];
|
||||
const activeQrScheme = qrResult?.schemes.find(
|
||||
@@ -1730,6 +1835,21 @@ export default function WeiboAccountsPage({
|
||||
const batchRechargeCreditedCount = batchRechargeEntries.filter(
|
||||
(entry) => entry.creditPhase === 'credited',
|
||||
).length;
|
||||
const detailPointIncome =
|
||||
detailPointResult?.items.reduce(
|
||||
(total, item) => total + Math.max(0, item.point_num),
|
||||
0,
|
||||
) ?? 0;
|
||||
const detailPointSpent =
|
||||
detailPointResult?.items.reduce(
|
||||
(total, item) => total + Math.max(0, -item.point_num),
|
||||
0,
|
||||
) ?? 0;
|
||||
const detailExchangeCost =
|
||||
detailExchangeResult?.items.reduce(
|
||||
(total, item) => total + Math.max(0, item.cost_weidou ?? 0),
|
||||
0,
|
||||
) ?? 0;
|
||||
|
||||
return (
|
||||
<Space orientation="vertical" size={16} style={{ width: '100%' }}>
|
||||
@@ -3025,6 +3145,342 @@ export default function WeiboAccountsPage({
|
||||
</Space>
|
||||
</Modal>
|
||||
|
||||
<Drawer
|
||||
title={
|
||||
detailAccount
|
||||
? `账号详情 · ${detailAccount.nickname || detailAccount.uid}`
|
||||
: '账号详情'
|
||||
}
|
||||
open={detailOpen}
|
||||
onClose={() => setDetailOpen(false)}
|
||||
size={920}
|
||||
destroyOnHidden
|
||||
>
|
||||
{detailAccount && (
|
||||
<Spin spinning={detailLoading}>
|
||||
<Tabs
|
||||
activeKey={detailTab}
|
||||
onChange={switchDetailTab}
|
||||
items={[
|
||||
{
|
||||
key: 'basic',
|
||||
label: '账号资料',
|
||||
children: (
|
||||
<Space
|
||||
orientation="vertical"
|
||||
size={16}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
{!detailAccount.is_enabled && (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
title="账号已停用"
|
||||
description={
|
||||
detailAccount.disabled_reason ||
|
||||
'停用期间仍可查看本地账号资料,但不能实时查询微豆明细和兑换记录。'
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<Descriptions
|
||||
bordered
|
||||
size="small"
|
||||
column={2}
|
||||
items={[
|
||||
{
|
||||
key: 'uid',
|
||||
label: '微博 UID',
|
||||
children: (
|
||||
<Text copyable>{detailAccount.uid}</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'nickname',
|
||||
label: '微博昵称',
|
||||
children: detailAccount.nickname || '-',
|
||||
},
|
||||
{
|
||||
key: 'created',
|
||||
label: '导入时间',
|
||||
children: formatTime(detailAccount.created_at),
|
||||
},
|
||||
{
|
||||
key: 'updated',
|
||||
label: '最近更新',
|
||||
children: formatTime(detailAccount.updated_at),
|
||||
},
|
||||
{
|
||||
key: 'assigned',
|
||||
label: '归属客服',
|
||||
children: detailAccount.assigned_username ? (
|
||||
<Tag color="cyan">
|
||||
{detailAccount.assigned_username}
|
||||
</Tag>
|
||||
) : (
|
||||
<Text type="secondary">未分配</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'tag',
|
||||
label: '标签',
|
||||
children: detailAccount.tag ? (
|
||||
<Tag color="blue">{detailAccount.tag}</Tag>
|
||||
) : (
|
||||
'-'
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'enabled',
|
||||
label: '启用状态',
|
||||
children: (
|
||||
<Tag
|
||||
color={
|
||||
detailAccount.is_enabled
|
||||
? 'success'
|
||||
: 'default'
|
||||
}
|
||||
>
|
||||
{detailAccount.is_enabled
|
||||
? '已启用'
|
||||
: '已停用'}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'balance',
|
||||
label: '微豆余额',
|
||||
children:
|
||||
detailAccount.weidou_balance?.toLocaleString() ??
|
||||
'-',
|
||||
},
|
||||
{
|
||||
key: 'ck',
|
||||
label: 'CK 状态',
|
||||
children: (
|
||||
<Space orientation="vertical" size={2}>
|
||||
<Tag
|
||||
color={
|
||||
CK_STATUS[detailAccount.ck_check_status]
|
||||
?.color || 'default'
|
||||
}
|
||||
>
|
||||
{CK_STATUS[detailAccount.ck_check_status]
|
||||
?.label || '未检测'}
|
||||
</Tag>
|
||||
{detailAccount.ck_check_message && (
|
||||
<Text type="secondary">
|
||||
{detailAccount.ck_check_message}
|
||||
</Text>
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'checked',
|
||||
label: '最近检查',
|
||||
children: formatTime(
|
||||
detailAccount.ck_checked_at,
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'cookie',
|
||||
label: 'CK 更新时间',
|
||||
children: formatTime(
|
||||
detailAccount.cookie_updated_at,
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'bind',
|
||||
label: '角色绑定',
|
||||
children: bindStatus(detailAccount),
|
||||
},
|
||||
{
|
||||
key: 'role',
|
||||
label: '游戏角色',
|
||||
children: detailAccount.game_role_name || '-',
|
||||
},
|
||||
{
|
||||
key: 'remark',
|
||||
label: '备注',
|
||||
span: 2,
|
||||
children: detailAccount.remark || '-',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'points',
|
||||
label: '微豆收支',
|
||||
children: (
|
||||
<Space
|
||||
orientation="vertical"
|
||||
size={12}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
title="微豆收支来自微博实时数据"
|
||||
description="展示充值、活动发放、过期和消费等变动;下方的收入、消耗统计只计算当前页。"
|
||||
/>
|
||||
{!detailAccount.is_enabled ? (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
title="账号已停用,无法查询实时微豆明细"
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<Space wrap>
|
||||
<Tag color="blue">
|
||||
当前余额{' '}
|
||||
{detailAccount.weidou_balance?.toLocaleString() ??
|
||||
'-'}
|
||||
</Tag>
|
||||
<Tag color="success">
|
||||
本页收入 +{detailPointIncome.toLocaleString()}
|
||||
</Tag>
|
||||
<Tag color="error">
|
||||
本页消耗 -{detailPointSpent.toLocaleString()}
|
||||
</Tag>
|
||||
</Space>
|
||||
{detailPointError && (
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
title="微豆明细查询失败"
|
||||
description={detailPointError}
|
||||
action={
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() =>
|
||||
void loadDetailPointHistory(
|
||||
detailAccount,
|
||||
detailPointResult?.page || 1,
|
||||
)
|
||||
}
|
||||
>
|
||||
重试
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<Table
|
||||
rowKey={(record) => record.id || record.source_id}
|
||||
size="small"
|
||||
loading={detailPointLoading}
|
||||
columns={pointColumns}
|
||||
dataSource={detailPointResult?.items || []}
|
||||
locale={{ emptyText: '暂无微豆收支明细' }}
|
||||
pagination={{
|
||||
current: detailPointResult?.page || 1,
|
||||
pageSize: detailPointResult?.page_size || 20,
|
||||
total: detailPointResult?.total || 0,
|
||||
showSizeChanger: false,
|
||||
showTotal: (total) => `共 ${total} 条`,
|
||||
onChange: (page) =>
|
||||
void loadDetailPointHistory(
|
||||
detailAccount,
|
||||
page,
|
||||
),
|
||||
}}
|
||||
scroll={{ x: 900 }}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'exchange',
|
||||
label: '兑换记录',
|
||||
children: (
|
||||
<Space
|
||||
orientation="vertical"
|
||||
size={12}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
title="兑换记录来自微博实时数据"
|
||||
description="展示商品、绑定角色、微豆消耗、兑换时间和发放状态;本页消耗只计算当前页。"
|
||||
/>
|
||||
{!detailAccount.is_enabled ? (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
title="账号已停用,无法查询实时兑换记录"
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<Space wrap>
|
||||
<Tag color="blue">
|
||||
共 {detailExchangeResult?.total ?? 0} 条
|
||||
</Tag>
|
||||
<Tag color="error">
|
||||
本页消耗 {detailExchangeCost.toLocaleString()}{' '}
|
||||
微豆
|
||||
</Tag>
|
||||
</Space>
|
||||
{detailExchangeError && (
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
title="兑换记录查询失败"
|
||||
description={detailExchangeError}
|
||||
action={
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() =>
|
||||
void loadDetailExchangeRecords(
|
||||
detailAccount,
|
||||
detailExchangeResult?.page || 1,
|
||||
)
|
||||
}
|
||||
>
|
||||
重试
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<Table
|
||||
rowKey={(record, index) =>
|
||||
`${record.goods_name}-${record.exchange_time}-${index}`
|
||||
}
|
||||
size="small"
|
||||
loading={detailExchangeLoading}
|
||||
columns={exchangeColumns}
|
||||
dataSource={detailExchangeResult?.items || []}
|
||||
locale={{ emptyText: '暂无兑换记录' }}
|
||||
pagination={{
|
||||
current: detailExchangeResult?.page || 1,
|
||||
pageSize:
|
||||
detailExchangeResult?.page_size || 20,
|
||||
total: detailExchangeResult?.total || 0,
|
||||
showSizeChanger: false,
|
||||
showTotal: (total) => `共 ${total} 条`,
|
||||
onChange: (page) =>
|
||||
void loadDetailExchangeRecords(
|
||||
detailAccount,
|
||||
page,
|
||||
),
|
||||
}}
|
||||
scroll={{ x: 760 }}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Spin>
|
||||
)}
|
||||
</Drawer>
|
||||
|
||||
<Drawer
|
||||
title={
|
||||
featureAccount
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
App,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
@@ -34,6 +35,7 @@ import {
|
||||
type SupportUserItem,
|
||||
type WeiboAccountItem,
|
||||
} from '../api/modules';
|
||||
import { usePermissions } from '../hooks/usePermissions';
|
||||
import { getErrorMessage } from '../utils/error';
|
||||
import { message } from '../utils/antdMessage';
|
||||
import { formatTime } from '../utils/time';
|
||||
@@ -62,6 +64,9 @@ function renderBindStatus(account: WeiboAccountItem) {
|
||||
|
||||
export default function WeiboAssignmentsPage() {
|
||||
const { token } = theme.useToken();
|
||||
const { modal } = App.useApp();
|
||||
const { can } = usePermissions();
|
||||
const canForceAssign = can('weibo:assign:force');
|
||||
const [summary, setSummary] = useState<AssignmentsSummary | null>(null);
|
||||
const [supportUsers, setSupportUsers] = useState<SupportUserItem[]>([]);
|
||||
const [selectedUser, setSelectedUser] = useState<SupportUserItem | null>(null);
|
||||
@@ -75,7 +80,10 @@ export default function WeiboAssignmentsPage() {
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [filterTag, setFilterTag] = useState<string | undefined>();
|
||||
const [enabledFilter, setEnabledFilter] = useState<string>('');
|
||||
const [activeTab, setActiveTab] = useState<'unassigned' | 'assigned'>('unassigned');
|
||||
const [assignFilter, setAssignFilter] = useState<string>('');
|
||||
const [activeTab, setActiveTab] = useState<
|
||||
'unassigned' | 'assigned' | 'all'
|
||||
>('unassigned');
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(() => {
|
||||
const saved = localStorage.getItem(STORAGE_KEY_PAGE_SIZE);
|
||||
@@ -119,7 +127,12 @@ export default function WeiboAssignmentsPage() {
|
||||
: enabledFilter === 'disabled'
|
||||
? false
|
||||
: undefined,
|
||||
assign_status: activeTab === 'unassigned' ? 'unassigned' : undefined,
|
||||
assign_status:
|
||||
activeTab === 'unassigned'
|
||||
? 'unassigned'
|
||||
: activeTab === 'all' && assignFilter
|
||||
? assignFilter
|
||||
: undefined,
|
||||
assigned_user_id:
|
||||
activeTab === 'assigned' ? selectedUser?.id : undefined,
|
||||
});
|
||||
@@ -133,6 +146,7 @@ export default function WeiboAssignmentsPage() {
|
||||
},
|
||||
[
|
||||
activeTab,
|
||||
assignFilter,
|
||||
currentPage,
|
||||
enabledFilter,
|
||||
filterTag,
|
||||
@@ -176,7 +190,7 @@ export default function WeiboAssignmentsPage() {
|
||||
|
||||
const handleSelectUser = (user: SupportUserItem) => {
|
||||
setSelectedUser(user);
|
||||
setActiveTab('unassigned');
|
||||
setActiveTab((current) => (current === 'all' ? 'all' : 'unassigned'));
|
||||
setSelectedRowKeys([]);
|
||||
setCurrentPage(1);
|
||||
localStorage.setItem(STORAGE_KEY_SELECTED_USER, String(user.id));
|
||||
@@ -211,6 +225,88 @@ export default function WeiboAssignmentsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleForceAssignSelected = async () => {
|
||||
if (!selectedUser || selectedRowKeys.length === 0) {
|
||||
message.warning('请先选择客服和微博账号');
|
||||
return;
|
||||
}
|
||||
setAssigning(true);
|
||||
try {
|
||||
const result = await weiboApi.forceAssign(
|
||||
selectedUser.id,
|
||||
selectedRowKeys.length,
|
||||
{ accountIds: selectedRowKeys },
|
||||
);
|
||||
message.success(
|
||||
`已强制将 ${result.updated} 个账号分配给 ${result.assigned_username}`,
|
||||
);
|
||||
setSelectedRowKeys([]);
|
||||
await refreshData();
|
||||
} catch (error) {
|
||||
message.error(getErrorMessage(error));
|
||||
} finally {
|
||||
setAssigning(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleForceAssignByTag = async () => {
|
||||
if (!selectedUser) {
|
||||
message.warning('请先选择客服');
|
||||
return;
|
||||
}
|
||||
const tag = filterTag?.trim();
|
||||
if (!tag) {
|
||||
message.warning('请先选择要强制分配的统一标签');
|
||||
return;
|
||||
}
|
||||
|
||||
setAssigning(true);
|
||||
try {
|
||||
const preview = await weiboApi.listAccountIds({ tag });
|
||||
const count = preview.account_ids.length;
|
||||
if (count === 0) {
|
||||
message.info(`标签「${tag}」下没有账号`);
|
||||
return;
|
||||
}
|
||||
modal.confirm({
|
||||
title: `强制分配标签「${tag}」下的 ${count} 个账号?`,
|
||||
content: (
|
||||
<Space orientation="vertical" size={4}>
|
||||
<Text>目标客服:{selectedUser.username}</Text>
|
||||
<Text type="warning">
|
||||
已分配给其他客服的账号也会转走,原客服的普通工作台映射会同步清理。
|
||||
</Text>
|
||||
</Space>
|
||||
),
|
||||
okText: '确认强制分配',
|
||||
cancelText: '取消',
|
||||
okButtonProps: { danger: true },
|
||||
onOk: async () => {
|
||||
setAssigning(true);
|
||||
try {
|
||||
const result = await weiboApi.forceAssign(selectedUser.id, count, {
|
||||
tag,
|
||||
});
|
||||
message.success(
|
||||
`已强制将标签「${tag}」下的 ${result.updated} 个账号分配给 ${result.assigned_username}`,
|
||||
);
|
||||
setSelectedRowKeys([]);
|
||||
await refreshData();
|
||||
} catch (error) {
|
||||
message.error(getErrorMessage(error));
|
||||
throw error;
|
||||
} finally {
|
||||
setAssigning(false);
|
||||
}
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
message.error(getErrorMessage(error));
|
||||
} finally {
|
||||
setAssigning(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAssignAllFiltered = async () => {
|
||||
if (!selectedUser) {
|
||||
message.warning('请先选择客服');
|
||||
@@ -302,6 +398,22 @@ export default function WeiboAssignmentsPage() {
|
||||
render: (tag: string) =>
|
||||
tag ? <Tag color="blue">{tag}</Tag> : <Text type="secondary">-</Text>,
|
||||
},
|
||||
...(activeTab === 'all'
|
||||
? [
|
||||
{
|
||||
title: '归属客服',
|
||||
dataIndex: 'assigned_username',
|
||||
width: 110,
|
||||
align: 'center' as const,
|
||||
render: (username: string) =>
|
||||
username ? (
|
||||
<Tag color="cyan">{username}</Tag>
|
||||
) : (
|
||||
<Text type="secondary">未分配</Text>
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
title: 'CK 状态',
|
||||
dataIndex: 'ck_check_status',
|
||||
@@ -524,13 +636,29 @@ export default function WeiboAssignmentsPage() {
|
||||
{ value: 'disabled', label: '已停用' },
|
||||
]}
|
||||
/>
|
||||
{activeTab === 'all' && (
|
||||
<Select
|
||||
value={assignFilter}
|
||||
onChange={(value) => {
|
||||
setAssignFilter(value);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
style={{ width: 120 }}
|
||||
size="small"
|
||||
options={[
|
||||
{ value: '', label: '全部归属' },
|
||||
{ value: 'unassigned', label: '未分配' },
|
||||
{ value: 'assigned', label: '已分配' },
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Tabs
|
||||
size="small"
|
||||
activeKey={activeTab}
|
||||
onChange={(key) => {
|
||||
setActiveTab(key as 'unassigned' | 'assigned');
|
||||
setActiveTab(key as 'unassigned' | 'assigned' | 'all');
|
||||
setSelectedRowKeys([]);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
@@ -544,6 +672,9 @@ export default function WeiboAssignmentsPage() {
|
||||
: '已分配账号',
|
||||
disabled: !selectedUser,
|
||||
},
|
||||
...(canForceAssign
|
||||
? [{ key: 'all', label: `全部账号 (${accountCount})` }]
|
||||
: []),
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -593,6 +724,43 @@ export default function WeiboAssignmentsPage() {
|
||||
取消分配选中 ({selectedRowKeys.length})
|
||||
</Button>
|
||||
)}
|
||||
{activeTab === 'all' && canForceAssign && selectedUser && (
|
||||
<>
|
||||
<Popconfirm
|
||||
title={`强制把选中的 ${selectedRowKeys.length} 个账号分配给 ${selectedUser.username}?`}
|
||||
description="已分配给其他客服的账号也会转走,原客服的普通工作台映射会同步清理。"
|
||||
okText="确认强制分配"
|
||||
cancelText="取消"
|
||||
okButtonProps={{ danger: true }}
|
||||
disabled={selectedRowKeys.length === 0}
|
||||
onConfirm={handleForceAssignSelected}
|
||||
>
|
||||
<Button
|
||||
danger
|
||||
size="small"
|
||||
icon={<SwapOutlined />}
|
||||
loading={assigning}
|
||||
disabled={selectedRowKeys.length === 0}
|
||||
>
|
||||
强制分配选中 ({selectedRowKeys.length}) 给 {selectedUser.username}
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
<Button
|
||||
danger
|
||||
type="primary"
|
||||
ghost
|
||||
size="small"
|
||||
icon={<SwapOutlined />}
|
||||
loading={assigning}
|
||||
disabled={!filterTag}
|
||||
onClick={() => void handleForceAssignByTag()}
|
||||
>
|
||||
{filterTag
|
||||
? `强制分配标签「${filterTag}」全部`
|
||||
: '选择标签后按标签强制分配'}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Table<WeiboAccountItem>
|
||||
@@ -615,7 +783,7 @@ export default function WeiboAssignmentsPage() {
|
||||
preserveSelectedRowKeys: true,
|
||||
onChange: (keys) => setSelectedRowKeys(keys.map(Number)),
|
||||
}}
|
||||
scroll={{ x: 940, y: 'calc(100vh - 500px)' }}
|
||||
scroll={{ x: activeTab === 'all' ? 1050 : 940, y: 'calc(100vh - 500px)' }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
Reference in New Issue
Block a user