feat(huya): 协作台账支持皮肤筛选与主页截图拖拽/粘贴上传

- 外部单列表新增礼包/皮肤精确筛选,并提供去重下拉选项
- 主页截图支持拖拽、多选及 Ctrl/Cmd+V 剪贴板粘贴上传
This commit is contained in:
yml2213
2026-10-17 12:49:42 +08:00
parent 1dd5c4a279
commit 26b8d6b054
8 changed files with 173 additions and 29 deletions
+27
View File
@@ -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 唯一性)。
+11
View File
@@ -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,
@@ -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()
+2
View File
@@ -209,6 +209,8 @@ export const huyaApi = {
api.get<HuyaCollabOwner[], HuyaCollabOwner[]>('/huya/external-orders/owners'),
/** 已用过的店铺(筛选下拉) */
collabOrderShops: () => api.get<string[], string[]>('/huya/external-orders/shops'),
/** 已用过的礼包/皮肤名(筛选下拉) */
collabOrderGifts: () => api.get<string[], string[]>('/huya/external-orders/gifts'),
// ---- 店铺字典(新建协作单时点选;见业务规则 docs/设计-虎牙外部CK协作台账.md)----
/** 全部店铺(含停用的:管理弹窗要看到;新建下拉由页面按 is_active 过滤) */
+1
View File
@@ -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;
@@ -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<FileUploadResult, 'url' | 'thumbnail_url'>;
/** 从剪贴板里取图片文件;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<void>>(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({
</Image.PreviewGroup>
)}
{!disabled && (
<Upload
accept="image/*"
<Dragger
className="huya-collab-home-drop"
accept={ACCEPT_ATTR}
multiple
showUploadList={false}
disabled={busy || full}
beforeUpload={(file) => {
void uploadOne(file as File);
beforeUpload={(file, fileList) => {
// beforeUpload 会按文件逐个调用;只在第一批触发一次,保持选择顺序
if (file === fileList[0]) enqueueUploads(fileList as File[]);
return false;
}}
>
<Button icon={<CloudUploadOutlined />} loading={busy} disabled={full}>
{full ? `最多 ${max} 张` : '上传主页截图'}
</Button>
</Upload>
<Space direction="vertical" size={2}>
<CloudUploadOutlined style={{ fontSize: 22 }} />
<Text strong>
{full
? `最多 ${max} 张`
: busy
? '上传中…'
: '点击、拖拽或 Ctrl/Cmd+V 粘贴主页截图'}
</Text>
{!full && (
<Text type="secondary" style={{ fontSize: 12 }}>
支持多张图片,第一张作封面
</Text>
)}
</Space>
</Dragger>
)}
</Space>
);
+5
View File
@@ -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;
}
+9 -2
View File
@@ -289,6 +289,7 @@ export default function HuyaCollabPage() {
const [summary, setSummary] = useState<HuyaCollabSummary | null>(null);
const [owners, setOwners] = useState<HuyaCollabOwner[]>([]);
const [shops, setShops] = useState<HuyaCollabShop[]>([]);
const [giftNames, setGiftNames] = useState<string[]>([]);
const [shopModalOpen, setShopModalOpen] = useState(false);
const [filters, setFilters] = useState<HuyaCollabOrderQuery>({ include_void: false });
const [selectedIds, setSelectedIds] = useState<number[]>([]);
@@ -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 })); }} />
<Select allowClear showSearch optionFilterProp="label" style={{ width: 180 }} placeholder="皮肤 / 礼包"
options={giftNames.map((name) => ({ value: name, label: name }))}
value={filters.gift_name || undefined}
onChange={(value?: string) => { setPage(1); setFilters((prev) => ({ ...prev, gift_name: value })); }} />
<Input.Search allowClear style={{ width: 340 }}
placeholder="搜订单号 / 店铺 / 绑定账号 / 角色 / 虎牙号 / 礼包 / 备注"
onSearch={(value) => { setPage(1); setFilters((prev) => ({ ...prev, keyword: value })); }} />
@@ -1842,7 +1849,7 @@ export default function HuyaCollabPage() {
<Form.Item
name="home_images"
label="主页截图(必填,第一张作封面)"
tooltip="上传主页截图;绑定图不用再传了 —— 绑了谁由「验证 CK」的探针自动读"
tooltip="支持点击选择、拖拽或 Ctrl/Cmd+V 粘贴上传;第一张作封面,绑定图不用再传"
rules={[{
validator: (_rule, value: string[] | undefined) => (
value && value.length > 0