diff --git a/tests/test_huya_collab_orders.py b/tests/test_huya_collab_orders.py index d246fc9..48908d4 100644 --- a/tests/test_huya_collab_orders.py +++ b/tests/test_huya_collab_orders.py @@ -1433,6 +1433,33 @@ class TestCollabKeywordSearch(TestCollabBase): assert self._search("查不到的东西") == [] +class TestCollabGiftFilter(TestCollabBase): + """按礼包/皮肤筛选:下拉选项去重,查询按礼包名精确匹配。""" + + def test_list_orders_filters_by_exact_gift_name(self): + target = self._create(order_no="G-1", gift_name="天网终结者大礼包") + self._create(order_no="G-2", gift_name="套装-纯白跃动") + + rows, total = collab.list_orders(self.db, gift_name="天网终结者大礼包") + assert total == 1 + assert [row.id for row in rows] == [target.id] + + # 筛选项是点选值,不做模糊匹配(模糊搜索仍走 keyword) + rows, total = collab.list_orders(self.db, gift_name="天网") + assert total == 0 + assert rows == [] + + def test_gift_names_are_unique_sorted_and_ignore_empty(self): + self._create(order_no="G-3", gift_name="套装-纯白跃动") + self._create(order_no="G-4", gift_name="套装-纯白跃动") + self._create(order_no="G-5", gift_name="天网终结者大礼包") + self._create(order_no="G-6", gift_name="") + + assert collab.list_gift_names(self.db) == sorted( + ["套装-纯白跃动", "天网终结者大礼包"] + ) + + class TestCollabBuyerCluster(TestCollabBase): """同一买家的多单:按「绑定账号 + 绑定角色」软识别(只读派生,不动 uid 唯一性)。 diff --git a/web/backend/routers/huya/collab.py b/web/backend/routers/huya/collab.py index 16ab191..d59a5c2 100644 --- a/web/backend/routers/huya/collab.py +++ b/web/backend/routers/huya/collab.py @@ -113,6 +113,15 @@ def external_orders_shops( return collab.list_shops(db) +@router.get("/external-orders/gifts", response_model=list[str]) +def external_orders_gifts( + db: Session = Depends(get_db), + _current: User = Depends(require_permission("huya:collab:view")), +): + """已用过的礼包/皮肤名(去重;筛选下拉用)。""" + return collab.list_gift_names(db) + + # -------------------------------------------------------------------------- # 店铺字典(新建协作单时点选;也支持自定义输入后自动登记) # -------------------------------------------------------------------------- @@ -190,6 +199,7 @@ def list_external_orders( owner_id: int | None = Query(None), status: str = Query("", max_length=16), ck_status: str = Query("", max_length=16), + gift_name: str = Query("", max_length=128), keyword: str = Query("", max_length=128), include_void: bool = Query(False, description="是否包含已作废的行(默认隐藏)"), page: int = Query(1, ge=1), @@ -204,6 +214,7 @@ def list_external_orders( owner_id=owner_id, status=status, ck_status=ck_status, + gift_name=gift_name, keyword=keyword, include_void=include_void, page=page, diff --git a/web/backend/services/huya_collab_service.py b/web/backend/services/huya_collab_service.py index 9a437de..9d04d57 100644 --- a/web/backend/services/huya_collab_service.py +++ b/web/backend/services/huya_collab_service.py @@ -2019,6 +2019,14 @@ def list_shops(db: Session) -> list[str]: return sorted(shop.name for shop in list_shop_records(db)) +def list_gift_names(db: Session) -> list[str]: + """台账里出现过的礼包/皮肤名(去重、去空;筛选下拉用)。""" + rows = db.query(HuyaExternalOrder.gift_name).distinct().all() + return sorted( + {str(row[0]).strip() for row in rows if row[0] and str(row[0]).strip()} + ) + + # -------------------------------------------------------------------------- # CRUD # -------------------------------------------------------------------------- @@ -2262,6 +2270,7 @@ def list_orders( owner_id: int | None = None, status: str = "", ck_status: str = "", + gift_name: str = "", keyword: str = "", include_void: bool = False, page: int = 1, @@ -2277,6 +2286,9 @@ def list_orders( query = query.filter(HuyaExternalOrder.status == status) if ck_status: query = query.filter(HuyaExternalOrder.ck_status == ck_status) + skin = (gift_name or "").strip() + if skin: + query = query.filter(HuyaExternalOrder.gift_name == skin) if not include_void: query = query.filter(HuyaExternalOrder.status != STATUS_VOID) text = (keyword or "").strip() diff --git a/web/frontend/src/api/huya.ts b/web/frontend/src/api/huya.ts index 8b037a9..8539d16 100644 --- a/web/frontend/src/api/huya.ts +++ b/web/frontend/src/api/huya.ts @@ -209,6 +209,8 @@ export const huyaApi = { api.get('/huya/external-orders/owners'), /** 已用过的店铺(筛选下拉) */ collabOrderShops: () => api.get('/huya/external-orders/shops'), + /** 已用过的礼包/皮肤名(筛选下拉) */ + collabOrderGifts: () => api.get('/huya/external-orders/gifts'), // ---- 店铺字典(新建协作单时点选;见业务规则 docs/设计-虎牙外部CK协作台账.md)---- /** 全部店铺(含停用的:管理弹窗要看到;新建下拉由页面按 is_active 过滤) */ diff --git a/web/frontend/src/api/types.ts b/web/frontend/src/api/types.ts index f46ee29..8e830c4 100644 --- a/web/frontend/src/api/types.ts +++ b/web/frontend/src/api/types.ts @@ -1454,6 +1454,7 @@ export interface HuyaCollabOrderQuery { owner_id?: number | null; status?: string; ck_status?: string; + gift_name?: string; keyword?: string; include_void?: boolean; page?: number; diff --git a/web/frontend/src/components/HuyaCollabHomeImageUploader.tsx b/web/frontend/src/components/HuyaCollabHomeImageUploader.tsx index 503273e..857988d 100644 --- a/web/frontend/src/components/HuyaCollabHomeImageUploader.tsx +++ b/web/frontend/src/components/HuyaCollabHomeImageUploader.tsx @@ -3,9 +3,10 @@ * * - 第一张就是封面(列表和详情都按第一张当封面); * - 新建时还没落库,所以预览用的地址来自上传接口返回的 thumbnail_url / url; - * - 放进 `Form.Item name="home_images"`,值就是 object_key 数组。 + * - 放进 `Form.Item name="home_images"`,值就是 object_key 数组; + * - 支持点击选择、拖拽,以及剪贴板里的截图直接 Ctrl/Cmd+V 粘贴。 */ -import { useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { Button, Image, Popconfirm, Space, Tag, Tooltip, Typography, Upload } from 'antd'; import { CloudUploadOutlined, DeleteOutlined, SwapOutlined } from '@ant-design/icons'; import { fileApi } from '../api/files'; @@ -14,9 +15,28 @@ import { message } from '../utils/antdMessage'; import { getErrorMessage } from '../utils/error'; const { Text } = Typography; +const { Dragger } = Upload; + +const ACCEPTED_IMAGE_TYPES = ['image/png', 'image/jpeg', 'image/webp']; +const ACCEPT_ATTR = ACCEPTED_IMAGE_TYPES.join(','); type Preview = Pick; +/** 从剪贴板里取图片文件;items 拿不到时退回 files。 */ +function clipboardImageFiles(data: DataTransfer | null): File[] { + if (!data) return []; + const fromItems: File[] = []; + for (const item of Array.from(data.items ?? [])) { + if (item.kind !== 'file' || !ACCEPTED_IMAGE_TYPES.includes(item.type)) continue; + const file = item.getAsFile(); + if (file) fromItems.push(file); + } + if (fromItems.length > 0) return fromItems; + return Array.from(data.files ?? []).filter((file) => + ACCEPTED_IMAGE_TYPES.includes(file.type), + ); +} + export default function HuyaCollabHomeImageUploader({ value = [], max = 20, @@ -39,24 +59,69 @@ export default function HuyaCollabHomeImageUploader({ const keys = value ?? []; const full = keys.length >= max; + // 上传串行执行:多选/拖拽/一次粘贴多张时,封面顺序和用户选择顺序一致 + const uploadQueue = useRef>(Promise.resolve()); - const uploadOne = async (file: File) => { - setBusy(true); - try { - const uploaded = await fileApi.upload(file, 'collab'); - setPreviews((prev) => ({ - ...prev, - [uploaded.object_key]: { url: uploaded.url, thumbnail_url: uploaded.thumbnail_url }, - })); - const next = [...keysRef.current, uploaded.object_key]; - keysRef.current = next; - onChange?.(next); - } catch (e: unknown) { - message.error(getErrorMessage(e)); - } finally { - setBusy(false); - } - }; + const enqueueUploads = useCallback( + (files: File[]) => { + const images = files.filter((file) => ACCEPTED_IMAGE_TYPES.includes(file.type)); + if (images.length === 0) { + if (files.length > 0) message.warning('只支持 PNG / JPG / WebP 图片'); + return; + } + if (images.length < files.length) { + message.warning('已忽略非 PNG / JPG / WebP 文件'); + } + + uploadQueue.current = uploadQueue.current.catch(() => {}).then(async () => { + const remaining = Math.max(0, max - keysRef.current.length); + if (remaining === 0) { + message.warning(`最多上传 ${max} 张主页截图`); + return; + } + const accepted = images.slice(0, remaining); + if (accepted.length < images.length) { + message.warning(`最多上传 ${max} 张,已忽略多余图片`); + } + + setBusy(true); + try { + for (const file of accepted) { + try { + const uploaded = await fileApi.upload(file, 'collab'); + setPreviews((prev) => ({ + ...prev, + [uploaded.object_key]: { + url: uploaded.url, + thumbnail_url: uploaded.thumbnail_url, + }, + })); + const next = [...keysRef.current, uploaded.object_key]; + keysRef.current = next; + onChange?.(next); + } catch (e: unknown) { + message.error(getErrorMessage(e)); + } + } + } finally { + setBusy(false); + } + }); + }, + [max, onChange], + ); + + useEffect(() => { + if (disabled) return undefined; + const handlePaste = (event: ClipboardEvent) => { + const files = clipboardImageFiles(event.clipboardData); + if (files.length === 0) return; + event.preventDefault(); + enqueueUploads(files); + }; + window.addEventListener('paste', handlePaste); + return () => window.removeEventListener('paste', handlePaste); + }, [disabled, enqueueUploads]); const remove = (key: string) => { const next = keysRef.current.filter((item) => item !== key); @@ -123,20 +188,34 @@ export default function HuyaCollabHomeImageUploader({ )} {!disabled && ( - { - void uploadOne(file as File); + beforeUpload={(file, fileList) => { + // beforeUpload 会按文件逐个调用;只在第一批触发一次,保持选择顺序 + if (file === fileList[0]) enqueueUploads(fileList as File[]); return false; }} > - - + + + + {full + ? `最多 ${max} 张` + : busy + ? '上传中…' + : '点击、拖拽或 Ctrl/Cmd+V 粘贴主页截图'} + + {!full && ( + + 支持多张图片,第一张作封面 + + )} + + )} ); diff --git a/web/frontend/src/index.css b/web/frontend/src/index.css index 284d92d..8fd9c73 100644 --- a/web/frontend/src/index.css +++ b/web/frontend/src/index.css @@ -29,3 +29,8 @@ html[data-theme='dark'] body { .huya-collab-create .ant-form-item-label { padding-bottom: 2px; } + +/* 主页截图:拖拽/粘贴区压紧,避免把新建弹窗撑得太高 */ +.huya-collab-home-drop .ant-upload-btn { + padding: 10px 12px !important; +} diff --git a/web/frontend/src/pages/HuyaCollabPage.tsx b/web/frontend/src/pages/HuyaCollabPage.tsx index 8e5c786..a279e83 100644 --- a/web/frontend/src/pages/HuyaCollabPage.tsx +++ b/web/frontend/src/pages/HuyaCollabPage.tsx @@ -289,6 +289,7 @@ export default function HuyaCollabPage() { const [summary, setSummary] = useState(null); const [owners, setOwners] = useState([]); const [shops, setShops] = useState([]); + const [giftNames, setGiftNames] = useState([]); const [shopModalOpen, setShopModalOpen] = useState(false); const [filters, setFilters] = useState({ include_void: false }); const [selectedIds, setSelectedIds] = useState([]); @@ -349,17 +350,19 @@ export default function HuyaCollabPage() { const load = useCallback(async () => { setLoading(true); try { - const [list, nextSummary, nextOwners, nextShops] = await Promise.all([ + const [list, nextSummary, nextOwners, nextShops, nextGiftNames] = await Promise.all([ huyaApi.listCollabOrders({ ...filters, page, page_size: pageSize }), huyaApi.collabOrdersSummary(), huyaApi.collabOrderOwners(), huyaApi.listCollabShops(), + huyaApi.collabOrderGifts(), ]); setRows(list.items); setTotal(list.total); setSummary(nextSummary); setOwners(nextOwners); setShops(nextShops); + setGiftNames(nextGiftNames); // 选中是「当前页」的概念:刷新后剔除已经不在这页上的行,避免点到别人的脏 id。 setSelectedIds((ids) => ids.filter((id) => list.items.some((row) => row.id === id))); } catch (e: unknown) { @@ -1306,6 +1309,10 @@ export default function HuyaCollabPage() { options={Object.entries(CK_META).map(([key, meta]) => ({ value: key, label: meta.label }))} value={filters.ck_status || undefined} onChange={(value?: string) => { setPage(1); setFilters((prev) => ({ ...prev, ck_status: value })); }} /> +