feat(huya): 协作台账补资料与管理员复制 CK

This commit is contained in:
yml2213
2026-10-17 20:47:31 +08:00
parent 8fac4a1d04
commit f06869c4b1
4 changed files with 528 additions and 28 deletions
+25 -1
View File
@@ -30,7 +30,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 AuditLog, HuyaExternalOrder, User
from ...permissions import get_user_permissions
from ...schemas import (
@@ -442,6 +442,30 @@ def export_external_orders_ck(
raise _http_error(exc) from exc
@router.post(
"/external-orders/{order_id}/ck/copy",
response_model=HuyaExternalOrderCkExportOut,
)
def copy_external_order_ck(
order_id: int,
db: Session = Depends(get_db),
current: User = Depends(get_current_user),
):
"""复制单行 CK 明文(**仅超级管理员**;前端直接写剪贴板)。
这个入口比勾选导出更敏感:它把「单行凭据」做成一键复制,因此只给管理员。
仍然复用 :func:`collab.export_cks`:逐行写 ``huya:collab:ck_export`` 审计,
但审计里只记单号,不写 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])
except collab.CollabError as exc:
raise _http_error(exc) from exc
@router.websocket("/external-orders/ws")
async def ws_external_orders(websocket: WebSocket):
"""协作台账实时失效通知:客户端收到后按自己的筛选静默重拉。"""
+5
View File
@@ -286,6 +286,11 @@ export const huyaApi = {
'/huya/external-orders/ck/export',
{ ids },
),
/** 单行复制 CK(仅超级管理员;后端再次校验角色) */
copyCollabOrderCk: (id: number) =>
api.post<HuyaCollabCkExportResult, HuyaCollabCkExportResult>(
`/huya/external-orders/${id}/ck/copy`,
),
/** 删除一行(只给运营:huya:collab:manage) */
deleteCollabOrder: (id: number) =>
api.delete<{ success: boolean; order_no: string }, { success: boolean; order_no: string }>(
+481 -27
View File
@@ -8,14 +8,14 @@
*/
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
Alert, App, AutoComplete, Button, Card, Checkbox, Col, Descriptions, Divider, Drawer, Dropdown,
Alert, App, Button, Card, Checkbox, Col, Descriptions, Divider, Drawer, Dropdown,
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, CopyOutlined, DeleteOutlined, EditOutlined, FileExcelOutlined,
LinkOutlined, PlusOutlined, ReloadOutlined, SafetyCertificateOutlined, ShopOutlined,
CheckCircleOutlined, CloudUploadOutlined, CopyOutlined, DeleteOutlined, EditOutlined, FileAddOutlined,
FileExcelOutlined, LinkOutlined, PlusOutlined, ReloadOutlined, SafetyCertificateOutlined, ShopOutlined,
StopOutlined, SwapOutlined, SyncOutlined, UndoOutlined,
} from '@ant-design/icons';
import {
@@ -267,6 +267,68 @@ function hasDraftContent(draft: Omit<CreateDraft, 'saved_at'>): boolean {
);
}
// 「补资料」草稿:按登录用户 + 订单 id 分开存。填一半关掉 / 刷新后,
// 再点这一行的「补资料」会原样恢复(含刚贴的 CK 和刚上传的截图 key)。
const SUPPLEMENT_DRAFT_KEY_PREFIX = 'huya.collab.supplementDraft';
interface SupplementDraft {
shop: string;
ck_text: string;
gift_name: string;
remark: string;
home_images: string[];
saved_at: number;
}
interface SupplementFormValues {
shop: string;
ck_text?: string;
gift_name?: string;
remark?: string;
home_images?: string[];
}
function supplementDraftKey(userId?: number | null, orderId?: number): string {
return userId && orderId ? `${SUPPLEMENT_DRAFT_KEY_PREFIX}.${userId}.${orderId}` : '';
}
function readSupplementDraft(userId: number | null | undefined, orderId: number): SupplementDraft | null {
const key = supplementDraftKey(userId, orderId);
if (!key) return null;
try {
const raw = window.localStorage.getItem(key);
if (!raw) return null;
const parsed = JSON.parse(raw) as Partial<SupplementDraft>;
const savedAt = Number(parsed.saved_at) || 0;
if (!savedAt || Date.now() - savedAt > 7 * 24 * 60 * 60 * 1000) {
window.localStorage.removeItem(key);
return null;
}
return {
shop: typeof parsed.shop === 'string' ? parsed.shop : '',
ck_text: typeof parsed.ck_text === 'string' ? parsed.ck_text : '',
gift_name: typeof parsed.gift_name === 'string' ? parsed.gift_name : '',
remark: typeof parsed.remark === 'string' ? parsed.remark : '',
home_images: Array.isArray(parsed.home_images)
? parsed.home_images.filter((item): item is string => typeof item === 'string')
: [],
saved_at: savedAt,
};
} catch {
return null;
}
}
function clearSupplementDraft(userId: number | null | undefined, orderId: number): void {
const key = supplementDraftKey(userId, orderId);
if (!key) return;
try {
window.localStorage.removeItem(key);
} catch {
// 忽略:清不掉也不影响当前表单
}
}
function formatTime(value: string | null | undefined): string {
if (!value) return '-';
const time = new Date(value);
@@ -410,6 +472,8 @@ export default function HuyaCollabPage() {
const canManage = can('huya:collab:manage');
// 导出 CK 是把凭据带出系统,单独一个权限点(默认只给运营,见 permissions.py)
const canExportCk = can('huya:collab:ck_export');
// 「复制 CK」是单行一键带走凭据,按用户要求只给超级管理员(后端也会再校验一次)
const isAdmin = user?.role === 'super_admin';
const [rows, setRows] = useState<HuyaCollabOrder[]>([]);
const [total, setTotal] = useState(0);
@@ -438,6 +502,16 @@ export default function HuyaCollabPage() {
const [createForm] = Form.useForm<Record<string, unknown>>();
const [draftRestoredAt, setDraftRestoredAt] = useState<number | null>(null);
const draftSaveTimer = useRef<number | null>(null);
// 「补资料」:对待补资料的单补 CK / 礼包 / 主页截图 / 备注,草稿按订单存在本机
const [supplementTarget, setSupplementTarget] = useState<HuyaCollabOrder | null>(null);
const [supplementForm] = Form.useForm<SupplementFormValues>();
const [supplementDraftAt, setSupplementDraftAt] = useState<number | null>(null);
const supplementDraftTimer = useRef<number | null>(null);
const createGiftValue = Form.useWatch('gift_name', createForm) as string | undefined;
const supplementGiftValue = Form.useWatch('gift_name', supplementForm) as string | undefined;
const supplementCkText = Form.useWatch('ck_text', supplementForm) as string | undefined;
const supplementHomeImages = Form.useWatch('home_images', supplementForm) as string[] | undefined;
const [ckPreview, setCkPreview] = useState<HuyaCollabCkPreview | null>(null);
const [previewing, setPreviewing] = useState(false);
// 这次预览有没有带「拉礼包」(没有就是纯探针 + 用本地礼包缓存),只影响提示文案
@@ -521,7 +595,7 @@ export default function HuyaCollabPage() {
const realtimeEnabled = can('huya:collab:view');
const collabModalOpen = Boolean(
createOpen || detail || statusAction || batchStatusOpen || ckTarget || ckExportOpen || shopModalOpen,
createOpen || supplementTarget || detail || statusAction || batchStatusOpen || ckTarget || ckExportOpen || shopModalOpen,
);
const handleRealtimeInvalidate = useCallback((event: CollabRealtimeEvent) => {
@@ -1019,6 +1093,203 @@ export default function HuyaCollabPage() {
}
};
/** 单行「复制 CK」:只有超级管理员能看到按钮,后端还会再校验一次角色。 */
const copyOneCk = async (row: HuyaCollabOrder) => {
setBusy(true);
try {
const result = await huyaApi.copyCollabOrderCk(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 (e: unknown) {
message.error(`复制失败:${getErrorMessage(e)}`);
} finally {
setBusy(false);
}
};
/** 把「补资料」表单写进本机草稿:仍按用户 + 订单分键,最多留 7 天。 */
const writeSupplementDraft = useCallback((values: SupplementFormValues, target: HuyaCollabOrder) => {
const userId = user?.id;
const key = supplementDraftKey(userId, target.id);
if (!key) return;
const images = Array.isArray(values.home_images)
? values.home_images.filter((item): item is string => typeof item === 'string')
: target.home_images;
const payload: Omit<SupplementDraft, 'saved_at'> = {
shop: String(values.shop ?? ''),
ck_text: String(values.ck_text ?? ''),
gift_name: String(values.gift_name ?? ''),
remark: String(values.remark ?? ''),
home_images: images,
};
const changed = Boolean(
payload.ck_text.trim()
|| payload.shop !== target.shop
|| payload.gift_name !== target.gift_name
|| payload.remark !== target.remark
|| payload.home_images.join('\n') !== target.home_images.join('\n'),
);
try {
if (!changed) {
window.localStorage.removeItem(key);
return;
}
window.localStorage.setItem(key, JSON.stringify({ ...payload, saved_at: Date.now() }));
} catch {
// 隐私模式 / 配额满:暂存失败不影响提交
}
}, [user?.id]);
const persistSupplementDraft = useCallback((values: SupplementFormValues, target: HuyaCollabOrder) => {
if (supplementDraftTimer.current) window.clearTimeout(supplementDraftTimer.current);
supplementDraftTimer.current = window.setTimeout(() => writeSupplementDraft(values, target), 400);
}, [writeSupplementDraft]);
const openSupplement = (row: HuyaCollabOrder) => {
if (supplementDraftTimer.current) {
window.clearTimeout(supplementDraftTimer.current);
supplementDraftTimer.current = null;
}
const draft = readSupplementDraft(user?.id, row.id);
const base: SupplementFormValues = {
shop: row.shop,
ck_text: '',
gift_name: row.gift_name,
remark: row.remark,
home_images: row.home_images,
};
supplementForm.setFieldsValue(draft ? { ...base, ...draft } : base);
setSupplementDraftAt(draft?.saved_at ?? null);
setSupplementTarget(row);
};
const closeSupplement = useCallback(() => {
if (supplementDraftTimer.current) {
window.clearTimeout(supplementDraftTimer.current);
supplementDraftTimer.current = null;
}
if (supplementTarget) {
writeSupplementDraft(
supplementForm.getFieldsValue(true) as SupplementFormValues,
supplementTarget,
);
}
setSupplementTarget(null);
setSupplementDraftAt(null);
supplementForm.resetFields();
}, [supplementForm, supplementTarget, writeSupplementDraft]);
const clearSupplement = () => {
if (!supplementTarget) return;
if (supplementDraftTimer.current) {
window.clearTimeout(supplementDraftTimer.current);
supplementDraftTimer.current = null;
}
clearSupplementDraft(user?.id, supplementTarget.id);
setSupplementDraftAt(null);
supplementForm.setFieldsValue({
shop: supplementTarget.shop,
ck_text: '',
gift_name: supplementTarget.gift_name,
remark: supplementTarget.remark,
home_images: supplementTarget.home_images,
});
};
/**
* 保存「补资料」:字段 / 新 CK / 截图分别走各自接口(每个都有乐观锁),
* 新 CK 保存后立即跑一次只读探针;用户点「保存并进入待处理」时再校验资料完整性。
*/
const submitSupplement = async (markPending = false) => {
if (!supplementTarget) return;
let values: SupplementFormValues;
try {
values = await supplementForm.validateFields();
} catch {
return;
}
setBusy(true);
try {
let current = supplementTarget;
const payload: HuyaCollabOrderUpdate = {};
const shop = String(values.shop ?? '').trim();
const giftName = String(values.gift_name ?? '').trim();
const remark = String(values.remark ?? '');
if (shop !== current.shop) payload.shop = shop;
if (giftName !== current.gift_name) payload.gift_name = giftName;
if (remark !== current.remark) payload.remark = remark;
if (Object.keys(payload).length > 0) {
current = await huyaApi.updateCollabOrder(current.id, {
...payload,
expected_version: current.version,
});
}
const ckText = String(values.ck_text ?? '').trim();
if (ckText) {
current = await huyaApi.updateCollabOrderCk(current.id, {
ck_text: ckText,
action: 'paste',
expected_version: current.version,
});
current = await huyaApi.verifyCollabOrder(current.id);
}
const images = values.home_images ?? current.home_images;
if (images.join('\n') !== current.home_images.join('\n')) {
current = await huyaApi.updateCollabOrderImages(current.id, {
slot: 'home_images',
images,
expected_version: current.version,
});
}
// 没有换 CK、但准备进「待处理」时,先跑一次只读探针;避免拿一份没验证的旧 CK 往前推
if (markPending && !ckText && current.has_cookie && current.ck_status !== 'valid') {
current = await huyaApi.verifyCollabOrder(current.id);
}
if (markPending && current.ck_status !== 'valid') {
applyUpdated(current);
setSupplementTarget(current);
supplementForm.setFieldValue('ck_text', '');
void load();
message.warning(
'资料已保存,但这份 CK 还没验证通过,暂时不能进入「待处理」;请先补 / 换一份有效 CK',
);
return;
}
if (markPending) {
current = await huyaApi.updateCollabOrderStatus(current.id, {
status: 'pending',
expected_version: current.version,
});
}
applyUpdated(current);
clearSupplementDraft(user?.id, supplementTarget.id);
setSupplementDraftAt(null);
supplementForm.resetFields();
setSupplementTarget(null);
message.success(
markPending
? `已补齐资料,${current.order_no} 进入「待处理」`
: `已保存 ${current.order_no} 的资料`,
);
void load();
} catch (e: unknown) {
message.error(getErrorMessage(e));
void load();
} finally {
setBusy(false);
}
};
const commitSlot = async (row: HuyaCollabOrder, slot: HuyaCollabImageSlot, keys: string[]) => {
const updated = await huyaApi.updateCollabOrderImages(row.id, {
slot, images: keys, expected_version: row.version,
@@ -1111,6 +1382,7 @@ export default function HuyaCollabPage() {
useEffect(() => () => {
if (draftSaveTimer.current) window.clearTimeout(draftSaveTimer.current);
if (supplementDraftTimer.current) window.clearTimeout(supplementDraftTimer.current);
}, []);
/** 清掉草稿并恢复空表单(「清空草稿」入口用) */
@@ -1229,6 +1501,27 @@ export default function HuyaCollabPage() {
? `礼包用本地缓存(${giftOptions.length} 个${giftCacheLabel ? ` · ${giftCacheLabel}` : ''}),要更新点「强制刷新礼包」`
: (ckPreview?.gifts_message || '');
/**
* 礼包下拉选项:只允许点选缓存里的礼包,不接受手输。
* 当前值是历史/已下线礼包时补一个只读选项,保证界面能显示,但依然只能从下拉里选。
*/
const giftSelectOptions = (current?: string | null) => {
const value = String(current ?? '').trim();
const options = giftOptions.map((gift) => ({
value: gift.name,
label: [
gift.category_name ? `[${gift.category_name}]` : '',
gift.name,
gift.price ? `· ${gift.price} 积分` : '',
gift.remain_text ? `· 剩 ${gift.remain_text}` : '',
].filter(Boolean).join(' '),
}));
if (value && !giftOptions.some((gift) => gift.name === value)) {
options.unshift({ value, label: `${value}(历史值)` });
}
return options;
};
// 表头排序:一次正序、再点反序(Table 上限制 sortDirections,不会出现「第三次取消」)。
const sortProps = (key: CollabSortKey) => ({
key,
@@ -1392,8 +1685,21 @@ export default function HuyaCollabPage() {
...sortProps('gift_name'),
render: (_, row) => (
<Space direction="vertical" size={0} style={{ width: '100%' }}>
<EditableText value={row.gift_name} disabled={!canEdit} placeholder="+ 礼包名"
onCommit={(next) => void patchOrder(row, { gift_name: next })} />
{/* 礼包名只能从缓存列表点选:手输会出现“看起来一样、接口拉不到”的脏值 */}
<Select
size="small"
variant="borderless"
style={{ width: '100%' }}
showSearch
optionFilterProp="label"
options={giftSelectOptions(row.gift_name)}
value={row.gift_name || undefined}
disabled={!canEdit}
placeholder="+ 点选礼包"
popupMatchSelectWidth={false}
notFoundContent={giftOptions.length === 0 ? '礼包缓存为空:先在「新建」里贴 CK 拉一次' : '没有匹配的礼包'}
onChange={(next: string) => void patchOrder(row, { gift_name: next })}
/>
<EditableText value={row.remark} disabled={!canEdit} placeholder="+ 备注"
onCommit={(next) => void patchOrder(row, { remark: next })} />
</Space>
@@ -1483,9 +1789,24 @@ export default function HuyaCollabPage() {
),
},
{
title: '操作', width: 150, fixed: 'right', align: 'center',
title: '操作', width: 300, fixed: 'right', align: 'center',
render: (_, row) => (
<Space size={4}>
<Space size={4} wrap>
{canEdit && row.status === 'incomplete' && (
<Button size="small" type="primary" ghost icon={<FileAddOutlined />}
onClick={() => openSupplement(row)}>
补资料
</Button>
)}
{isAdmin && (
<Tooltip title={row.has_cookie ? '复制这一行的 CK 明文(仅管理员)' : '这行还没贴 CK'}>
<Button size="small" icon={<CopyOutlined />}
disabled={busy || !row.has_cookie}
onClick={() => void copyOneCk(row)}>
复制CK
</Button>
</Tooltip>
)}
<Button size="small" icon={<EditOutlined />} onClick={() => void openDetail(row)}>详情</Button>
{canEdit && (
<Dropdown
@@ -1710,16 +2031,31 @@ export default function HuyaCollabPage() {
const draft = readCreateDraft(user?.id);
if (draft && hasDraftContent(draft)) {
// 上次填了一半:原样恢复(CK / 礼包 / 截图 / 备注都在),并重新验证暂存的 CK
createForm.setFieldsValue({ ...draft });
const restored = { ...draft };
const restoredGift = String(restored.gift_name ?? '').trim();
if (
restoredGift
&& giftOptions.length > 0
&& !giftOptions.some((gift) => gift.name === restoredGift)
) {
restored.gift_name = '';
setGiftHint(`草稿里的「${restoredGift}」不在当前礼包缓存里,已清空,请重新点选`);
} else {
setGiftHint('');
}
createForm.setFieldsValue({ ...restored });
setDraftRestoredAt(draft.saved_at || Date.now());
setCreateOpen(true);
if (draft.ck_text.trim()) scheduleCkPreview(draft.ck_text, true);
return;
}
setDraftRestoredAt(null);
// 记住最近选的礼包:同一个礼包连开几单时不用反复选
// 记住最近选的礼包:只在当前缓存里还有它时才自动带上(礼包名不接受手输)
const lastGift = window.localStorage.getItem(LAST_GIFT_KEY);
if (lastGift) createForm.setFieldsValue({ gift_name: lastGift });
if (lastGift && giftOptions.some((gift) => gift.name === lastGift)) {
createForm.setFieldsValue({ gift_name: lastGift });
}
setGiftHint('');
setCreateOpen(true);
}}
>
@@ -1736,7 +2072,7 @@ export default function HuyaCollabPage() {
loading={loading}
columns={columns}
dataSource={rows}
scroll={{ x: 1800, y: 'calc(100vh - 460px)' }}
scroll={{ x: 2200, y: 'calc(100vh - 460px)' }}
sortDirections={['ascend', 'descend']}
onChange={(_pagination, _filters, sorter, extra) => {
if (extra.action !== 'sort') return;
@@ -1890,7 +2226,19 @@ export default function HuyaCollabPage() {
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="gift_name" label="礼包名"><Input maxLength={128} /></Form.Item>
<Form.Item name="gift_name" label="礼包名"
tooltip="只接受礼包缓存里的点选结果,可输入关键字搜索;不接受手工输入">
<Select
allowClear
showSearch
optionFilterProp="label"
placeholder="输入关键字搜索,点选礼包"
options={giftSelectOptions(detail.gift_name)}
notFoundContent={giftOptions.length === 0
? '礼包缓存为空:先在「新建」里贴 CK 拉一次'
: '没有匹配的礼包'}
/>
</Form.Item>
</Col>
<Col span={24}>
<Form.Item name="remark" label="备注"><Input.TextArea rows={2} maxLength={512} /></Form.Item>
@@ -2184,26 +2532,20 @@ export default function HuyaCollabPage() {
<Form.Item
name="gift_name"
label="礼包名(用户购买的物品,可稍后补)"
tooltip="先贴有效 CK,再从礼包列表里点选(列表有本地缓存,不用每次重拉;拉不到时可以直接手输)。不选也能先建单,之后在详情里补。"
tooltip="只接受礼包缓存里的点选结果,可输入关键字搜索。先贴有效 CK 拉一次礼包;不选也能先建单,之后点「补资料」再选。"
extra={giftHint || (giftsCachedAt && giftOptions.length > 0
? `礼包列表缓存于 ${giftCacheLabel}(${giftOptions.length} 个)`
: undefined)}
validateStatus={giftHint ? 'warning' : undefined}
>
<AutoComplete
<Select
allowClear
placeholder={ckReady ? '从礼包里点选(可直接输)' : '可先空着,之后在详情里补'}
options={giftOptions.map((gift) => ({
value: gift.name,
label: [
gift.category_name ? `[${gift.category_name}]` : '',
gift.name,
gift.price ? `· ${gift.price} 积分` : '',
gift.remain_text ? `· 剩 ${gift.remain_text}` : '',
].filter(Boolean).join(' '),
}))}
filterOption={(input, option) =>
String(option?.value ?? '').toLowerCase().includes(input.toLowerCase())}
showSearch
optionFilterProp="label"
placeholder={giftOptions.length > 0 ? '输入关键字搜索,点选礼包' : '礼包缓存为空:先贴 CK 拉取'}
options={giftSelectOptions(createGiftValue)}
disabled={giftOptions.length === 0 && !createGiftValue}
notFoundContent={giftOptions.length === 0 ? '先在左侧贴一份有效 CK 拉取礼包列表' : '没有匹配的礼包'}
onChange={(value: string) => {
// 记住最近选的礼包:连开几单同一个礼包就不用反复点
setGiftHint('');
@@ -2246,6 +2588,118 @@ export default function HuyaCollabPage() {
</Form>
</Modal>
<Modal
title={supplementTarget ? `补资料 · ${supplementTarget.order_no}` : '补资料'}
open={!!supplementTarget}
width={760}
onCancel={closeSupplement}
destroyOnHidden
styles={{ body: { maxHeight: '74vh', overflowY: 'auto', paddingRight: 12 } }}
footer={[
<Button key="cancel" onClick={closeSupplement} disabled={busy}>
取消
</Button>,
<Button key="save" onClick={() => void submitSupplement(false)} loading={busy}>
保存资料
</Button>,
<Button key="pending" type="primary" onClick={() => void submitSupplement(true)} loading={busy}>
保存并进入待处理
</Button>,
]}
>
{supplementTarget && (
<>
{supplementDraftAt && (
<Alert
type="info"
showIcon
style={{ marginBottom: 12 }}
message={`已恢复上次没填完的补资料草稿(${formatTime(new Date(supplementDraftAt).toISOString())} 暂存)`}
action={<Button size="small" type="link" onClick={clearSupplement}>清空草稿</Button>}
/>
)}
<Alert
type="warning"
showIcon
style={{ marginBottom: 12 }}
message="还缺什么就补什么;填到一半可以直接关掉,下次再点「补资料」会从这里继续"
description={(() => {
const hasCk = supplementTarget.has_cookie || Boolean(supplementCkText?.trim());
const images = supplementHomeImages ?? supplementTarget.home_images;
const missing = [
!hasCk ? '外部 CK' : '',
!supplementGiftValue ? '礼包名' : '',
images.length === 0 ? '主页截图' : '',
].filter(Boolean);
return missing.length > 0 ? `当前还缺:${missing.join('、')}` : '资料已齐,可以直接「保存并进入待处理」。';
})()}
/>
<Form
form={supplementForm}
layout="vertical"
size="middle"
onValuesChange={(_changed, allValues) => {
persistSupplementDraft(allValues as SupplementFormValues, supplementTarget);
}}
>
<Row gutter={16}>
<Col span={12}>
<Form.Item name="shop" label="店铺"
rules={[{ required: true, message: '请点一下选店铺' }]}
tooltip="点一下选;历史订单的店铺即使已停用也会保留">
<HuyaCollabShopPicker shops={shops} allowClear={false} size="large" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="gift_name" label="礼包名"
tooltip="只接受礼包缓存里的点选结果,可输入关键字搜索;不接受手工输入">
<Select
allowClear
showSearch
optionFilterProp="label"
placeholder={giftOptions.length > 0 ? '输入关键字搜索,点选礼包' : '礼包缓存为空:先在「新建」里贴 CK 拉一次'}
options={giftSelectOptions(supplementGiftValue ?? supplementTarget.gift_name)}
disabled={giftOptions.length === 0 && !supplementGiftValue}
notFoundContent={giftOptions.length === 0
? '礼包缓存为空:先在「新建」里贴 CK 拉一次'
: '没有匹配的礼包'}
/>
</Form.Item>
</Col>
</Row>
<Form.Item
name="ck_text"
label="外部 CK(不换就留空)"
tooltip="支持「纯CK」或「账号----密码----CK」。贴新 CK 会覆盖并立即验证;这行已经存过 CK 时,不换就留空"
extra={supplementTarget.has_cookie
? `这行已存 CK(当前:${supplementTarget.ck_status_label};最近验证 ${formatTime(supplementTarget.ck_checked_at)})`
: '这行还没有 CK,可以现在补'}
>
<Input.TextArea
autoSize={{ minRows: 3, maxRows: 7 }}
placeholder={supplementTarget.has_cookie
? '已存 CK;要换就贴一份新的,不换留空'
: '粘贴外部 CK,保存后自动验证'}
/>
</Form.Item>
<Row gutter={16}>
<Col span={14}>
<Form.Item name="home_images" label="主页截图(第一张作封面)"
tooltip="支持点击选择、拖拽或 Ctrl/Cmd+V 粘贴;补资料时也可以只补截图">
<HuyaCollabHomeImageUploader max={MAX_IMAGES_PER_SLOT} optional />
</Form.Item>
</Col>
<Col span={10}>
<Form.Item name="remark" label="备注(可选)">
<Input.TextArea rows={3} maxLength={512} />
</Form.Item>
</Col>
</Row>
</Form>
</>
)}
</Modal>
<Modal
title="粘贴导入(Excel 复制出来的 TSV)"
open={importOpen}