feat(huya): 协作台账支持待补资料一键建单与表单暂存

This commit is contained in:
yml2213
2026-10-17 14:21:36 +08:00
parent cad06ae789
commit fcc2fbdc09
8 changed files with 401 additions and 50 deletions
+4
View File
@@ -1200,6 +1200,8 @@ export interface AccountExchangeRecords {
/** 业务状态:一单卡在哪一步(void 是旁路) */
export type HuyaCollabStatus =
/** 只登记了订单号 + 店铺,CK / 礼包 / 主页截图等客户资料还没到 */
| 'incomplete'
| 'pending'
| 'bound'
| 'recharged'
@@ -1473,6 +1475,8 @@ export interface HuyaCollabOrderCreate {
remark?: string;
/** 严格模式(默认 true):店铺/CK/礼包名/主页截图必填,且 CK 必须读到绑定角色 */
require_verified?: boolean;
/** 先建单后补资料:只填订单号 + 店铺,状态进「待补资料」 */
draft?: boolean;
}
export interface HuyaCollabOrderUpdate {
@@ -41,11 +41,14 @@ export default function HuyaCollabHomeImageUploader({
value = [],
max = 20,
disabled,
optional = false,
onChange,
}: {
value?: string[];
max?: number;
disabled?: boolean;
/** 草稿建单:主页截图可以先空着 */
optional?: boolean;
onChange?: (value: string[]) => void;
}) {
const [busy, setBusy] = useState(false);
@@ -138,7 +141,9 @@ export default function HuyaCollabHomeImageUploader({
return (
<Space direction="vertical" size={8} style={{ width: '100%' }}>
{keys.length === 0 ? (
<Text type="secondary">还没有图(必填:至少 1 张,第一张作封面)</Text>
<Text type="secondary">
{optional ? '还没有图(可稍后补,第一张作封面)' : '还没有图(必填:至少 1 张,第一张作封面)'}
</Text>
) : (
<Image.PreviewGroup
items={keys.map((key) => previews[key]?.url || `/api/files/object?key=${encodeURIComponent(key)}`)}
+269 -37
View File
@@ -55,6 +55,8 @@ function hasTextInputFocus(): boolean {
}
const STATUS_META: Record<HuyaCollabStatus, { label: string; color: string }> = {
// 只登记了订单号 + 店铺,客户资料(CK / 礼包 / 主页截图)到了再补
incomplete: { label: '待补资料', color: 'gold' },
pending: { label: '待处理', color: 'default' },
// 人工核对过「探针读到的绑定角色」与「主页截图」一致
bound: { label: '已确认绑定', color: 'blue' },
@@ -74,7 +76,8 @@ const VERDICT_META: Record<string, { label: string; color: string }> = {
unknown: { label: '无法判定', color: 'default' },
};
/** 「改状态」能选的目标(顺序 = 业务流程顺序,回退要显式勾选) */
/** 「改状态」能选的目标(顺序 = 业务流程顺序,回退要显式勾选)
* 「待补资料」只作为新建起点,不放进「改状态」:它只能通过「回退状态」进入。 */
const STATUS_ORDER_OPTIONS: { value: HuyaCollabStatus; label: string }[] = [
{ value: 'pending', label: '待处理' },
{ value: 'bound', label: '已确认绑定' },
@@ -85,10 +88,15 @@ const STATUS_ORDER_OPTIONS: { value: HuyaCollabStatus; label: string }[] = [
{ value: 'void', label: '已作废' },
];
/** 「回退」能选的目标:只在流程链里选(要进「已作废 / 抢购失败」走「改状态」或「作废」入口) */
const FLOW_STATUS_OPTIONS = STATUS_ORDER_OPTIONS.filter(
(option) => option.value !== 'failed' && option.value !== 'void',
);
/** 「回退」能选的目标:流程链(含「待补资料」;要进「已作废 / 抢购失败」走「改状态」或「作废」入口) */
const FLOW_STATUS_OPTIONS: { value: HuyaCollabStatus; label: string }[] = [
{ value: 'incomplete', label: '待补资料' },
{ value: 'pending', label: '待处理' },
{ value: 'bound', label: '已确认绑定' },
{ value: 'recharged', label: '已充值' },
{ value: 'scripted', label: '已上脚本' },
{ value: 'done', label: '抢购成功' },
];
const CK_META: Record<string, { label: string; color: string }> = {
unverified: { label: '未验证', color: 'default' },
@@ -145,6 +153,94 @@ function writeGiftCache(gifts: HuyaCollabGift[]): void {
}
}
// 「新建协作单」草稿:填一半关掉 / 刷新页面后再打开不能清空(2026-10-17 用户需求)。
// 按登录用户分键,避免同一台电脑换账号后看到上一个人的 CK。
const CREATE_DRAFT_KEY_PREFIX = 'huya.collab.createDraft';
// 「只填订单号 + 店铺直接建单」的首次提示只弹一次(按浏览器记,不再打扰)
const CREATE_DRAFT_HINT_KEY = 'huya.collab.draftHintShown';
interface CreateDraft {
order_no: string;
shop: string;
ck_text: string;
gift_name: string;
remark: string;
home_images: string[];
saved_at: number;
}
function createDraftKey(userId?: number | null): string {
return userId ? `${CREATE_DRAFT_KEY_PREFIX}.${userId}` : '';
}
/** 读草稿(读不到 / 解析坏都当没有) */
function readCreateDraft(userId?: number | null): CreateDraft | null {
const key = createDraftKey(userId);
if (!key) return null;
try {
const raw = window.localStorage.getItem(key);
if (!raw) return null;
const parsed = JSON.parse(raw) as Partial<CreateDraft>;
const savedAt = Number(parsed.saved_at) || 0;
// 草稿只留 7 天:里面可能有一份 CK,不能无限期躺在浏览器里
if (!savedAt || Date.now() - savedAt > 7 * 24 * 60 * 60 * 1000) {
window.localStorage.removeItem(key);
return null;
}
return {
order_no: typeof parsed.order_no === 'string' ? parsed.order_no : '',
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: Number(parsed.saved_at) || 0,
};
} catch {
return null;
}
}
function clearCreateDraft(userId?: number | null): void {
const key = createDraftKey(userId);
if (!key) return;
try {
window.localStorage.removeItem(key);
} catch {
// 忽略:清不掉也不影响当前表单
}
}
/** 「资料没填全时直接建单 → 待补资料」的首次提示是否已经展示过 */
function draftHintShown(): boolean {
try {
return window.localStorage.getItem(CREATE_DRAFT_HINT_KEY) === '1';
} catch {
return true; // 存不了就不要再反复弹
}
}
function markDraftHintShown(): void {
try {
window.localStorage.setItem(CREATE_DRAFT_HINT_KEY, '1');
} catch {
// 忽略:提示只是体验
}
}
function hasDraftContent(draft: Omit<CreateDraft, 'saved_at'>): boolean {
return Boolean(
draft.order_no.trim()
|| draft.shop.trim()
|| draft.ck_text.trim()
|| draft.gift_name.trim()
|| draft.remark.trim()
|| draft.home_images.length,
);
}
function formatTime(value: string | null | undefined): string {
if (!value) return '-';
const time = new Date(value);
@@ -311,6 +407,8 @@ export default function HuyaCollabPage() {
const [createOpen, setCreateOpen] = useState(false);
const [createForm] = Form.useForm<Record<string, unknown>>();
const [draftRestoredAt, setDraftRestoredAt] = useState<number | null>(null);
const draftSaveTimer = useRef<number | null>(null);
const [ckPreview, setCkPreview] = useState<HuyaCollabCkPreview | null>(null);
const [previewing, setPreviewing] = useState(false);
// 这次预览有没有带「拉礼包」(没有就是纯探针 + 用本地礼包缓存),只影响提示文案
@@ -934,28 +1032,120 @@ export default function HuyaCollabPage() {
}
};
const submitCreate = async () => {
if (!ckReady) {
message.warning('这份 CK 没通过验证(或读不到绑定角色),不能提交');
/** 立即把当前表单写进本地草稿(关闭弹窗 / 防抖到点时都走这里) */
const writeCreateDraft = useCallback((values: Record<string, unknown>) => {
const userId = user?.id;
const key = createDraftKey(userId);
if (!key) return;
const payload = {
order_no: String(values.order_no ?? ''),
shop: String(values.shop ?? ''),
ck_text: String(values.ck_text ?? ''),
gift_name: String(values.gift_name ?? ''),
remark: String(values.remark ?? ''),
home_images: Array.isArray(values.home_images)
? (values.home_images as unknown[]).filter((item): item is string => typeof item === 'string')
: [],
};
if (!hasDraftContent(payload)) {
clearCreateDraft(userId);
return;
}
try {
window.localStorage.setItem(key, JSON.stringify({ ...payload, saved_at: Date.now() }));
} catch {
// 隐私模式 / 配额满:暂存失败不影响提交
}
}, [user?.id]);
/** 新建弹窗表单变化后写本地草稿(防抖 400ms) */
const persistCreateDraft = useCallback((values: Record<string, unknown>) => {
if (draftSaveTimer.current) window.clearTimeout(draftSaveTimer.current);
draftSaveTimer.current = window.setTimeout(() => writeCreateDraft(values), 400);
}, [writeCreateDraft]);
/** 关闭弹窗时立刻落盘一次,避免 400ms 内重新打开读不到最新输入 */
const closeCreateModal = useCallback(() => {
if (draftSaveTimer.current) {
window.clearTimeout(draftSaveTimer.current);
draftSaveTimer.current = null;
}
writeCreateDraft(createForm.getFieldsValue(true) as Record<string, unknown>);
setCreateOpen(false);
}, [createForm, writeCreateDraft]);
useEffect(() => () => {
if (draftSaveTimer.current) window.clearTimeout(draftSaveTimer.current);
}, []);
/** 清掉草稿并恢复空表单(「清空草稿」入口用) */
const discardCreateDraft = useCallback(() => {
if (draftSaveTimer.current) {
window.clearTimeout(draftSaveTimer.current);
draftSaveTimer.current = null;
}
clearCreateDraft(user?.id);
setDraftRestoredAt(null);
setCkPreview(null);
setPreviewing(false);
setGiftHint('');
previewSeq.current += 1;
createForm.resetFields();
}, [createForm, user?.id]);
const submitCreate = async () => {
try {
// 只校验订单号 + 店铺:CK / 礼包 / 主页截图即使空着也能一键建单(自动进「待补资料」)
const values = await createForm.validateFields();
const orderNo = String(values.order_no ?? '').trim();
const ckText = String(values.ck_text ?? '').trim();
const giftName = String(values.gift_name ?? '').trim();
const homeImages = (values.home_images as string[] | undefined) ?? [];
const missing: string[] = [];
if (!ckText) missing.push('外部 CK');
if (!giftName) missing.push('礼包名');
if (homeImages.length === 0) missing.push('主页截图');
// 资料齐全且 CK 已验证通过才走完整建单;否则一律按「待补资料」保存
const draft = missing.length > 0 || !ckReady;
const created = await huyaApi.createCollabOrder({
order_no: String(values.order_no ?? '').trim(),
order_no: orderNo,
shop: String(values.shop ?? ''),
ck_text: String(values.ck_text ?? ''),
ck_text: ckText,
// 「绑定斗鱼号」不再录入:绑了谁由探针读(bind_game_*),人工那一列已下线
gift_name: String(values.gift_name ?? ''),
home_images: (values.home_images as string[] | undefined) ?? [],
gift_name: giftName,
home_images: homeImages,
remark: String(values.remark ?? ''),
require_verified: true, // 服务端再校验一次:CK 必须读到绑定角色才让建
draft,
require_verified: !draft, // 草稿单不跑探针;完整建单仍要求 CK 读到绑定角色
});
if (created.gift_name) window.localStorage.setItem(LAST_GIFT_KEY, created.gift_name);
message.success(`已新建 ${created.order_no}`);
if (draft) {
const reason = missing.length > 0
? `还缺 ${missing.join('、')}`
: (previewing ? 'CK 还在验证中' : 'CK 还没验证通过');
if (!draftHintShown()) {
markDraftHintShown();
// 首次建草稿单给一次明确提示:以后直接点「新建」就行,不用先勾任何东西
message.info(
`已按「待补资料」创建 ${created.order_no}:${reason}。`
+ '之后在详情里补齐,再点「标记为待处理」即可。',
8,
);
} else {
message.success(`已新建 ${created.order_no}(待补资料,资料可稍后补)`);
}
} else {
message.success(`已新建 ${created.order_no}`);
}
setCreateOpen(false);
setCkPreview(null);
setGiftHint('');
setDraftRestoredAt(null);
if (draftSaveTimer.current) {
window.clearTimeout(draftSaveTimer.current);
draftSaveTimer.current = null;
}
clearCreateDraft(user?.id);
createForm.resetFields();
void load();
} catch (e: unknown) {
@@ -1303,6 +1493,12 @@ export default function HuyaCollabPage() {
{/* 卡片用 flex 平分:状态多了一个「抢购失败」,固定 span=4 会换行参差不齐 */}
<Row gutter={12}>
<Col flex="1"><Card size="small"><Statistic title="全部" value={summary?.total ?? 0} /></Card></Col>
<Col flex="1">
<Card size="small">
<Statistic title={STATUS_META.incomplete.label} value={summary?.by_status.incomplete ?? 0}
valueStyle={{ color: '#fa8c16' }} />
</Card>
</Col>
<Col flex="1">
<Card size="small">
<Statistic title="待处理" value={summary?.by_status.pending ?? 0} valueStyle={{ color: '#faad14' }} />
@@ -1456,6 +1652,16 @@ export default function HuyaCollabPage() {
setGiftHint('');
previewSeq.current += 1; // 丢掉在途的预览结论
createForm.resetFields();
const draft = readCreateDraft(user?.id);
if (draft && hasDraftContent(draft)) {
// 上次填了一半:原样恢复(CK / 礼包 / 截图 / 备注都在),并重新验证暂存的 CK
createForm.setFieldsValue({ ...draft });
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 });
@@ -1499,6 +1705,23 @@ export default function HuyaCollabPage() {
>
{detail && (
<Space direction="vertical" size={12} style={{ width: '100%' }}>
{detail.status === 'incomplete' && (
<Alert
type="warning"
showIcon
message="这单还在「待补资料」"
description={(() => {
const missing = [
!detail.has_cookie ? '外部 CK' : '',
!detail.gift_name ? '礼包名' : '',
detail.home_images.length === 0 ? '主页截图' : '',
].filter(Boolean);
return missing.length > 0
? `还缺:${missing.join('、')}。补齐后点下面的「标记为待处理」按钮即可进入正常流程。`
: '资料已齐,点下面的「标记为待处理」按钮进入正常流程。';
})()}
/>
)}
<Descriptions size="small" column={2} bordered
items={[
{ key: 'uid', label: '虎牙号', children: detail.uid || '-' },
@@ -1770,11 +1993,11 @@ export default function HuyaCollabPage() {
title="新建协作单"
open={createOpen}
width={920}
onCancel={() => setCreateOpen(false)}
onCancel={closeCreateModal}
onOk={() => void submitCreate()}
confirmLoading={busy}
okText="新建"
okButtonProps={{ disabled: !ckReady || previewing }}
// 一键建单:不再要求先勾「先建单」或等 CK 验证通过;资料没填全自动存成「待补资料」
// 内容比一屏高:给个滚动区,别让底部按钮被顶出可视范围
styles={{ body: { maxHeight: '78vh', overflowY: 'auto', paddingRight: 12 } }}
destroyOnHidden
@@ -1784,10 +2007,28 @@ export default function HuyaCollabPage() {
form={createForm}
layout="vertical"
size="middle"
onValuesChange={(changed: Record<string, unknown>) => {
onValuesChange={(changed: Record<string, unknown>, allValues: Record<string, unknown>) => {
if (typeof changed.ck_text === 'string') scheduleCkPreview(changed.ck_text);
persistCreateDraft(allValues);
}}
>
{draftRestoredAt && (
<Alert
type="info"
showIcon
style={{ marginBottom: 12 }}
message={`已恢复上次未提交的草稿(${formatTime(new Date(draftRestoredAt).toISOString())} 暂存)`}
action={(
<Button size="small" type="link" onClick={discardCreateDraft}>
清空草稿
</Button>
)}
/>
)}
<Text type="secondary" style={{ display: 'block', marginBottom: 12, fontSize: 12 }}>
只填订单号 + 店铺也能直接点右下角「新建」:资料没填全会自动按「待补资料」创建,
CK / 礼包 / 主页截图之后在详情里补即可。填写内容会自动暂存在本机,关闭 / 刷新后再打开会恢复。
</Text>
<Row gutter={16}>
<Col span={12}>
<Form.Item name="order_no" label="订单编号" rules={[{ required: true, message: '订单号不能为空' }]}>
@@ -1811,9 +2052,8 @@ export default function HuyaCollabPage() {
</Form.Item>
<Form.Item
name="ck_text"
label="外部 CK"
tooltip="必填:支持「纯CK」或「账号----密码----CK」。贴进来即自动探针验证 + 读绑定角色;礼包列表本地有缓存就不再重复拉,要更新点旁边的「强制刷新礼包」"
rules={[{ required: true, message: '请贴一份外部 CK' }]}
label="外部 CK(可稍后补)"
tooltip="支持「纯CK」或「账号----密码----CK」。贴进来即自动探针验证 + 读绑定角色;不贴也可以先建单,之后在详情里补。礼包列表本地有缓存就不再重复拉,要更新点旁边的「强制刷新礼包」"
>
{/* 高度随内容走:短 CK 就 3 行,别固定 5 行把弹窗撑高 */}
<Input.TextArea
@@ -1857,10 +2097,10 @@ export default function HuyaCollabPage() {
/>
) : (
<Alert
type="error"
type="warning"
showIcon
message="这份 CK 不能用,禁止提交"
description={`${ckPreview?.message || ''}${ckPreview?.ok && !ckPreview.has_role ? '(探针通了但读不到绑定角色)' : ''}`}
message="这份 CK 没验证通过;点「新建」会先按「待补资料」保存"
description={`${ckPreview?.message || ''}${ckPreview?.ok && !ckPreview.has_role ? '(探针通了但读不到绑定角色)' : ''} 之后补 / 换 CK,再在详情里验证即可。`}
/>
)}
</div>
@@ -1869,9 +2109,8 @@ export default function HuyaCollabPage() {
<Col span={14}>
<Form.Item
name="gift_name"
label="礼包名(用户购买的物品)"
tooltip="必填:先贴有效 CK,再从礼包列表里点选(列表有本地缓存,不用每次重拉;拉不到时可以直接手输)"
rules={[{ required: true, message: '请选一个礼包' }]}
label="礼包名(用户购买的物品,可稍后补)"
tooltip="先贴有效 CK,再从礼包列表里点选(列表有本地缓存,不用每次重拉;拉不到时可以直接手输)。不选也能先建单,之后在详情里补。"
extra={giftHint || (giftsCachedAt && giftOptions.length > 0
? `礼包列表缓存于 ${giftCacheLabel}(${giftOptions.length} 个)`
: undefined)}
@@ -1879,7 +2118,7 @@ export default function HuyaCollabPage() {
>
<AutoComplete
allowClear
placeholder={ckReady ? '从礼包里点选(可直接输)' : '先贴一份有效 CK'}
placeholder={ckReady ? '从礼包里点选(可直接输)' : '可先空着,之后在详情里补'}
options={giftOptions.map((gift) => ({
value: gift.name,
label: [
@@ -1918,17 +2157,10 @@ export default function HuyaCollabPage() {
<Col span={14}>
<Form.Item
name="home_images"
label="主页截图(必填,第一张作封面)"
tooltip="支持点击选择、拖拽或 Ctrl/Cmd+V 粘贴上传;第一张作封面,绑定图不用再传"
rules={[{
validator: (_rule, value: string[] | undefined) => (
value && value.length > 0
? Promise.resolve()
: Promise.reject(new Error('请至少上传 1 张主页截图'))
),
}]}
label="主页截图(可稍后补,第一张作封面)"
tooltip="支持点击选择、拖拽或 Ctrl/Cmd+V 粘贴上传;第一张作封面,绑定图不用再传。不传也能先建单,之后在详情里补。"
>
<HuyaCollabHomeImageUploader max={MAX_IMAGES_PER_SLOT} />
<HuyaCollabHomeImageUploader max={MAX_IMAGES_PER_SLOT} optional />
</Form.Item>
</Col>
<Col span={10}>