feat(huya): 礼包名默认空白,改用「常用礼包」备选按钮

新建协作单以前会把「上次选的礼包」自动填进礼包名(localStorage
huya.collab.lastGift)。连开几单同一个礼包是省事,但每开一单都顶着上一次的
选择,稍不留神就按错礼包建单;而且这个自动值会被草稿自动保存吃进去,
下次打开又「恢复」出来。

- 新建弹窗**不再预填礼包名**:删掉 lastGift 的读写,字段默认空白。
- 草稿恢复只带订单号 / 店铺 / CK / 截图 / 备注,礼包名留空;
  hasDraftContent() 也不再认「只有礼包名」的草稿(否则被旧逻辑自动填过的
  那份草稿会一直弹「已恢复上次未提交的草稿」)。
- 新增 GET /external-orders/gifts/frequent:按台账里的使用次数排序
  (次数相同看最近一次使用),新建与补资料弹窗在字段上方渲染最多 6 个按钮,
  只显示**当前礼包缓存里还有的**(活动换 SID 后老礼包会整批下架)。
- 新增 5 例测试,并做了 3 次反向验证(去掉次数排序 / 改成升序 / 去掉空名过滤)。

补资料弹窗的初值仍是「这一单自己的礼包」,详情抽屉编辑也不动 —— 那不是
「上次选的」。
This commit is contained in:
yml2213
2026-10-18 10:53:04 +08:00
parent c441b6c663
commit 21f7ee3f3f
8 changed files with 244 additions and 28 deletions
+28
View File
@@ -904,6 +904,34 @@ CK 失效了业务上可能仍然有效。列表上只做**显式提示**(红
**没有动的地方**:单行「查积分 / 兑换记录」与详情抽屉里的按钮仍是 `both`(一个动作查两件事),
`scope` 是**可选参数**、默认值就是原来的行为 —— 老的调用方一个字段不加也照旧。
### 11.10 2026-10-18 第七轮:礼包名默认空白 + 常用礼包备选
需求原文:"礼包名 需要优化, 每次都会选择上次选中的, 修复这个, 可以在上方留几个经常选择的";
补充:"不能带上次的, 需要默认空白, 只留下 几个备选"。
原来的行为:新建时自动把「上次选的礼包」填进 `gift_name`(`localStorage['huya.collab.lastGift']`)。
连开几单同一个礼包确实省事,但**每开一单都顶着上一次的选择**,稍不留神就按错礼包建单;
而且这个自动值会被草稿自动保存吃进去,下次打开又"恢复"出来。
| 面 | 落地 |
| --- | --- |
| 默认值 | **礼包名一律空白**:删掉 `LAST_GIFT_KEY`(不再写、不再读),新建弹窗不预填 |
| 草稿 | 恢复草稿时**只恢复订单号 / 店铺 / CK / 截图 / 备注,礼包名留空**;`hasDraftContent()` 不再把「只有礼包名」当有内容(否则一份被旧逻辑自动填过的草稿会一直弹"已恢复草稿") |
| 常用礼包 | 新增 `GET /external-orders/gifts/frequent?limit=8` → `service.frequent_gift_names()`:按使用次数降序、次数相同按最近一次使用;新建与补资料弹窗在字段**上方**渲染最多 6 个按钮(短标签 + Tooltip 显示全名与用过次数) |
| 过滤 | 按钮只显示**当前礼包缓存里还有的**那几个(活动换 SID 后老礼包会整批下架,见 §11.4);缓存为空时整行不显示 |
**四条口径**:
1. **礼包名是"点一下"的事,不值得替用户记住**:CK / 截图那些重活才需要草稿,礼包有备选按钮后一次点击即可,
所以宁可默认空白。
2. **服务层只回答"历史上哪些用得多"**:`frequent_gift_names` **不判断**当前活动还有没有这个礼包 ——
那是前端拿礼包缓存比对的事;活动换 SID 后礼包整批变,服务层不该替前端猜(§11.4 的教训)。
3. **空礼包名不进榜单**:那是"还没填",不是一种礼包(SQL 里滤掉,有用例钉着)。
4. **次数相同看最近**:并列时让最近用过的排前面,符合"常用"的直觉(有用例 + 反向验证)。
**没有动的地方**:详情抽屉里编辑既有订单的礼包名仍是"显示这一单自己的值"(本来就该如此);
补资料弹窗的初值仍是**这一单自己的礼包**(不是"上次选的"),没有改成空白。
---
## 12. 风险
+56
View File
@@ -2259,6 +2259,62 @@ class TestCollabActivityScopeSplit(TestCollabBase):
assert row["last_verdict_message"]
class TestCollabFrequentGifts(TestCollabBase):
"""「常用礼包」:礼包名默认空白,只在上面留几个用得最多的备选(2026-10-18 用户要求)。
以前新建会自动带上「上次选的那个」礼包,每开一单都顶着上一次的选择、
没注意就建错单。现在改成:**字段默认空白**,想要少点两下就点「常用礼包」按钮。
"""
def test_orders_by_usage_count_then_recency(self):
for index in range(3):
self._create(order_no=f"FREQ-A{index}", gift_name="甲礼包")
self._create(order_no="FREQ-B1", gift_name="乙礼包")
self._create(order_no="FREQ-B2", gift_name="乙礼包")
self._create(order_no="FREQ-C1", gift_name="丙礼包")
rows = collab.frequent_gift_names(self.db)
assert [row["name"] for row in rows] == ["甲礼包", "乙礼包", "丙礼包"]
assert [row["used"] for row in rows] == [3, 2, 1]
def test_blank_gift_name_is_not_a_gift(self):
self._create(order_no="FREQ-EMPTY1")
self._create(order_no="FREQ-EMPTY2")
self._create(order_no="FREQ-ONE", gift_name="真礼包")
rows = collab.frequent_gift_names(self.db)
assert [row["name"] for row in rows] == ["真礼包"]
def test_same_count_falls_back_to_most_recently_used(self):
self._create(order_no="FREQ-R1", gift_name="老礼包")
self._create(order_no="FREQ-R2", gift_name="新礼包")
rows = collab.frequent_gift_names(self.db)
assert [row["name"] for row in rows] == ["新礼包", "老礼包"]
def test_limit_caps_the_list(self):
for index in range(4):
self._create(order_no=f"FREQ-L{index}", gift_name="同次数礼包")
rows = collab.frequent_gift_names(self.db, limit=1)
assert [row["name"] for row in rows] == ["同次数礼包"]
def test_api_returns_name_and_used(self):
self._create(order_no="FREQ-API1", gift_name="接口礼包")
self._create(order_no="FREQ-API2", gift_name="接口礼包")
rows = collab_api.external_orders_frequent_gifts(
limit=8, db=self.db, _current=self.user
)
assert rows[0]["name"] == "接口礼包"
assert rows[0]["used"] == 2
class TestCollabCkExport(TestCollabBase):
"""勾选导出 CK(写剪贴板):凭据外带要留痕,但审计里不许出现 CK 本体。"""
+18
View File
@@ -36,6 +36,7 @@ from ...permissions import get_user_permissions
from ...schemas import (
HuyaCollabCkPreviewOut,
HuyaCollabCkPreviewRequest,
HuyaCollabFrequentGiftOut,
HuyaCollabMetricOut,
HuyaCollabShopCreate,
HuyaCollabShopOut,
@@ -138,6 +139,23 @@ def external_orders_gifts(
return collab.list_gift_names(db)
@router.get(
"/external-orders/gifts/frequent",
response_model=list[HuyaCollabFrequentGiftOut],
)
def external_orders_frequent_gifts(
limit: int = Query(8, ge=1, le=20),
db: Session = Depends(get_db),
_current: User = Depends(require_permission("huya:collab:view")),
):
"""「常用礼包」:台账里用得最多的几个礼包名(新建/补资料时的快捷按钮)。
只回答"历史上哪些用得多";**当前活动还有没有这个礼包由前端拿礼包缓存比对**
—— 活动换 SID 后礼包列表会整批变,这里不该替前端猜。字号限制见 ``limit``。
"""
return collab.frequent_gift_names(db, limit=limit)
# --------------------------------------------------------------------------
# 店铺字典(新建协作单时点选;也支持自定义输入后自动登记)
# --------------------------------------------------------------------------
+11
View File
@@ -1389,6 +1389,17 @@ class HuyaCollabMetricOut(BaseModel):
created_at: datetime | None = None
class HuyaCollabFrequentGiftOut(BaseModel):
"""「常用礼包」:台账里出现过的礼包名 + 用过几次(新建/补资料时的快捷按钮)。
只有 ``name`` 与 ``used`` —— 当前活动还剩多少是礼包列表那边的事,
两边混在一起会让人以为这个数字是库存(见设计文档 §11.4 的 SID 教训)。
"""
name: str
used: int = 0
class HuyaExternalOrderActivityItemOut(BaseModel):
"""批量查询里的一行结论。"""
@@ -2217,6 +2217,37 @@ def list_gift_names(db: Session) -> list[str]:
)
def frequent_gift_names(db: Session, *, limit: int = 8) -> list[dict]:
"""台账里用得最多的礼包名(新建 / 补资料时的「常用礼包」快捷按钮)。
口径:**按出现次数降序,次数相同按最近一次使用(最大 id)降序**。
空礼包名不统计 —— 那是"还没填",不是一种礼包。
**这里不判断"当前活动还有没有这个礼包"**:活动换 SID 之后礼包列表会整批变
(见设计文档 §11.4),那件事由前端拿礼包缓存去比对;服务层只回答
"历史上哪些用得多"。
"""
rows = (
db.query(
HuyaExternalOrder.gift_name.label("name"),
func.count(HuyaExternalOrder.id).label("used"),
)
.filter(
HuyaExternalOrder.gift_name.isnot(None),
HuyaExternalOrder.gift_name != "",
)
.group_by(HuyaExternalOrder.gift_name)
.order_by(
func.count(HuyaExternalOrder.id).desc(),
func.max(HuyaExternalOrder.id).desc(),
)
.limit(max(1, min(20, int(limit))))
.all()
)
# 空名在 SQL 里已经滤掉(见上面的 filter):这里只做类型收口。
return [{"name": str(name), "used": int(used)} for name, used in rows]
# --------------------------------------------------------------------------
# CRUD
# --------------------------------------------------------------------------
+11
View File
@@ -39,6 +39,7 @@ import type {
HuyaCollabCkPreview,
HuyaCollabCkRequest,
HuyaCollabCkStatusRequest,
HuyaCollabFrequentGift,
HuyaCollabImagesRequest,
HuyaCollabImportResult,
HuyaCollabMetric,
@@ -212,6 +213,16 @@ export const huyaApi = {
collabOrderShops: () => api.get<string[], string[]>('/huya/external-orders/shops'),
/** 已用过的礼包/皮肤名(筛选下拉) */
collabOrderGifts: () => api.get<string[], string[]>('/huya/external-orders/gifts'),
/**
* 常用礼包(台账里用得最多的几个):新建/补资料的礼包名快捷按钮。
*
* 只来自历史记录,**是不是当前活动还在卖的**由页面拿礼包缓存比对后决定要不要显示。
*/
collabFrequentGifts: (limit = 8) =>
api.get<HuyaCollabFrequentGift[], HuyaCollabFrequentGift[]>(
'/huya/external-orders/gifts/frequent',
{ params: { limit } },
),
// ---- 店铺字典(新建协作单时点选;见业务规则 docs/设计-虎牙外部CK协作台账.md)----
/** 全部店铺(含停用的:管理弹窗要看到;新建下拉由页面按 is_active 过滤) */
+11
View File
@@ -1597,6 +1597,17 @@ export interface HuyaCollabGift {
icon: string;
}
/**
* 「常用礼包」:台账里用得最多的礼包名 + 用过几次。
*
* 只来自历史记录;**当前活动还有没有它**要拿 `HuyaCollabGift` 缓存比对
* (活动换 SID 后整批礼包会变,见设计文档 §11.4)。
*/
export interface HuyaCollabFrequentGift {
name: string;
used: number;
}
/** 粘贴 CK 后的即时预览:ok 且 has_role 才允许提交新建 */
export interface HuyaCollabCkPreview {
ok: boolean;
+78 -28
View File
@@ -25,6 +25,7 @@ import {
type HuyaCollabAuditItem,
type HuyaCollabCkExportResult,
type HuyaCollabCkPreview,
type HuyaCollabFrequentGift,
type HuyaCollabGift,
type HuyaCollabImageSlot,
type HuyaCollabMetric,
@@ -150,8 +151,6 @@ const SLOT_META: Record<HuyaCollabImageSlot, { label: string; keysKey: 'bind_ima
};
const IMPORT_PREVIEW_ROWS = 20;
// 「记住最近选的礼包」的本地存储键(同一个礼包连开几单时不用反复选)
const LAST_GIFT_KEY = 'huya.collab.lastGift';
// 「礼包列表」的本地缓存键:一个活动期间就那一批礼包,没必要每贴一份 CK 都去虎牙拉一次;
// 要更新就点「强制刷新礼包」(活动换 SID 后必点一次)。
const GIFTS_CACHE_KEY = 'huya.collab.gifts';
@@ -258,11 +257,12 @@ function markDraftHintShown(): void {
}
function hasDraftContent(draft: Omit<CreateDraft, 'saved_at'>): boolean {
// 礼包名不算「填过东西」:它现在默认空白、也不从草稿恢复(见下面 createForm 那段的说明),
// 否则一份「只被上次自动填了礼包」的老草稿会一直弹「已恢复上次未提交的草稿」。
return Boolean(
draft.order_no.trim()
|| draft.shop.trim()
|| draft.ck_text.trim()
|| draft.gift_name.trim()
|| draft.remark.trim()
|| draft.home_images.length,
);
@@ -486,6 +486,8 @@ export default function HuyaCollabPage() {
const [owners, setOwners] = useState<HuyaCollabOwner[]>([]);
const [shops, setShops] = useState<HuyaCollabShop[]>([]);
const [giftNames, setGiftNames] = useState<string[]>([]);
// 「常用礼包」:台账里用得最多的几个(新建/补资料的礼包名快捷按钮,见 2026-10-18 需求)
const [frequentGifts, setFrequentGifts] = useState<HuyaCollabFrequentGift[]>([]);
const [shopModalOpen, setShopModalOpen] = useState(false);
const [filters, setFilters] = useState<HuyaCollabOrderQuery>({ include_void: false });
// 默认按固定创建序号正序:老单在上、新单永远追加在最后。
@@ -564,7 +566,14 @@ export default function HuyaCollabPage() {
const withMeta = options?.withMeta !== false;
if (!silent) setLoading(true);
try {
const [list, nextSummary, nextOwners, nextShops, nextGiftNames] = await Promise.all([
const [
list,
nextSummary,
nextOwners,
nextShops,
nextGiftNames,
nextFrequentGifts,
] = await Promise.all([
huyaApi.listCollabOrders({
...filters,
sort_by: sortKey,
@@ -576,6 +585,7 @@ export default function HuyaCollabPage() {
withMeta ? huyaApi.collabOrderOwners() : Promise.resolve(null),
withMeta ? huyaApi.listCollabShops() : Promise.resolve(null),
withMeta ? huyaApi.collabOrderGifts() : Promise.resolve(null),
withMeta ? huyaApi.collabFrequentGifts() : Promise.resolve(null),
]);
setRows(list.items);
setTotal(list.total);
@@ -583,6 +593,7 @@ export default function HuyaCollabPage() {
if (nextOwners) setOwners(nextOwners);
if (nextShops) setShops(nextShops);
if (nextGiftNames) setGiftNames(nextGiftNames);
if (nextFrequentGifts) setFrequentGifts(nextFrequentGifts);
// 选中是「当前页」的概念:刷新后剔除已经不在这页上的行,避免点到别人的脏 id。
setSelectedIds((ids) => ids.filter((id) => list.items.some((row) => row.id === id)));
} catch (e: unknown) {
@@ -1446,7 +1457,6 @@ export default function HuyaCollabPage() {
draft,
require_verified: !draft, // 草稿单不跑探针;完整建单仍要求 CK 读到绑定角色
});
if (created.gift_name) window.localStorage.setItem(LAST_GIFT_KEY, created.gift_name);
if (draft) {
const reason = missing.length > 0
? `还缺 ${missing.join('、')}`
@@ -1543,6 +1553,50 @@ export default function HuyaCollabPage() {
return options;
};
/**
* 「常用礼包」快捷按钮(2026-10-18 用户要求):
* 礼包名**默认空白**(不再自动带上次选的那个,避免没注意时建错单),
* 想少点两下就点这里的备选。
*
* 只列**当前礼包缓存里还有的**那几个:活动换 SID 之后老礼包基本都下架了,
* 点一个拉不到的名字等于白点(见设计文档 §11.4)。缓存为空时整行不显示。
*/
const frequentGiftChoices = (): HuyaCollabFrequentGift[] => {
if (giftOptions.length === 0) return [];
const available = new Set(giftOptions.map((gift) => gift.name));
return frequentGifts.filter((item) => available.has(item.name)).slice(0, 6);
};
/** 按钮上的短标签:礼包名很长(「[限量返场] 怪诞乔克服装礼包」),去掉类目前缀再截断 */
const giftChipLabel = (name: string): string => {
const short = name.replace(/^\[[^\]]*\]\s*/, '').trim() || name;
return short.length > 10 ? `${short.slice(0, 10)}…` : short;
};
const renderFrequentGiftChips = (
current: string | undefined,
pick: (name: string) => void,
) => {
const choices = frequentGiftChoices();
if (choices.length === 0) return null;
return (
<Space size={6} wrap style={{ marginBottom: 6 }}>
<Text type="secondary" style={{ fontSize: 12 }}>常用礼包:</Text>
{choices.map((item) => (
<Tooltip key={item.name} title={`${item.name}(用过 ${item.used} 次)`}>
<Button
size="small"
type={current === item.name ? 'primary' : 'default'}
onClick={() => pick(item.name)}
>
{giftChipLabel(item.name)}
</Button>
</Tooltip>
))}
</Space>
);
};
// 表头排序:一次正序、再点反序(Table 上限制 sortDirections,不会出现「第三次取消」)。
const sortProps = (key: CollabSortKey) => ({
key,
@@ -2069,31 +2123,20 @@ export default function HuyaCollabPage() {
createForm.resetFields();
const draft = readCreateDraft(user?.id);
if (draft && hasDraftContent(draft)) {
// 上次填了一半:原样恢复(CK / 礼包 / 截图 / 备注都在),并重新验证暂存的 CK
const restored = { ...draft };
const restoredGift = String(restored.gift_name ?? '').trim();
if (
restoredGift
&& giftOptions.length > 0
&& !giftOptions.some((gift) => gift.name === restoredGift)
) {
restored.gift_name = '';
setGiftHint(`草稿里的「${restoredGift}」不在当前礼包缓存里,已清空,请重新点选`);
} else {
setGiftHint('');
}
createForm.setFieldsValue({ ...restored });
// 上次填了一半:订单号 / 店铺 / CK / 截图 / 备注都原样恢复,并重新验证暂存的 CK。
// **礼包名一律留空**(2026-10-18 用户要求):礼包默认空白,不再带上次选的那个;
// 反正上面有「常用礼包」备选,点一下就有,不值得为它记住上一次的选择。
createForm.setFieldsValue({ ...draft, gift_name: '' });
setGiftHint('');
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 && giftOptions.some((gift) => gift.name === lastGift)) {
createForm.setFieldsValue({ gift_name: lastGift });
}
// 礼包名**不预填**(2026-10-18 用户要求):以前会自动带上"上次选的",
// 每开一单都顶着上一次的礼包,容易在没注意时建错单。
// 想少点两下就用上面的「常用礼包」快捷按钮,点了才算选了。
setGiftHint('');
setCreateOpen(true);
}}
@@ -2587,10 +2630,14 @@ export default function HuyaCollabPage() {
)}
<Row gutter={16}>
<Col span={14}>
{renderFrequentGiftChips(createGiftValue, (name) => {
createForm.setFieldsValue({ gift_name: name });
setGiftHint('');
})}
<Form.Item
name="gift_name"
label="礼包名(用户购买的物品,可稍后补)"
tooltip="只接受礼包缓存里的点选结果,可输入关键字搜索。先贴有效 CK 拉一次礼包;不选也能先建单,之后点「补资料」再选。"
tooltip="默认空白,不会自动带上次选的礼包(2026-10-18)。只接受礼包缓存里的点选结果,可输入关键字搜索;不选也能先建单,之后点「补资料」再选。"
extra={giftHint || (giftsCachedAt && giftOptions.length > 0
? `礼包列表缓存于 ${giftCacheLabel}(${giftOptions.length} 个)`
: undefined)}
@@ -2604,10 +2651,9 @@ export default function HuyaCollabPage() {
options={giftSelectOptions(createGiftValue)}
disabled={giftOptions.length === 0 && !createGiftValue}
notFoundContent={giftOptions.length === 0 ? '先在左侧贴一份有效 CK 拉取礼包列表' : '没有匹配的礼包'}
onChange={(value: string) => {
// 记住最近选的礼包:连开几单同一个礼包就不用反复点
onChange={() => {
// 选了就清掉「上次那个礼包不在本次列表里」的提示
setGiftHint('');
if (value) window.localStorage.setItem(LAST_GIFT_KEY, value);
}}
/>
</Form.Item>
@@ -2709,6 +2755,10 @@ export default function HuyaCollabPage() {
</Form.Item>
</Col>
<Col span={12}>
{renderFrequentGiftChips(
supplementGiftValue ?? supplementTarget.gift_name,
(name) => supplementForm.setFieldsValue({ gift_name: name }),
)}
<Form.Item name="gift_name" label="礼包名"
tooltip="只接受礼包缓存里的点选结果,可输入关键字搜索;不接受手工输入">
<Select