feat(huya): 协作台账支持待补资料一键建单与表单暂存
This commit is contained in:
+10
-6
@@ -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`):
|
||||
|
||||
|
||||
@@ -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):
|
||||
"""列表搜索:一个输入框打穿订单号 / 店铺 / 绑定账号·角色 / 平台区服 / 虎牙号 / 礼包 / 备注。"""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 +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,
|
||||
},
|
||||
|
||||
@@ -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)}`)}
|
||||
|
||||
@@ -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}>
|
||||
|
||||
Reference in New Issue
Block a user