feat(weibo): 增加强制分配权限与账号详情

This commit is contained in:
yml
2026-11-13 13:36:41 +08:00
parent 9cf4f65a3d
commit 4e47b5b299
10 changed files with 995 additions and 34 deletions
@@ -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` 防止筛选结果变化。
- 客服列表展示每个客服当前名下账号数。
工作台页:
+153 -3
View File
@@ -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",
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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)
+9
View File
@@ -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;
+16
View File
@@ -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: () =>
+40 -1
View File
@@ -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[])}
+460 -4
View File
@@ -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
+173 -5
View File
@@ -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>