feat(douyu): 外部单协作支持复制 CK

This commit is contained in:
yml2213
2026-10-23 09:29:53 +08:00
parent 96c4f41674
commit bf2f120fdc
7 changed files with 530 additions and 8 deletions
+37
View File
@@ -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) =>
+184 -3
View File
@@ -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}