From 21f7ee3f3f931642b317be3d29114f62e1511268 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Sun, 18 Oct 2026 10:53:04 +0800 Subject: [PATCH] =?UTF-8?q?feat(huya):=20=E7=A4=BC=E5=8C=85=E5=90=8D?= =?UTF-8?q?=E9=BB=98=E8=AE=A4=E7=A9=BA=E7=99=BD=EF=BC=8C=E6=94=B9=E7=94=A8?= =?UTF-8?q?=E3=80=8C=E5=B8=B8=E7=94=A8=E7=A4=BC=E5=8C=85=E3=80=8D=E5=A4=87?= =?UTF-8?q?=E9=80=89=E6=8C=89=E9=92=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新建协作单以前会把「上次选的礼包」自动填进礼包名(localStorage huya.collab.lastGift)。连开几单同一个礼包是省事,但每开一单都顶着上一次的 选择,稍不留神就按错礼包建单;而且这个自动值会被草稿自动保存吃进去, 下次打开又「恢复」出来。 - 新建弹窗**不再预填礼包名**:删掉 lastGift 的读写,字段默认空白。 - 草稿恢复只带订单号 / 店铺 / CK / 截图 / 备注,礼包名留空; hasDraftContent() 也不再认「只有礼包名」的草稿(否则被旧逻辑自动填过的 那份草稿会一直弹「已恢复上次未提交的草稿」)。 - 新增 GET /external-orders/gifts/frequent:按台账里的使用次数排序 (次数相同看最近一次使用),新建与补资料弹窗在字段上方渲染最多 6 个按钮, 只显示**当前礼包缓存里还有的**(活动换 SID 后老礼包会整批下架)。 - 新增 5 例测试,并做了 3 次反向验证(去掉次数排序 / 改成升序 / 去掉空名过滤)。 补资料弹窗的初值仍是「这一单自己的礼包」,详情抽屉编辑也不动 —— 那不是 「上次选的」。 --- docs/设计-虎牙外部CK协作台账.md | 28 ++++++ tests/test_huya_collab_orders.py | 56 +++++++++++ web/backend/routers/huya/collab.py | 18 ++++ web/backend/schemas.py | 11 ++ web/backend/services/huya_collab_service.py | 31 ++++++ web/frontend/src/api/huya.ts | 11 ++ web/frontend/src/api/types.ts | 11 ++ web/frontend/src/pages/HuyaCollabPage.tsx | 106 ++++++++++++++------ 8 files changed, 244 insertions(+), 28 deletions(-) diff --git a/docs/设计-虎牙外部CK协作台账.md b/docs/设计-虎牙外部CK协作台账.md index 8d14668..58b9a35 100644 --- a/docs/设计-虎牙外部CK协作台账.md +++ b/docs/设计-虎牙外部CK协作台账.md @@ -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. 风险 diff --git a/tests/test_huya_collab_orders.py b/tests/test_huya_collab_orders.py index 9d8942e..d7f5ef8 100644 --- a/tests/test_huya_collab_orders.py +++ b/tests/test_huya_collab_orders.py @@ -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 本体。""" diff --git a/web/backend/routers/huya/collab.py b/web/backend/routers/huya/collab.py index 2cbdc24..acedf76 100644 --- a/web/backend/routers/huya/collab.py +++ b/web/backend/routers/huya/collab.py @@ -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) + + # -------------------------------------------------------------------------- # 店铺字典(新建协作单时点选;也支持自定义输入后自动登记) # -------------------------------------------------------------------------- diff --git a/web/backend/schemas.py b/web/backend/schemas.py index 374e70d..c660a79 100644 --- a/web/backend/schemas.py +++ b/web/backend/schemas.py @@ -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): """批量查询里的一行结论。""" diff --git a/web/backend/services/huya_collab_service.py b/web/backend/services/huya_collab_service.py index f0dec6a..50919ba 100644 --- a/web/backend/services/huya_collab_service.py +++ b/web/backend/services/huya_collab_service.py @@ -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 # -------------------------------------------------------------------------- diff --git a/web/frontend/src/api/huya.ts b/web/frontend/src/api/huya.ts index 6942d5c..69a89e6 100644 --- a/web/frontend/src/api/huya.ts +++ b/web/frontend/src/api/huya.ts @@ -39,6 +39,7 @@ import type { HuyaCollabCkPreview, HuyaCollabCkRequest, HuyaCollabCkStatusRequest, + HuyaCollabFrequentGift, HuyaCollabImagesRequest, HuyaCollabImportResult, HuyaCollabMetric, @@ -212,6 +213,16 @@ export const huyaApi = { collabOrderShops: () => api.get('/huya/external-orders/shops'), /** 已用过的礼包/皮肤名(筛选下拉) */ collabOrderGifts: () => api.get('/huya/external-orders/gifts'), + /** + * 常用礼包(台账里用得最多的几个):新建/补资料的礼包名快捷按钮。 + * + * 只来自历史记录,**是不是当前活动还在卖的**由页面拿礼包缓存比对后决定要不要显示。 + */ + collabFrequentGifts: (limit = 8) => + api.get( + '/huya/external-orders/gifts/frequent', + { params: { limit } }, + ), // ---- 店铺字典(新建协作单时点选;见业务规则 docs/设计-虎牙外部CK协作台账.md)---- /** 全部店铺(含停用的:管理弹窗要看到;新建下拉由页面按 is_active 过滤) */ diff --git a/web/frontend/src/api/types.ts b/web/frontend/src/api/types.ts index 6334abd..b1c8a9a 100644 --- a/web/frontend/src/api/types.ts +++ b/web/frontend/src/api/types.ts @@ -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; diff --git a/web/frontend/src/pages/HuyaCollabPage.tsx b/web/frontend/src/pages/HuyaCollabPage.tsx index 2af4950..aba6cd3 100644 --- a/web/frontend/src/pages/HuyaCollabPage.tsx +++ b/web/frontend/src/pages/HuyaCollabPage.tsx @@ -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): 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([]); const [shops, setShops] = useState([]); const [giftNames, setGiftNames] = useState([]); + // 「常用礼包」:台账里用得最多的几个(新建/补资料的礼包名快捷按钮,见 2026-10-18 需求) + const [frequentGifts, setFrequentGifts] = useState([]); const [shopModalOpen, setShopModalOpen] = useState(false); const [filters, setFilters] = useState({ 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 ( + + 常用礼包: + {choices.map((item) => ( + + + + ))} + + ); + }; + // 表头排序:一次正序、再点反序(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() { )} + {renderFrequentGiftChips(createGiftValue, (name) => { + createForm.setFieldsValue({ gift_name: name }); + setGiftHint(''); + })} 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); }} /> @@ -2709,6 +2755,10 @@ export default function HuyaCollabPage() { + {renderFrequentGiftChips( + supplementGiftValue ?? supplementTarget.gift_name, + (name) => supplementForm.setFieldsValue({ gift_name: name }), + )}