feat(douyu): 外部单协作支持复制 CK
This commit is contained in:
@@ -3,8 +3,13 @@
|
||||
import pytest
|
||||
|
||||
from tests.support.db import get_test_engine, make_test_session
|
||||
from web.backend.models import Account, User
|
||||
from web.backend.schemas import DouyuCollabAccountLookupOut, DouyuCollabOrderOut
|
||||
from web.backend.models import Account, AuditLog, User
|
||||
from web.backend.routers import douyu_collab as collab_api
|
||||
from web.backend.schemas import (
|
||||
DouyuCollabAccountLookupOut,
|
||||
DouyuCollabCkExportOut,
|
||||
DouyuCollabOrderOut,
|
||||
)
|
||||
from web.backend.services import douyu_collab_service as collab
|
||||
|
||||
|
||||
@@ -381,6 +386,128 @@ class TestDouyuCollabOrders:
|
||||
assert collab.list_orders(self.session, ck_status=collab.CK_VALID)["total"] == 0
|
||||
assert collab.summary(self.session)["ck_invalid"] == 1
|
||||
|
||||
def test_export_cks_reads_linked_account_and_skips_missing_cookie(self):
|
||||
first = collab.create_order(
|
||||
self.session,
|
||||
self.admin,
|
||||
order_no="DY-EXP-1",
|
||||
account_username="douyu-account-a",
|
||||
draft=True,
|
||||
)
|
||||
blank_account = Account(
|
||||
username="douyu-account-blank",
|
||||
password="password",
|
||||
email="blank@example.com",
|
||||
email_password="",
|
||||
cookie="",
|
||||
)
|
||||
self.session.add(blank_account)
|
||||
self.session.commit()
|
||||
blank = collab.create_order(
|
||||
self.session,
|
||||
self.admin,
|
||||
order_no="DY-EXP-2",
|
||||
account_username="douyu-account-blank",
|
||||
draft=True,
|
||||
)
|
||||
missing = collab.create_order(
|
||||
self.session,
|
||||
self.admin,
|
||||
order_no="DY-EXP-3",
|
||||
account_username="douyu-account-missing",
|
||||
draft=True,
|
||||
)
|
||||
self.account.ck_check_status = collab.CK_VALID
|
||||
self.session.commit()
|
||||
|
||||
result = collab.export_cks(
|
||||
self.session,
|
||||
self.admin,
|
||||
[first.id, blank.id, missing.id],
|
||||
)
|
||||
|
||||
assert result["total"] == 3
|
||||
assert result["exported"] == 1
|
||||
assert result["items"][0]["order_no"] == "DY-EXP-1"
|
||||
assert result["items"][0]["ck_text"] == self.account.cookie
|
||||
assert result["items"][0]["account_username"] == self.account.username
|
||||
assert result["items"][0]["ck_status_label"] == "CK 正常"
|
||||
assert [item["order_no"] for item in result["skipped"]] == [
|
||||
"DY-EXP-2",
|
||||
"DY-EXP-3",
|
||||
]
|
||||
assert "没有 CK" in result["skipped"][0]["reason"]
|
||||
assert "还没绑定" in result["skipped"][1]["reason"]
|
||||
DouyuCollabCkExportOut.model_validate(result)
|
||||
|
||||
def test_export_ck_audit_never_contains_the_cookie_body(self):
|
||||
order = collab.create_order(
|
||||
self.session,
|
||||
self.admin,
|
||||
order_no="DY-EXP-4",
|
||||
account_username="douyu-account-a",
|
||||
draft=True,
|
||||
)
|
||||
|
||||
collab.export_cks(self.session, self.admin, [order.id])
|
||||
|
||||
audit = (
|
||||
self.session.query(AuditLog)
|
||||
.filter(AuditLog.action == collab.CK_EXPORT_ACTION)
|
||||
.order_by(AuditLog.id.desc())
|
||||
.first()
|
||||
)
|
||||
assert audit is not None
|
||||
assert audit.username == self.admin.username
|
||||
assert order.order_no in audit.detail
|
||||
assert "douyu-account-a" in audit.detail
|
||||
assert self.account.cookie not in audit.detail
|
||||
|
||||
def test_export_cks_rejects_empty_and_too_many(self):
|
||||
with pytest.raises(collab.CollabError) as excinfo:
|
||||
collab.export_cks(self.session, self.admin, [])
|
||||
assert excinfo.value.code == "ck_export_empty"
|
||||
|
||||
with pytest.raises(collab.CollabError) as excinfo:
|
||||
collab.export_cks(
|
||||
self.session,
|
||||
self.admin,
|
||||
list(range(1, collab.CK_EXPORT_BATCH_MAX + 2)),
|
||||
)
|
||||
assert excinfo.value.code == "ck_export_too_large"
|
||||
|
||||
def test_single_row_copy_is_super_admin_only(self):
|
||||
operator = User(
|
||||
username="douyu-collab-operator",
|
||||
password_hash="hash",
|
||||
role="operation",
|
||||
is_active=True,
|
||||
)
|
||||
self.session.add(operator)
|
||||
self.session.commit()
|
||||
order = collab.create_order(
|
||||
self.session,
|
||||
self.admin,
|
||||
order_no="DY-EXP-5",
|
||||
account_username="douyu-account-a",
|
||||
draft=True,
|
||||
)
|
||||
|
||||
result = collab_api.copy_external_order_ck(order.id, self.session, self.admin)
|
||||
assert result["exported"] == 1
|
||||
assert result["items"][0]["ck_text"] == self.account.cookie
|
||||
|
||||
with pytest.raises(collab_api.HTTPException) as excinfo:
|
||||
collab_api.copy_external_order_ck(order.id, self.session, operator)
|
||||
assert excinfo.value.status_code == 403
|
||||
|
||||
def test_ck_export_permission_defaults_to_operation(self):
|
||||
from web.backend.permissions import ROLE_PERMISSIONS
|
||||
|
||||
assert "douyu:collab:ck_export" in ROLE_PERMISSIONS["operation"]
|
||||
assert "douyu:collab:ck_export" in ROLE_PERMISSIONS["super_admin"]
|
||||
assert "douyu:collab:ck_export" not in ROLE_PERMISSIONS["support"]
|
||||
|
||||
def test_douyu_collab_routes_registered(self):
|
||||
from web.backend.main import app
|
||||
|
||||
@@ -390,6 +517,8 @@ class TestDouyuCollabOrders:
|
||||
assert "/api/douyu/external-orders/status/batch" in paths
|
||||
assert "/api/douyu/external-orders/verify/batch" in paths
|
||||
assert "/api/douyu/external-orders/accounts/lookup" in paths
|
||||
assert "/api/douyu/external-orders/ck/export" in paths
|
||||
assert "/api/douyu/external-orders/{order_id}/ck/copy" in paths
|
||||
assert "/api/douyu/collab-shops/{shop_id}" in paths
|
||||
assert "put" in paths["/api/douyu/collab-shops/{shop_id}"]
|
||||
assert "delete" in paths["/api/douyu/collab-shops/{shop_id}"]
|
||||
|
||||
@@ -31,10 +31,12 @@ PERMISSIONS = {
|
||||
# 斗鱼活动
|
||||
"douyu:task": "斗鱼任务管理",
|
||||
"douyu:config": "斗鱼配置管理",
|
||||
# 斗鱼外部单协作(订单挂内部账号,不复制 CK)
|
||||
# 斗鱼外部单协作(订单挂内部账号,导出时实时读取账号 CK)
|
||||
"douyu:collab:view": "查看斗鱼外部单协作",
|
||||
"douyu:collab:edit": "编辑斗鱼外部单协作",
|
||||
"douyu:collab:manage": "删除斗鱼外部单协作",
|
||||
# CK 是凭据:导到剪贴板 = 把凭据带出系统,单独一个点,默认只给运营
|
||||
"douyu:collab:ck_export": "导出斗鱼外部单协作账号的 CK(写剪贴板)",
|
||||
"yyb:session": "应用宝扫码登录与角色查询",
|
||||
"yyb:recharge": "应用宝充值与付款",
|
||||
"yyb:manage": "接管并操作他人的应用宝任务",
|
||||
@@ -94,6 +96,7 @@ ROLE_PERMISSIONS = {
|
||||
"douyu:collab:view",
|
||||
"douyu:collab:edit",
|
||||
"douyu:collab:manage",
|
||||
"douyu:collab:ck_export",
|
||||
"yyb:session",
|
||||
"yyb:recharge",
|
||||
"yyb:manage",
|
||||
|
||||
@@ -19,7 +19,7 @@ from fastapi import (
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..database import get_db
|
||||
from ..deps import authenticate_websocket, require_permission
|
||||
from ..deps import authenticate_websocket, get_current_user, require_permission
|
||||
from ..models import User
|
||||
from ..permissions import get_user_permissions
|
||||
from ..schemas import (
|
||||
@@ -27,6 +27,8 @@ from ..schemas import (
|
||||
DouyuCollabAccountLookupRequest,
|
||||
DouyuCollabAccountRequest,
|
||||
DouyuCollabArchiveRequest,
|
||||
DouyuCollabCkExportOut,
|
||||
DouyuCollabCkExportRequest,
|
||||
DouyuCollabGoodsOut,
|
||||
DouyuCollabImagesRequest,
|
||||
DouyuCollabMetricOut,
|
||||
@@ -331,6 +333,42 @@ def verify_external_orders_batch(
|
||||
return {"results": results, "success": all(item["success"] for item in results)}
|
||||
|
||||
|
||||
@router.post("/external-orders/ck/export", response_model=DouyuCollabCkExportOut)
|
||||
def export_external_orders_ck(
|
||||
req: DouyuCollabCkExportRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current: User = Depends(require_permission("douyu:collab:ck_export")),
|
||||
):
|
||||
"""勾选多行导出关联内部账号的 CK 明文(前端写剪贴板)。
|
||||
|
||||
斗鱼协作单不存 CK,导出的始终是关联账号的实时 ``cookie``。逐行写审计,
|
||||
但审计里只记单号/账号/CK 状态,不把 CK 本体写进审计。
|
||||
"""
|
||||
try:
|
||||
return collab.export_cks(db, current, req.ids)
|
||||
except collab.CollabError as exc:
|
||||
raise _http_error(exc) from exc
|
||||
|
||||
|
||||
@router.post(
|
||||
"/external-orders/{order_id}/ck/copy",
|
||||
response_model=DouyuCollabCkExportOut,
|
||||
)
|
||||
def copy_external_order_ck(
|
||||
order_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current: User = Depends(get_current_user),
|
||||
):
|
||||
"""复制单行 CK 明文(仅超级管理员;与虎牙协作台账口径一致)。"""
|
||||
if current.role != "super_admin":
|
||||
raise HTTPException(status_code=403, detail="仅超级管理员可复制 CK")
|
||||
try:
|
||||
order = collab.get_order(db, order_id)
|
||||
return collab.export_cks(db, current, [order.id])
|
||||
except collab.CollabError as exc:
|
||||
raise _http_error(exc) from exc
|
||||
|
||||
|
||||
@router.post("/external-orders/{order_id}/activity/query")
|
||||
def query_external_order_activity(
|
||||
order_id: int,
|
||||
|
||||
@@ -1870,6 +1870,42 @@ class DouyuCollabArchiveRequest(BaseModel):
|
||||
reason: str = Field("", max_length=256)
|
||||
|
||||
|
||||
class DouyuCollabCkExportRequest(BaseModel):
|
||||
"""勾选多行,导出关联内部账号的 CK 明文(一次最多 50 条)。"""
|
||||
|
||||
ids: list[int] = Field(default_factory=list)
|
||||
|
||||
|
||||
class DouyuCollabCkExportItemOut(BaseModel):
|
||||
"""导出结果里的一行:``ck_text`` 是明文凭据,只走这个接口、不留日志。"""
|
||||
|
||||
id: int
|
||||
order_no: str = ""
|
||||
account_id: int | None = None
|
||||
account_username: str = ""
|
||||
account_nickname: str = ""
|
||||
status: str = ""
|
||||
status_label: str = ""
|
||||
ck_status: str = ""
|
||||
ck_status_label: str = ""
|
||||
ck_text: str = ""
|
||||
|
||||
|
||||
class DouyuCollabCkExportSkippedOut(BaseModel):
|
||||
id: int
|
||||
order_no: str = ""
|
||||
reason: str = ""
|
||||
|
||||
|
||||
class DouyuCollabCkExportOut(BaseModel):
|
||||
"""导出 CK 的结果:``exported + len(skipped) = total``。"""
|
||||
|
||||
total: int
|
||||
exported: int
|
||||
skipped: list[DouyuCollabCkExportSkippedOut] = Field(default_factory=list)
|
||||
items: list[DouyuCollabCkExportItemOut] = Field(default_factory=list)
|
||||
|
||||
|
||||
class DouyuCollabOrderOut(BaseModel):
|
||||
id: int
|
||||
order_no: str
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from collections.abc import Mapping, Sequence
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
@@ -104,6 +104,8 @@ POINTS_GROUP_STATUSES: tuple[str, ...] = (
|
||||
POINTS_THRESHOLD_DEFAULT = 100
|
||||
POINTS_THRESHOLD_MAX = 1_000_000
|
||||
MAX_IMAGES = 20
|
||||
CK_EXPORT_BATCH_MAX = 50
|
||||
CK_EXPORT_ACTION = "douyu:collab:ck_export"
|
||||
|
||||
VERDICT_SUCCESS = "success"
|
||||
VERDICT_FAILED = "failed"
|
||||
@@ -1986,3 +1988,99 @@ def delete_order(
|
||||
db.delete(order)
|
||||
db.commit()
|
||||
return {"success": True, "order_no": order_no}
|
||||
|
||||
|
||||
def export_cks(
|
||||
db: Session,
|
||||
current: User,
|
||||
order_ids: Sequence[int],
|
||||
*,
|
||||
commit: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
"""导出协作单关联内部账号的 **CK 明文**(前端写剪贴板用)。
|
||||
|
||||
斗鱼协作单本身不存 CK,导出的始终是关联 ``accounts.cookie`` 的实时值。
|
||||
和虎牙台账保持同一口径:
|
||||
|
||||
1. 需要独立权限点 ``douyu:collab:ck_export``;
|
||||
2. 每条成功导出都写审计,但审计里只记单号/账号/CK 状态,**不记 CK 本体**;
|
||||
3. 未绑定账号、账号没有 CK、订单已删除的行显式跳过并返回原因。
|
||||
"""
|
||||
requested = list(dict.fromkeys(int(item) for item in order_ids if item))
|
||||
if not requested:
|
||||
raise CollabError("请先勾选要导出的行", code="ck_export_empty")
|
||||
if len(requested) > CK_EXPORT_BATCH_MAX:
|
||||
raise CollabError(
|
||||
f"一次最多导出 {CK_EXPORT_BATCH_MAX} 行,请分批操作",
|
||||
code="ck_export_too_large",
|
||||
)
|
||||
|
||||
rows = (
|
||||
db.query(DouyuExternalOrder).filter(DouyuExternalOrder.id.in_(requested)).all()
|
||||
)
|
||||
by_id = {int(order.id): order for order in rows}
|
||||
items: list[dict[str, Any]] = []
|
||||
skipped: list[dict[str, Any]] = []
|
||||
for order_id in requested:
|
||||
order = by_id.get(order_id)
|
||||
if order is None:
|
||||
skipped.append(
|
||||
{
|
||||
"id": order_id,
|
||||
"order_no": "",
|
||||
"reason": "协作单不存在或已删除",
|
||||
}
|
||||
)
|
||||
continue
|
||||
account = account_by_id(db, order.account_id) or account_by_username(
|
||||
db, order.account_username
|
||||
)
|
||||
ck_text = str(account.cookie or "").strip() if account else ""
|
||||
if not ck_text:
|
||||
reason = (
|
||||
"这单还没绑定斗鱼账号" if account is None else "关联的斗鱼账号还没有 CK"
|
||||
)
|
||||
skipped.append(
|
||||
{
|
||||
"id": int(order.id),
|
||||
"order_no": order.order_no,
|
||||
"reason": reason,
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
ck_status = str(account.ck_check_status or "")
|
||||
items.append(
|
||||
{
|
||||
"id": int(order.id),
|
||||
"order_no": order.order_no,
|
||||
"account_id": int(account.id),
|
||||
"account_username": account.username or order.account_username or "",
|
||||
"account_nickname": account.nickname or order.account_nickname or "",
|
||||
"status": order.status,
|
||||
"status_label": ORDER_STATUS_LABELS.get(order.status, order.status),
|
||||
"ck_status": ck_status,
|
||||
"ck_status_label": CK_LABELS.get(ck_status, ""),
|
||||
"ck_text": ck_text,
|
||||
}
|
||||
)
|
||||
record_audit(
|
||||
db,
|
||||
current,
|
||||
action=CK_EXPORT_ACTION,
|
||||
target=f"douyu_external_order:{order.id}",
|
||||
detail={
|
||||
"order_no": order.order_no,
|
||||
"account_username": account.username or order.account_username or "",
|
||||
"ck_status": ck_status,
|
||||
"batch_size": len(requested),
|
||||
},
|
||||
)
|
||||
if commit:
|
||||
db.commit()
|
||||
return {
|
||||
"total": len(requested),
|
||||
"exported": len(items),
|
||||
"skipped": skipped,
|
||||
"items": items,
|
||||
}
|
||||
|
||||
@@ -104,6 +104,33 @@ export interface DouyuCollabOrderList {
|
||||
page_size: number;
|
||||
}
|
||||
|
||||
export interface DouyuCollabCkExportItem {
|
||||
id: number;
|
||||
order_no: string;
|
||||
account_id: number | null;
|
||||
account_username: string;
|
||||
account_nickname: string;
|
||||
status: DouyuCollabStatus;
|
||||
status_label: string;
|
||||
ck_status: string;
|
||||
ck_status_label: string;
|
||||
ck_text: string;
|
||||
}
|
||||
|
||||
export interface DouyuCollabCkExportSkipped {
|
||||
id: number;
|
||||
order_no: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface DouyuCollabCkExportResult {
|
||||
total: number;
|
||||
exported: number;
|
||||
skipped: DouyuCollabCkExportSkipped[];
|
||||
items: DouyuCollabCkExportItem[];
|
||||
}
|
||||
|
||||
|
||||
export interface DouyuCollabSummary {
|
||||
total: number;
|
||||
by_status: Record<string, number>;
|
||||
@@ -264,6 +291,16 @@ export const douyuCollabApi = {
|
||||
),
|
||||
createOrder: (data: DouyuCollabOrderCreate) =>
|
||||
api.post<DouyuCollabOrder, DouyuCollabOrder>('/douyu/external-orders', data),
|
||||
exportOrderCk: (ids: number[]) =>
|
||||
api.post<DouyuCollabCkExportResult, DouyuCollabCkExportResult>(
|
||||
'/douyu/external-orders/ck/export',
|
||||
{ ids },
|
||||
),
|
||||
/** 单行复制 CK(仅超级管理员;后端再次校验角色) */
|
||||
copyOrderCk: (id: number) =>
|
||||
api.post<DouyuCollabCkExportResult, DouyuCollabCkExportResult>(
|
||||
`/douyu/external-orders/${id}/ck/copy`,
|
||||
),
|
||||
getOrder: (id: number) =>
|
||||
api.get<DouyuCollabOrder, DouyuCollabOrder>(`/douyu/external-orders/${id}`),
|
||||
updateOrder: (id: number, data: DouyuCollabOrderUpdate) =>
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
Input,
|
||||
InputNumber,
|
||||
Modal,
|
||||
Radio,
|
||||
Row,
|
||||
Select,
|
||||
Space,
|
||||
@@ -29,6 +30,7 @@ import {
|
||||
} from 'antd';
|
||||
import {
|
||||
CheckCircleOutlined,
|
||||
CopyOutlined,
|
||||
DeleteOutlined,
|
||||
EditOutlined,
|
||||
FormOutlined,
|
||||
@@ -48,6 +50,7 @@ import DouyuCollabShopPicker from '../components/DouyuCollabShopPicker';
|
||||
import { douyuCollabApi } from '../api/modules';
|
||||
import type {
|
||||
DouyuCollabAccountLookup,
|
||||
DouyuCollabCkExportResult,
|
||||
DouyuCollabGoods,
|
||||
DouyuCollabMetric,
|
||||
DouyuCollabOrder,
|
||||
@@ -60,6 +63,7 @@ import { usePermissions } from '../hooks/usePermissions';
|
||||
import { useCollabRealtime, type CollabRealtimeEvent } from '../hooks/useCollabRealtime';
|
||||
import { message } from '../utils/antdMessage';
|
||||
import { getErrorMessage } from '../utils/error';
|
||||
import { copyTextToClipboard } from '../utils/clipboard';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
@@ -192,6 +196,9 @@ export default function DouyuCollabPage() {
|
||||
const canView = can('douyu:collab:view');
|
||||
const canEdit = can('douyu:collab:edit');
|
||||
const canManage = can('douyu:collab:manage');
|
||||
const canExportCk = can('douyu:collab:ck_export');
|
||||
// 单行复制是一键带走凭据,和虎牙台账保持一致:只给超级管理员(后端再校验)
|
||||
const isAdmin = user?.role === 'super_admin';
|
||||
|
||||
const [orders, setOrders] = useState<DouyuCollabOrder[]>([]);
|
||||
const [summary, setSummary] = useState<DouyuCollabSummary>({
|
||||
@@ -244,6 +251,13 @@ export default function DouyuCollabPage() {
|
||||
const [statusReason, setStatusReason] = useState('');
|
||||
const [statusBusy, setStatusBusy] = useState(false);
|
||||
|
||||
// 勾选导出 CK:明文只在弹窗 state 里,关闭即清空
|
||||
const [ckExportOpen, setCkExportOpen] = useState(false);
|
||||
const [ckExporting, setCkExporting] = useState(false);
|
||||
const [ckCopyingId, setCkCopyingId] = useState<number | null>(null);
|
||||
const [ckExport, setCkExport] = useState<DouyuCollabCkExportResult | null>(null);
|
||||
const [ckExportFormat, setCkExportFormat] = useState<'plain' | 'with_order'>('plain');
|
||||
|
||||
const loadOrders = useCallback(async (options?: { silent?: boolean }) => {
|
||||
const silent = options?.silent === true;
|
||||
if (!silent) setLoading(true);
|
||||
@@ -318,7 +332,12 @@ export default function DouyuCollabPage() {
|
||||
}, [loadMeta]);
|
||||
|
||||
const collabModalOpen = Boolean(
|
||||
createOpen || detailOpen || shopModalOpen || batchStatusOpen || statusTarget,
|
||||
createOpen ||
|
||||
detailOpen ||
|
||||
shopModalOpen ||
|
||||
batchStatusOpen ||
|
||||
statusTarget ||
|
||||
ckExportOpen,
|
||||
);
|
||||
// 暂停后连 WebSocket 也不建:既不自动重拉,也不做断线兜底轮询。
|
||||
const realtimeSyncEnabled = canView && !realtimePaused;
|
||||
@@ -406,6 +425,22 @@ export default function DouyuCollabPage() {
|
||||
selectedOrders.length === selectedIds.length &&
|
||||
selectedOrders.every((row) => row.status === 'done' && !row.is_archived);
|
||||
|
||||
/** 导出文本:默认每行一份纯 CK,也可以带订单号方便多选对号。 */
|
||||
const ckExportText = useMemo(() => {
|
||||
if (!ckExport) return '';
|
||||
return ckExport.items
|
||||
.map((item) =>
|
||||
ckExportFormat === 'with_order' ? `${item.order_no}\t${item.ck_text}` : item.ck_text,
|
||||
)
|
||||
.join('\n');
|
||||
}, [ckExport, ckExportFormat]);
|
||||
|
||||
const ckExportRisky = useMemo(
|
||||
() => (ckExport?.items ?? []).filter((item) => item.ck_status === 'invalid'),
|
||||
[ckExport],
|
||||
);
|
||||
|
||||
|
||||
const refreshAll = () => {
|
||||
void loadOrders();
|
||||
void loadSummary();
|
||||
@@ -710,6 +745,57 @@ export default function DouyuCollabPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const openCkExport = async () => {
|
||||
if (selectedIds.length === 0) {
|
||||
message.warning('请先勾选协作单');
|
||||
return;
|
||||
}
|
||||
setCkExporting(true);
|
||||
try {
|
||||
const result = await douyuCollabApi.exportOrderCk(selectedIds);
|
||||
setCkExport(result);
|
||||
setCkExportOpen(true);
|
||||
} catch (error) {
|
||||
message.error(getErrorMessage(error));
|
||||
} finally {
|
||||
setCkExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const closeCkExport = () => {
|
||||
setCkExportOpen(false);
|
||||
setCkExport(null);
|
||||
};
|
||||
|
||||
const copyCkExport = async () => {
|
||||
if (!ckExport?.exported) return;
|
||||
try {
|
||||
await copyTextToClipboard(ckExportText);
|
||||
message.success(`已复制 ${ckExport.exported} 份 CK 到剪贴板`);
|
||||
} catch (error) {
|
||||
message.error(`复制失败:${getErrorMessage(error)}(可以在下面的框里手动选中复制)`);
|
||||
}
|
||||
};
|
||||
|
||||
/** 单行复制:斗鱼协作单不存 CK,这里实时读取关联内部账号的 cookie。 */
|
||||
const copyOneCk = async (row: DouyuCollabOrder) => {
|
||||
setCkCopyingId(row.id);
|
||||
try {
|
||||
const result = await douyuCollabApi.copyOrderCk(row.id);
|
||||
const item = result.items[0];
|
||||
if (!item) {
|
||||
message.warning(result.skipped[0]?.reason || '这行还没有可复制的 CK');
|
||||
return;
|
||||
}
|
||||
await copyTextToClipboard(item.ck_text);
|
||||
message.success(`已复制 ${row.order_no} 的 CK`);
|
||||
} catch (error) {
|
||||
message.error(`复制失败:${getErrorMessage(error)}`);
|
||||
} finally {
|
||||
setCkCopyingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const archiveOrders = async (order: DouyuCollabOrder) => {
|
||||
try {
|
||||
const result = await douyuCollabApi.archive([order.id], '');
|
||||
@@ -1028,7 +1114,7 @@ export default function DouyuCollabPage() {
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 350,
|
||||
width: 400,
|
||||
fixed: 'right',
|
||||
align: 'center',
|
||||
render: (_, row) => (
|
||||
@@ -1043,6 +1129,19 @@ export default function DouyuCollabPage() {
|
||||
补资料
|
||||
</Button>
|
||||
)}
|
||||
{isAdmin && !row.is_archived && (
|
||||
<Tooltip title={row.has_cookie ? '复制关联账号的 CK 明文(仅管理员)' : '关联账号还没有 CK'}>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<CopyOutlined />}
|
||||
disabled={!row.has_cookie || ckCopyingId === row.id}
|
||||
loading={ckCopyingId === row.id}
|
||||
onClick={() => void copyOneCk(row)}
|
||||
>
|
||||
复制CK
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Button size="small" icon={<EditOutlined />} onClick={() => void openDetail(row)}>
|
||||
详情
|
||||
</Button>
|
||||
@@ -1322,6 +1421,18 @@ export default function DouyuCollabPage() {
|
||||
</Button>
|
||||
{realtimePending && <Tag color="processing">有更新待同步</Tag>}
|
||||
<Text type="secondary">已选 {selectedCount} 单</Text>
|
||||
{canExportCk && archiveScope !== 'archived' && (
|
||||
<Tooltip title="勾选几行就复制几份关联账号的 CK;明文只在弹窗里出现">
|
||||
<Button
|
||||
icon={<CopyOutlined />}
|
||||
disabled={selectedCount === 0 || loading || ckExporting}
|
||||
loading={ckExporting}
|
||||
onClick={() => void openCkExport()}
|
||||
>
|
||||
批量复制 CK{selectedCount > 0 ? `(${selectedCount})` : ''}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{canEdit && archiveScope !== 'archived' && (
|
||||
<>
|
||||
<Button
|
||||
@@ -1385,7 +1496,7 @@ export default function DouyuCollabPage() {
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={orders}
|
||||
scroll={{ x: 2040 }}
|
||||
scroll={{ x: 2090 }}
|
||||
rowSelection={{
|
||||
selectedRowKeys,
|
||||
onChange: (keys) => setSelectedRowKeys(keys),
|
||||
@@ -1629,6 +1740,76 @@ export default function DouyuCollabPage() {
|
||||
</Space>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title={`导出 CK(勾选 ${ckExport?.total ?? 0} 条,可导出 ${ckExport?.exported ?? 0} 条)`}
|
||||
open={ckExportOpen}
|
||||
onCancel={closeCkExport}
|
||||
width={760}
|
||||
footer={[
|
||||
<Button key="close" onClick={closeCkExport}>
|
||||
关闭
|
||||
</Button>,
|
||||
<Button
|
||||
key="copy"
|
||||
type="primary"
|
||||
icon={<CopyOutlined />}
|
||||
disabled={!ckExport?.exported}
|
||||
onClick={() => void copyCkExport()}
|
||||
>
|
||||
复制到剪贴板
|
||||
</Button>,
|
||||
]}
|
||||
destroyOnHidden
|
||||
>
|
||||
<Space direction="vertical" size={8} style={{ width: '100%' }}>
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message="CK 是账号凭据"
|
||||
description="斗鱼协作单不存 CK,这里导出的是关联内部账号当前的 cookie。复制后请勿发群 / 截图外传;每次导出都会写审计(谁、什么时候、导出了哪几单),审计里不存 CK 本体。"
|
||||
/>
|
||||
{ckExport && ckExport.skipped.length > 0 && (
|
||||
<Text type="warning" style={{ fontSize: 12 }}>
|
||||
跳过 {ckExport.skipped.length} 条:
|
||||
{ckExport.skipped
|
||||
.map((item) =>
|
||||
item.order_no ? `${item.order_no}(${item.reason})` : item.reason,
|
||||
)
|
||||
.join(';')}
|
||||
</Text>
|
||||
)}
|
||||
{ckExportRisky.length > 0 && (
|
||||
<Text type="danger" style={{ fontSize: 12 }}>
|
||||
其中 {ckExportRisky.length} 条关联账号的 CK 已失效:
|
||||
{ckExportRisky
|
||||
.map((item) => `${item.order_no}(${item.ck_status_label || 'CK 异常'})`)
|
||||
.join(';')}
|
||||
</Text>
|
||||
)}
|
||||
<Radio.Group
|
||||
value={ckExportFormat}
|
||||
onChange={(event) => setCkExportFormat(event.target.value)}
|
||||
>
|
||||
<Radio value="plain">每行一份纯 CK(直接粘)</Radio>
|
||||
<Radio value="with_order">订单号 + Tab + CK(多选时好对号)</Radio>
|
||||
</Radio.Group>
|
||||
<Input.TextArea
|
||||
rows={10}
|
||||
value={ckExportText}
|
||||
readOnly
|
||||
placeholder="没有可导出的 CK"
|
||||
style={{
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
|
||||
fontSize: 12,
|
||||
}}
|
||||
/>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
共 {ckExport?.exported ?? 0} 份 · {ckExportText.length} 字符;
|
||||
关掉这个弹窗就清掉,不会留在页面上。
|
||||
</Text>
|
||||
</Space>
|
||||
</Modal>
|
||||
|
||||
<Drawer
|
||||
title={detail ? `协作单 ${detail.order_no}` : '协作单详情'}
|
||||
open={detailOpen}
|
||||
|
||||
Reference in New Issue
Block a user