diff --git a/docs/微博/微博账号池与个人工作台-2026-11-08.md b/docs/微博/微博账号池与个人工作台-2026-11-08.md index d64d8f9..06e0d2f 100644 --- a/docs/微博/微博账号池与个人工作台-2026-11-08.md +++ b/docs/微博/微博账号池与个人工作台-2026-11-08.md @@ -149,6 +149,13 @@ UNIQUE(account_id) -- 同一账号同时只能属于一个工作台 重名时必须改用 UID。导入结果逐项区分:已导入、已存在、未找到、重名、 被其他工作台占用、账号停用和名单内重复。原来的「从账号池选择」页签完整保留。 +### 5.3 工作台转入外部单协作 + +所有能访问微博工作台的角色都可以把单个选中账号转入外部单协作,不再只限客服。 +服务端只要求账号确实在当前用户工作台、已启用且 CK 非空;转移后删除当前工作台映射, +保留全局账号、归属和 CK,并打开外部单协作新建弹窗自动预选该账号。 +原工作台账号仍可通过正常导入流程重新加入,不会被删除或停用。 + ## 6. 数据隔离边界 - 工作台列表和增删接口都按当前登录用户过滤。 diff --git a/tests/test_weibo_accounts.py b/tests/test_weibo_accounts.py index 4ff2047..6133550 100644 --- a/tests/test_weibo_accounts.py +++ b/tests/test_weibo_accounts.py @@ -41,6 +41,7 @@ from web.backend.routers.weibo import ( list_workbench_tags, refresh_account, remove_workbench_accounts, + transfer_workbench_to_collab, update_account, ) from web.backend.schemas import ( @@ -54,6 +55,7 @@ from web.backend.schemas import ( WeiboRechargeRequest, WeiboWorkbenchAccountsRequest, WeiboWorkbenchBatchImportRequest, + WeiboWorkbenchTransferToCollabRequest, ) from web.backend.services import weibo_service @@ -564,6 +566,78 @@ def test_batch_import_workbench_accounts_reports_each_identifier(db, current_use assert workbench_ids == {existing.id, added.id} +def test_operation_can_transfer_workbench_account_to_collab(db): + operator = User( + username="weibo-transfer-operator", + password_hash="hash", + role="operation", + is_active=True, + ) + db.add(operator) + db.commit() + account = WeiboAccount( + uid="4014000951", + nickname="转入协作运营账号", + cookie="SCF=a; SUB=transfer", + game_role_name="测试角色", + assigned_to=operator.id, + ) + db.add(account) + db.commit() + db.add( + WeiboWorkbenchAccount( + user_id=operator.id, + account_id=account.id, + ) + ) + db.commit() + + result = transfer_workbench_to_collab( + WeiboWorkbenchTransferToCollabRequest(account_id=account.id), + db=db, + current=operator, + ).model_dump() + + assert result == { + "success": True, + "account_id": account.id, + "uid": account.uid, + "nickname": account.nickname, + "game_role_name": "测试角色", + } + assert ( + db.query(WeiboWorkbenchAccount) + .filter(WeiboWorkbenchAccount.account_id == account.id) + .count() + == 0 + ) + audit = ( + db.query(AuditLog) + .filter(AuditLog.action == "weibo:workbench:transfer_collab") + .one() + ) + assert audit.target == f"weibo_account:{account.id}" + + +def test_transfer_to_collab_requires_account_in_current_workbench(db, current_user): + account = WeiboAccount( + uid="4014000952", + nickname="不在工作台", + cookie="SCF=a; SUB=not-member", + ) + db.add(account) + db.commit() + + with pytest.raises(HTTPException) as excinfo: + transfer_workbench_to_collab( + WeiboWorkbenchTransferToCollabRequest(account_id=account.id), + db=db, + current=current_user, + ) + + assert excinfo.value.status_code == 404 + + def test_batch_tag_updates_selected_accounts(db, current_user): first = WeiboAccount( uid="4014000201", diff --git a/web/backend/routers/weibo.py b/web/backend/routers/weibo.py index e3e34df..e32c366 100644 --- a/web/backend/routers/weibo.py +++ b/web/backend/routers/weibo.py @@ -45,6 +45,8 @@ from ..schemas import ( WeiboWorkbenchBatchImportItem, WeiboWorkbenchBatchImportOut, WeiboWorkbenchBatchImportRequest, + WeiboWorkbenchTransferToCollabOut, + WeiboWorkbenchTransferToCollabRequest, ) from ..services.audit_service import record_audit from ..services.weibo_service import ( @@ -607,6 +609,57 @@ def add_workbench_accounts( return {"account_ids": account_ids, "success": True} +@router.post( + "/workbench/accounts/transfer-to-collab", + response_model=WeiboWorkbenchTransferToCollabOut, +) +def transfer_workbench_to_collab( + req: WeiboWorkbenchTransferToCollabRequest, + db: Session = Depends(get_db), + current: User = Depends(get_current_user), +): + """把当前工作台账号移出,并引导到外部单协作新建订单。""" + + _require_weibo_perm(current, "weibo:workbench") + membership = ( + db.query(WeiboWorkbenchAccount) + .filter( + WeiboWorkbenchAccount.user_id == current.id, + WeiboWorkbenchAccount.account_id == req.account_id, + ) + .first() + ) + if membership is None: + raise HTTPException(status_code=404, detail="该账号不在当前微博工作台") + + account = _get_account(db, req.account_id, current) + _ensure_enabled(account) + if not str(account.cookie or "").strip(): + raise HTTPException( + status_code=400, detail="该账号没有可用 Cookie,无法转入外部单协作" + ) + + db.delete(membership) + db.commit() + _record_audit_safely( + db, + current, + action="weibo:workbench:transfer_collab", + target=f"weibo_account:{account.id}", + detail={ + "account_id": account.id, + "uid": account.uid, + "nickname": account.nickname, + }, + ) + return WeiboWorkbenchTransferToCollabOut( + account_id=account.id, + uid=account.uid, + nickname=account.nickname, + game_role_name=account.game_role_name, + ) + + @router.post("/workbench/accounts/remove") def remove_workbench_accounts( req: WeiboWorkbenchAccountsRequest, diff --git a/web/backend/schemas.py b/web/backend/schemas.py index 9359a89..1e6cf31 100644 --- a/web/backend/schemas.py +++ b/web/backend/schemas.py @@ -521,6 +521,18 @@ class WeiboWorkbenchAccountsRequest(BaseModel): account_ids: list[int] = Field(default_factory=list) +class WeiboWorkbenchTransferToCollabRequest(BaseModel): + account_id: int = Field(..., gt=0) + + +class WeiboWorkbenchTransferToCollabOut(BaseModel): + success: bool = True + account_id: int + uid: str = "" + nickname: str = "" + game_role_name: str = "" + + class WeiboWorkbenchBatchImportRequest(BaseModel): text: str = Field(..., min_length=1, max_length=100_000) diff --git a/web/frontend/src/api/types.ts b/web/frontend/src/api/types.ts index e194bfc..c00d0f8 100644 --- a/web/frontend/src/api/types.ts +++ b/web/frontend/src/api/types.ts @@ -839,6 +839,14 @@ export interface WeiboWorkbenchBatchImportResult { items: WeiboWorkbenchBatchImportItem[]; } +export interface WeiboWorkbenchTransferToCollabResult { + success: boolean; + account_id: number; + uid: string; + nickname: string; + game_role_name: string; +} + export interface WeiboBatchTagResult { updated: number; tag: string; diff --git a/web/frontend/src/api/weibo.ts b/web/frontend/src/api/weibo.ts index ac237a3..c2bd000 100644 --- a/web/frontend/src/api/weibo.ts +++ b/web/frontend/src/api/weibo.ts @@ -16,6 +16,7 @@ import type { WeiboImportResult, WeiboWorkbenchMutationResult, WeiboWorkbenchBatchImportResult, + WeiboWorkbenchTransferToCollabResult, } from './types'; export const weiboApi = { @@ -128,6 +129,13 @@ export const weiboApi = { '/weibo/workbench/accounts/batch-import', { text }, ), + transferWorkbenchToCollab: (accountId: number) => + api.post< + WeiboWorkbenchTransferToCollabResult, + WeiboWorkbenchTransferToCollabResult + >('/weibo/workbench/accounts/transfer-to-collab', { + account_id: accountId, + }), removeWorkbenchAccounts: (accountIds: number[]) => api.post( '/weibo/workbench/accounts/remove', diff --git a/web/frontend/src/pages/WeiboAccountsPage.tsx b/web/frontend/src/pages/WeiboAccountsPage.tsx index 5ef754e..be3d2de 100644 --- a/web/frontend/src/pages/WeiboAccountsPage.tsx +++ b/web/frontend/src/pages/WeiboAccountsPage.tsx @@ -1,4 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; import { Alert, AutoComplete, @@ -37,6 +38,7 @@ import { ReloadOutlined, SearchOutlined, StopOutlined, + SwapOutlined, ZoomInOutlined, } from '@ant-design/icons'; import { @@ -159,6 +161,7 @@ export default function WeiboAccountsPage({ }) { const { token } = theme.useToken(); const { can } = usePermissions(); + const navigate = useNavigate(); const isWorkbench = mode === 'workbench'; const canImport = can('weibo:import'); const canManage = can('weibo:manage'); @@ -168,6 +171,7 @@ export default function WeiboAccountsPage({ const canExchange = can('weibo:exchange'); const canDelete = can('weibo:delete'); const canAssign = can('weibo:assign'); + const canTransferCollab = can('weibo:workbench'); const [items, setItems] = useState([]); const [total, setTotal] = useState(0); @@ -184,6 +188,7 @@ export default function WeiboAccountsPage({ const [mutatingId, setMutatingId] = useState(null); const [selectedIds, setSelectedIds] = useState([]); const [batchBusy, setBatchBusy] = useState(false); + const [transferringId, setTransferringId] = useState(null); const [batchTagOpen, setBatchTagOpen] = useState(false); const [batchTagValue, setBatchTagValue] = useState(''); const [batchTagging, setBatchTagging] = useState(false); @@ -463,6 +468,28 @@ export default function WeiboAccountsPage({ } }; + const transferToCollab = async () => { + if (!isWorkbench || selectedIds.length !== 1) { + message.warning('请选择一个微博工作台账号'); + return; + } + const accountId = selectedIds[0]; + setTransferringId(accountId); + try { + const result = await weiboApi.transferWorkbenchToCollab(accountId); + message.success( + `${result.nickname || result.uid} 已移出工作台,正在打开外部单协作`, + ); + navigate('/weibo/collab', { + state: { transferAccountId: result.account_id }, + }); + } catch (error) { + message.error(getErrorMessage(error)); + } finally { + setTransferringId(null); + } + }; + const handleBatchTag = async () => { if (selectedIds.length === 0) return; setBatchTagging(true); @@ -1735,6 +1762,16 @@ export default function WeiboAccountsPage({ 批量充值 )} + {isWorkbench && canTransferCollab && selectedIds.length === 1 && ( + + )} {!isWorkbench && canManage && (