From 82c59bb4af7b5e966bc893bbb9e40681fe1e3eaa Mon Sep 17 00:00:00 2001 From: yml2213 Date: Fri, 16 Oct 2026 22:24:08 +0800 Subject: [PATCH] =?UTF-8?q?feat(huya):=20=E5=8D=8F=E4=BD=9C=E5=8F=B0?= =?UTF-8?q?=E8=B4=A6=E5=8B=BE=E9=80=89=E5=AF=BC=E5=87=BA=20CK=20=E5=88=B0?= =?UTF-8?q?=E5=89=AA=E8=B4=B4=E6=9D=BF=EF=BC=88=E5=8D=95=E7=82=B9=E6=9D=83?= =?UTF-8?q?=E9=99=90=20+=20=E9=80=90=E8=A1=8C=E5=AE=A1=E8=AE=A1=EF=BC=89+?= =?UTF-8?q?=20=E5=B7=A5=E5=85=B7=E5=8C=BA=E5=9B=BA=E5=AE=9A=E4=B8=A4?= =?UTF-8?q?=E8=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/设计-虎牙外部CK协作台账.md | 41 +++++- tests/test_huya_collab_orders.py | 75 +++++++++++ web/backend/permissions.py | 5 + web/backend/routers/huya/collab.py | 21 +++ web/backend/schemas.py | 37 ++++++ web/backend/services/huya_collab_service.py | 84 ++++++++++++ web/frontend/src/api/huya.ts | 7 + web/frontend/src/api/types.ts | 28 ++++ web/frontend/src/pages/HuyaCollabPage.tsx | 139 +++++++++++++++++++- 9 files changed, 431 insertions(+), 6 deletions(-) diff --git a/docs/设计-虎牙外部CK协作台账.md b/docs/设计-虎牙外部CK协作台账.md index da649c2..32831b9 100644 --- a/docs/设计-虎牙外部CK协作台账.md +++ b/docs/设计-虎牙外部CK协作台账.md @@ -510,6 +510,11 @@ CK 失效了业务上可能仍然有效。列表上只做**显式提示**(红 | POST | `/api/huya/external-orders/{id}/verify` | `huya:collab:edit` | 验证这一行的 CK(只读探针,§5.2) | | POST | `/api/huya/external-orders/ck/preview` | `huya:collab:edit` | 粘贴 CK 后的即时预览(探针 + 拉活动礼包,**只读不写库**) | | POST | `/api/huya/external-orders/ck/verify` | `huya:collab:edit` | 批量验证 CK(探针,body `{ids: [...]}`,最多 50 条) | +| POST | `/api/huya/external-orders/status/batch` | `huya:collab:edit` | 勾选多行切成同一个状态(逐行 SAVEPOINT 隔离,最多 50 条;§4.1) | +| POST | `/api/huya/external-orders/activity/query` | `huya:collab:edit` | 勾选多行查积分 + 兑换记录(串行带间隔,每行写一条生命周期快照;§5.7) | +| POST | `/api/huya/external-orders/{id}/activity/query` | `huya:collab:edit` | 查这一行的积分 + 兑换记录(单行「查询」按钮) | +| GET | `/api/huya/external-orders/{id}/metrics` | `huya:collab:view` | 账号全生命周期时间线(每次查询的快照,新→旧;§3.5) | +| POST | `/api/huya/external-orders/ck/export` | **`huya:collab:ck_export`** | **导出勾选行的 CK 明文**(前端写剪贴板;逐行写审计;最多 50 条;§11.8) | | POST | `/api/huya/external-orders/import` | `huya:collab:edit` | 粘贴批量导入 | | GET | `/api/huya/collab-shops` | `huya:collab:view` | 店铺字典(含停用的,管理弹窗用) | | POST | `/api/huya/collab-shops` | `huya:collab:edit` | 新增店铺(重名 409) | @@ -542,7 +547,12 @@ CK 失效了业务上可能仍然有效。列表上只做**显式提示**(红 - **图片列**:缩略图 + 张数,点击开大图(`Image.PreviewGroup`); - **状态**:Tag 展示,行内"下一步"按钮推进(带二次确认); - **CK 状态**:Tag + 一键"验证"; -- 筛选:店铺 / 负责人 / 业务状态 / CK 状态 / 关键词(订单号、绑定账号、角色、uid); +- **工具栏批量动作**(都要先勾选):批量验证 CK / 批量改状态 / 批量查积分·兑换 / **导出 CK**(勾选几行导几行,写剪贴板;§11.8); +- **顶部工具区固定两行**(2026-10-16 用户反馈「不要选中后两行」): + 第一行永远筛选 = 店铺 / 负责人 / 业务状态 / CK 状态 / 关键词(订单号、店铺、绑定账号、角色、虎牙号、礼包、备注)/ 显示已作废; + 第二行永远动作 = 刷新 + 上面那四个批量动作 + 粘贴导入 / 店铺管理 / 新建。 + **不要**用「筛选 + 动作放在同一个 `wrap` 里靠 `space-between` 排」那种写法 —— + 不勾选时两组挤成一行、勾选后按钮多了「(N)」就被顶到第二行,列表跟着往下跳; - 页头统计条:`待处理 N / 已确认绑定 N / 已充值·已上脚本 N / 抢购成功 N / 抢购失败 N / 已作废 N`(`done` / `failed` 的展示名 2026-10-16 由「已完成」/「兑换失败」改成「抢购成功」/「抢购失败」,枚举值与库里数据不变)。 > 交互细节(2026-10-16 用户拍板):**店铺是点选按钮组**(列表里点单元格弹出同一组按钮, @@ -830,6 +840,34 @@ CK 失效了业务上可能仍然有效。列表上只做**显式提示**(红 注意「判定结论」(verdict)那三个标签仍是「兑换成功 / 兑换失败 / 无法判定」: 它说的是"兑换记录里查到没有",与业务状态名是两个概念,徽标放在列表「积分」列与生命周期时间线上。 +### 11.8 2026-10-16 第五轮:勾选导出 CK 到剪贴板 + +需求原文:"选择、多选后增加一个导出 CK 功能,可以导出选中的 CK 到剪贴板。" + +这是本模块**第一个把凭据往外送的读接口**(列表/详情都刻意不返回 `cookie`,见 §2.3), +所以按"凭据出口"来设计,而不是当成一个普通导出: + +| 面 | 落地 | +| --- | --- | +| 权限 | 新增权限点 **`huya:collab:ck_export`**("导出外部 CK 协作台账的 CK");**默认只给运营**,客服默认没有(贴 CK 是"录入",导出是"把凭据带走",两件事不共用一个权限;口径同 `huya:cookie:export`) | +| 服务层 | `export_cks(db, current, orders)`:只读 + 逐行写审计;返回 `{total, exported, skipped[], items[]}`,`items[].ck_text` 是明文(**只有这个接口会返回**);上限 `PROBE_BATCH_MAX=50` | +| 接口 | `POST /api/huya/external-orders/ck/export`(字面量路径,排在 `/{order_id}` 之前),body `{ids: [...]}` | +| 审计 | 逐行写 `huya:collab:ck_export`,`target=huya_external_order:`,detail 只记 `order_no / uid / ck_status / batch_size` —— **绝不写 CK 本体**(测试里用 `assert "C"*152 not in detail` 钉死) | +| 前端 | 工具栏「导出 CK(N)」→ 拉明文 → 弹窗预览 + 「复制到剪贴板」(走 `utils/clipboard.ts`,兼容 http 远程访问的 `execCommand` 回退) | + +**六条约定(改这个功能前先读)**: + +1. **明文只活在弹窗的 state 里**:不写 `localStorage`、不进 URL、不缓存(关弹窗即清); +2. **复制失败不吞**:回退提示"在下面的框里手动选中复制",明文本来就在框里; +3. **格式两种**:默认「每行一份纯 CK」(直接粘),可切「订单号 + Tab + CK」(多选时对号); +4. **没 CK 的行显式跳过并回原因**(`skipped`),界面逐条列出来,不静默丢; +5. **作废 / 停用的单只要还有 CK 就照导**,但把 `status_label / ck_status_label` 一起给, + 界面上点名"其中 N 条已失效/已停用" —— 不替用户做隐含决策(服务层只管"有没有凭据"); +6. **上限 50 行**(和批量验证/查询一致),一次别点太多,人看不过来也难追溯。 + +**待跟进**:导出动作目前只写审计;如果以后要做"谁在什么时候导出了什么"的专门报表, +`audit_logs.action = 'huya:collab:ck_export'` 已经可以直接筛(不用加表)。 + --- ## 12. 风险 @@ -838,6 +876,7 @@ CK 失效了业务上可能仍然有效。列表上只做**显式提示**(红 | --- | --- | | 与 `huya_accounts` 出现"两份 CK 谁是准的" | §2.3 已写死:本表 CK 只在协作语境有效,不自动同步;需要跑任务时人工导入 | | 探针接口被风控 | 只做人工触发、串行、带间隔;不做定时轮询 | +| CK 被导出后外传(导出到剪贴板 = 凭据出门) | 独立权限点 `huya:collab:ck_export`(默认只给运营)+ 逐行审计(不含 CK 本体)+ 界面常驻警告;明文只在弹窗 state 里,关掉即清(§11.8) | | 积分 / 兑换记录查询被风控 | 同上:只手动触发(单行 + 勾选批量,串行带间隔),不做定时轮询(§5.7) | | 自动改状态误判(接口抖动 → 判成"抢购失败") | 三态判定:取不到数据一律 `unknown` 且不动状态;只有 `scripted` 的单才自动改;每次查询留快照,人工可回退(`rollback`) | | 活动换 ID(SID 过期)→ 礼包列表空白 | 礼包 SID 一律读 `huya_config.sid`(工作台「虎牙配置」可改,见 §11.4);空列表时结论直接提示去改 SID | diff --git a/tests/test_huya_collab_orders.py b/tests/test_huya_collab_orders.py index 79d135c..d246fc9 100644 --- a/tests/test_huya_collab_orders.py +++ b/tests/test_huya_collab_orders.py @@ -1848,6 +1848,75 @@ class TestCollabActivityLifecycle(TestCollabBase): assert payload["last_verdict"] == "success" +class TestCollabCkExport(TestCollabBase): + """勾选导出 CK(写剪贴板):凭据外带要留痕,但审计里不许出现 CK 本体。""" + + def test_export_returns_plaintext_and_audits_each_exported_row(self): + first = self._create(order_no="EXP-1", ck_text=fake_ck("1199647000001")) + second = self._create(order_no="EXP-2", ck_text=fake_ck("1199647000002")) + blank = self._create(order_no="EXP-3") # 没贴 CK + + result = collab.export_cks(self.db, self.user, [first, second, blank]) + + assert result["total"] == 3 + assert result["exported"] == 2 + assert [item["order_no"] for item in result["items"]] == ["EXP-1", "EXP-2"] + # 明文就是库里的那一份(不是脱敏/预览;库里存的是**归一化后**的 CK, + # 所以跟 `fake_ck()` 的原始串不完全一样:重复的 guid 已被去掉) + assert result["items"][0]["ck_text"] == first.cookie + assert "udb_uid=1199647000001" in result["items"][0]["ck_text"] + assert result["items"][0]["ck_text"].count("guid=") == 1 + assert result["items"][0]["uid"] == "1199647000001" + + # 没 CK 的行显式跳过并回原因,不静默丢 + assert [row["order_no"] for row in result["skipped"]] == ["EXP-3"] + assert "CK" in result["skipped"][0]["reason"] + + def test_export_audit_never_contains_the_credential_body(self): + order = self._create(order_no="EXP-4", ck_text=fake_ck("1199647000004")) + collab.export_cks(self.db, self.user, [order]) + + audits = self._audits(order) + assert audits[-1].action == "huya:collab:ck_export" + assert audits[-1].username == self.user.username + assert order.order_no in audits[-1].detail # 只记「导出了哪一单」 + # 凭据本体(udb_cred / udb_biztoken 那两段超长值)绝不能进审计 + assert "udb_cred" not in audits[-1].detail + assert "C" * 152 not in audits[-1].detail + assert "T" * 200 not in audits[-1].detail + + def test_export_skips_blank_rows_without_audit(self): + blank = self._create(order_no="EXP-5") + collab.export_cks(self.db, self.user, [blank]) + assert [row.action for row in self._audits(blank)] == ["huya:collab:create"] + + def test_export_keeps_void_and_disabled_rows_with_labels(self): + """作废 / 停用的单只要还有 CK 就照导 —— 但状态标签要一起给,由人自己判断。""" + disabled = self._create(order_no="EXP-6", ck_text=fake_ck("1199647000006")) + collab.set_ck_status( + self.db, self.user, disabled, "disabled", message="不再使用" + ) + voided = self._create(order_no="EXP-7", ck_text=fake_ck("1199647000007")) + collab.set_status(self.db, self.user, voided, "void", reason="客户退单") + + result = collab.export_cks(self.db, self.user, [disabled, voided]) + + assert result["exported"] == 2 + by_no = {item["order_no"]: item for item in result["items"]} + assert by_no["EXP-6"]["ck_status_label"] == "已停用" + assert by_no["EXP-7"]["status_label"] == "已作废" + assert by_no["EXP-7"]["status"] == "void" + + def test_export_rejects_empty_and_too_many(self): + with pytest.raises(collab.CollabError) as excinfo: + collab.export_cks(self.db, self.user, []) + assert excinfo.value.code == "ck_export_empty" + + with pytest.raises(collab.CollabError) as excinfo: + collab.export_cks(self.db, self.user, [None] * (collab.PROBE_BATCH_MAX + 1)) + assert excinfo.value.code == "ck_export_too_large" + + class TestProbeCache(TestCollabBase): def test_same_ck_probed_twice_only_hits_the_api_once(self, monkeypatch): calls: list[dict] = [] @@ -1902,6 +1971,12 @@ class TestCollabPermissions: assert "file:view" in ROLE_PERMISSIONS[role] assert "file:upload" in ROLE_PERMISSIONS[role] + def test_ck_export_is_operation_only_by_default(self): + """导出 CK = 把凭据带出系统:默认只给运营(要发给客服就在权限页单独勾)。""" + assert "huya:collab:ck_export" in ROLE_PERMISSIONS["operation"] + assert "huya:collab:ck_export" not in ROLE_PERMISSIONS["support"] + assert "huya:collab:ck_export" in ROLE_PERMISSIONS["super_admin"] + def test_collab_module_does_not_touch_huya_accounts(self): """边界(设计文档 §2.3):本模块不建 HuyaAccount、不写工作台。""" assert collab.HuyaExternalOrder.__tablename__ == "huya_external_orders" diff --git a/web/backend/permissions.py b/web/backend/permissions.py index 2121d58..b502f80 100644 --- a/web/backend/permissions.py +++ b/web/backend/permissions.py @@ -53,6 +53,8 @@ PERMISSIONS = { "huya:collab:view": "查看外部 CK 协作台账", "huya:collab:edit": "编辑外部 CK 协作台账", "huya:collab:manage": "删除外部 CK 协作台账", + # CK 是凭据:导到剪贴板 = 把凭据带出系统,单独一个点(同 cookie:export 的口径),默认只给运营 + "huya:collab:ck_export": "导出外部 CK 协作台账的 CK(写剪贴板)", # 代理 & 白名单 "proxy:manage": "代理配置管理", "whitelist:manage": "白名单配置管理", @@ -105,6 +107,7 @@ ROLE_PERMISSIONS = { "huya:collab:view", "huya:collab:edit", "huya:collab:manage", + "huya:collab:ck_export", "proxy:manage", "whitelist:manage", "whitelist:test", @@ -125,6 +128,8 @@ ROLE_PERMISSIONS = { # 客服能看能改(共享表,靠审计留痕),但删除只给运营。 "huya:collab:view", "huya:collab:edit", + # 注意:**不给** huya:collab:ck_export —— 贴 CK 是「录入」,导出是「把凭据带走」, + # 需要时在权限页单独勾(口径同 huya:cookie:export,见设计文档 §11.8)。 "file:view", "file:upload", ], diff --git a/web/backend/routers/huya/collab.py b/web/backend/routers/huya/collab.py index ca9110c..16ab191 100644 --- a/web/backend/routers/huya/collab.py +++ b/web/backend/routers/huya/collab.py @@ -34,6 +34,8 @@ from ...schemas import ( HuyaExternalOrderActivityQueryOut, HuyaExternalOrderActivityQueryRequest, HuyaExternalOrderAuditOut, + HuyaExternalOrderCkExportOut, + HuyaExternalOrderCkExportRequest, HuyaExternalOrderCkRequest, HuyaExternalOrderCkStatusRequest, HuyaExternalOrderCreate, @@ -375,6 +377,25 @@ def verify_external_orders_ck( raise _http_error(exc) from exc +@router.post("/external-orders/ck/export", response_model=HuyaExternalOrderCkExportOut) +def export_external_orders_ck( + req: HuyaExternalOrderCkExportRequest, + db: Session = Depends(get_db), + current: User = Depends(require_permission("huya:collab:ck_export")), +): + """勾选多行导出 **CK 明文**(前端写进剪贴板)。 + + 单独一个权限点 ``huya:collab:ck_export``(默认只给运营):贴 CK 是「录入」, + 导出是「把凭据带走」。逐行写审计(``huya:collab:ck_export``),但**只记单号**, + 不把 CK 本体写进审计;没 CK 的行在 ``skipped`` 里显式回原因。 + """ + try: + orders = collab.list_orders_by_ids(db, req.ids) + return collab.export_cks(db, current, orders) + except collab.CollabError as exc: + raise _http_error(exc) from exc + + # -------------------------------------------------------------------------- # 单行操作 # -------------------------------------------------------------------------- diff --git a/web/backend/schemas.py b/web/backend/schemas.py index 5f3f67f..74d1b05 100644 --- a/web/backend/schemas.py +++ b/web/backend/schemas.py @@ -1304,6 +1304,43 @@ class HuyaExternalOrderActivityQueryRequest(BaseModel): ids: list[int] = Field(default_factory=list) +class HuyaExternalOrderCkExportRequest(BaseModel): + """勾选多行,导出这些行的 CK 明文(前端写剪贴板;一次最多 50 条)。""" + + ids: list[int] = Field(default_factory=list) + + +class HuyaExternalOrderCkExportItemOut(BaseModel): + """导出结果里的一行:``ck_text`` 是**明文凭据**,只走这个接口、不留日志。""" + + id: int + order_no: str = "" + uid: str = "" + nickname: str = "" + status: str = "" + status_label: str = "" + ck_status: str = "" + ck_status_label: str = "" + ck_text: str = "" + + +class HuyaExternalOrderCkExportSkippedOut(BaseModel): + """没导出的行(例如这行还没贴 CK)——把原因显式回给前端,别静默丢。""" + + id: int + order_no: str = "" + reason: str = "" + + +class HuyaExternalOrderCkExportOut(BaseModel): + """导出 CK 的结果:``exported + len(skipped) = total``。""" + + total: int + exported: int + skipped: list[HuyaExternalOrderCkExportSkippedOut] = Field(default_factory=list) + items: list[HuyaExternalOrderCkExportItemOut] = Field(default_factory=list) + + class HuyaCollabMetricMatchedOut(BaseModel): """匹配到的那条兑换记录(只留界面要用的几个字段)。""" diff --git a/web/backend/services/huya_collab_service.py b/web/backend/services/huya_collab_service.py index d2adc4b..9a437de 100644 --- a/web/backend/services/huya_collab_service.py +++ b/web/backend/services/huya_collab_service.py @@ -1678,6 +1678,90 @@ def list_orders_by_ids(db: Session, ids: Sequence[int]) -> list[HuyaExternalOrde return [by_id[value] for value in wanted if value in by_id] +# 导出 CK 的审计动作名(导出=把凭据带出系统,必须留痕) +CK_EXPORT_ACTION = "huya:collab:ck_export" + + +def export_cks( + db: Session, + current: User | None, + orders: Sequence[HuyaExternalOrder], + *, + commit: bool = True, +) -> dict: + """把勾选行的 **CK 明文**取出来(前端写剪贴板用)。 + + 这是本模块唯一一个把凭据**往外送**的读接口,所以三条约束写死在这里: + + 1. **权限单独一个点**(``huya:collab:ck_export``,默认只给运营)—— 贴 CK 是「录入」, + 导出是「把凭据带走」,两件事不该共用一个权限(口径同 ``huya:cookie:export``); + 2. **逐行写审计**(``huya:collab:ck_export``):只记「谁在什么时候导出了哪一单」, + **绝不把 CK 本体写进审计/日志**(列表接口也照旧不返回 cookie); + 3. **没 CK 的行显式跳过并回原因**,不静默丢;也不替用户猜 —— + 作废/停用的单只要还有 CK 照样导出,界面把状态标出来让人自己判断。 + + Returns: + ``{total, exported, skipped: [{id, order_no, reason}], items: [{..., ck_text}]}`` + ——只有这个接口会返回 ``ck_text``。 + """ + rows = list(orders) + if not rows: + raise CollabError("请先勾选要导出的行", code="ck_export_empty") + if len(rows) > PROBE_BATCH_MAX: + raise CollabError( + f"一次最多导出 {PROBE_BATCH_MAX} 行,请分批操作", code="ck_export_too_large" + ) + + items: list[dict] = [] + skipped: list[dict] = [] + for order in rows: + ck_text = str(order.cookie or "").strip() + if not ck_text: + skipped.append( + { + "id": int(order.id), + "order_no": order.order_no, + "reason": "这行还没贴 CK", + } + ) + continue + items.append( + { + "id": int(order.id), + "order_no": order.order_no, + "uid": str(order.uid or ""), + "nickname": order.nickname or "", + "status": order.status, + "status_label": ORDER_STATUS_LABELS.get(order.status, order.status), + "ck_status": order.ck_status, + "ck_status_label": CK_STATUS_LABELS.get( + order.ck_status, order.ck_status + ), + "ck_text": ck_text, + } + ) + record_audit( + db, + current, + action=CK_EXPORT_ACTION, + target=_audit_target(order), + detail={ + "order_no": order.order_no, + "uid": str(order.uid or ""), + "ck_status": order.ck_status, + "batch_size": len(rows), + }, + ) + if commit: + db.commit() + return { + "total": len(rows), + "exported": len(items), + "skipped": skipped, + "items": items, + } + + # -------------------------------------------------------------------------- # 店铺字典(新建协作单时点一下选;也支持自定义输入) # -------------------------------------------------------------------------- diff --git a/web/frontend/src/api/huya.ts b/web/frontend/src/api/huya.ts index 871d0b8..8b037a9 100644 --- a/web/frontend/src/api/huya.ts +++ b/web/frontend/src/api/huya.ts @@ -34,6 +34,7 @@ import type { HuyaCollabAuditItem, HuyaCollabActivityBatchResult, HuyaCollabActivityItem, + HuyaCollabCkExportResult, HuyaCollabCkPreview, HuyaCollabCkRequest, HuyaCollabCkStatusRequest, @@ -277,6 +278,12 @@ export const huyaApi = { /** 这一单的「账号全生命周期」时间线(每次查询的快照,新→旧) */ collabOrderMetrics: (id: number) => api.get(`/huya/external-orders/${id}/metrics`), + /** 勾选导出 CK 明文(前端写剪贴板;单独权限点 huya:collab:ck_export,逐行写审计) */ + exportCollabOrderCk: (ids: number[]) => + api.post( + '/huya/external-orders/ck/export', + { ids }, + ), /** 删除一行(只给运营:huya:collab:manage) */ deleteCollabOrder: (id: number) => api.delete<{ success: boolean; order_no: string }, { success: boolean; order_no: string }>( diff --git a/web/frontend/src/api/types.ts b/web/frontend/src/api/types.ts index 1881755..f46ee29 100644 --- a/web/frontend/src/api/types.ts +++ b/web/frontend/src/api/types.ts @@ -1394,6 +1394,34 @@ export interface HuyaCollabStatusBatchResult { results: HuyaCollabStatusBatchItem[]; } +/** 导出 CK 里的一行(ck_text 是**明文凭据**,只有导出接口会返回) */ +export interface HuyaCollabCkExportItem { + id: number; + order_no: string; + uid: string; + nickname: string; + status: HuyaCollabStatus; + status_label: string; + ck_status: string; + ck_status_label: string; + ck_text: string; +} + +/** 没导出的行(例如这行还没贴 CK)——原因显式回给界面,不静默丢 */ +export interface HuyaCollabCkExportSkipped { + id: number; + order_no: string; + reason: string; +} + +/** 勾选导出 CK 的结果:exported + skipped.length = total */ +export interface HuyaCollabCkExportResult { + total: number; + exported: number; + skipped: HuyaCollabCkExportSkipped[]; + items: HuyaCollabCkExportItem[]; +} + export interface HuyaCollabOrderList { items: HuyaCollabOrder[]; total: number; diff --git a/web/frontend/src/pages/HuyaCollabPage.tsx b/web/frontend/src/pages/HuyaCollabPage.tsx index 95fd0cf..8e5c786 100644 --- a/web/frontend/src/pages/HuyaCollabPage.tsx +++ b/web/frontend/src/pages/HuyaCollabPage.tsx @@ -9,12 +9,12 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Alert, App, AutoComplete, Button, Card, Checkbox, Col, Descriptions, Divider, Drawer, Dropdown, - Empty, Form, Image, Input, Modal, Popconfirm, Popover, Progress, Row, Select, Space, Statistic, + Empty, Form, Image, Input, 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, DeleteOutlined, EditOutlined, FileExcelOutlined, + CheckCircleOutlined, CloudUploadOutlined, CopyOutlined, DeleteOutlined, EditOutlined, FileExcelOutlined, LinkOutlined, PlusOutlined, ReloadOutlined, SafetyCertificateOutlined, ShopOutlined, StopOutlined, SwapOutlined, SyncOutlined, UndoOutlined, } from '@ant-design/icons'; @@ -22,6 +22,7 @@ import { fileApi, huyaApi, type HuyaCollabAuditItem, + type HuyaCollabCkExportResult, type HuyaCollabCkPreview, type HuyaCollabGift, type HuyaCollabImageSlot, @@ -39,6 +40,7 @@ import HuyaCollabShopPicker from '../components/HuyaCollabShopPicker'; import HuyaCollabHomeImageUploader from '../components/HuyaCollabHomeImageUploader'; import { message } from '../utils/antdMessage'; import { getErrorMessage } from '../utils/error'; +import { copyTextToClipboard } from '../utils/clipboard'; import { usePermissions } from '../hooks/usePermissions'; const { Text, Paragraph } = Typography; @@ -275,6 +277,8 @@ export default function HuyaCollabPage() { const { can, user } = usePermissions(); const canEdit = can('huya:collab:edit'); const canManage = can('huya:collab:manage'); + // 导出 CK 是把凭据带出系统,单独一个权限点(默认只给运营,见 permissions.py) + const canExportCk = can('huya:collab:ck_export'); const [rows, setRows] = useState([]); const [total, setTotal] = useState(0); @@ -336,6 +340,11 @@ export default function HuyaCollabPage() { const [querying, setQuerying] = useState(false); const [metrics, setMetrics] = useState([]); const [metricsLoading, setMetricsLoading] = useState(false); + // 勾选导出 CK(明文只在这一个弹窗里出现;复制走剪贴板) + const [ckExportOpen, setCkExportOpen] = useState(false); + const [ckExporting, setCkExporting] = useState(false); + const [ckExport, setCkExport] = useState(null); + const [ckExportFormat, setCkExportFormat] = useState<'plain' | 'with_order'>('plain'); const load = useCallback(async () => { setLoading(true); @@ -549,6 +558,60 @@ export default function HuyaCollabPage() { } }; + /** + * 勾选导出 CK:先把明文拉回来(后端逐行写审计),再让用户复制。 + * + * 明文只活在这次弹窗的 state 里:不进 localStorage、不写 URL、 + * 关掉弹窗就清掉(`closeCkExport()`)。 + */ + const openCkExport = async () => { + if (selectedIds.length === 0) return; + setCkExporting(true); + try { + const result = await huyaApi.exportCollabOrderCk(selectedIds); + setCkExport(result); + setCkExportOpen(true); + } catch (e: unknown) { + message.error(getErrorMessage(e)); + } finally { + setCkExporting(false); + } + }; + + const closeCkExport = () => { + setCkExportOpen(false); + setCkExport(null); + }; + + /** 导出文本:默认每行一份纯 CK(直接粘),也可以带上订单号(多选时好对号) */ + const ckExportText = useMemo(() => { + if (!ckExport) return ''; + return ckExport.items + .map((item) => ( + ckExportFormat === 'with_order' ? `${item.order_no}\t${item.ck_text}` : item.ck_text + )) + .join('\n'); + }, [ckExport, ckExportFormat]); + + // 已失效 / 已停用的 CK 照样导出(用户可能就是要看它),但得在界面上点名 + const ckExportRisky = useMemo( + () => (ckExport?.items ?? []).filter( + (item) => item.ck_status === 'invalid' || item.ck_status === 'disabled', + ), + [ckExport], + ); + + const copyCkExport = async () => { + if (!ckExport?.exported) return; + try { + await copyTextToClipboard(ckExportText); + message.success(`已复制 ${ckExport.exported} 份 CK 到剪贴板`); + } catch (e: unknown) { + // 复制失败别吞:明文就在下面的框里,让人手动选中复制 + message.error(`复制失败:${getErrorMessage(e)}(可以在下面的框里手动选中复制)`); + } + }; + const patchOrder = async (row: HuyaCollabOrder, payload: HuyaCollabOrderUpdate) => { try { const updated = await huyaApi.updateCollabOrder(row.id, { ...payload, expected_version: row.version }); @@ -1213,8 +1276,13 @@ export default function HuyaCollabPage() { - - + {/* + 固定两行:第一行永远是筛选,第二行永远是动作按钮组 —— 不要用「筛选 + 动作同一个 wrap」 + 那种排法:不勾选时两组挤成一行,勾选后按钮多了「(N)」就会被顶到第二行, + 列表跟着往下跳(2026-10-16 用户反馈「不要选中后两行」)。 + */} + + 店铺 { setPage(1); setFilters((prev) => ({ ...prev, include_void: checked })); }} /> - + {/* 第二行:动作(固定在这一行;勾选后加「(N)」只是按钮变宽,不会把列表顶下去) */} + {canEdit && ( )} + {canExportCk && ( + // 导出 CK 到剪贴板(勾选几行就导几行;明文只在这个弹窗里出现) + + )} {canEdit && ( , + , + ]} + destroyOnHidden + > + + + {ckExport && ckExport.skipped.length > 0 && ( + + 跳过 {ckExport.skipped.length} 条: + {ckExport.skipped.map((item) => `${item.order_no}(${item.reason})`).join(';')} + + )} + {ckExportRisky.length > 0 && ( + + 其中 {ckExportRisky.length} 条的 CK 已失效 / 已停用: + {ckExportRisky.map((item) => `${item.order_no}(${item.ck_status_label})`).join(';')} + + )} + setCkExportFormat(event.target.value)}> + 每行一份纯 CK(直接粘) + 订单号 + Tab + CK(多选时好对号) + + + + 共 {ckExport?.exported ?? 0} 份 · {ckExportText.length} 字符; + 关掉这个弹窗就清掉,不会留在页面上。 + + + +