feat: 重做协作台账状态机并支持充值核账

This commit is contained in:
yml2213
2026-10-19 16:06:02 +08:00
parent 781b27e63a
commit 3a6649a054
15 changed files with 2330 additions and 363 deletions
@@ -0,0 +1,137 @@
"""虎牙协作台账:待充值 / 积分异常 + 礼包积分快照。
2026-10-19 用户拍板:
- 删除 pending(待处理)/ bound(已确认绑定);
- 资料齐全且 CK 有效自动进 to_recharge(待充值);
- 充值完成按同一 bind_game_account 整组核账,结余为负或超过阈值进
points_error(积分异常)。
同时新增 required_points 与最近一次核账快照字段。可重复执行:逐列检查。
"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "20261019_0061"
down_revision: str | None = "20261019_0060"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
TABLE = "huya_external_orders"
def _columns(bind) -> set[str]:
return {column["name"] for column in sa.inspect(bind).get_columns(TABLE)}
def upgrade() -> None:
bind = op.get_bind()
if TABLE not in set(sa.inspect(bind).get_table_names()):
return
columns = _columns(bind)
additions = {
"required_points": sa.Column("required_points", sa.Integer(), nullable=True),
"points_checked_at": sa.Column(
"points_checked_at", sa.DateTime(), nullable=True
),
"points_balance": sa.Column("points_balance", sa.Integer(), nullable=True),
"points_required_total": sa.Column(
"points_required_total", sa.Integer(), nullable=True
),
"points_surplus": sa.Column("points_surplus", sa.Integer(), nullable=True),
"points_threshold": sa.Column("points_threshold", sa.Integer(), nullable=True),
"points_check_reason": sa.Column(
"points_check_reason",
sa.String(length=16),
nullable=False,
server_default="",
),
"points_check_message": sa.Column(
"points_check_message",
sa.String(length=512),
nullable=False,
server_default="",
),
"points_check_detail": sa.Column(
"points_check_detail", sa.JSON(), nullable=True
),
}
for name, column in additions.items():
if name not in columns:
op.add_column(TABLE, column)
op.alter_column(
TABLE,
"status",
existing_type=sa.String(length=16),
existing_nullable=False,
server_default="incomplete",
)
# 旧 pending/bound:资料齐全的转待充值,其余退回待补资料。
bind.execute(
sa.text(
f"""
UPDATE {TABLE}
SET status = 'to_recharge'
WHERE status IN ('pending', 'bound')
AND cookie IS NOT NULL AND cookie <> ''
AND ck_status = 'valid'
AND bind_game_account IS NOT NULL AND bind_game_account <> ''
AND bind_game_role IS NOT NULL AND bind_game_role <> ''
AND gift_name IS NOT NULL AND gift_name <> ''
AND home_images IS NOT NULL
AND JSON_LENGTH(home_images) > 0
"""
)
)
bind.execute(
sa.text(
f"""
UPDATE {TABLE}
SET status = 'incomplete'
WHERE status IN ('pending', 'bound')
"""
)
)
def downgrade() -> None:
bind = op.get_bind()
if TABLE not in set(sa.inspect(bind).get_table_names()):
return
# 新状态尽量映射回旧口径;历史 pending 不恢复。
bind.execute(
sa.text(f"UPDATE {TABLE} SET status = 'bound' WHERE status = 'to_recharge'")
)
bind.execute(
sa.text(
f"UPDATE {TABLE} SET status = 'recharged' WHERE status = 'points_error'"
)
)
op.alter_column(
TABLE,
"status",
existing_type=sa.String(length=16),
existing_nullable=False,
server_default="pending",
)
columns = _columns(bind)
for column in (
"points_check_detail",
"points_check_message",
"points_check_reason",
"points_threshold",
"points_surplus",
"points_required_total",
"points_balance",
"points_checked_at",
"required_points",
):
if column in columns:
op.drop_column(TABLE, column)
+21 -1
View File
@@ -1157,11 +1157,14 @@ class HuyaExternalOrder(Base):
# ---- Excel G 列(表头写的「备注」,实际是礼包名)+ 自由备注 ----
gift_name: Mapped[str] = mapped_column(String(128), default="", server_default="")
# 购买该礼包所需积分快照(来自积分中心 queryAllocation 的 tag75.tag0.tag2)。
# 积分会随活动/商品变化,必须在下单时存一份,不能每次按礼包名现查。
required_points: Mapped[int | None] = mapped_column(Integer, nullable=True)
remark: Mapped[str] = mapped_column(String(512), default="", server_default="")
# ---- 业务进度 ----
status: Mapped[str] = mapped_column(
String(16), default="pending", server_default="pending", index=True
String(16), default="incomplete", server_default="incomplete", index=True
)
bound_at: Mapped[datetime | None] = mapped_column(UTCDateTime, nullable=True)
recharged_at: Mapped[datetime | None] = mapped_column(UTCDateTime, nullable=True)
@@ -1169,6 +1172,23 @@ class HuyaExternalOrder(Base):
done_at: Mapped[datetime | None] = mapped_column(UTCDateTime, nullable=True)
void_reason: Mapped[str] = mapped_column(String(256), default="", server_default="")
# ---- 充值核账快照(按 bind_game_account 整组计算)----
points_checked_at: Mapped[datetime | None] = mapped_column(
UTCDateTime, nullable=True
)
points_balance: Mapped[int | None] = mapped_column(Integer, nullable=True)
points_required_total: Mapped[int | None] = mapped_column(Integer, nullable=True)
points_surplus: Mapped[int | None] = mapped_column(Integer, nullable=True)
points_threshold: Mapped[int | None] = mapped_column(Integer, nullable=True)
points_check_reason: Mapped[str] = mapped_column(
String(16), default="", server_default=""
)
points_check_message: Mapped[str] = mapped_column(
String(512), default="", server_default=""
)
# 组内订单、各 CK 余额、缺口/多余等明细(JSON,只做展示与审计)
points_check_detail: Mapped[dict | None] = mapped_column(JSON, nullable=True)
# ---- 归档(与业务状态正交:归档后仍是「抢购成功」,只是默认不出现在主台账)----
# NULL = 未归档;软隐藏不删数据,取消归档即可恢复。
archived_at: Mapped[datetime | None] = mapped_column(
+26 -2
View File
@@ -57,6 +57,8 @@ from ...schemas import (
HuyaExternalOrderListOut,
HuyaExternalOrderOut,
HuyaExternalOrderOwnerOut,
HuyaExternalOrderRechargeOut,
HuyaExternalOrderRechargeRequest,
HuyaExternalOrderStatusBatchOut,
HuyaExternalOrderStatusBatchRequest,
HuyaExternalOrderStatusRequest,
@@ -317,6 +319,7 @@ def create_external_order(
ck_text=req.ck_text,
bind_douyu_account=req.bind_douyu_account,
gift_name=req.gift_name,
required_points=req.required_points,
home_images=req.home_images,
remark=req.remark,
require_verified=req.require_verified,
@@ -374,6 +377,27 @@ def preview_external_order_ck(
return preview
@router.post("/external-orders/recharge", response_model=HuyaExternalOrderRechargeOut)
def recharge_external_orders(
req: HuyaExternalOrderRechargeRequest,
db: Session = Depends(get_db),
current: User = Depends(require_permission("huya:collab:edit")),
):
"""充值核账:按绑定账号整组校验 CK + 积分,整组落「已充值 / 积分异常」。"""
try:
orders = collab.list_orders_by_ids(db, req.ids)
result = collab.check_recharge_groups(
db,
current,
orders,
threshold=req.threshold,
)
except collab.CollabError as exc:
raise _http_error(exc) from exc
_publish_change(current, scope="orders", action="orders.recharge")
return result
@router.post(
"/external-orders/status/batch", response_model=HuyaExternalOrderStatusBatchOut
)
@@ -384,8 +408,8 @@ def batch_update_external_order_status(
):
"""勾选多行一次改业务状态(单个也走这里)。
人工切换允许**往前跳步**(待处理 → 已上脚本),回退要带 ``rollback=true``;
某一行前置校验没过(如没主页截图不能进「已确认绑定」)只记在这一条的 ``message`` 里。
人工切换允许**往前跳步**(待补资料 → 已上脚本),回退要带 ``rollback=true``;
「已充值 / 积分异常」不走这里,必须用 ``POST /external-orders/recharge`` 整组核账。
"""
try:
orders = collab.list_orders_by_ids(db, req.ids)
+56 -2
View File
@@ -1141,7 +1141,7 @@ class HuyaCollabBuyerOrderOut(BaseModel):
shop: str = ""
gift_name: str = ""
uid: str = ""
status: str = "pending"
status: str = "incomplete"
status_label: str = ""
created_at: datetime | None = None
@@ -1183,6 +1183,7 @@ class HuyaExternalOrderCreate(BaseModel):
ck_text: str = ""
bind_douyu_account: str = Field("", max_length=128)
gift_name: str = Field("", max_length=128)
required_points: int | None = Field(None, ge=0)
# 主页截图(object_key 数组,第一张作封面)。绑定图不再收:绑定关系由探针自动读,
# 人工截图没有意义(2026-10-16 用户拍板)。
home_images: list[str] = Field(default_factory=list)
@@ -1198,6 +1199,7 @@ class HuyaExternalOrderUpdate(BaseModel):
bind_douyu_account: str | None = Field(None, max_length=128)
bind_douyu_uid: str | None = Field(None, max_length=32)
gift_name: str | None = Field(None, max_length=128)
required_points: int | None = Field(None, ge=0)
remark: str | None = Field(None, max_length=512)
# 乐观锁:客户端拿到的版本号。不一致说明别人先改了 → 409。
expected_version: int | None = None
@@ -1325,6 +1327,49 @@ class HuyaExternalOrderStatusBatchOut(BaseModel):
results: list[HuyaExternalOrderStatusBatchItemOut] = Field(default_factory=list)
class HuyaExternalOrderRechargeRequest(BaseModel):
"""充值核账请求:按绑定账号整组核账,阈值默认 100。"""
ids: list[int] = Field(default_factory=list)
threshold: int = Field(100, ge=0, le=1_000_000)
class HuyaExternalOrderRechargeOrderOut(BaseModel):
id: int
order_no: str = ""
status: str = ""
status_label: str = ""
class HuyaExternalOrderRechargeGroupOut(BaseModel):
group_key: str = ""
status: str = ""
status_label: str = ""
balance: int | None = None
required_total: int | None = None
surplus: int | None = None
threshold: int = 100
reason: str = ""
message: str = ""
order_ids: list[int] = Field(default_factory=list)
orders: list[HuyaExternalOrderRechargeOrderOut] = Field(default_factory=list)
class HuyaExternalOrderRechargeFailureOut(BaseModel):
id: int
order_no: str = ""
message: str = ""
class HuyaExternalOrderRechargeOut(BaseModel):
total: int
updated: int
failed: int
threshold: int = 100
groups: list[HuyaExternalOrderRechargeGroupOut] = Field(default_factory=list)
failures: list[HuyaExternalOrderRechargeFailureOut] = Field(default_factory=list)
class HuyaExternalOrderArchiveItemOut(BaseModel):
"""归档/取消归档里的一行结论。"""
@@ -1534,15 +1579,24 @@ class HuyaExternalOrderOut(BaseModel):
home_image_thumb_urls: list[str] = Field(default_factory=list)
gift_name: str = ""
required_points: int | None = None
remark: str = ""
status: str = "pending"
status: str = "incomplete"
status_label: str = ""
next_status: str | None = None
bound_at: datetime | None = None
recharged_at: datetime | None = None
scripted_at: datetime | None = None
done_at: datetime | None = None
points_checked_at: datetime | None = None
points_balance: int | None = None
points_required_total: int | None = None
points_surplus: int | None = None
points_threshold: int | None = None
points_check_reason: str = ""
points_check_message: str = ""
points_check_detail: dict[str, Any] | None = None
void_reason: str = ""
# 归档是可见性状态,不影响上面的业务状态。
File diff suppressed because it is too large Load Diff
+8
View File
@@ -52,6 +52,8 @@ import type {
HuyaCollabOrderQuery,
HuyaCollabOrderUpdate,
HuyaCollabOwner,
HuyaCollabRechargeRequest,
HuyaCollabRechargeResult,
HuyaCollabShop,
HuyaCollabShopCreate,
HuyaCollabShopUpdate,
@@ -284,6 +286,12 @@ export const huyaApi = {
'/huya/external-orders/status/batch',
data,
),
/** 充值核账:按绑定账号整组校验 CK + 积分,整组落「已充值 / 积分异常」 */
rechargeCollabOrders: (data: HuyaCollabRechargeRequest) =>
api.post<HuyaCollabRechargeResult, HuyaCollabRechargeResult>(
'/huya/external-orders/recharge',
data,
),
/** 归档抢购成功的订单(可带备注;archive_filtered 时按当前筛选批量归档) */
archiveCollabOrders: (data: HuyaCollabArchiveRequest) =>
api.post<HuyaCollabArchiveResult, HuyaCollabArchiveResult>(
+60 -3
View File
@@ -1202,11 +1202,13 @@ export interface AccountExchangeRecords {
export type HuyaCollabStatus =
/** 只登记了订单号 + 店铺,CK / 礼包 / 主页截图等客户资料还没到 */
| 'incomplete'
| 'pending'
| 'bound'
/** 资料齐全且 CK 有效;等充值 */
| 'to_recharge'
| 'recharged'
| 'scripted'
| 'done'
/** 充值完成但积分不足或超出阈值(可重新核账恢复) */
| 'points_error'
/** 旁路终态:抢购后查兑换记录没查到这一单的礼包(人工确认后可作废或回退重试) */
| 'failed'
| 'void';
@@ -1252,6 +1254,8 @@ export interface HuyaCollabOrder {
bind_image_thumb_urls: string[];
home_image_thumb_urls: string[];
gift_name: string;
/** 购买该礼包所需积分快照(来自积分中心 queryAllocation) */
required_points: number | null;
remark: string;
status: HuyaCollabStatus;
status_label: string;
@@ -1260,6 +1264,14 @@ export interface HuyaCollabOrder {
recharged_at: string | null;
scripted_at: string | null;
done_at: string | null;
points_checked_at: string | null;
points_balance: number | null;
points_required_total: number | null;
points_surplus: number | null;
points_threshold: number | null;
points_check_reason: string;
points_check_message: string;
points_check_detail: Record<string, unknown> | null;
void_reason: string;
/** 归档是可见性状态,不改业务状态;归档行只读 */
is_archived: boolean;
@@ -1430,6 +1442,49 @@ export interface HuyaCollabStatusBatchResult {
results: HuyaCollabStatusBatchItem[];
}
/** 充值核账:按绑定账号整组核账 */
export interface HuyaCollabRechargeRequest {
ids: number[];
/** 允许结余上限,默认 100 */
threshold: number;
}
export interface HuyaCollabRechargeOrder {
id: number;
order_no: string;
status: HuyaCollabStatus;
status_label: string;
}
export interface HuyaCollabRechargeGroup {
group_key: string;
status: HuyaCollabStatus | '';
status_label: string;
balance: number | null;
required_total: number | null;
surplus: number | null;
threshold: number;
reason: string;
message: string;
order_ids: number[];
orders: HuyaCollabRechargeOrder[];
}
export interface HuyaCollabRechargeFailure {
id: number;
order_no: string;
message: string;
}
export interface HuyaCollabRechargeResult {
total: number;
updated: number;
failed: number;
threshold: number;
groups: HuyaCollabRechargeGroup[];
failures: HuyaCollabRechargeFailure[];
}
/** 归档请求:ids 为空且 archive_filtered=true 时按筛选范围归档 */
export interface HuyaCollabArchiveRequest {
ids?: number[];
@@ -1540,6 +1595,7 @@ export interface HuyaCollabOrderCreate {
ck_text?: string;
bind_douyu_account?: string;
gift_name?: string;
required_points?: number | null;
/** 主页截图(object_key 数组,第一张作封面);严格模式下必填至少 1 张 */
home_images?: string[];
remark?: string;
@@ -1556,6 +1612,7 @@ export interface HuyaCollabOrderUpdate {
bind_douyu_account?: string;
bind_douyu_uid?: string;
gift_name?: string;
required_points?: number | null;
remark?: string;
expected_version?: number;
}
@@ -1627,7 +1684,7 @@ export interface FileUploadResult {
size: number;
}
/** 活动礼包(用一份有效 CK 现拉) */
/** 积分中心商品(用一份有效 CK 现拉;PlayMallUI.queryAllocation) */
export interface HuyaCollabGift {
product_id: string;
name: string;
+322 -91
View File
@@ -9,14 +9,14 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
Alert, App, Badge, Button, Card, Checkbox, Col, Descriptions, Divider, Drawer, Dropdown,
Empty, Form, Image, Input, Modal, Popconfirm, Popover, Progress, Radio, Row, Select, Space, Statistic,
Empty, Form, Image, Input, InputNumber, 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, FileAddOutlined,
FileExcelOutlined, GiftOutlined, InboxOutlined, LinkOutlined, PlusOutlined, ReloadOutlined, SafetyCertificateOutlined,
ShopOutlined, StopOutlined, SwapOutlined, SyncOutlined, UndoOutlined,
AuditOutlined, CheckCircleOutlined, CloudUploadOutlined, CopyOutlined, DeleteOutlined, EditOutlined,
FileAddOutlined, FileExcelOutlined, GiftOutlined, InboxOutlined, LinkOutlined, PlusOutlined, ReloadOutlined,
SafetyCertificateOutlined, ShopOutlined, StopOutlined, SwapOutlined, SyncOutlined, UndoOutlined,
} from '@ant-design/icons';
import {
fileApi,
@@ -70,13 +70,14 @@ function readRealtimePaused(): boolean {
const STATUS_META: Record<HuyaCollabStatus, { label: string; color: string }> = {
// 只登记了订单号 + 店铺,客户资料(CK / 礼包 / 主页截图)到了再补
incomplete: { label: '待补资料', color: 'gold' },
pending: { label: '待处理', color: 'default' },
// 人工核对过「探针读到的绑定角色」与「主页截图」一致
bound: { label: '已确认绑定', color: 'blue' },
// 资料齐全且 CK 有效;等充值
to_recharge: { label: '待充值', color: 'orange' },
recharged: { label: '已充值', color: 'geekblue' },
scripted: { label: '已上脚本', color: 'purple' },
// 抢购成功(枚举值仍是 done):从「已上脚本」查到兑换记录 → 自动进这一步
done: { label: '抢购成功', color: 'success' },
// 充值完成但积分不足/超出阈值
points_error: { label: '积分异常', color: 'volcano' },
// 旁路终态:抢购后查兑换记录没查到这一单的礼包(枚举值仍是 failed)
failed: { label: '抢购失败', color: 'red' },
void: { label: '已作废', color: 'error' },
@@ -92,9 +93,8 @@ const VERDICT_META: Record<string, { label: string; color: string }> = {
/** 「改状态」能选的目标(顺序 = 业务流程顺序,回退要显式勾选)
* 「待补资料」只作为新建起点,不放进「改状态」:它只能通过「回退状态」进入。 */
const STATUS_ORDER_OPTIONS: { value: HuyaCollabStatus; label: string }[] = [
{ value: 'pending', label: '待处理' },
{ value: 'bound', label: '已确认绑定' },
{ value: 'recharged', label: '已充值' },
{ value: 'to_recharge', label: '待充值' },
{ value: 'recharged', label: '已充值(核账)' },
{ value: 'scripted', label: '已上脚本' },
{ value: 'done', label: '抢购成功' },
{ value: 'failed', label: '抢购失败' },
@@ -104,9 +104,8 @@ const STATUS_ORDER_OPTIONS: { value: HuyaCollabStatus; label: string }[] = [
/** 「回退」能选的目标:流程链(含「待补资料」;要进「已作废 / 抢购失败」走「改状态」或「作废」入口) */
const FLOW_STATUS_OPTIONS: { value: HuyaCollabStatus; label: string }[] = [
{ value: 'incomplete', label: '待补资料' },
{ value: 'pending', label: '待处理' },
{ value: 'bound', label: '已确认绑定' },
{ value: 'recharged', label: '已充值' },
{ value: 'to_recharge', label: '待充值' },
{ value: 'recharged', label: '已充值(核账)' },
{ value: 'scripted', label: '已上脚本' },
{ value: 'done', label: '抢购成功' },
];
@@ -163,8 +162,21 @@ const SLOT_META: Record<HuyaCollabImageSlot, { label: string; keysKey: 'bind_ima
const IMPORT_PREVIEW_ROWS = 20;
// 「礼包列表」的本地缓存键:一个活动期间就那一批礼包,没必要每贴一份 CK 都去虎牙拉一次;
// 要更新就点「强制刷新礼包」(活动换 SID 后必点一次)。
const GIFTS_CACHE_KEY = 'huya.collab.gifts';
// v2 = 2026-10-19 切到积分中心 queryAllocation 后换键,避免继续用旧 getActPrizeList 的缓存。
const GIFTS_CACHE_KEY = 'huya.collab.gifts.v2';
const RECHARGE_THRESHOLD_KEY = 'huya.collab.rechargeThreshold';
const RECHARGE_THRESHOLD_DEFAULT = 100;
function readRechargeThreshold(): number {
try {
const raw = window.localStorage.getItem(RECHARGE_THRESHOLD_KEY);
if (raw === null || raw === '') return RECHARGE_THRESHOLD_DEFAULT;
const value = Number(raw);
return Number.isFinite(value) && value >= 0 ? value : RECHARGE_THRESHOLD_DEFAULT;
} catch {
return RECHARGE_THRESHOLD_DEFAULT;
}
}
const MAX_IMAGES_PER_SLOT = 20;
/** 读礼包缓存(读不到 / 解析坏 / 空列表都当没缓存) */
@@ -201,6 +213,7 @@ interface CreateDraft {
shop: string;
ck_text: string;
gift_name: string;
required_points: number | null;
remark: string;
home_images: string[];
saved_at: number;
@@ -229,6 +242,7 @@ function readCreateDraft(userId?: number | null): CreateDraft | null {
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 : '',
required_points: typeof parsed.required_points === 'number' ? parsed.required_points : null,
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')
@@ -287,6 +301,7 @@ interface SupplementDraft {
shop: string;
ck_text: string;
gift_name: string;
required_points: number | null;
remark: string;
home_images: string[];
saved_at: number;
@@ -296,6 +311,7 @@ interface SupplementFormValues {
shop: string;
ck_text?: string;
gift_name?: string;
required_points?: number | null;
remark?: string;
home_images?: string[];
}
@@ -320,6 +336,7 @@ function readSupplementDraft(userId: number | null | undefined, orderId: number)
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 : '',
required_points: typeof parsed.required_points === 'number' ? parsed.required_points : null,
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')
@@ -510,6 +527,8 @@ export default function HuyaCollabPage() {
const [realtimePending, setRealtimePending] = useState(false);
const [realtimePaused, setRealtimePaused] = useState(readRealtimePaused);
const [selectedIds, setSelectedIds] = useState<number[]>([]);
// 点顶部统计卡筛选后滚到列表,避免用户还要自己往下找
const listAnchorRef = useRef<HTMLDivElement | null>(null);
const [detail, setDetail] = useState<HuyaCollabOrder | null>(null);
const [timeline, setTimeline] = useState<HuyaCollabAuditItem[]>([]);
@@ -560,12 +579,16 @@ export default function HuyaCollabPage() {
order: HuyaCollabOrder; mode: 'void' | 'rollback' | 'set'; target?: HuyaCollabStatus;
} | null>(null);
const [statusReason, setStatusReason] = useState('');
const [rollbackTarget, setRollbackTarget] = useState<HuyaCollabStatus>('pending');
const [rollbackTarget, setRollbackTarget] = useState<HuyaCollabStatus>('to_recharge');
// 批量改状态(勾选多行一次切)
const [batchStatusOpen, setBatchStatusOpen] = useState(false);
const [batchStatusTarget, setBatchStatusTarget] = useState<HuyaCollabStatus>('bound');
const [batchStatusTarget, setBatchStatusTarget] = useState<HuyaCollabStatus>('to_recharge');
const [batchStatusReason, setBatchStatusReason] = useState('');
const [batchStatusRollback, setBatchStatusRollback] = useState(false);
// 充值核账弹窗:按 bind_game_account 整组核账,阈值默认 100
const [rechargeOpen, setRechargeOpen] = useState(false);
const [rechargeIds, setRechargeIds] = useState<number[]>([]);
const [rechargeThreshold, setRechargeThreshold] = useState(readRechargeThreshold);
// 归档弹窗:ids=null 表示按当前筛选结果批量归档
const [archiveOpen, setArchiveOpen] = useState(false);
const [archiveIds, setArchiveIds] = useState<number[] | null>(null);
@@ -634,7 +657,7 @@ export default function HuyaCollabPage() {
// 暂停后连 WebSocket 也不建:既不自动重拉,也不做断线兜底轮询。
const realtimeSyncEnabled = realtimeEnabled && !realtimePaused;
const collabModalOpen = Boolean(
createOpen || supplementTarget || detail || statusAction || batchStatusOpen || archiveOpen || ckTarget || ckExportOpen || shopModalOpen,
createOpen || supplementTarget || detail || statusAction || batchStatusOpen || rechargeOpen || archiveOpen || ckTarget || ckExportOpen || shopModalOpen,
);
const toggleRealtimeSync = (nextEnabled: boolean) => {
@@ -905,6 +928,11 @@ export default function HuyaCollabPage() {
/** 勾选多行一次改状态(往前可跳步;回退要在弹窗里勾「回退」) */
const submitBatchStatus = async () => {
if (batchStatusTarget === 'recharged') {
setBatchStatusOpen(false);
openRecharge(selectedIds);
return;
}
setBusy(true);
try {
const result = await huyaApi.batchUpdateCollabOrderStatus({
@@ -1095,6 +1123,7 @@ export default function HuyaCollabPage() {
shop: row.shop,
owner_id: row.owner_id,
gift_name: row.gift_name,
required_points: row.required_points,
remark: row.remark,
});
setTimeline([]);
@@ -1172,8 +1201,75 @@ export default function HuyaCollabPage() {
}
};
const openRecharge = (ids: number[]) => {
if (ids.length === 0) return;
setRechargeIds(ids);
setRechargeThreshold(readRechargeThreshold());
setRechargeOpen(true);
};
const submitRecharge = async () => {
if (rechargeIds.length === 0) return;
setBusy(true);
try {
const threshold = Math.max(0, Number(rechargeThreshold) || 0);
try {
window.localStorage.setItem(RECHARGE_THRESHOLD_KEY, String(threshold));
} catch {
// 隐私模式 / 配额满:记不住阈值不影响本次核账
}
const result = await huyaApi.rechargeCollabOrders({
ids: rechargeIds,
threshold,
});
const lines = result.groups.map((group) => ({
key: group.group_key,
ok: Boolean(group.status),
text: group.status
? `${group.group_key}:${group.message}`
: `${group.group_key}:${group.message}`,
}));
if (lines.length === 0) {
message.warning('没有可核账的订单');
} else {
modal.info({
title: `核账完成:更新 ${result.updated} 条,失败 ${result.failed} 条`,
width: 680,
content: (
<Space direction="vertical" style={{ width: '100%' }}>
{lines.map((line) => (
<Text key={line.key} type={line.ok ? undefined : 'danger'}>
{line.text}
</Text>
))}
{result.failures.map((item) => (
<Text key={`failure-${item.id}`} type="danger">
{item.order_no}:{item.message}
</Text>
))}
</Space>
),
});
}
setRechargeOpen(false);
setSelectedIds([]);
if (detail && rechargeIds.includes(detail.id)) {
void openDetail(detail);
}
void load();
} catch (e: unknown) {
message.error(getErrorMessage(e));
} finally {
setBusy(false);
}
};
const advance = async (row: HuyaCollabOrder) => {
if (!row.next_status) return;
if (row.next_status === 'recharged') {
openRecharge([row.id]);
return;
}
setBusy(true);
try {
applyUpdated(await huyaApi.updateCollabOrderStatus(row.id, {
@@ -1194,12 +1290,18 @@ export default function HuyaCollabPage() {
* 单行状态弹窗的提交(三种模式共用):
* - `void`:作废(必须填原因);
* - `rollback`:回退到选定状态(`rollback=true`,会清掉后面阶段的时间戳);
* - `set`:人工往前切,允许跳步(如 待处理 → 已上脚本),不用勾回退。
* - `set`:人工往前切,允许跳步(如 待补资料 → 已上脚本),不用勾回退。
*/
const submitStatusAction = async () => {
if (!statusAction) return;
const { order, mode, target } = statusAction;
const nextStatus: HuyaCollabStatus = mode === 'void' ? 'void' : (target ?? rollbackTarget);
if (nextStatus === 'recharged') {
setStatusAction(null);
setStatusReason('');
openRecharge([order.id]);
return;
}
// 服务层对「作废」一律要求原因(不管从哪个入口进来的)
if (nextStatus === 'void' && !statusReason.trim()) {
message.warning('作废必须填原因');
@@ -1317,6 +1419,7 @@ export default function HuyaCollabPage() {
shop: String(values.shop ?? ''),
ck_text: String(values.ck_text ?? ''),
gift_name: String(values.gift_name ?? ''),
required_points: typeof values.required_points === 'number' ? values.required_points : null,
remark: String(values.remark ?? ''),
home_images: images,
};
@@ -1324,6 +1427,7 @@ export default function HuyaCollabPage() {
payload.ck_text.trim()
|| payload.shop !== target.shop
|| payload.gift_name !== target.gift_name
|| payload.required_points !== target.required_points
|| payload.remark !== target.remark
|| payload.home_images.join('\n') !== target.home_images.join('\n'),
);
@@ -1353,6 +1457,7 @@ export default function HuyaCollabPage() {
shop: row.shop,
ck_text: '',
gift_name: row.gift_name,
required_points: row.required_points,
remark: row.remark,
home_images: row.home_images,
};
@@ -1389,6 +1494,7 @@ export default function HuyaCollabPage() {
shop: supplementTarget.shop,
ck_text: '',
gift_name: supplementTarget.gift_name,
required_points: supplementTarget.required_points,
remark: supplementTarget.remark,
home_images: supplementTarget.home_images,
});
@@ -1396,7 +1502,7 @@ export default function HuyaCollabPage() {
/**
* 保存「补资料」:字段 / 新 CK / 截图分别走各自接口(每个都有乐观锁),
* 新 CK 保存后立即跑一次只读探针;用户点「保存并进入待处理」时再校验资料完整性。
* 新 CK 保存后立即跑一次只读探针;用户点「保存并进入待充值」时再校验资料完整性。
*/
const submitSupplement = async (markPending = false) => {
if (!supplementTarget) return;
@@ -1412,9 +1518,11 @@ export default function HuyaCollabPage() {
const payload: HuyaCollabOrderUpdate = {};
const shop = String(values.shop ?? '').trim();
const giftName = String(values.gift_name ?? '').trim();
const requiredPoints = typeof values.required_points === 'number' ? values.required_points : null;
const remark = String(values.remark ?? '');
if (shop !== current.shop) payload.shop = shop;
if (giftName !== current.gift_name) payload.gift_name = giftName;
if (requiredPoints !== current.required_points) payload.required_points = requiredPoints;
if (remark !== current.remark) payload.remark = remark;
if (Object.keys(payload).length > 0) {
current = await huyaApi.updateCollabOrder(current.id, {
@@ -1442,7 +1550,7 @@ export default function HuyaCollabPage() {
});
}
// 没有换 CK、但准备进「待处理」时,先跑一次只读探针;避免拿一份没验证的旧 CK 往前推
// 没有换 CK、但准备检查资料是否齐全时,先跑一次只读探针;避免拿一份没验证的旧 CK 往前推
if (markPending && !ckText && current.has_cookie && current.ck_status !== 'valid') {
current = await huyaApi.verifyCollabOrder(current.id);
}
@@ -1453,16 +1561,10 @@ export default function HuyaCollabPage() {
supplementForm.setFieldValue('ck_text', '');
void load();
message.warning(
'资料已保存,但这份 CK 还没验证通过,暂时不能进入「待处理」;请先补 / 换一份有效 CK',
'资料已保存,但这份 CK 还没验证通过,暂时不能进入「待充值」;请先补 / 换一份有效 CK',
);
return;
}
if (markPending) {
current = await huyaApi.updateCollabOrderStatus(current.id, {
status: 'pending',
expected_version: current.version,
});
}
applyUpdated(current);
clearSupplementDraft(user?.id, supplementTarget.id);
@@ -1471,7 +1573,9 @@ export default function HuyaCollabPage() {
setSupplementTarget(null);
message.success(
markPending
? `已补齐资料,${current.order_no} 进入「待处理」`
? current.status === 'to_recharge'
? `资料已补齐,${current.order_no} 已进入「待充值」`
: `资料已保存;绑定账号/角色、礼包、主页截图齐全后会自动进入「待充值」`
: `已保存 ${current.order_no} 的资料`,
);
void load();
@@ -1541,6 +1645,7 @@ export default function HuyaCollabPage() {
shop: String(values.shop ?? ''),
ck_text: String(values.ck_text ?? ''),
gift_name: String(values.gift_name ?? ''),
required_points: typeof values.required_points === 'number' ? values.required_points : null,
remark: String(values.remark ?? ''),
home_images: Array.isArray(values.home_images)
? (values.home_images as unknown[]).filter((item): item is string => typeof item === 'string')
@@ -1600,6 +1705,7 @@ export default function HuyaCollabPage() {
const orderNo = String(values.order_no ?? '').trim();
const ckText = String(values.ck_text ?? '').trim();
const giftName = String(values.gift_name ?? '').trim();
const requiredPoints = typeof values.required_points === 'number' ? values.required_points : null;
const homeImages = (values.home_images as string[] | undefined) ?? [];
const missing: string[] = [];
if (!ckText) missing.push('外部 CK');
@@ -1613,6 +1719,7 @@ export default function HuyaCollabPage() {
ck_text: ckText,
// 「绑定斗鱼号」不再录入:绑了谁由探针读(bind_game_*),人工那一列已下线
gift_name: giftName,
required_points: requiredPoints,
home_images: homeImages,
remark: String(values.remark ?? ''),
draft,
@@ -1627,7 +1734,7 @@ export default function HuyaCollabPage() {
// 首次建草稿单给一次明确提示:以后直接点「新建」就行,不用先勾任何东西
message.info(
`已按「待补资料」创建 ${created.order_no}:${reason}。`
+ '之后在详情里补齐,再点「标记为待处理」即可。',
+ '之后在详情里补齐,资料齐全并验证 CK 后会自动进入「待充值」。',
8,
);
} else {
@@ -1685,6 +1792,32 @@ export default function HuyaCollabPage() {
// 筛选里的店铺也是点选(含停用店铺:历史订单还在用它们)
const finished = summary ? (summary.by_status.done ?? 0) : 0;
/** 顶部统计卡点一下 = 筛到对应状态,并滚回列表顶部。 */
const scrollToList = () => {
window.setTimeout(() => {
listAnchorRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
}, 0);
};
const applyStatusCard = (status?: HuyaCollabStatus) => {
setPage(1);
setFilters((prev) => ({ ...prev, status, ck_status: undefined }));
scrollToList();
};
const applyCkInvalidCard = () => {
setPage(1);
setFilters((prev) => ({ ...prev, status: undefined, ck_status: 'invalid' }));
scrollToList();
};
const statusCardActive = (status: HuyaCollabStatus) =>
filters.status === status && !filters.ck_status;
const allCardActive = !filters.status && !filters.ck_status;
const ckInvalidCardActive = filters.ck_status === 'invalid';
const cardStyle = (active: boolean) => ({
cursor: 'pointer',
borderColor: active ? '#1677ff' : undefined,
boxShadow: active ? '0 0 0 2px rgba(22, 119, 255, 0.16)' : undefined,
});
// 礼包来源提示:这次真去虎牙拉了就说拉了,用的是缓存就把缓存时间摆出来(让人知道要不要点强制刷新)
const giftCacheLabel = giftsCachedAt ? formatTime(new Date(giftsCachedAt).toISOString()) : '';
const giftSourceText = ckPreview?.gifts?.length
@@ -1714,6 +1847,13 @@ export default function HuyaCollabPage() {
return options;
};
/** 礼包名对应的积分单价;缓存里没有 / 没价格时返回 null(后端核账时还会按名称补一次)。 */
const giftRequiredPoints = (name?: string | null): number | null => {
const value = String(name ?? '').trim();
if (!value) return null;
return giftOptions.find((gift) => gift.name === value)?.price ?? null;
};
/**
* 「常用礼包」快捷按钮(2026-10-18 用户要求):
* 礼包名**默认空白**(不再自动带上次选的那个,避免没注意时建错单),
@@ -1968,7 +2108,10 @@ export default function HuyaCollabPage() {
labelRender={() => renderGiftSelectedLabel(row.gift_name)}
styles={{ content: { whiteSpace: 'normal', textOverflow: 'clip', lineHeight: 1.3 } }}
notFoundContent={giftOptions.length === 0 ? '礼包缓存为空:先在「新建」里贴 CK 拉一次' : '没有匹配的礼包'}
onChange={(next: string) => void patchOrder(row, { gift_name: next })}
onChange={(next: string) => void patchOrder(row, {
gift_name: next,
required_points: giftRequiredPoints(next),
})}
/>
<EditableText value={row.remark} disabled={!canEdit || row.is_archived} placeholder="+ 备注"
onCommit={(next) => void patchOrder(row, { remark: next })} />
@@ -2128,7 +2271,7 @@ export default function HuyaCollabPage() {
setStatusAction({ order: row, mode: 'set' });
}
if (key === 'rollback') {
setRollbackTarget('pending');
setRollbackTarget('to_recharge');
setStatusReason('');
setStatusAction({ order: row, mode: 'rollback' });
}
@@ -2157,52 +2300,42 @@ export default function HuyaCollabPage() {
return (
<Space direction="vertical" size={12} style={{ width: '100%' }}>
{/* 卡片用 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' }} />
{/* 卡片用 flex 平分并允许换行:状态多,固定 span 会参差不齐 */}
<Row gutter={[8, 8]}>
<Col flex="1 1 104px">
<Card size="small" hoverable style={cardStyle(allCardActive)} onClick={() => applyStatusCard(undefined)}>
<Statistic title="全部" value={summary?.total ?? 0} />
</Card>
</Col>
<Col flex="1">
<Card size="small">
<Statistic title="待处理" value={summary?.by_status.pending ?? 0} valueStyle={{ color: '#faad14' }} />
</Card>
</Col>
<Col flex="1">
<Card size="small">
<Statistic title={STATUS_META.bound.label} value={summary?.by_status.bound ?? 0}
valueStyle={{ color: '#1677ff' }} />
</Card>
</Col>
<Col flex="1">
<Card size="small">
<Statistic title={STATUS_META.recharged.label} value={summary?.by_status.recharged ?? 0}
valueStyle={{ color: '#2f54eb' }} />
</Card>
</Col>
<Col flex="1">
<Card size="small">
<Statistic title={STATUS_META.scripted.label} value={summary?.by_status.scripted ?? 0}
valueStyle={{ color: '#722ed1' }} />
</Card>
</Col>
<Col flex="1">
<Card size="small">
<Statistic title={STATUS_META.done.label} value={finished} valueStyle={{ color: '#52c41a' }} />
<Progress percent={summary?.total ? Math.round((finished / summary.total) * 100) : 0} size="small" />
</Card>
</Col>
<Col flex="1">
<Card size="small">
<Statistic title={STATUS_META.failed.label} value={summary?.by_status.failed ?? 0}
valueStyle={{ color: '#ff4d4f' }} />
</Card>
</Col>
<Col flex="1">
<Card size="small">
{([
['incomplete', '#fa8c16'],
['to_recharge', '#faad14'],
['recharged', '#2f54eb'],
['scripted', '#722ed1'],
['done', '#52c41a'],
['failed', '#ff4d4f'],
['points_error', '#fa541c'],
] as const).map(([status, color]) => (
<Col key={status} flex="1 1 104px">
<Card
size="small"
hoverable
style={cardStyle(statusCardActive(status))}
onClick={() => applyStatusCard(status)}
>
<Statistic
title={STATUS_META[status].label}
value={status === 'done' ? finished : (summary?.by_status[status] ?? 0)}
valueStyle={{ color }}
/>
{status === 'done' && (
<Progress percent={summary?.total ? Math.round((finished / summary.total) * 100) : 0} size="small" />
)}
</Card>
</Col>
))}
<Col flex="1 1 104px">
<Card size="small" hoverable style={cardStyle(ckInvalidCardActive)} onClick={applyCkInvalidCard}>
<Statistic title="CK 失效" value={summary?.ck_invalid ?? 0} valueStyle={{ color: '#ff4d4f' }} />
</Card>
</Col>
@@ -2245,6 +2378,7 @@ export default function HuyaCollabPage() {
)}
</Row>
<div ref={listAnchorRef} style={{ scrollMarginTop: 8 }} />
<Card size="small">
{/*
固定两行:第一行永远是筛选,第二行永远是动作按钮组 —— 不要用「筛选 + 动作同一个 wrap」
@@ -2353,6 +2487,14 @@ export default function HuyaCollabPage() {
</Button>
</Dropdown>
)}
{canEdit && !isArchiveView && (
<Tooltip title="按绑定账号整组校验 CK 和积分,整组落到「已充值 / 积分异常」">
<Button icon={<AuditOutlined />} disabled={selectedIds.length === 0 || busy}
onClick={() => openRecharge(selectedIds)}>
充值核账{selectedIds.length > 0 ? `(${selectedIds.length})` : ''}
</Button>
</Tooltip>
)}
{canEdit && !isArchiveView && (
// 查积分 / 查兑换记录**拆成两个按钮**(2026-10-17):查积分只写积分快照,
// 既不判定也不自动改状态;只有查兑换记录才产出判定并可能自动改状态。
@@ -2401,7 +2543,7 @@ export default function HuyaCollabPage() {
// 上次填了一半:订单号 / 店铺 / CK / 截图 / 备注都原样恢复,并重新验证暂存的 CK。
// **礼包名一律留空**(2026-10-18 用户要求):礼包默认空白,不再带上次选的那个;
// 反正上面有「常用礼包」备选,点一下就有,不值得为它记住上一次的选择。
createForm.setFieldsValue({ ...draft, gift_name: '' });
createForm.setFieldsValue({ ...draft, gift_name: '', required_points: null });
setGiftHint('');
setDraftRestoredAt(draft.saved_at || Date.now());
setCreateOpen(true);
@@ -2506,15 +2648,27 @@ export default function HuyaCollabPage() {
description={(() => {
const missing = [
!detail.has_cookie ? '外部 CK' : '',
!detail.bind_game_account ? '绑定账号(先验证 CK)' : '',
!detail.bind_game_role ? '绑定角色(先验证 CK)' : '',
!detail.gift_name ? '礼包名' : '',
detail.home_images.length === 0 ? '主页截图' : '',
].filter(Boolean);
return missing.length > 0
? `还缺:${missing.join('、')}。补齐后点下面的「标记为待处理」按钮即可进入正常流程。`
: '资料已齐,点下面的「标记为待处理」按钮进入正常流程。';
? `还缺:${missing.join('、')}。资料齐全且 CK 有效后会自动进入「待充值」。`
: '资料已齐,验证 CK 后会自动进入「待充值」。';
})()}
/>
)}
{detail.status === 'points_error' && (
<Alert
type="error"
showIcon
message={detail.points_check_message || '积分异常:账号积分与礼包所需积分不匹配'}
description={detail.points_checked_at
? `最近核账 ${formatTime(detail.points_checked_at)};如已补足/调整积分,重新勾选后点「充值核账」。`
: '请重新勾选后点「充值核账」。'}
/>
)}
<Descriptions size="small" column={2} bordered
items={[
{ key: 'uid', label: '虎牙号', children: detail.uid || '-' },
@@ -2532,6 +2686,11 @@ export default function HuyaCollabPage() {
{ key: 'game_role', label: '绑定角色(探针)', children: detail.bind_game_role || '-' },
{ key: 'game_channel', label: '平台 / 区服', children: detail.bind_game_channel || '-' },
{ key: 'snapshot_at', label: '绑定快照时间', children: formatTime(detail.bind_snapshot_at) },
{ key: 'gift', label: '礼包 / 所需积分', children: `${detail.gift_name || '-'} / ${detail.required_points ?? '未记录'}` },
{ key: 'points_check', label: '最近核账(账号积分 / 礼包合计 / 结余 / 阈值)',
children: detail.points_checked_at
? `${detail.points_balance ?? '-'} / ${detail.points_required_total ?? '-'} / ${detail.points_surplus ?? '-'} / ${detail.points_threshold ?? '-'}(${formatTime(detail.points_checked_at)})`
: '-' },
{ key: 'status', label: '业务状态',
children: <Tag color={STATUS_META[detail.status]?.color}>{detail.status_label}</Tag> },
...(detail.is_archived
@@ -2595,7 +2754,7 @@ export default function HuyaCollabPage() {
size="small"
initialValues={{
order_no: detail.order_no, shop: detail.shop, owner_id: detail.owner_id,
gift_name: detail.gift_name, remark: detail.remark,
gift_name: detail.gift_name, required_points: detail.required_points, remark: detail.remark,
}}
onFinish={(values) => void patchOrder(detail, values)}
>
@@ -2627,9 +2786,19 @@ export default function HuyaCollabPage() {
notFoundContent={giftOptions.length === 0
? '礼包缓存为空:先在「新建」里贴 CK 拉一次'
: '没有匹配的礼包'}
onChange={(value?: string) => {
detailForm.setFieldValue('required_points', giftRequiredPoints(value));
}}
/>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="required_points" label="礼包所需积分"
tooltip="选中礼包时自动带出积分中心价格;也可按实际活动手工修正,核账时按这个值合计">
<InputNumber min={0} max={1_000_000} precision={0} style={{ width: '100%' }}
addonAfter="积分" placeholder="未记录时核账会按礼包名自动补" />
</Form.Item>
</Col>
<Col span={24}>
<Form.Item name="remark" label="备注"><Input.TextArea rows={2} maxLength={512} /></Form.Item>
</Col>
@@ -2702,7 +2871,7 @@ export default function HuyaCollabPage() {
改状态…
</Button>
<Button icon={<UndoOutlined />} disabled={busy}
onClick={() => { setRollbackTarget('pending'); setStatusReason(''); setStatusAction({ order: detail, mode: 'rollback' }); }}>
onClick={() => { setRollbackTarget('to_recharge'); setStatusReason(''); setStatusAction({ order: detail, mode: 'rollback' }); }}>
回退状态
</Button>
{detail.status !== 'void' && (
@@ -2939,7 +3108,10 @@ export default function HuyaCollabPage() {
<Row gutter={16}>
<Col span={14}>
{renderFrequentGiftChips(createGiftValue, (name) => {
createForm.setFieldsValue({ gift_name: name });
createForm.setFieldsValue({
gift_name: name,
required_points: giftRequiredPoints(name),
});
setGiftHint('');
})}
<Form.Item
@@ -2959,12 +3131,21 @@ export default function HuyaCollabPage() {
options={giftSelectOptions(createGiftValue)}
disabled={giftOptions.length === 0 && !createGiftValue}
notFoundContent={giftOptions.length === 0 ? '先在左侧贴一份有效 CK 拉取礼包列表' : '没有匹配的礼包'}
onChange={() => {
// 选了就清掉「上次那个礼包不在本次列表里」的提示
onChange={(value?: string) => {
// 选了就清掉「上次那个礼包不在本次列表里」的提示,并带出积分价格
setGiftHint('');
createForm.setFieldValue('required_points', giftRequiredPoints(value));
}}
/>
</Form.Item>
<Form.Item
name="required_points"
label="礼包所需积分(可稍后补)"
tooltip="选择礼包时自动带出积分中心价格;核账时按每单这个值合计。留空也能核账,后端会按礼包名补一次"
>
<InputNumber min={0} max={1_000_000} precision={0} style={{ width: '100%' }}
addonAfter="积分" placeholder="选中礼包自动带出" />
</Form.Item>
</Col>
<Col span={10}>
<Form.Item
@@ -3015,7 +3196,7 @@ export default function HuyaCollabPage() {
保存资料
</Button>,
<Button key="pending" type="primary" onClick={() => void submitSupplement(true)} loading={busy}>
保存并进入待处理
保存并检查资料
</Button>,
]}
>
@@ -3040,10 +3221,14 @@ export default function HuyaCollabPage() {
const images = supplementHomeImages ?? supplementTarget.home_images;
const missing = [
!hasCk ? '外部 CK' : '',
!supplementTarget.bind_game_account ? '绑定账号(保存后验证 CK)' : '',
!supplementTarget.bind_game_role ? '绑定角色(保存后验证 CK)' : '',
!supplementGiftValue ? '礼包名' : '',
images.length === 0 ? '主页截图' : '',
].filter(Boolean);
return missing.length > 0 ? `当前还缺:${missing.join('、')}` : '资料已齐,可以直接「保存并进入待处理」。';
return missing.length > 0
? `当前还缺:${missing.join('、')}`
: '资料已齐,保存并验证 CK 后会自动进入「待充值」。';
})()}
/>
<Form
@@ -3065,7 +3250,10 @@ export default function HuyaCollabPage() {
<Col span={12}>
{renderFrequentGiftChips(
supplementGiftValue ?? supplementTarget.gift_name,
(name) => supplementForm.setFieldsValue({ gift_name: name }),
(name) => supplementForm.setFieldsValue({
gift_name: name,
required_points: giftRequiredPoints(name),
}),
)}
<Form.Item name="gift_name" label="礼包名"
tooltip="只接受礼包缓存里的点选结果,可输入关键字搜索;不接受手工输入">
@@ -3079,8 +3267,16 @@ export default function HuyaCollabPage() {
notFoundContent={giftOptions.length === 0
? '礼包缓存为空:先在「新建」里贴 CK 拉一次'
: '没有匹配的礼包'}
onChange={(value?: string) => {
supplementForm.setFieldValue('required_points', giftRequiredPoints(value));
}}
/>
</Form.Item>
<Form.Item name="required_points" label="礼包所需积分"
tooltip="选中礼包时自动带出积分中心价格;核账时按每单这个值合计">
<InputNumber min={0} max={1_000_000} precision={0} style={{ width: '100%' }}
addonAfter="积分" placeholder="选中礼包自动带出" />
</Form.Item>
</Col>
</Row>
<Form.Item
@@ -3190,6 +3386,41 @@ export default function HuyaCollabPage() {
</Space>
</Modal>
<Modal
title={`充值核账(已选 ${rechargeIds.length} 条)`}
open={rechargeOpen}
onCancel={() => setRechargeOpen(false)}
onOk={() => void submitRecharge()}
confirmLoading={busy}
okText="核账并落状态"
destroyOnHidden
>
<Space direction="vertical" size={10} style={{ width: '100%' }}>
<Alert
type="info"
showIcon
message="按绑定账号整组核账"
description="同一 bind_game_account 下的待充值/已充值/积分异常订单会一起核账;CK 失效或积分查不到时整组保持原状态。"
/>
<Space size={8} align="center">
<Text>允许结余阈值</Text>
<InputNumber
min={0}
max={1_000_000}
precision={0}
value={rechargeThreshold}
onChange={(value) => setRechargeThreshold(Number(value) || 0)}
addonAfter="积分"
style={{ width: 160 }}
/>
</Space>
<Text type="secondary" style={{ fontSize: 12 }}>
结余 = 账号积分合计 - 礼包积分合计。结余小于 0 或大于阈值 → 整组「积分异常」;
其余 → 整组「已充值」。阈值默认 100。
</Text>
</Space>
</Modal>
<Modal
title={statusAction?.mode === 'void'
? `作废订单 ${statusAction.order.order_no}`
@@ -3225,8 +3456,8 @@ export default function HuyaCollabPage() {
} />
{statusAction?.mode === 'set' && (
<Text type="secondary">
人工切换允许往前跳步(比如 待处理 → 已上脚本),不用一步一步点;
切到「已确认绑定」要求先有绑定角色 + 主页截图,切到「已作废」必须填原因。
人工切换允许往前跳步(比如 待补资料 → 已上脚本),不用一步一步点;
选「已充值」会转为整组核账;切到「已作废」必须填原因。
</Text>
)}
{statusAction?.mode === 'rollback' && (
@@ -3287,10 +3518,10 @@ export default function HuyaCollabPage() {
onChange={(event) => setBatchStatusReason(event.target.value)}
placeholder={batchStatusTarget === 'void' ? '作废原因(必填)' : '改状态说明(可选)'} />
<Checkbox checked={batchStatusRollback} onChange={(event) => setBatchStatusRollback(event.target.checked)}>
回退(目标不是「往前」时必须勾选,比如 已上脚本 → 待处理)
回退(目标不是「往前」时必须勾选,比如 已上脚本 → 待充值)
</Checkbox>
<Text type="secondary">
逐行独立提交:某一行前置校验没过(例如没主页截图进不了「已确认绑定」)只跳过那一行,
逐行独立提交:某一行前置校验没过(例如资料不全进不了「待充值」)只跳过那一行,
结果会在弹窗里逐条列出来。一次最多 50 行。
</Text>
</Space>