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
+10 -6
View File
@@ -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`):
+53
View File
@@ -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):
"""列表搜索:一个输入框打穿订单号 / 店铺 / 绑定账号·角色 / 平台区服 / 虎牙号 / 礼包 / 备注。"""
+4
View File
@@ -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
+4
View File
@@ -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)
+51 -6
View File
@@ -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,
},
+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}>