From 4e47b5b299956c25ca680286b4e86556f42fab67 Mon Sep 17 00:00:00 2001 From: yml Date: Fri, 13 Nov 2026 13:36:41 +0800 Subject: [PATCH] =?UTF-8?q?feat(weibo):=20=E5=A2=9E=E5=8A=A0=E5=BC=BA?= =?UTF-8?q?=E5=88=B6=E5=88=86=E9=85=8D=E6=9D=83=E9=99=90=E4=B8=8E=E8=B4=A6?= =?UTF-8?q?=E5=8F=B7=E8=AF=A6=E6=83=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../微博/微博账号池与个人工作台-2026-11-08.md | 10 + tests/test_weibo_accounts.py | 156 +++++- web/backend/permissions.py | 1 + web/backend/routers/weibo.py | 135 ++++- web/backend/schemas.py | 19 +- web/frontend/src/api/types.ts | 9 + web/frontend/src/api/weibo.ts | 16 + web/frontend/src/pages/UsersPage.tsx | 41 +- web/frontend/src/pages/WeiboAccountsPage.tsx | 464 +++++++++++++++++- .../src/pages/WeiboAssignmentsPage.tsx | 178 ++++++- 10 files changed, 995 insertions(+), 34 deletions(-) diff --git a/docs/微博/微博账号池与个人工作台-2026-11-08.md b/docs/微博/微博账号池与个人工作台-2026-11-08.md index 951c469..fb10986 100644 --- a/docs/微博/微博账号池与个人工作台-2026-11-08.md +++ b/docs/微博/微博账号池与个人工作台-2026-11-08.md @@ -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` 防止筛选结果变化。 - 客服列表展示每个客服当前名下账号数。 工作台页: diff --git a/tests/test_weibo_accounts.py b/tests/test_weibo_accounts.py index f396785..9c26727 100644 --- a/tests/test_weibo_accounts.py +++ b/tests/test_weibo_accounts.py @@ -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", diff --git a/web/backend/permissions.py b/web/backend/permissions.py index 72f99be..c9effb9 100644 --- a/web/backend/permissions.py +++ b/web/backend/permissions.py @@ -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(写剪贴板)", diff --git a/web/backend/routers/weibo.py b/web/backend/routers/weibo.py index e86467e..4fedadc 100644 --- a/web/backend/routers/weibo.py +++ b/web/backend/routers/weibo.py @@ -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), diff --git a/web/backend/schemas.py b/web/backend/schemas.py index 12743a5..ec9978d 100644 --- a/web/backend/schemas.py +++ b/web/backend/schemas.py @@ -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) diff --git a/web/frontend/src/api/types.ts b/web/frontend/src/api/types.ts index b5bd4be..b3bee07 100644 --- a/web/frontend/src/api/types.ts +++ b/web/frontend/src/api/types.ts @@ -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; diff --git a/web/frontend/src/api/weibo.ts b/web/frontend/src/api/weibo.ts index cc603ec..6242530 100644 --- a/web/frontend/src/api/weibo.ts +++ b/web/frontend/src/api/weibo.ts @@ -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( + '/weibo/assignments/force', + { + assigned_to: assignedTo, + expected_count: expectedCount, + ...('accountIds' in scope + ? { account_ids: scope.accountIds } + : { tag: scope.tag }), + }, + ), assignmentsSummary: () => api.get('/weibo/assignments/summary'), accountTags: () => diff --git a/web/frontend/src/pages/UsersPage.tsx b/web/frontend/src/pages/UsersPage.tsx index 03e5e31..d333753 100644 --- a/web/frontend/src/pages/UsersPage.tsx +++ b/web/frontend/src/pages/UsersPage.tsx @@ -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 = { 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['columns'] = [ { title: 'ID', dataIndex: 'id', width: 60 }, { title: '用户名', dataIndex: 'username', width: 128, ellipsis: true }, @@ -443,6 +459,29 @@ export default function UsersPage() { )} + 微博高级分配 +
+ +
+ 全部账号与强制分配 +
+ + 打开后可查看“全部账号”页签,并把勾选账号或指定标签的账号强制转给客服。 + 该权限仅超级管理员默认拥有,运营默认关闭。 + +
+
+
setSelectedPerms(vals as string[])} diff --git a/web/frontend/src/pages/WeiboAccountsPage.tsx b/web/frontend/src/pages/WeiboAccountsPage.tsx index ad31970..fd18d79 100644 --- a/web/frontend/src/pages/WeiboAccountsPage.tsx +++ b/web/frontend/src/pages/WeiboAccountsPage.tsx @@ -33,6 +33,7 @@ import { CopyOutlined, DownloadOutlined, EditOutlined, + EyeOutlined, ImportOutlined, QrcodeOutlined, ReloadOutlined, @@ -287,6 +288,21 @@ export default function WeiboAccountsPage({ (entry: BatchRechargeEntry) => Promise >(async () => {}); + const [detailOpen, setDetailOpen] = useState(false); + const [detailAccount, setDetailAccount] = useState( + null, + ); + const [detailTab, setDetailTab] = useState('basic'); + const [detailLoading, setDetailLoading] = useState(false); + const [detailExchangeLoading, setDetailExchangeLoading] = useState(false); + const [detailExchangeError, setDetailExchangeError] = useState(''); + const [detailExchangeResult, setDetailExchangeResult] = + useState(null); + const [detailPointLoading, setDetailPointLoading] = useState(false); + const [detailPointError, setDetailPointError] = useState(''); + const [detailPointResult, setDetailPointResult] = + useState(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({ 绑定码 + {!isWorkbench && ( + + )} + } + /> + )} + 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 }} + /> + + )} + + ), + }, + { + key: 'exchange', + label: '兑换记录', + children: ( + + + {!detailAccount.is_enabled ? ( + + ) : ( + <> + + + 共 {detailExchangeResult?.total ?? 0} 条 + + + 本页消耗 {detailExchangeCost.toLocaleString()}{' '} + 微豆 + + + {detailExchangeError && ( + + void loadDetailExchangeRecords( + detailAccount, + detailExchangeResult?.page || 1, + ) + } + > + 重试 + + } + /> + )} +
+ `${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 }} + /> + + )} + + ), + }, + ]} + /> + + )} + + (null); const [supportUsers, setSupportUsers] = useState([]); const [selectedUser, setSelectedUser] = useState(null); @@ -75,7 +80,10 @@ export default function WeiboAssignmentsPage() { const [searchText, setSearchText] = useState(''); const [filterTag, setFilterTag] = useState(); const [enabledFilter, setEnabledFilter] = useState(''); - const [activeTab, setActiveTab] = useState<'unassigned' | 'assigned'>('unassigned'); + const [assignFilter, setAssignFilter] = useState(''); + 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: ( + + 目标客服:{selectedUser.username} + + 已分配给其他客服的账号也会转走,原客服的普通工作台映射会同步清理。 + + + ), + 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} : -, }, + ...(activeTab === 'all' + ? [ + { + title: '归属客服', + dataIndex: 'assigned_username', + width: 110, + align: 'center' as const, + render: (username: string) => + username ? ( + {username} + ) : ( + 未分配 + ), + }, + ] + : []), { title: 'CK 状态', dataIndex: 'ck_check_status', @@ -524,13 +636,29 @@ export default function WeiboAssignmentsPage() { { value: 'disabled', label: '已停用' }, ]} /> + {activeTab === 'all' && ( +