feat(huya): 协作台账勾选导出 CK 到剪贴板(单点权限 + 逐行审计)+ 工具区固定两行

This commit is contained in:
yml2213
2026-10-16 22:24:08 +08:00
parent 6ef74c8e45
commit 82c59bb4af
9 changed files with 431 additions and 6 deletions
+7
View File
@@ -34,6 +34,7 @@ import type {
HuyaCollabAuditItem,
HuyaCollabActivityBatchResult,
HuyaCollabActivityItem,
HuyaCollabCkExportResult,
HuyaCollabCkPreview,
HuyaCollabCkRequest,
HuyaCollabCkStatusRequest,
@@ -277,6 +278,12 @@ export const huyaApi = {
/** 这一单的「账号全生命周期」时间线(每次查询的快照,新→旧) */
collabOrderMetrics: (id: number) =>
api.get<HuyaCollabMetric[], HuyaCollabMetric[]>(`/huya/external-orders/${id}/metrics`),
/** 勾选导出 CK 明文(前端写剪贴板;单独权限点 huya:collab:ck_export,逐行写审计) */
exportCollabOrderCk: (ids: number[]) =>
api.post<HuyaCollabCkExportResult, HuyaCollabCkExportResult>(
'/huya/external-orders/ck/export',
{ ids },
),
/** 删除一行(只给运营:huya:collab:manage) */
deleteCollabOrder: (id: number) =>
api.delete<{ success: boolean; order_no: string }, { success: boolean; order_no: string }>(
+28
View File
@@ -1394,6 +1394,34 @@ export interface HuyaCollabStatusBatchResult {
results: HuyaCollabStatusBatchItem[];
}
/** 导出 CK 里的一行(ck_text 是**明文凭据**,只有导出接口会返回) */
export interface HuyaCollabCkExportItem {
id: number;
order_no: string;
uid: string;
nickname: string;
status: HuyaCollabStatus;
status_label: string;
ck_status: string;
ck_status_label: string;
ck_text: string;
}
/** 没导出的行(例如这行还没贴 CK)——原因显式回给界面,不静默丢 */
export interface HuyaCollabCkExportSkipped {
id: number;
order_no: string;
reason: string;
}
/** 勾选导出 CK 的结果:exported + skipped.length = total */
export interface HuyaCollabCkExportResult {
total: number;
exported: number;
skipped: HuyaCollabCkExportSkipped[];
items: HuyaCollabCkExportItem[];
}
export interface HuyaCollabOrderList {
items: HuyaCollabOrder[];
total: number;
+134 -5
View File
@@ -9,12 +9,12 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
Alert, App, AutoComplete, Button, Card, Checkbox, Col, Descriptions, Divider, Drawer, Dropdown,
Empty, Form, Image, Input, Modal, Popconfirm, Popover, Progress, Row, Select, Space, Statistic,
Empty, Form, Image, Input, Modal, Popconfirm, Popover, Progress, Radio, Row, Select, Space, Statistic,
Switch, Table, Tag, Timeline, Tooltip, Typography, Upload,
} from 'antd';
import type { TableProps } from 'antd';
import {
CheckCircleOutlined, CloudUploadOutlined, DeleteOutlined, EditOutlined, FileExcelOutlined,
CheckCircleOutlined, CloudUploadOutlined, CopyOutlined, DeleteOutlined, EditOutlined, FileExcelOutlined,
LinkOutlined, PlusOutlined, ReloadOutlined, SafetyCertificateOutlined, ShopOutlined,
StopOutlined, SwapOutlined, SyncOutlined, UndoOutlined,
} from '@ant-design/icons';
@@ -22,6 +22,7 @@ import {
fileApi,
huyaApi,
type HuyaCollabAuditItem,
type HuyaCollabCkExportResult,
type HuyaCollabCkPreview,
type HuyaCollabGift,
type HuyaCollabImageSlot,
@@ -39,6 +40,7 @@ import HuyaCollabShopPicker from '../components/HuyaCollabShopPicker';
import HuyaCollabHomeImageUploader from '../components/HuyaCollabHomeImageUploader';
import { message } from '../utils/antdMessage';
import { getErrorMessage } from '../utils/error';
import { copyTextToClipboard } from '../utils/clipboard';
import { usePermissions } from '../hooks/usePermissions';
const { Text, Paragraph } = Typography;
@@ -275,6 +277,8 @@ export default function HuyaCollabPage() {
const { can, user } = usePermissions();
const canEdit = can('huya:collab:edit');
const canManage = can('huya:collab:manage');
// 导出 CK 是把凭据带出系统,单独一个权限点(默认只给运营,见 permissions.py)
const canExportCk = can('huya:collab:ck_export');
const [rows, setRows] = useState<HuyaCollabOrder[]>([]);
const [total, setTotal] = useState(0);
@@ -336,6 +340,11 @@ export default function HuyaCollabPage() {
const [querying, setQuerying] = useState(false);
const [metrics, setMetrics] = useState<HuyaCollabMetric[]>([]);
const [metricsLoading, setMetricsLoading] = useState(false);
// 勾选导出 CK(明文只在这一个弹窗里出现;复制走剪贴板)
const [ckExportOpen, setCkExportOpen] = useState(false);
const [ckExporting, setCkExporting] = useState(false);
const [ckExport, setCkExport] = useState<HuyaCollabCkExportResult | null>(null);
const [ckExportFormat, setCkExportFormat] = useState<'plain' | 'with_order'>('plain');
const load = useCallback(async () => {
setLoading(true);
@@ -549,6 +558,60 @@ export default function HuyaCollabPage() {
}
};
/**
* 勾选导出 CK:先把明文拉回来(后端逐行写审计),再让用户复制。
*
* 明文只活在这次弹窗的 state 里:不进 localStorage、不写 URL、
* 关掉弹窗就清掉(`closeCkExport()`)。
*/
const openCkExport = async () => {
if (selectedIds.length === 0) return;
setCkExporting(true);
try {
const result = await huyaApi.exportCollabOrderCk(selectedIds);
setCkExport(result);
setCkExportOpen(true);
} catch (e: unknown) {
message.error(getErrorMessage(e));
} finally {
setCkExporting(false);
}
};
const closeCkExport = () => {
setCkExportOpen(false);
setCkExport(null);
};
/** 导出文本:默认每行一份纯 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]);
// 已失效 / 已停用的 CK 照样导出(用户可能就是要看它),但得在界面上点名
const ckExportRisky = useMemo(
() => (ckExport?.items ?? []).filter(
(item) => item.ck_status === 'invalid' || item.ck_status === 'disabled',
),
[ckExport],
);
const copyCkExport = async () => {
if (!ckExport?.exported) return;
try {
await copyTextToClipboard(ckExportText);
message.success(`已复制 ${ckExport.exported} 份 CK 到剪贴板`);
} catch (e: unknown) {
// 复制失败别吞:明文就在下面的框里,让人手动选中复制
message.error(`复制失败:${getErrorMessage(e)}(可以在下面的框里手动选中复制)`);
}
};
const patchOrder = async (row: HuyaCollabOrder, payload: HuyaCollabOrderUpdate) => {
try {
const updated = await huyaApi.updateCollabOrder(row.id, { ...payload, expected_version: row.version });
@@ -1213,8 +1276,13 @@ export default function HuyaCollabPage() {
</Row>
<Card size="small">
<Space wrap style={{ width: '100%', justifyContent: 'space-between' }}>
<Space wrap>
{/*
固定两行:第一行永远是筛选,第二行永远是动作按钮组 —— 不要用「筛选 + 动作同一个 wrap」
那种排法:不勾选时两组挤成一行,勾选后按钮多了「(N)」就会被顶到第二行,
列表跟着往下跳(2026-10-16 用户反馈「不要选中后两行」)。
*/}
<Space direction="vertical" size={8} style={{ width: '100%' }}>
<Space wrap style={{ width: '100%' }}>
<Space size={4}>
<Text type="secondary">店铺</Text>
<HuyaCollabShopPicker
@@ -1247,7 +1315,8 @@ export default function HuyaCollabPage() {
onChange={(checked) => { setPage(1); setFilters((prev) => ({ ...prev, include_void: checked })); }} />
</Space>
</Space>
<Space wrap>
{/* 第二行:动作(固定在这一行;勾选后加「(N)」只是按钮变宽,不会把列表顶下去) */}
<Space wrap style={{ width: '100%' }}>
<Button icon={<ReloadOutlined />} onClick={() => void load()} disabled={loading}>刷新</Button>
{canEdit && (
<Button icon={<SafetyCertificateOutlined />} disabled={selectedIds.length === 0 || busy}
@@ -1284,6 +1353,14 @@ export default function HuyaCollabPage() {
批量查积分/兑换{selectedIds.length > 0 ? `(${selectedIds.length})` : ''}
</Button>
)}
{canExportCk && (
// 导出 CK 到剪贴板(勾选几行就导几行;明文只在这个弹窗里出现)
<Button icon={<CopyOutlined />} disabled={selectedIds.length === 0 || busy}
loading={ckExporting}
onClick={() => void openCkExport()}>
导出 CK{selectedIds.length > 0 ? `(${selectedIds.length})` : ''}
</Button>
)}
{canEdit && (
<Button icon={<FileExcelOutlined />} onClick={() => { setImportResult(null); setImportOpen(true); }}>
粘贴导入
@@ -1934,6 +2011,58 @@ export default function HuyaCollabPage() {
</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 本体。"
/>
{ckExport && ckExport.skipped.length > 0 && (
<Text type="warning" style={{ fontSize: 12 }}>
跳过 {ckExport.skipped.length} 条:
{ckExport.skipped.map((item) => `${item.order_no}(${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})`).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>
<HuyaCollabShopModal
open={shopModalOpen}
shops={shops}