feat(douyu): 外部单协作支持复制 CK
This commit is contained in:
@@ -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