diff --git a/tests/test_huya_collab_orders.py b/tests/test_huya_collab_orders.py index 92c2f67..b289dd1 100644 --- a/tests/test_huya_collab_orders.py +++ b/tests/test_huya_collab_orders.py @@ -2055,6 +2055,23 @@ class TestCollabCkExport(TestCollabBase): collab.export_cks(self.db, self.user, [None] * (collab.PROBE_BATCH_MAX + 1)) assert excinfo.value.code == "ck_export_too_large" + def test_single_row_copy_is_super_admin_only(self): + """操作列「复制 CK」只给管理员;运营/客服即使有导出权限也不能走这个入口。""" + order = self._create(order_no="EXP-ADMIN-1", ck_text=fake_ck("1199647000099")) + + with pytest.raises(collab_api.HTTPException) as excinfo: + collab_api.copy_external_order_ck(order.id, self.db, self.user) + assert excinfo.value.status_code == 403 + + admin = User(username="collab_admin", password_hash="h", role="super_admin") + self.db.add(admin) + self.db.commit() + result = collab_api.copy_external_order_ck(order.id, self.db, admin) + assert result["exported"] == 1 + assert result["items"][0]["order_no"] == "EXP-ADMIN-1" + assert result["items"][0]["ck_text"] == order.cookie + assert self._audits(order)[-1].username == "collab_admin" + class TestProbeCache(TestCollabBase): def test_same_ck_probed_twice_only_hits_the_api_once(self, monkeypatch): diff --git a/web/backend/routers/huya/collab.py b/web/backend/routers/huya/collab.py index 4fbf11a..3c0e91e 100644 --- a/web/backend/routers/huya/collab.py +++ b/web/backend/routers/huya/collab.py @@ -30,7 +30,7 @@ from fastapi import ( from sqlalchemy.orm import Session from ...database import get_db -from ...deps import authenticate_websocket, require_permission +from ...deps import authenticate_websocket, get_current_user, require_permission from ...models import AuditLog, HuyaExternalOrder, User from ...permissions import get_user_permissions from ...schemas import ( @@ -442,6 +442,30 @@ def export_external_orders_ck( raise _http_error(exc) from exc +@router.post( + "/external-orders/{order_id}/ck/copy", + response_model=HuyaExternalOrderCkExportOut, +) +def copy_external_order_ck( + order_id: int, + db: Session = Depends(get_db), + current: User = Depends(get_current_user), +): + """复制单行 CK 明文(**仅超级管理员**;前端直接写剪贴板)。 + + 这个入口比勾选导出更敏感:它把「单行凭据」做成一键复制,因此只给管理员。 + 仍然复用 :func:`collab.export_cks`:逐行写 ``huya:collab:ck_export`` 审计, + 但审计里只记单号,不写 CK 本体。 + """ + if current.role != "super_admin": + raise HTTPException(status_code=403, detail="仅超级管理员可复制 CK") + try: + order = collab.get_order(db, order_id) + return collab.export_cks(db, current, [order]) + except collab.CollabError as exc: + raise _http_error(exc) from exc + + @router.websocket("/external-orders/ws") async def ws_external_orders(websocket: WebSocket): """协作台账实时失效通知:客户端收到后按自己的筛选静默重拉。""" diff --git a/web/frontend/src/api/huya.ts b/web/frontend/src/api/huya.ts index 8539d16..be81dbf 100644 --- a/web/frontend/src/api/huya.ts +++ b/web/frontend/src/api/huya.ts @@ -286,6 +286,11 @@ export const huyaApi = { '/huya/external-orders/ck/export', { ids }, ), + /** 单行复制 CK(仅超级管理员;后端再次校验角色) */ + copyCollabOrderCk: (id: number) => + api.post( + `/huya/external-orders/${id}/ck/copy`, + ), /** 删除一行(只给运营:huya:collab:manage) */ deleteCollabOrder: (id: number) => api.delete<{ success: boolean; order_no: string }, { success: boolean; order_no: string }>( diff --git a/web/frontend/src/pages/HuyaCollabPage.tsx b/web/frontend/src/pages/HuyaCollabPage.tsx index 29f640c..778c12f 100644 --- a/web/frontend/src/pages/HuyaCollabPage.tsx +++ b/web/frontend/src/pages/HuyaCollabPage.tsx @@ -8,14 +8,14 @@ */ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { - Alert, App, AutoComplete, Button, Card, Checkbox, Col, Descriptions, Divider, Drawer, Dropdown, + Alert, App, Button, Card, Checkbox, Col, Descriptions, Divider, Drawer, Dropdown, 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, CopyOutlined, DeleteOutlined, EditOutlined, FileExcelOutlined, - LinkOutlined, PlusOutlined, ReloadOutlined, SafetyCertificateOutlined, ShopOutlined, + CheckCircleOutlined, CloudUploadOutlined, CopyOutlined, DeleteOutlined, EditOutlined, FileAddOutlined, + FileExcelOutlined, LinkOutlined, PlusOutlined, ReloadOutlined, SafetyCertificateOutlined, ShopOutlined, StopOutlined, SwapOutlined, SyncOutlined, UndoOutlined, } from '@ant-design/icons'; import { @@ -267,6 +267,68 @@ function hasDraftContent(draft: Omit): boolean { ); } +// 「补资料」草稿:按登录用户 + 订单 id 分开存。填一半关掉 / 刷新后, +// 再点这一行的「补资料」会原样恢复(含刚贴的 CK 和刚上传的截图 key)。 +const SUPPLEMENT_DRAFT_KEY_PREFIX = 'huya.collab.supplementDraft'; + +interface SupplementDraft { + shop: string; + ck_text: string; + gift_name: string; + remark: string; + home_images: string[]; + saved_at: number; +} + +interface SupplementFormValues { + shop: string; + ck_text?: string; + gift_name?: string; + remark?: string; + home_images?: string[]; +} + +function supplementDraftKey(userId?: number | null, orderId?: number): string { + return userId && orderId ? `${SUPPLEMENT_DRAFT_KEY_PREFIX}.${userId}.${orderId}` : ''; +} + +function readSupplementDraft(userId: number | null | undefined, orderId: number): SupplementDraft | null { + const key = supplementDraftKey(userId, orderId); + if (!key) return null; + try { + const raw = window.localStorage.getItem(key); + if (!raw) return null; + const parsed = JSON.parse(raw) as Partial; + const savedAt = Number(parsed.saved_at) || 0; + if (!savedAt || Date.now() - savedAt > 7 * 24 * 60 * 60 * 1000) { + window.localStorage.removeItem(key); + return null; + } + return { + shop: typeof parsed.shop === 'string' ? parsed.shop : '', + ck_text: typeof parsed.ck_text === 'string' ? parsed.ck_text : '', + gift_name: typeof parsed.gift_name === 'string' ? parsed.gift_name : '', + remark: typeof parsed.remark === 'string' ? parsed.remark : '', + home_images: Array.isArray(parsed.home_images) + ? parsed.home_images.filter((item): item is string => typeof item === 'string') + : [], + saved_at: savedAt, + }; + } catch { + return null; + } +} + +function clearSupplementDraft(userId: number | null | undefined, orderId: number): void { + const key = supplementDraftKey(userId, orderId); + if (!key) return; + try { + window.localStorage.removeItem(key); + } catch { + // 忽略:清不掉也不影响当前表单 + } +} + function formatTime(value: string | null | undefined): string { if (!value) return '-'; const time = new Date(value); @@ -410,6 +472,8 @@ export default function HuyaCollabPage() { const canManage = can('huya:collab:manage'); // 导出 CK 是把凭据带出系统,单独一个权限点(默认只给运营,见 permissions.py) const canExportCk = can('huya:collab:ck_export'); + // 「复制 CK」是单行一键带走凭据,按用户要求只给超级管理员(后端也会再校验一次) + const isAdmin = user?.role === 'super_admin'; const [rows, setRows] = useState([]); const [total, setTotal] = useState(0); @@ -438,6 +502,16 @@ export default function HuyaCollabPage() { const [createForm] = Form.useForm>(); const [draftRestoredAt, setDraftRestoredAt] = useState(null); const draftSaveTimer = useRef(null); + + // 「补资料」:对待补资料的单补 CK / 礼包 / 主页截图 / 备注,草稿按订单存在本机 + const [supplementTarget, setSupplementTarget] = useState(null); + const [supplementForm] = Form.useForm(); + const [supplementDraftAt, setSupplementDraftAt] = useState(null); + const supplementDraftTimer = useRef(null); + const createGiftValue = Form.useWatch('gift_name', createForm) as string | undefined; + const supplementGiftValue = Form.useWatch('gift_name', supplementForm) as string | undefined; + const supplementCkText = Form.useWatch('ck_text', supplementForm) as string | undefined; + const supplementHomeImages = Form.useWatch('home_images', supplementForm) as string[] | undefined; const [ckPreview, setCkPreview] = useState(null); const [previewing, setPreviewing] = useState(false); // 这次预览有没有带「拉礼包」(没有就是纯探针 + 用本地礼包缓存),只影响提示文案 @@ -521,7 +595,7 @@ export default function HuyaCollabPage() { const realtimeEnabled = can('huya:collab:view'); const collabModalOpen = Boolean( - createOpen || detail || statusAction || batchStatusOpen || ckTarget || ckExportOpen || shopModalOpen, + createOpen || supplementTarget || detail || statusAction || batchStatusOpen || ckTarget || ckExportOpen || shopModalOpen, ); const handleRealtimeInvalidate = useCallback((event: CollabRealtimeEvent) => { @@ -1019,6 +1093,203 @@ export default function HuyaCollabPage() { } }; + /** 单行「复制 CK」:只有超级管理员能看到按钮,后端还会再校验一次角色。 */ + const copyOneCk = async (row: HuyaCollabOrder) => { + setBusy(true); + try { + const result = await huyaApi.copyCollabOrderCk(row.id); + const item = result.items[0]; + if (!item) { + message.warning(result.skipped[0]?.reason || '这行还没有可复制的 CK'); + return; + } + await copyTextToClipboard(item.ck_text); + message.success(`已复制 ${row.order_no} 的 CK`); + } catch (e: unknown) { + message.error(`复制失败:${getErrorMessage(e)}`); + } finally { + setBusy(false); + } + }; + + /** 把「补资料」表单写进本机草稿:仍按用户 + 订单分键,最多留 7 天。 */ + const writeSupplementDraft = useCallback((values: SupplementFormValues, target: HuyaCollabOrder) => { + const userId = user?.id; + const key = supplementDraftKey(userId, target.id); + if (!key) return; + const images = Array.isArray(values.home_images) + ? values.home_images.filter((item): item is string => typeof item === 'string') + : target.home_images; + const payload: Omit = { + shop: String(values.shop ?? ''), + ck_text: String(values.ck_text ?? ''), + gift_name: String(values.gift_name ?? ''), + remark: String(values.remark ?? ''), + home_images: images, + }; + const changed = Boolean( + payload.ck_text.trim() + || payload.shop !== target.shop + || payload.gift_name !== target.gift_name + || payload.remark !== target.remark + || payload.home_images.join('\n') !== target.home_images.join('\n'), + ); + try { + if (!changed) { + window.localStorage.removeItem(key); + return; + } + window.localStorage.setItem(key, JSON.stringify({ ...payload, saved_at: Date.now() })); + } catch { + // 隐私模式 / 配额满:暂存失败不影响提交 + } + }, [user?.id]); + + const persistSupplementDraft = useCallback((values: SupplementFormValues, target: HuyaCollabOrder) => { + if (supplementDraftTimer.current) window.clearTimeout(supplementDraftTimer.current); + supplementDraftTimer.current = window.setTimeout(() => writeSupplementDraft(values, target), 400); + }, [writeSupplementDraft]); + + const openSupplement = (row: HuyaCollabOrder) => { + if (supplementDraftTimer.current) { + window.clearTimeout(supplementDraftTimer.current); + supplementDraftTimer.current = null; + } + const draft = readSupplementDraft(user?.id, row.id); + const base: SupplementFormValues = { + shop: row.shop, + ck_text: '', + gift_name: row.gift_name, + remark: row.remark, + home_images: row.home_images, + }; + supplementForm.setFieldsValue(draft ? { ...base, ...draft } : base); + setSupplementDraftAt(draft?.saved_at ?? null); + setSupplementTarget(row); + }; + + const closeSupplement = useCallback(() => { + if (supplementDraftTimer.current) { + window.clearTimeout(supplementDraftTimer.current); + supplementDraftTimer.current = null; + } + if (supplementTarget) { + writeSupplementDraft( + supplementForm.getFieldsValue(true) as SupplementFormValues, + supplementTarget, + ); + } + setSupplementTarget(null); + setSupplementDraftAt(null); + supplementForm.resetFields(); + }, [supplementForm, supplementTarget, writeSupplementDraft]); + + const clearSupplement = () => { + if (!supplementTarget) return; + if (supplementDraftTimer.current) { + window.clearTimeout(supplementDraftTimer.current); + supplementDraftTimer.current = null; + } + clearSupplementDraft(user?.id, supplementTarget.id); + setSupplementDraftAt(null); + supplementForm.setFieldsValue({ + shop: supplementTarget.shop, + ck_text: '', + gift_name: supplementTarget.gift_name, + remark: supplementTarget.remark, + home_images: supplementTarget.home_images, + }); + }; + + /** + * 保存「补资料」:字段 / 新 CK / 截图分别走各自接口(每个都有乐观锁), + * 新 CK 保存后立即跑一次只读探针;用户点「保存并进入待处理」时再校验资料完整性。 + */ + const submitSupplement = async (markPending = false) => { + if (!supplementTarget) return; + let values: SupplementFormValues; + try { + values = await supplementForm.validateFields(); + } catch { + return; + } + setBusy(true); + try { + let current = supplementTarget; + const payload: HuyaCollabOrderUpdate = {}; + const shop = String(values.shop ?? '').trim(); + const giftName = String(values.gift_name ?? '').trim(); + const remark = String(values.remark ?? ''); + if (shop !== current.shop) payload.shop = shop; + if (giftName !== current.gift_name) payload.gift_name = giftName; + if (remark !== current.remark) payload.remark = remark; + if (Object.keys(payload).length > 0) { + current = await huyaApi.updateCollabOrder(current.id, { + ...payload, + expected_version: current.version, + }); + } + + const ckText = String(values.ck_text ?? '').trim(); + if (ckText) { + current = await huyaApi.updateCollabOrderCk(current.id, { + ck_text: ckText, + action: 'paste', + expected_version: current.version, + }); + current = await huyaApi.verifyCollabOrder(current.id); + } + + const images = values.home_images ?? current.home_images; + if (images.join('\n') !== current.home_images.join('\n')) { + current = await huyaApi.updateCollabOrderImages(current.id, { + slot: 'home_images', + images, + expected_version: current.version, + }); + } + + // 没有换 CK、但准备进「待处理」时,先跑一次只读探针;避免拿一份没验证的旧 CK 往前推 + if (markPending && !ckText && current.has_cookie && current.ck_status !== 'valid') { + current = await huyaApi.verifyCollabOrder(current.id); + } + + if (markPending && current.ck_status !== 'valid') { + applyUpdated(current); + setSupplementTarget(current); + supplementForm.setFieldValue('ck_text', ''); + void load(); + message.warning( + '资料已保存,但这份 CK 还没验证通过,暂时不能进入「待处理」;请先补 / 换一份有效 CK', + ); + return; + } + if (markPending) { + current = await huyaApi.updateCollabOrderStatus(current.id, { + status: 'pending', + expected_version: current.version, + }); + } + + applyUpdated(current); + clearSupplementDraft(user?.id, supplementTarget.id); + setSupplementDraftAt(null); + supplementForm.resetFields(); + setSupplementTarget(null); + message.success( + markPending + ? `已补齐资料,${current.order_no} 进入「待处理」` + : `已保存 ${current.order_no} 的资料`, + ); + void load(); + } catch (e: unknown) { + message.error(getErrorMessage(e)); + void load(); + } finally { + setBusy(false); + } + }; + const commitSlot = async (row: HuyaCollabOrder, slot: HuyaCollabImageSlot, keys: string[]) => { const updated = await huyaApi.updateCollabOrderImages(row.id, { slot, images: keys, expected_version: row.version, @@ -1111,6 +1382,7 @@ export default function HuyaCollabPage() { useEffect(() => () => { if (draftSaveTimer.current) window.clearTimeout(draftSaveTimer.current); + if (supplementDraftTimer.current) window.clearTimeout(supplementDraftTimer.current); }, []); /** 清掉草稿并恢复空表单(「清空草稿」入口用) */ @@ -1229,6 +1501,27 @@ export default function HuyaCollabPage() { ? `礼包用本地缓存(${giftOptions.length} 个${giftCacheLabel ? ` · ${giftCacheLabel}` : ''}),要更新点「强制刷新礼包」` : (ckPreview?.gifts_message || ''); + /** + * 礼包下拉选项:只允许点选缓存里的礼包,不接受手输。 + * 当前值是历史/已下线礼包时补一个只读选项,保证界面能显示,但依然只能从下拉里选。 + */ + const giftSelectOptions = (current?: string | null) => { + const value = String(current ?? '').trim(); + const options = giftOptions.map((gift) => ({ + value: gift.name, + label: [ + gift.category_name ? `[${gift.category_name}]` : '', + gift.name, + gift.price ? `· ${gift.price} 积分` : '', + gift.remain_text ? `· 剩 ${gift.remain_text}` : '', + ].filter(Boolean).join(' '), + })); + if (value && !giftOptions.some((gift) => gift.name === value)) { + options.unshift({ value, label: `${value}(历史值)` }); + } + return options; + }; + // 表头排序:一次正序、再点反序(Table 上限制 sortDirections,不会出现「第三次取消」)。 const sortProps = (key: CollabSortKey) => ({ key, @@ -1392,8 +1685,21 @@ export default function HuyaCollabPage() { ...sortProps('gift_name'), render: (_, row) => ( - void patchOrder(row, { gift_name: next })} /> + {/* 礼包名只能从缓存列表点选:手输会出现“看起来一样、接口拉不到”的脏值 */} + + + 0 ? '输入关键字搜索,点选礼包' : '礼包缓存为空:先在「新建」里贴 CK 拉一次'} + options={giftSelectOptions(supplementGiftValue ?? supplementTarget.gift_name)} + disabled={giftOptions.length === 0 && !supplementGiftValue} + notFoundContent={giftOptions.length === 0 + ? '礼包缓存为空:先在「新建」里贴 CK 拉一次' + : '没有匹配的礼包'} + /> + + + + + + + + + + + + + + + + + + + + + )} + +