feat(huya): 精英宝典账号支持转入外部单协作

This commit is contained in:
yml2213
2026-11-09 11:39:39 +08:00
parent 84883015b4
commit 2292864754
8 changed files with 223 additions and 2 deletions
+2
View File
@@ -50,6 +50,7 @@ from .workbench import (
stop_batch,
task_types,
tasks_summary,
transfer_workbench_to_collab,
update_config,
update_huya_workbench_accounts,
ws_huya_logs,
@@ -105,6 +106,7 @@ __all__ = [
"task_types",
"tasks_summary",
"test_sms_url_endpoint",
"transfer_workbench_to_collab",
"update_config",
"update_huya_workbench_accounts",
"ws_huya_logs",
+46
View File
@@ -2,6 +2,7 @@
from fastapi import APIRouter
from ...schemas import HuyaWorkbenchTransferToCollab
from ._common import *
router = APIRouter()
@@ -114,6 +115,51 @@ def update_huya_workbench_accounts(
return {"account_ids": ids, "success": True}
@router.post("/workbench-accounts/transfer-to-collab")
def transfer_workbench_to_collab(
req: HuyaWorkbenchTransferToCollab,
db: Session = Depends(get_db),
current: User = Depends(require_permission("huya:task")),
):
"""把单个精英宝典账号移出工作台,并返回其网页 CK 供外部单新建表单使用。"""
_require_huya_perm(current, "huya:collab:edit")
_require_huya_task_account_access(db, current, [req.account_id])
membership = (
db.query(HuyaWorkbenchAccount)
.filter(
HuyaWorkbenchAccount.user_id == current.id,
HuyaWorkbenchAccount.handbook_scope == "elite",
HuyaWorkbenchAccount.account_id == req.account_id,
)
.first()
)
if membership is None:
raise HTTPException(status_code=404, detail="该账号不在精英宝典工作台")
account = db.query(HuyaAccount).filter(HuyaAccount.id == req.account_id).first()
ck_text = str(account.cookie or "").strip() if account else ""
if not ck_text:
raise HTTPException(
status_code=400, detail="该账号没有可用 Cookie,无法转入外部单协作"
)
db.delete(membership)
record_audit(
db,
current,
action="huya:workbench:transfer_collab",
target=f"huya_account:{account.id}",
detail={"account_id": account.id, "uid": account.uid},
)
db.commit()
return {
"account_id": account.id,
"ck_text": ck_text,
"success": True,
}
@router.get("/goods", response_model=list[HuyaGoodsOut])
def list_goods(
db: Session = Depends(get_db),
+6
View File
@@ -1199,6 +1199,12 @@ class HuyaWorkbenchCookieImport(BaseModel):
handbook_scope: str = Field("elite", pattern="^elite$|^browser_exchange$")
class HuyaWorkbenchTransferToCollab(BaseModel):
"""精英宝典工作台把单个账号转入外部单协作。"""
account_id: int = Field(..., gt=0)
class HuyaBrowserExchangeRequest(BaseModel):
"""浏览器兑换的目标页选择(常量见 services/huya_exchange_targets.py)。"""
+8
View File
@@ -68,6 +68,8 @@ import type {
HuyaExchangeTargetKey,
HuyaExternalCkImportRequest,
HuyaExternalCkImportResult,
HuyaWorkbenchTransferToCollabRequest,
HuyaWorkbenchTransferToCollabResult,
HuyaWorkbenchScope,
MessageCountResponse,
MessageDeletedResponse,
@@ -179,6 +181,12 @@ export const huyaApi = {
/** 工作台导入外部 CK: 入库并把账号加入自己的工作台 (text 每行一条 CK) */
importWorkbenchCookies: (data: HuyaExternalCkImportRequest) =>
api.post<HuyaExternalCkImportResult, HuyaExternalCkImportResult>('/huya/workbench-accounts/import-cookies', data),
/** 精英宝典账号转入外部单协作:移出工作台并取回该账号网页 CK */
transferWorkbenchToCollab: (accountId: number) =>
api.post<HuyaWorkbenchTransferToCollabResult, HuyaWorkbenchTransferToCollabResult>(
'/huya/workbench-accounts/transfer-to-collab',
{ account_id: accountId } satisfies HuyaWorkbenchTransferToCollabRequest,
),
listWorkbenchAccounts: (scope: HuyaWorkbenchScope = 'elite') =>
api.get<{ account_ids: number[]; configured: boolean }, { account_ids: number[]; configured: boolean }>(
'/huya/workbench-accounts', { params: { handbook_scope: scope } },
+11
View File
@@ -1274,6 +1274,17 @@ export interface HuyaExternalCkImportResult extends MessageCountResponse {
account_ids: number[];
}
/** 精英宝典工作台「转入外部单协作」:请求体 */
export interface HuyaWorkbenchTransferToCollabRequest {
account_id: number;
}
/** 精英宝典工作台「转入外部单协作」:结果里的 ck_text 用于外部单新建表单预填 */
export interface HuyaWorkbenchTransferToCollabResult extends MessageResponse {
account_id: number;
ck_text: string;
}
// ==================== Proxy ====================
export interface ProxyConfig {
+20
View File
@@ -7,6 +7,7 @@
* 3. **内联编辑必须带 `expected_version`**:别人先改过会 409,绝不静默覆盖(Excel 最痛的点)。
*/
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import {
Alert, App, Badge, Button, Card, Checkbox, Col, Descriptions, Divider, Drawer, Dropdown,
Empty, Form, Image, Input, InputNumber, Modal, Popconfirm, Popover, Progress, Radio, Row, Select, Space, Statistic,
@@ -499,6 +500,8 @@ function ImageSlotEditor({
export default function HuyaCollabPage() {
const { modal } = App.useApp();
const { can, user } = usePermissions();
const location = useLocation();
const navigate = useNavigate();
const canEdit = can('huya:collab:edit');
const canManage = can('huya:collab:manage');
// 单行复制与批量导出共用这个权限点(默认运营和客服都有)
@@ -819,6 +822,23 @@ export default function HuyaCollabPage() {
[runCkPreview],
);
useEffect(() => {
const state = location.state as { transferCk?: string } | null;
const transferCk = state?.transferCk?.trim();
if (!transferCk) return;
setCreateOpen(true);
setCkPreview(null);
setPreviewing(false);
setGiftHint('');
setDraftRestoredAt(null);
previewSeq.current += 1;
createForm.resetFields();
createForm.setFieldsValue({ ck_text: transferCk });
scheduleCkPreview(transferCk, true);
navigate(location.pathname, { replace: true, state: null });
}, [createForm, location.pathname, location.state, navigate, scheduleCkPreview]);
const isArchiveView = filters.archive_scope === 'archived';
const selectedRows = useMemo(
() => rows.filter((row) => selectedIds.includes(row.id)),
+31 -2
View File
@@ -1,12 +1,13 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { MouseEvent } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Button, Card, Input, InputNumber, Modal, QRCode, Select, Space, Table, Tag, Tooltip, Typography,
} from 'antd';
import type { TableProps } from 'antd';
import {
CheckCircleOutlined, ColumnWidthOutlined, CopyOutlined, GiftOutlined, ImportOutlined, QrcodeOutlined,
ReloadOutlined, SearchOutlined, SettingOutlined, ShoppingOutlined, StopOutlined, ZoomInOutlined,
ReloadOutlined, SearchOutlined, SettingOutlined, ShoppingOutlined, StopOutlined, SwapOutlined, ZoomInOutlined,
} from '@ant-design/icons';
import { huyaApi, type HuyaAccountItem, type HuyaConfig, type HuyaGoodsItem, type HuyaRechargeGoodsItem, type HuyaTaskItem } from '../api/modules';
import HuyaExternalCkImportModal from '../components/HuyaExternalCkImportModal';
@@ -169,9 +170,11 @@ function taskQrUrl(task: HuyaTaskItem | undefined): string {
}
export default function HuyaElitePage() {
const navigate = useNavigate();
const { can } = usePermissions();
const canConfig = can('huya:config');
const canImport = can('huya:import') || can('huya:elite:import_ck');
const canTransferCollab = can('huya:collab:edit');
const [pool, setPool] = useState<HuyaAccountItem[]>([]);
const [accounts, setAccounts] = useState<HuyaAccountItem[]>([]);
const [selectedIds, setSelectedIds] = useState<number[]>([]);
@@ -188,6 +191,7 @@ export default function HuyaElitePage() {
const [pageSize, setPageSize] = useState(savedPageSize);
const [currentPage, setCurrentPage] = useState(1);
const [loading, setLoading] = useState(false);
const [transferring, setTransferring] = useState(false);
const [starting, setStarting] = useState(false);
const [concurrency, setConcurrency] = useState(3);
const [refreshInterval, setRefreshInterval] = useState(savedInterval);
@@ -272,6 +276,31 @@ export default function HuyaElitePage() {
setSelectedIds((prev) => prev.filter((id) => saved.account_ids.includes(id)));
};
const transferToCollab = async () => {
if (selectedIds.length !== 1) {
message.warning('一次只能选择一个账号转入外部单协作');
return;
}
const accountId = selectedIds[0];
const account = accounts.find((item) => item.id === accountId);
if (account && account.has_cookie === false) {
message.warning('该账号没有可用 Cookie,不能转入外部单协作');
return;
}
setTransferring(true);
try {
const result = await huyaApi.transferWorkbenchToCollab(accountId);
message.success('已从精英宝典工作台移除,正在打开外部单协作');
navigate('/huya/collab', {
replace: true,
state: { transferCk: result.ck_text },
});
} catch (error) {
setTransferring(false);
message.error(getErrorMessage(error));
}
};
// 开通商品分组:精英宝典(限购主商品,单独一块)与其他商品(币/徽章/令…,带数量那一块)
const rechargeGoodsSorted = [...rechargeGoods].sort(byRechargeOrder);
const eliteRechargeGoods = rechargeGoodsSorted.filter(isEliteHandbookGood);
@@ -478,7 +507,7 @@ export default function HuyaElitePage() {
</Space>
);
const accountsBody = <><Space style={{ marginBottom: 8, width: '100%', justifyContent: 'space-between' }} wrap><Input size="small" allowClear prefix={<SearchOutlined />} placeholder="搜索账号/昵称/游戏名/手机号" value={search} onChange={(event) => { setSearch(event.target.value); setCurrentPage(1); }} style={{ width: 240 }} /><Space><Button size="small" icon={<ImportOutlined />} onClick={() => setImportOpen(true)}>导入账号</Button>{canImport && <Button size="small" icon={<ImportOutlined />} onClick={() => setCkImportOpen(true)}>导入外部 CK</Button>}{selectedIds.length > 0 && <Button size="small" danger onClick={() => void saveAccounts(accounts.map((item) => item.id).filter((id) => !selectedIds.includes(id)))}>移出选中</Button>}</Space></Space><div className="huya-account-table"><Table rowKey="id" size="small" loading={loading} tableLayout="fixed" rowSelection={{ selectedRowKeys: selectedIds, onChange: (keys) => setSelectedIds(keys.map(Number)), preserveSelectedRowKeys: true }} columns={accountColumns} dataSource={filteredAccounts} locale={{ emptyText: '工作台暂无账号,点击右上角「导入账号」添加' }} pagination={{ current: currentPage, pageSize, total: filteredAccounts.length, showSizeChanger: true, showTotal: (total) => `共 ${total} 条`, onChange: (page, size) => { setCurrentPage(page); if (size !== pageSize) { setPageSize(size); localStorage.setItem(PAGE_SIZE_KEY, String(size)); setCurrentPage(1); } } }} scroll={{ y: 'calc(100vh - 300px)' }} onRow={(row) => ({ onClick: () => { const task = latestTask(tasks, row.id); if (!task?.result) return; if (task.task_type === 'get_bind_qr') void openQrTask(task); else if (task.task_type === 'create_recharge_order') openPayTasks(task.batch_id); }, onContextMenu: (event) => handleRowContextMenu(row, event), style: { cursor: 'context-menu' }, title: '右键打开账号动作' })} /></div></>;
const accountsBody = <><Space style={{ marginBottom: 8, width: '100%', justifyContent: 'space-between' }} wrap><Input size="small" allowClear prefix={<SearchOutlined />} placeholder="搜索账号/昵称/游戏名/手机号" value={search} onChange={(event) => { setSearch(event.target.value); setCurrentPage(1); }} style={{ width: 240 }} /><Space><Button size="small" icon={<ImportOutlined />} onClick={() => setImportOpen(true)}>导入账号</Button>{canImport && <Button size="small" icon={<ImportOutlined />} onClick={() => setCkImportOpen(true)}>导入外部 CK</Button>}{selectedIds.length > 0 && <Button size="small" danger onClick={() => void saveAccounts(accounts.map((item) => item.id).filter((id) => !selectedIds.includes(id)))}>移出选中</Button>}{canTransferCollab && selectedIds.length === 1 && <Button size="small" icon={<SwapOutlined />} loading={transferring} onClick={() => void transferToCollab()}>转入外部单协作</Button>}</Space></Space><div className="huya-account-table"><Table rowKey="id" size="small" loading={loading} tableLayout="fixed" rowSelection={{ selectedRowKeys: selectedIds, onChange: (keys) => setSelectedIds(keys.map(Number)), preserveSelectedRowKeys: true }} columns={accountColumns} dataSource={filteredAccounts} locale={{ emptyText: '工作台暂无账号,点击右上角「导入账号」添加' }} pagination={{ current: currentPage, pageSize, total: filteredAccounts.length, showSizeChanger: true, showTotal: (total) => `共 ${total} 条`, onChange: (page, size) => { setCurrentPage(page); if (size !== pageSize) { setPageSize(size); localStorage.setItem(PAGE_SIZE_KEY, String(size)); setCurrentPage(1); } } }} scroll={{ y: 'calc(100vh - 300px)' }} onRow={(row) => ({ onClick: () => { const task = latestTask(tasks, row.id); if (!task?.result) return; if (task.task_type === 'get_bind_qr') void openQrTask(task); else if (task.task_type === 'create_recharge_order') openPayTasks(task.batch_id); }, onContextMenu: (event) => handleRowContextMenu(row, event), style: { cursor: 'context-menu' }, title: '右键打开账号动作' })} /></div></>;
const configField = (key: Exclude<keyof HuyaConfig, 'updated_at'>, label: string) => configDraft && <Space key={key} style={{ width: '100%', justifyContent: 'space-between' }}><Text>{label}</Text><Input value={configDraft[key] || ''} onChange={(event) => setConfigDraft({ ...configDraft, [key]: event.target.value })} style={{ width: 300 }} /></Space>;
const contextMenuStyle = contextMenu ? {