From fcc2fbdc096607c80e47eab700a6638f1fc492b1 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Sat, 17 Oct 2026 14:21:36 +0800 Subject: [PATCH] =?UTF-8?q?feat(huya):=20=E5=8D=8F=E4=BD=9C=E5=8F=B0?= =?UTF-8?q?=E8=B4=A6=E6=94=AF=E6=8C=81=E5=BE=85=E8=A1=A5=E8=B5=84=E6=96=99?= =?UTF-8?q?=E4=B8=80=E9=94=AE=E5=BB=BA=E5=8D=95=E4=B8=8E=E8=A1=A8=E5=8D=95?= =?UTF-8?q?=E6=9A=82=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/设计-虎牙外部CK协作台账.md | 16 +- tests/test_huya_collab_orders.py | 53 +++ web/backend/routers/huya/collab.py | 4 + web/backend/schemas.py | 4 + web/backend/services/huya_collab_service.py | 57 +++- web/frontend/src/api/types.ts | 4 + .../HuyaCollabHomeImageUploader.tsx | 7 +- web/frontend/src/pages/HuyaCollabPage.tsx | 306 +++++++++++++++--- 8 files changed, 401 insertions(+), 50 deletions(-) diff --git a/docs/设计-虎牙外部CK协作台账.md b/docs/设计-虎牙外部CK协作台账.md index 32831b9..b5a7801 100644 --- a/docs/设计-虎牙外部CK协作台账.md +++ b/docs/设计-虎牙外部CK协作台账.md @@ -200,16 +200,17 @@ CK 自带完整生命周期(导入 → 验证 → 使用/失效 → 重贴/重 ### 4.1 业务状态机(一行的进度) ``` -pending ─人工确认─▶ bound ─充值─▶ recharged ─上脚本─▶ scripted ─抢购成功─▶ done - │ │ │ │ │ - └─────────────────┴──────────────┴─────────────────┴──────────────────┴──▶ failed(抢购失败) - │ │ │ │ │ - └─────────────────┴──────────────┴─────────────────┴──────────────────┴──▶ void(作废) +incomplete ─资料齐全─▶ pending ─人工确认─▶ bound ─充值─▶ recharged ─上脚本─▶ scripted ─抢购成功─▶ done + │ │ │ │ │ │ + └──────────────────┴─────────────────┴──────────────┴─────────────────┴──────────────────┴──▶ failed(抢购失败) + │ │ │ │ │ │ + └──────────────────┴─────────────────┴──────────────┴─────────────────┴──────────────────┴──▶ void(作废) ``` | 状态 | 含义 | 进入条件 | 谁可触发 | | --- | --- | --- | --- | -| `pending` | 待处理 | 新建 / 上传导入后的默认态(**上传完就是这里**) | — | +| `incomplete` | **待补资料** | 新建时只填订单号 + 店铺、CK / 礼包 / 主页截图留空即可(点一次「新建」自动进这里,2026-10-17) | 人工 | +| `pending` | 待处理 | 完整新建 / 上传导入后的默认态;或「待补资料」补齐 CK + 礼包名 + 主页截图后进入 | — / 人工 | | `bound` | **已确认绑定** | 人工核对「探针读到的绑定角色」与「主页截图」一致后手动切 | 人工(单行 / 批量) | | `recharged` | 已充值 | 充值完成后手动切(可跳步);写 `recharged_at` | 人工 | | `scripted` | 已上脚本 | 脚本上完后手动切(可跳步);写 `scripted_at` | 人工 | @@ -238,6 +239,9 @@ pending ─人工确认─▶ bound ─充值─▶ recharged ─上脚本─▶ 6. **不用一个布尔列表达"充值/上脚本"**:`recharged_at` / `scripted_at` 是 "什么时候做过"的证据,`status` 是"现在卡在哪"。两者都留, 才能既显示"已充值/已上脚本"这种组合,也能显示"只充了没上脚本"的中间态。 +7. **离开「待补资料」要求资料齐全**(`_assert_can_leave_incomplete()`): + 外部 CK / 礼包名 / 主页截图三样缺一不可,缺什么在错误里写清(400)。 + 作废 / 抢购失败是旁路,不受这条限制;新建草稿单不跑探针、不做严格校验。 **批量改状态**(`set_status_batch()`,接口 `POST /external-orders/status/batch`): diff --git a/tests/test_huya_collab_orders.py b/tests/test_huya_collab_orders.py index e886c9c..4101394 100644 --- a/tests/test_huya_collab_orders.py +++ b/tests/test_huya_collab_orders.py @@ -1390,6 +1390,59 @@ class TestCollabCreateStrict(TestCollabBase): assert result["created"] == 1 assert self.db.query(HuyaExternalOrder).one().ck_status == "unverified" + def test_draft_needs_only_order_no_and_shop(self): + """先建单后补资料:只填订单号 + 店铺,状态直接进「待补资料」。""" + order = collab.create_order( + self.db, + self.user, + order_no="DRAFT-1", + shop="佳佳", + draft=True, + require_verified=True, # 草稿模式必须压过严格模式,不跑探针 + ) + + assert order.status == collab.STATUS_INCOMPLETE + assert order.shop == "佳佳" + assert order.cookie == "" + assert order.gift_name == "" + assert order.home_images == [] + assert order.ck_status == "unverified" + assert self._row_count() == 1 + detail = self._audits(order)[-1].detail + assert '"draft"' in detail and "true" in detail + assert collab.summary(self.db)["by_status"]["incomplete"] == 1 + + def test_incomplete_cannot_advance_until_customer_info_complete(self): + order = collab.create_order( + self.db, self.user, order_no="DRAFT-2", shop="佳佳", draft=True + ) + + with pytest.raises(collab.CollabError) as excinfo: + collab.set_status(self.db, self.user, order, collab.STATUS_PENDING) + + assert excinfo.value.code == "incomplete_precondition" + assert "外部 CK" in excinfo.value.detail + assert "礼包名" in excinfo.value.detail + assert "主页截图" in excinfo.value.detail + assert order.status == collab.STATUS_INCOMPLETE + + def test_incomplete_advances_after_info_complete(self): + order = collab.create_order( + self.db, + self.user, + order_no="DRAFT-3", + shop="佳佳", + gift_name="不朽伊人大礼包", + home_images=[self.HOME_IMAGE], + draft=True, + ) + collab.set_ck(self.db, self.user, order, fake_ck("1199647239697")) + + collab.set_status(self.db, self.user, order, collab.STATUS_PENDING) + + assert order.status == collab.STATUS_PENDING + assert order.void_reason == "" + class TestCollabKeywordSearch(TestCollabBase): """列表搜索:一个输入框打穿订单号 / 店铺 / 绑定账号·角色 / 平台区服 / 虎牙号 / 礼包 / 备注。""" diff --git a/web/backend/routers/huya/collab.py b/web/backend/routers/huya/collab.py index e0b232f..c0bf462 100644 --- a/web/backend/routers/huya/collab.py +++ b/web/backend/routers/huya/collab.py @@ -267,6 +267,9 @@ def create_external_order( ): """新建一行(可同时贴一份外部 CK)。订单号唯一,重复会 409。 + ``draft=true``:先建单后补资料 —— 订单号 + 店铺即可,状态进「待补资料」, + CK / 礼包名 / 主页截图稍后在详情里补(不跑探针)。 + ``require_verified``(默认 true):人工新建表单的严格模式 —— 订单编号 / 店铺 / CK / 礼包名 / 主页截图必填,且 CK 必须探针通并读到绑定角色,否则 400 (粘贴导入走另一个接口,不受影响)。 @@ -284,6 +287,7 @@ def create_external_order( home_images=req.home_images, remark=req.remark, require_verified=req.require_verified, + draft=req.draft, ) except collab.CollabError as exc: raise _http_error(exc) from exc diff --git a/web/backend/schemas.py b/web/backend/schemas.py index 74d1b05..3e89bed 100644 --- a/web/backend/schemas.py +++ b/web/backend/schemas.py @@ -1170,11 +1170,15 @@ class HuyaExternalOrderCreate(BaseModel): ``require_verified``(默认开):人工新建表单的严格模式 —— 订单编号 / 店铺 / CK / 礼包名 / **主页截图**必填,且 CK 必须探针通并读到绑定角色;粘贴导入不走这条路。 + + ``draft``(默认关):先建单后补资料 —— 只登记订单号 + 店铺,状态进「待补资料」, + CK / 礼包名 / 主页截图都可以稍后在详情里补(2026-10-17 用户需求)。 """ order_no: str = Field(..., min_length=1, max_length=64) shop: str = Field("", max_length=64) require_verified: bool = True + draft: bool = False owner_id: int | None = None ck_text: str = "" bind_douyu_account: str = Field("", max_length=128) diff --git a/web/backend/services/huya_collab_service.py b/web/backend/services/huya_collab_service.py index 9d04d57..cdc46a3 100644 --- a/web/backend/services/huya_collab_service.py +++ b/web/backend/services/huya_collab_service.py @@ -51,6 +51,9 @@ from .huya_service import build_web_cookie_from_raw, parse_huya_cookie_line # 业务状态机(一单卡在哪一步) # -------------------------------------------------------------------------- +# 「待补资料」是流程链的起点:只登记订单号 + 店铺,CK / 礼包 / 主页截图等客户资料 +# 到了再补(2026-10-17 用户需求)。资料齐全后才能进「待处理」。 +STATUS_INCOMPLETE = "incomplete" STATUS_PENDING = "pending" STATUS_BOUND = "bound" STATUS_RECHARGED = "recharged" @@ -60,6 +63,7 @@ STATUS_FAILED = "failed" STATUS_VOID = "void" ORDER_STATUSES: tuple[str, ...] = ( + STATUS_INCOMPLETE, STATUS_PENDING, STATUS_BOUND, STATUS_RECHARGED, @@ -70,6 +74,7 @@ ORDER_STATUSES: tuple[str, ...] = ( ) ORDER_STATUS_LABELS = { + STATUS_INCOMPLETE: "待补资料", STATUS_PENDING: "待处理", # 人工核对过「探针读到的绑定角色」与「主页截图」一致,才允许进这一步(见 _assert_can_enter_bound) STATUS_BOUND: "已确认绑定", @@ -84,6 +89,7 @@ ORDER_STATUS_LABELS = { # 流程链(时间戳同步与「能不能向前走」都只看这条链);failed / void 是**旁路**终态。 STATUS_FLOW: tuple[str, ...] = ( + STATUS_INCOMPLETE, STATUS_PENDING, STATUS_BOUND, STATUS_RECHARGED, @@ -93,6 +99,7 @@ STATUS_FLOW: tuple[str, ...] = ( # 「向前一步」的下一站;done 是终点,failed / void 是任意态可达的旁路,都不在这条链上。 NEXT_STATUS = { + STATUS_INCOMPLETE: STATUS_PENDING, STATUS_PENDING: STATUS_BOUND, STATUS_BOUND: STATUS_RECHARGED, STATUS_RECHARGED: STATUS_SCRIPTED, @@ -101,11 +108,12 @@ NEXT_STATUS = { # 阶段序号:用来同步时间戳(回退时要清掉后面阶段的时间戳,否则状态与时间戳互相矛盾)。 STAGE_INDEX = { - STATUS_PENDING: 0, - STATUS_BOUND: 1, - STATUS_RECHARGED: 2, - STATUS_SCRIPTED: 3, - STATUS_DONE: 4, + STATUS_INCOMPLETE: 0, + STATUS_PENDING: 1, + STATUS_BOUND: 2, + STATUS_RECHARGED: 3, + STATUS_SCRIPTED: 4, + STATUS_DONE: 5, } STATUS_TIMESTAMP_FIELDS = { @@ -451,6 +459,26 @@ def _assert_can_enter_bound(order: HuyaExternalOrder) -> None: ) +def _assert_can_leave_incomplete(order: HuyaExternalOrder) -> None: + """离开「待补资料」的前置校验:客户资料必须齐全。 + + 只在 ``incomplete`` 这个起点用:CK / 礼包名 / 主页截图至少各一份, + 否则「待处理」只是个空壳(后面谁都处理不了)。作废 / 判失败是旁路,不拦。 + """ + missing: list[str] = [] + if not str(order.cookie or "").strip(): + missing.append("外部 CK") + if not str(order.gift_name or "").strip(): + missing.append("礼包名") + if not images_of(order, "home_images"): + missing.append("主页截图(至少 1 张)") + if missing: + raise CollabError( + f"「{ORDER_STATUS_LABELS[STATUS_INCOMPLETE]}」还不能往前改,缺少:{'、'.join(missing)}", + code="incomplete_precondition", + ) + + def _sync_stage_timestamps( order: HuyaExternalOrder, target: str, now: datetime ) -> None: @@ -520,6 +548,13 @@ def set_status( code="status_from_void", ) + if from_status == STATUS_INCOMPLETE and target not in ( + STATUS_VOID, + STATUS_FAILED, + ): + # 「待补资料」是起点:客户资料没齐之前不许往前,否则下一个环节无从下手。 + _assert_can_leave_incomplete(order) + if target == STATUS_BOUND: _assert_can_enter_bound(order) @@ -2045,6 +2080,7 @@ def create_order( home_images: Any = None, remark: str = "", require_verified: bool = False, + draft: bool = False, client: Any | None = None, commit: bool = True, ) -> HuyaExternalOrder: @@ -2053,6 +2089,10 @@ def create_order( **负责人不填就是当前登录用户**(创建人)—— 新建时不用选,谁建的单归谁; 要转给别人在列表里点一下改负责人即可(负责人不是权限边界,见设计文档 §7.1)。 + ``draft=True``(**先建单后补资料**):只要求订单号,店铺也允许先空着; + 状态直接进「待补资料」,CK / 礼包名 / 主页截图都可以等客户资料到了再补。 + 这条路径不跑探针、不做严格校验(2026-10-17 用户需求)。 + ``require_verified=True``(**人工新建表单用**,粘贴导入不传)时按"人工必填项"严格校验: **订单编号 / 店铺 / CK / 礼包名 / 主页截图**都不许空,且 CK 必须探针通、 **读到绑定角色**才让建;读不到角色直接 400 —— 这条在服务层做,前端只是体验(AGENTS §2)。 @@ -2065,6 +2105,10 @@ def create_order( _assert_order_no_available(db, order_no) home_images_keys = normalize_images(home_images, field="home_images") + if draft: + # 草稿单不验 CK、不跑探针:先落一行占住订单号,资料到了在详情里补。 + require_verified = False + order = HuyaExternalOrder( order_no=order_no, shop=clean_text(shop, field="shop"), @@ -2074,7 +2118,7 @@ def create_order( remark=clean_text(remark, field="remark"), bind_images=[], home_images=home_images_keys, - status=STATUS_PENDING, + status=STATUS_INCOMPLETE if draft else STATUS_PENDING, ck_status=CK_UNVERIFIED, created_by=current.id, updated_by=current.id, @@ -2140,6 +2184,7 @@ def create_order( "gift_name": order.gift_name, "bind_game_role": order.bind_game_role or "", "home_images": len(order.home_images or []), + "draft": bool(draft), # 这个虎牙号还挂在哪些单上(允许同一份 CK 服务多单,留个痕) "uid_reused_in": uid_reused, }, diff --git a/web/frontend/src/api/types.ts b/web/frontend/src/api/types.ts index 8e830c4..0708c2a 100644 --- a/web/frontend/src/api/types.ts +++ b/web/frontend/src/api/types.ts @@ -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 { diff --git a/web/frontend/src/components/HuyaCollabHomeImageUploader.tsx b/web/frontend/src/components/HuyaCollabHomeImageUploader.tsx index 857988d..23c7852 100644 --- a/web/frontend/src/components/HuyaCollabHomeImageUploader.tsx +++ b/web/frontend/src/components/HuyaCollabHomeImageUploader.tsx @@ -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 ( {keys.length === 0 ? ( - 还没有图(必填:至少 1 张,第一张作封面) + + {optional ? '还没有图(可稍后补,第一张作封面)' : '还没有图(必填:至少 1 张,第一张作封面)'} + ) : ( previews[key]?.url || `/api/files/object?key=${encodeURIComponent(key)}`)} diff --git a/web/frontend/src/pages/HuyaCollabPage.tsx b/web/frontend/src/pages/HuyaCollabPage.tsx index d3740be..c2131df 100644 --- a/web/frontend/src/pages/HuyaCollabPage.tsx +++ b/web/frontend/src/pages/HuyaCollabPage.tsx @@ -55,6 +55,8 @@ function hasTextInputFocus(): boolean { } const STATUS_META: Record = { + // 只登记了订单号 + 店铺,客户资料(CK / 礼包 / 主页截图)到了再补 + incomplete: { label: '待补资料', color: 'gold' }, pending: { label: '待处理', color: 'default' }, // 人工核对过「探针读到的绑定角色」与「主页截图」一致 bound: { label: '已确认绑定', color: 'blue' }, @@ -74,7 +76,8 @@ const VERDICT_META: Record = { 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 = { 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; + 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): 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>(); + const [draftRestoredAt, setDraftRestoredAt] = useState(null); + const draftSaveTimer = useRef(null); const [ckPreview, setCkPreview] = useState(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) => { + 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) => { + 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); + 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 会换行参差不齐 */} + + + + + @@ -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 && ( + {detail.status === 'incomplete' && ( + { + const missing = [ + !detail.has_cookie ? '外部 CK' : '', + !detail.gift_name ? '礼包名' : '', + detail.home_images.length === 0 ? '主页截图' : '', + ].filter(Boolean); + return missing.length > 0 + ? `还缺:${missing.join('、')}。补齐后点下面的「标记为待处理」按钮即可进入正常流程。` + : '资料已齐,点下面的「标记为待处理」按钮进入正常流程。'; + })()} + /> + )} 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) => { + onValuesChange={(changed: Record, allValues: Record) => { if (typeof changed.ck_text === 'string') scheduleCkPreview(changed.ck_text); + persistCreateDraft(allValues); }} > + {draftRestoredAt && ( + + 清空草稿 + + )} + /> + )} + + 只填订单号 + 店铺也能直接点右下角「新建」:资料没填全会自动按「待补资料」创建, + CK / 礼包 / 主页截图之后在详情里补即可。填写内容会自动暂存在本机,关闭 / 刷新后再打开会恢复。 + @@ -1811,9 +2052,8 @@ export default function HuyaCollabPage() { {/* 高度随内容走:短 CK 就 3 行,别固定 5 行把弹窗撑高 */} ) : ( )} @@ -1869,9 +2109,8 @@ export default function HuyaCollabPage() { 0 ? `礼包列表缓存于 ${giftCacheLabel}(${giftOptions.length} 个)` : undefined)} @@ -1879,7 +2118,7 @@ export default function HuyaCollabPage() { > ({ value: gift.name, label: [ @@ -1918,17 +2157,10 @@ export default function HuyaCollabPage() { ( - value && value.length > 0 - ? Promise.resolve() - : Promise.reject(new Error('请至少上传 1 张主页截图')) - ), - }]} + label="主页截图(可稍后补,第一张作封面)" + tooltip="支持点击选择、拖拽或 Ctrl/Cmd+V 粘贴上传;第一张作封面,绑定图不用再传。不传也能先建单,之后在详情里补。" > - +