修改并发上传

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; name: string;
} }
interface UploadTask {
file: File;
fileName: string;
kind: AssetKind;
row: BatchGiftRow;
}
const props = defineProps<{ const props = defineProps<{
initialSysOrigin?: number | string; initialSysOrigin?: number | string;
open: boolean; open: boolean;
@ -82,6 +89,9 @@ const sequence = ref(0);
const showOnlyErrors = ref(false); const showOnlyErrors = ref(false);
const submitting = ref(false); const submitting = ref(false);
const droppedFilePathMap = new WeakMap<File, string>(); 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) => ({ const giftTabOptions = GIFT_CONFIG_TAB_OPTIONS.map((item) => ({
label: item.name, label: item.name,
@ -171,6 +181,7 @@ function resetBatch() {
activeRowKey.value = ''; activeRowKey.value = '';
showOnlyErrors.value = false; showOnlyErrors.value = false;
sequence.value = 0; sequence.value = 0;
uploadQueue.length = 0;
} }
watch( watch(
@ -342,7 +353,26 @@ function setRowAsset(row: BatchGiftRow, kind: AssetKind, file: File) {
} }
row.saved = false; row.saved = false;
refreshAllRows(); 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( async function uploadAsset(
@ -1131,6 +1161,18 @@ async function submitBatch() {
width: 52px; 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 { .asset-empty {
align-items: center; align-items: center;
background: #f8fafc; background: #f8fafc;

View File

@ -79,6 +79,13 @@ interface StructuredResourceFile {
type: string; type: string;
} }
interface UploadTask {
file: File;
fileName: string;
kind: AssetKind;
row: BatchRow;
}
const props = defineProps<{ const props = defineProps<{
initialSysOrigin?: number | string; initialSysOrigin?: number | string;
initialType?: number | string; initialType?: number | string;
@ -109,6 +116,9 @@ const sequence = ref(0);
const showOnlyErrors = ref(false); const showOnlyErrors = ref(false);
const submitting = ref(false); const submitting = ref(false);
const droppedFilePathMap = new WeakMap<File, string>(); 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) => ({ const propsTypeOptions = PROPS_RESOURCE_CONFIG_TYPES.map((item) => ({
label: item.name, label: item.name,
@ -243,6 +253,7 @@ function resetBatch() {
activeRowKey.value = ''; activeRowKey.value = '';
showOnlyErrors.value = false; showOnlyErrors.value = false;
sequence.value = 0; sequence.value = 0;
uploadQueue.length = 0;
} }
watch( watch(
@ -456,7 +467,26 @@ function setRowAsset(row: BatchRow, kind: AssetKind, file: File) {
} }
row.saved = false; row.saved = false;
refreshAllRows(); 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( async function uploadAsset(
@ -1410,6 +1440,18 @@ function customRow(record: Record<string, any>) {
width: 52px; 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 { .asset-empty {
align-items: center; align-items: center;
background: #f8fafc; background: #f8fafc;

View File

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

View File

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