修改并发上传

This commit is contained in:
zhx 2026-05-19 18:25:02 +08:00
parent d2a994cfa9
commit ec97291d0e
4 changed files with 204 additions and 57 deletions

View File

@ -54,6 +54,13 @@ interface ParsedGiftFile {
name: string;
}
interface UploadTask {
file: File;
fileName: string;
kind: AssetKind;
row: BatchGiftRow;
}
const props = defineProps<{
initialSysOrigin?: number | string;
open: boolean;
@ -82,6 +89,9 @@ const sequence = ref(0);
const showOnlyErrors = ref(false);
const submitting = ref(false);
const droppedFilePathMap = new WeakMap<File, string>();
const uploadQueue: UploadTask[] = [];
const MAX_UPLOAD_CONCURRENCY = 2;
let activeUploadCount = 0;
const giftTabOptions = GIFT_CONFIG_TAB_OPTIONS.map((item) => ({
label: item.name,
@ -171,6 +181,7 @@ function resetBatch() {
activeRowKey.value = '';
showOnlyErrors.value = false;
sequence.value = 0;
uploadQueue.length = 0;
}
watch(
@ -342,7 +353,26 @@ function setRowAsset(row: BatchGiftRow, kind: AssetKind, file: File) {
}
row.saved = false;
refreshAllRows();
void uploadAsset(row, kind, file, fileName);
enqueueUpload({ file, fileName, kind, row });
}
function enqueueUpload(task: UploadTask) {
uploadQueue.push(task);
runUploadQueue();
}
function runUploadQueue() {
while (activeUploadCount < MAX_UPLOAD_CONCURRENCY && uploadQueue.length > 0) {
const task = uploadQueue.shift();
if (!task) {
return;
}
activeUploadCount += 1;
void uploadAsset(task.row, task.kind, task.file, task.fileName).finally(() => {
activeUploadCount -= 1;
runUploadQueue();
});
}
}
async function uploadAsset(
@ -1131,6 +1161,18 @@ async function submitBatch() {
width: 52px;
}
.asset-cover {
border-radius: 6px;
display: block;
overflow: hidden;
}
.asset-cover :deep(.ant-image-img) {
height: 52px;
object-fit: contain;
width: 52px;
}
.asset-empty {
align-items: center;
background: #f8fafc;

View File

@ -79,6 +79,13 @@ interface StructuredResourceFile {
type: string;
}
interface UploadTask {
file: File;
fileName: string;
kind: AssetKind;
row: BatchRow;
}
const props = defineProps<{
initialSysOrigin?: number | string;
initialType?: number | string;
@ -109,6 +116,9 @@ const sequence = ref(0);
const showOnlyErrors = ref(false);
const submitting = ref(false);
const droppedFilePathMap = new WeakMap<File, string>();
const uploadQueue: UploadTask[] = [];
const MAX_UPLOAD_CONCURRENCY = 2;
let activeUploadCount = 0;
const propsTypeOptions = PROPS_RESOURCE_CONFIG_TYPES.map((item) => ({
label: item.name,
@ -243,6 +253,7 @@ function resetBatch() {
activeRowKey.value = '';
showOnlyErrors.value = false;
sequence.value = 0;
uploadQueue.length = 0;
}
watch(
@ -456,7 +467,26 @@ function setRowAsset(row: BatchRow, kind: AssetKind, file: File) {
}
row.saved = false;
refreshAllRows();
void uploadAsset(row, kind, file, fileName);
enqueueUpload({ file, fileName, kind, row });
}
function enqueueUpload(task: UploadTask) {
uploadQueue.push(task);
runUploadQueue();
}
function runUploadQueue() {
while (activeUploadCount < MAX_UPLOAD_CONCURRENCY && uploadQueue.length > 0) {
const task = uploadQueue.shift();
if (!task) {
return;
}
activeUploadCount += 1;
void uploadAsset(task.row, task.kind, task.file, task.fileName).finally(() => {
activeUploadCount -= 1;
runUploadQueue();
});
}
}
async function uploadAsset(
@ -1410,6 +1440,18 @@ function customRow(record: Record<string, any>) {
width: 52px;
}
.asset-cover {
border-radius: 6px;
display: block;
overflow: hidden;
}
.asset-cover :deep(.ant-image-img) {
height: 52px;
object-fit: contain;
width: 52px;
}
.asset-empty {
align-items: center;
background: #f8fafc;

View File

@ -13,12 +13,12 @@ import {
InputNumber,
message,
Pagination,
Select,
Spin,
Tag,
} from 'antdv-next';
import { listBadgePictureBySysOrigin } from '#/api/legacy/badge';
import { listGiftBySysOrigin } from '#/api/legacy/gift';
import {
listNotFamilyBySysOriginType,
sendPropsGiveUser,
@ -31,7 +31,7 @@ import { BADGE_TYPE_OPTIONS, PROPS_TYPES } from './shared';
defineOptions({ name: 'PropsSendTool' });
type ResourceKind = 'badge' | 'props';
type ResourceKind = 'badge' | 'gift' | 'props';
type TypeOption = {
icon: string;
@ -50,6 +50,7 @@ type DisplayResource = {
name: string;
raw: Record<string, any>;
secondaryType: string;
type: string;
typeLabel: string;
};
@ -62,9 +63,11 @@ type RecipientProfile = {
};
const BADGE_TYPE_VALUE = '__BADGE__';
const GIFT_TYPE_VALUE = '__GIFT__';
const MAX_BATCH_ACCOUNTS = 15;
const RESOURCE_PAGE_SIZE = 72;
const QUICK_DAYS = [1, 7, 15, 30, -1, -7];
const QUICK_GIFT_QUANTITIES = [1, 5, 10, 50, 100];
const USER_AVATAR_FALLBACK =
'https://dummyimage.com/40x40/e2e8f0/64748b&text=U';
@ -76,14 +79,7 @@ const sysOriginOptions = computed(() => {
return options.length > 0 ? options : getAllowedSysOrigins([]);
});
const propsTypeOptions = PROPS_TYPES.filter(
(item) => item.value !== 'CUSTOMIZE' && item.value !== 'FRAGMENTS',
);
const vipOriginOptions = [
{ label: '系统赠送', value: 'SYSTEM_GIVE' as any },
{ label: '购买或朋友赠送', value: 'BUY_OR_GIVE' as any },
{ label: '活动奖励', value: 'ACTIVITY_AWARD' as any },
];
const propsTypeOptions = PROPS_TYPES.filter((item) => item.value !== 'CUSTOMIZE');
const propsFormLoading = ref(false);
const sourceLoading = ref(false);
@ -98,10 +94,10 @@ let recipientSearchTimer: number | undefined;
const form = reactive({
exchangeDays: '',
giftQuantity: 1,
receiverAccounts: '',
secondaryType: '',
sysOrigin: sysOriginOptions.value[0]?.value ?? '',
vipOrigin: '',
});
function hasPermission(code: string) {
@ -113,7 +109,12 @@ const canSendProps = computed(() => hasPermission('send:props'));
const canSendBadge = computed(
() => canSendProps.value || hasPermission('send:props:badge'),
);
const canUsePage = computed(() => canSendProps.value || canSendBadge.value);
const canSendGift = computed(
() => canSendProps.value || hasPermission('send:props:GIFT'),
);
const canUsePage = computed(
() => canSendProps.value || canSendBadge.value || canSendGift.value,
);
const typeOptions = computed<TypeOption[]>(() => {
const options: TypeOption[] = [];
@ -135,6 +136,14 @@ const typeOptions = computed<TypeOption[]>(() => {
value: BADGE_TYPE_VALUE,
});
}
if (canSendGift.value) {
options.push({
icon: 'lucide:gift',
kind: 'gift',
label: '礼物',
value: GIFT_TYPE_VALUE,
});
}
return options;
});
@ -156,14 +165,27 @@ const displayedRecipients = computed(() =>
);
}),
);
const sendActionText = computed(() => (Number(form.exchangeDays) < 0 ? '回收' : '赠送'));
const selectedCount = computed(() => selectedResources.value.length);
const needsVipOrigin = computed(
() =>
selectedResources.value.some(
(item) => item.kind === 'props' && item.secondaryType === 'NOBLE_VIP',
),
const selectedHasGift = computed(() =>
selectedResources.value.some((item) => item.kind === 'gift'),
);
const selectedHasNonGift = computed(() =>
selectedResources.value.some((item) => item.kind !== 'gift'),
);
const currentTypeIsGift = computed(() => currentTypeOption.value?.kind === 'gift');
const showGiftQuantity = computed(() => selectedHasGift.value || currentTypeIsGift.value);
const showExchangeDays = computed(
() => selectedHasNonGift.value || (!selectedHasGift.value && !currentTypeIsGift.value),
);
const sendActionText = computed(() => {
if (selectedHasGift.value && selectedHasNonGift.value) {
return Number(form.exchangeDays) < 0 ? '礼物赠送 / 道具回收' : '赠送';
}
if (selectedHasGift.value) {
return '赠送';
}
return Number(form.exchangeDays) < 0 ? '回收' : '赠送';
});
const selectedTypeSummary = computed(() => {
const labels = [
...new Set(selectedResources.value.map((item) => item.typeLabel).filter(Boolean)),
@ -331,17 +353,19 @@ function normalizeDisplayResource(
secondaryType: string,
typeLabel: string,
): DisplayResource {
const id =
kind === 'badge'
? firstPresent(item, ['badgeConfigId', 'id', 'badgeId'])
: firstPresent(item, ['id', 'resourceId', 'propsSourceId', 'propsId']);
const name =
kind === 'badge'
? firstPresent(item, ['badgeName', 'name'])
: firstPresent(item, ['name', 'resourceName', 'sourceName']);
let id = firstPresent(item, ['id', 'resourceId', 'propsSourceId', 'propsId']);
let name = firstPresent(item, ['name', 'resourceName', 'sourceName']);
if (kind === 'badge') {
id = firstPresent(item, ['badgeConfigId', 'id', 'badgeId']);
name = firstPresent(item, ['badgeName', 'name']);
} else if (kind === 'gift') {
id = firstPresent(item, ['id', 'giftId']);
name = firstPresent(item, ['giftName', 'name']);
}
const cover = firstPresent(item, [
'cover',
'coverUrl',
'giftPhoto',
'resourceCover',
'resourceCoverUrl',
'selectUrl',
@ -349,7 +373,7 @@ function normalizeDisplayResource(
]);
return {
amount: firstPresent(item, ['amount', 'price', 'goldAmount']),
amount: firstPresent(item, ['amount', 'giftCandy', 'price', 'goldAmount']),
badgeTypeLabel: kind === 'badge' ? typeLabel : '',
cover: String(cover || ''),
id: id as number | string,
@ -358,6 +382,7 @@ function normalizeDisplayResource(
name: String(name || (id ? `资源 ${id}` : '-')),
raw: item,
secondaryType,
type: secondaryType,
typeLabel,
};
}
@ -387,12 +412,15 @@ function setQuickDays(value: number) {
form.exchangeDays = String(value);
}
function setQuickGiftQuantity(value: number) {
form.giftQuantity = value;
}
function selectType(option: TypeOption) {
if (form.secondaryType === option.value) {
return;
}
form.secondaryType = option.value;
form.vipOrigin = option.value === 'NOBLE_VIP' ? form.vipOrigin : '';
resourceKeyword.value = '';
resourcePage.value = 1;
sourceOptions.value = [];
@ -429,6 +457,14 @@ async function loadSources() {
return;
}
if (type.kind === 'gift') {
const rows = await listGiftBySysOrigin(form.sysOrigin);
sourceOptions.value = (rows || []).map((item) =>
normalizeDisplayResource(item, 'gift', 'GIFT', '礼物'),
);
return;
}
const rows = await listNotFamilyBySysOriginType(form.sysOrigin, type.value);
sourceOptions.value = (rows || []).map((item) =>
normalizeDisplayResource(item, 'props', type.value, type.label),
@ -483,18 +519,37 @@ function validateBeforeSend() {
message.warning('请选择资源');
return null;
}
if (form.exchangeDays === '' || Number.isNaN(Number(form.exchangeDays))) {
if (
selectedHasNonGift.value &&
(form.exchangeDays === '' || Number.isNaN(Number(form.exchangeDays)))
) {
message.warning('请输入有效天数');
return null;
}
if (needsVipOrigin.value && !form.vipOrigin) {
message.warning('请选择贵族来源');
if (
selectedHasGift.value &&
(!Number.isFinite(Number(form.giftQuantity)) || Number(form.giftQuantity) <= 0)
) {
message.warning('请输入礼物数量');
return null;
}
return accounts;
}
function buildSendPayload(resource: DisplayResource, accounts: string[]) {
if (resource.kind === 'gift') {
const quantity = Number(form.giftQuantity);
return {
content: resource.id,
exchangeDays: quantity,
quantity,
receiverAccounts: accounts.join(','),
secondaryType: 'GIFT',
sysOrigin: form.sysOrigin,
type: 'GIFT',
};
}
const payload: Record<string, any> = {
content: resource.id,
exchangeDays: Number(form.exchangeDays),
@ -504,11 +559,6 @@ function buildSendPayload(resource: DisplayResource, accounts: string[]) {
type: resource.kind === 'badge' ? 'BADGE' : 'PROPS',
};
if (resource.kind === 'props') {
payload.vipOrigin =
resource.secondaryType === 'NOBLE_VIP' ? form.vipOrigin : null;
}
return payload;
}
@ -542,7 +592,7 @@ function resetCurrentForm() {
form.receiverAccounts = '';
form.secondaryType = '';
form.exchangeDays = '';
form.vipOrigin = '';
form.giftQuantity = 1;
resourceKeyword.value = '';
resourcePage.value = 1;
sourceOptions.value = [];
@ -551,7 +601,7 @@ function resetCurrentForm() {
</script>
<template>
<Page title="道具赠送">
<Page title="资源赠送">
<div v-if="canUsePage" class="send-tool">
<div class="send-layout">
<section class="send-main">
@ -687,7 +737,7 @@ function resetCurrentForm() {
<aside class="send-summary">
<div class="summary-title">
<IconifyIcon icon="lucide:package-check" />
<span>赠送道具</span>
<span>赠送资源</span>
</div>
<div class="selected-panel">
@ -732,7 +782,7 @@ function resetCurrentForm() {
<div v-else class="summary-empty">未选择资源</div>
</div>
<div class="summary-control summary-control--days">
<div v-if="showExchangeDays" class="summary-control summary-control--days">
<div class="summary-control__row">
<label class="summary-control__label" for="props-send-days">
有效天数
@ -760,15 +810,33 @@ function resetCurrentForm() {
</div>
</div>
<div v-if="needsVipOrigin" class="summary-control">
<label class="summary-control__label">贵族来源</label>
<Select
v-model:value="form.vipOrigin"
:options="vipOriginOptions"
class="summary-vip-origin"
option-label-prop="label"
placeholder="请选择贵族来源"
/>
<div v-if="showGiftQuantity" class="summary-control summary-control--days">
<div class="summary-control__row">
<label class="summary-control__label" for="gift-send-quantity">
礼物数量
</label>
<InputNumber
id="gift-send-quantity"
v-model:value="form.giftQuantity"
:controls="false"
:min="1"
:precision="0"
class="summary-days-input"
placeholder="请输入数量"
/>
</div>
<div class="quick-days summary-quick-days">
<button
v-for="quantity in QUICK_GIFT_QUANTITIES"
:key="quantity"
:class="{ 'quick-day--active': Number(form.giftQuantity) === quantity }"
class="quick-day"
type="button"
@click="setQuickGiftQuantity(quantity)"
>
{{ quantity }}
</button>
</div>
</div>
<dl class="summary-list">
@ -1256,8 +1324,7 @@ function resetCurrentForm() {
font-weight: 600;
}
.summary-days-input,
.summary-vip-origin {
.summary-days-input {
width: 100%;
}

View File

@ -8,12 +8,8 @@ export interface OptionItem {
export const PROPS_TYPES: OptionItem[] = [
{ value: 'AVATAR_FRAME', name: '头像框' },
{ value: 'RIDE', name: '座驾' },
{ value: 'NOBLE_VIP', name: '贵族' },
{ value: 'THEME', name: '主题' },
{ value: 'LAYOUT', name: '装扮' },
{ value: 'CHAT_BUBBLE', name: '聊天气泡' },
{ value: 'FLOAT_PICTURE', name: '飘窗' },
{ value: 'FRAGMENTS', name: '碎片' },
{ value: 'DATA_CARD', name: '资料卡' },
{ value: 'SPECIAL_ID', name: '靓号' },
{ value: 'CUSTOMIZE', name: '自定义(只读)' },