feat(huya): 协作台账支持皮肤筛选与主页截图拖拽/粘贴上传
- 外部单列表新增礼包/皮肤精确筛选,并提供去重下拉选项 - 主页截图支持拖拽、多选及 Ctrl/Cmd+V 剪贴板粘贴上传
This commit is contained in:
@@ -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 唯一性)。
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 过滤) */
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user