1417 lines
34 KiB
Vue
1417 lines
34 KiB
Vue
<script lang="ts" setup>
|
||
import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue';
|
||
|
||
import { Page } from '@vben/common-ui';
|
||
import { IconifyIcon } from '@vben/icons';
|
||
import { useAccessStore } from '@vben/stores';
|
||
|
||
import {
|
||
Button,
|
||
Empty,
|
||
Form,
|
||
Input,
|
||
InputNumber,
|
||
message,
|
||
Pagination,
|
||
Spin,
|
||
Tag,
|
||
} from 'antdv-next';
|
||
|
||
import { listBadgePictureBySysOrigin } from '#/api/legacy/badge';
|
||
import { listGiftBySysOrigin } from '#/api/legacy/gift';
|
||
import {
|
||
listNotFamilyBySysOriginType,
|
||
sendPropsGiveUser,
|
||
} from '#/api/legacy/props';
|
||
import { getUserBaseInfoBySysOriginAccount } from '#/api/legacy/user';
|
||
import { getAllowedSysOrigins } from '#/views/system/shared';
|
||
|
||
import RewardIcon from './components/reward-icon.vue';
|
||
import { BADGE_TYPE_OPTIONS, PROPS_TYPES } from './shared';
|
||
|
||
defineOptions({ name: 'PropsSendTool' });
|
||
|
||
type ResourceKind = 'badge' | 'gift' | 'props';
|
||
|
||
type TypeOption = {
|
||
icon: string;
|
||
kind: ResourceKind;
|
||
label: string;
|
||
value: string;
|
||
};
|
||
|
||
type DisplayResource = {
|
||
amount?: number | string;
|
||
badgeTypeLabel?: string;
|
||
cover: string;
|
||
id: number | string;
|
||
key: string;
|
||
kind: ResourceKind;
|
||
name: string;
|
||
raw: Record<string, any>;
|
||
secondaryType: string;
|
||
type: string;
|
||
typeLabel: string;
|
||
};
|
||
|
||
type RecipientProfile = {
|
||
account: string;
|
||
avatar: string;
|
||
id: string;
|
||
name: string;
|
||
status: 'found' | 'missing';
|
||
};
|
||
|
||
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';
|
||
|
||
const accessStore = useAccessStore();
|
||
|
||
const accessCodes = computed(() => accessStore.accessCodes || []);
|
||
const sysOriginOptions = computed(() => {
|
||
const options = getAllowedSysOrigins(accessCodes.value);
|
||
return options.length > 0 ? options : getAllowedSysOrigins([]);
|
||
});
|
||
|
||
const propsTypeOptions = PROPS_TYPES.filter((item) => item.value !== 'CUSTOMIZE');
|
||
|
||
const propsFormLoading = ref(false);
|
||
const sourceLoading = ref(false);
|
||
const sourceOptions = ref<DisplayResource[]>([]);
|
||
const selectedResources = ref<DisplayResource[]>([]);
|
||
const resourceKeyword = ref('');
|
||
const resourcePage = ref(1);
|
||
const recipientLoading = ref(false);
|
||
const recipientProfiles = ref<Record<string, RecipientProfile>>({});
|
||
let recipientSearchSeq = 0;
|
||
let recipientSearchTimer: number | undefined;
|
||
|
||
const form = reactive({
|
||
exchangeDays: '',
|
||
giftQuantity: 1,
|
||
receiverAccounts: '',
|
||
secondaryType: '',
|
||
sysOrigin: sysOriginOptions.value[0]?.value ?? '',
|
||
});
|
||
|
||
function hasPermission(code: string) {
|
||
const codes = accessCodes.value;
|
||
return codes.length === 0 || codes.includes(code);
|
||
}
|
||
|
||
const canSendProps = computed(() => hasPermission('send:props'));
|
||
const canSendBadge = computed(
|
||
() => canSendProps.value || hasPermission('send:props:badge'),
|
||
);
|
||
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[] = [];
|
||
if (canSendProps.value) {
|
||
options.push(
|
||
...propsTypeOptions.map((item) => ({
|
||
icon: 'lucide:package',
|
||
kind: 'props' as ResourceKind,
|
||
label: item.name,
|
||
value: String(item.value),
|
||
})),
|
||
);
|
||
}
|
||
if (canSendBadge.value) {
|
||
options.push({
|
||
icon: 'lucide:award',
|
||
kind: 'badge',
|
||
label: '徽章',
|
||
value: BADGE_TYPE_VALUE,
|
||
});
|
||
}
|
||
if (canSendGift.value) {
|
||
options.push({
|
||
icon: 'lucide:gift',
|
||
kind: 'gift',
|
||
label: '礼物',
|
||
value: GIFT_TYPE_VALUE,
|
||
});
|
||
}
|
||
return options;
|
||
});
|
||
|
||
const currentTypeOption = computed(
|
||
() => typeOptions.value.find((item) => item.value === form.secondaryType) || null,
|
||
);
|
||
const currentTypeLabel = computed(() => currentTypeOption.value?.label || '请选择类型');
|
||
const currentAccounts = computed(() => normalizeAccounts(form.receiverAccounts));
|
||
const displayedRecipients = computed(() =>
|
||
currentAccounts.value.slice(0, MAX_BATCH_ACCOUNTS).map((account) => {
|
||
return (
|
||
recipientProfiles.value[account] || {
|
||
account,
|
||
avatar: '',
|
||
id: '',
|
||
name: account,
|
||
status: 'missing' as const,
|
||
}
|
||
);
|
||
}),
|
||
);
|
||
const selectedCount = computed(() => selectedResources.value.length);
|
||
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)),
|
||
];
|
||
return labels.length > 0 ? labels.join('、') : '-';
|
||
});
|
||
|
||
const filteredResources = computed(() => {
|
||
const keyword = resourceKeyword.value.trim().toLowerCase();
|
||
if (!keyword) {
|
||
return sourceOptions.value;
|
||
}
|
||
return sourceOptions.value.filter(
|
||
(item) =>
|
||
String(item.id).toLowerCase().includes(keyword) ||
|
||
item.name.toLowerCase().includes(keyword) ||
|
||
item.typeLabel.toLowerCase().includes(keyword),
|
||
);
|
||
});
|
||
|
||
const pagedResources = computed(() => {
|
||
const start = (resourcePage.value - 1) * RESOURCE_PAGE_SIZE;
|
||
return filteredResources.value.slice(start, start + RESOURCE_PAGE_SIZE);
|
||
});
|
||
|
||
const selectedResourceKeys = computed(
|
||
() => new Set(selectedResources.value.map((item) => item.key)),
|
||
);
|
||
|
||
watch(
|
||
sysOriginOptions,
|
||
(options) => {
|
||
if (!form.sysOrigin && options[0]?.value) {
|
||
form.sysOrigin = String(options[0].value);
|
||
}
|
||
},
|
||
{ immediate: true },
|
||
);
|
||
|
||
watch(
|
||
[currentAccounts, () => form.sysOrigin],
|
||
([accounts]) => {
|
||
scheduleRecipientLookup(accounts);
|
||
},
|
||
{ immediate: true },
|
||
);
|
||
|
||
watch(resourceKeyword, () => {
|
||
resourcePage.value = 1;
|
||
});
|
||
|
||
watch(filteredResources, () => {
|
||
const pageCount = Math.max(
|
||
1,
|
||
Math.ceil(filteredResources.value.length / RESOURCE_PAGE_SIZE),
|
||
);
|
||
if (resourcePage.value > pageCount) {
|
||
resourcePage.value = pageCount;
|
||
}
|
||
});
|
||
|
||
function normalizeAccounts(value: string) {
|
||
return value
|
||
.split(/[,,;\s]+/)
|
||
.map((item) => item.trim())
|
||
.filter(Boolean);
|
||
}
|
||
|
||
onBeforeUnmount(() => {
|
||
if (recipientSearchTimer) {
|
||
window.clearTimeout(recipientSearchTimer);
|
||
}
|
||
});
|
||
|
||
function firstPresent(record: Record<string, any>, keys: string[]) {
|
||
for (const key of keys) {
|
||
const value = record?.[key];
|
||
if (value !== undefined && value !== null && String(value).trim() !== '') {
|
||
return value;
|
||
}
|
||
}
|
||
return '';
|
||
}
|
||
|
||
function normalizeRecipientProfile(
|
||
account: string,
|
||
profile?: null | Record<string, any>,
|
||
): RecipientProfile {
|
||
const id = firstPresent(profile || {}, ['id', 'userId']);
|
||
const name = firstPresent(profile || {}, ['userNickname', 'nickname', 'name']);
|
||
const profileAccount = firstPresent(profile || {}, [
|
||
'actualAccount',
|
||
'account',
|
||
'shortAccount',
|
||
]);
|
||
const avatar = firstPresent(profile || {}, ['userAvatar', 'avatar']);
|
||
|
||
return {
|
||
account: String(profileAccount || account),
|
||
avatar: String(avatar || ''),
|
||
id: String(id || ''),
|
||
name: String(name || account),
|
||
status: id || profileAccount || name ? 'found' : 'missing',
|
||
};
|
||
}
|
||
|
||
function scheduleRecipientLookup(accounts: string[]) {
|
||
if (recipientSearchTimer) {
|
||
window.clearTimeout(recipientSearchTimer);
|
||
}
|
||
|
||
const seq = ++recipientSearchSeq;
|
||
const visibleAccounts = [...new Set(accounts)].slice(0, MAX_BATCH_ACCOUNTS);
|
||
const nextProfiles: Record<string, RecipientProfile> = {};
|
||
visibleAccounts.forEach((account) => {
|
||
const existing = recipientProfiles.value[account];
|
||
if (existing) {
|
||
nextProfiles[account] = existing;
|
||
}
|
||
});
|
||
recipientProfiles.value = nextProfiles;
|
||
|
||
if (visibleAccounts.length === 0 || !form.sysOrigin) {
|
||
recipientLoading.value = false;
|
||
return;
|
||
}
|
||
|
||
recipientSearchTimer = window.setTimeout(() => {
|
||
void lookupRecipientProfiles(visibleAccounts, seq);
|
||
}, 350);
|
||
}
|
||
|
||
async function lookupRecipientProfiles(accounts: string[], seq: number) {
|
||
recipientLoading.value = true;
|
||
const results = await Promise.allSettled(
|
||
accounts.map(async (account) => {
|
||
const profile = await getUserBaseInfoBySysOriginAccount(form.sysOrigin, account);
|
||
return [account, normalizeRecipientProfile(account, profile)] as const;
|
||
}),
|
||
);
|
||
|
||
if (seq !== recipientSearchSeq) {
|
||
return;
|
||
}
|
||
|
||
const nextProfiles: Record<string, RecipientProfile> = {};
|
||
results.forEach((result, index) => {
|
||
const account = accounts[index] || '';
|
||
if (!account) {
|
||
return;
|
||
}
|
||
if (result.status === 'fulfilled') {
|
||
nextProfiles[result.value[0]] = result.value[1];
|
||
return;
|
||
}
|
||
nextProfiles[account] = normalizeRecipientProfile(account, null);
|
||
});
|
||
recipientProfiles.value = nextProfiles;
|
||
recipientLoading.value = false;
|
||
}
|
||
|
||
function normalizeDisplayResource(
|
||
item: Record<string, any>,
|
||
kind: ResourceKind,
|
||
secondaryType: string,
|
||
typeLabel: string,
|
||
): DisplayResource {
|
||
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',
|
||
'icon',
|
||
]);
|
||
|
||
return {
|
||
amount: firstPresent(item, ['amount', 'giftCandy', 'price', 'goldAmount']),
|
||
badgeTypeLabel: kind === 'badge' ? typeLabel : '',
|
||
cover: String(cover || ''),
|
||
id: id as number | string,
|
||
key: `${kind}-${secondaryType}-${id}`,
|
||
kind,
|
||
name: String(name || (id ? `资源 ${id}` : '-')),
|
||
raw: item,
|
||
secondaryType,
|
||
type: secondaryType,
|
||
typeLabel,
|
||
};
|
||
}
|
||
|
||
function uniqueResources(list: DisplayResource[]) {
|
||
const map = new Map<string, DisplayResource>();
|
||
list.forEach((item) => {
|
||
if (!map.has(item.key)) {
|
||
map.set(item.key, item);
|
||
}
|
||
});
|
||
return [...map.values()];
|
||
}
|
||
|
||
function formatCurrentAccounts() {
|
||
form.receiverAccounts = currentAccounts.value.join(', ');
|
||
}
|
||
|
||
function formatResourceAmount(value: number | string | undefined) {
|
||
if (value === undefined || value === null || String(value).trim() === '') {
|
||
return '-';
|
||
}
|
||
return String(value);
|
||
}
|
||
|
||
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;
|
||
resourceKeyword.value = '';
|
||
resourcePage.value = 1;
|
||
sourceOptions.value = [];
|
||
void loadSources();
|
||
}
|
||
|
||
async function loadSources() {
|
||
const type = currentTypeOption.value;
|
||
if (!type || !form.sysOrigin) {
|
||
sourceOptions.value = [];
|
||
return;
|
||
}
|
||
|
||
sourceLoading.value = true;
|
||
try {
|
||
if (type.kind === 'badge') {
|
||
const results = await Promise.all(
|
||
BADGE_TYPE_OPTIONS.map(async (badgeType) => {
|
||
const rows = await listBadgePictureBySysOrigin(
|
||
form.sysOrigin,
|
||
String(badgeType.value),
|
||
);
|
||
return (rows || []).map((item) =>
|
||
normalizeDisplayResource(
|
||
item,
|
||
'badge',
|
||
String(badgeType.value),
|
||
badgeType.name,
|
||
),
|
||
);
|
||
}),
|
||
);
|
||
sourceOptions.value = uniqueResources(results.flat());
|
||
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),
|
||
);
|
||
} finally {
|
||
sourceLoading.value = false;
|
||
}
|
||
}
|
||
|
||
function toggleResource(resource: DisplayResource) {
|
||
const index = selectedResources.value.findIndex((item) => item.key === resource.key);
|
||
if (index !== -1) {
|
||
selectedResources.value.splice(index, 1);
|
||
return;
|
||
}
|
||
selectedResources.value.push(resource);
|
||
}
|
||
|
||
function removeSelectedResource(resource: DisplayResource) {
|
||
selectedResources.value = selectedResources.value.filter(
|
||
(item) => item.key !== resource.key,
|
||
);
|
||
}
|
||
|
||
function clearSelectedResources() {
|
||
selectedResources.value = [];
|
||
}
|
||
|
||
function validateAccounts(value: string) {
|
||
const accounts = normalizeAccounts(value);
|
||
if (accounts.length === 0) {
|
||
message.warning('请输入接收人短账号');
|
||
return null;
|
||
}
|
||
if (accounts.length > MAX_BATCH_ACCOUNTS) {
|
||
message.warning(`一批最多 ${MAX_BATCH_ACCOUNTS} 个接收人`);
|
||
return null;
|
||
}
|
||
return accounts;
|
||
}
|
||
|
||
function validateBeforeSend() {
|
||
const accounts = validateAccounts(form.receiverAccounts);
|
||
if (!accounts) {
|
||
return null;
|
||
}
|
||
if (!form.sysOrigin) {
|
||
message.warning('系统参数为空,请刷新页面后重试');
|
||
return null;
|
||
}
|
||
if (selectedResources.value.length === 0) {
|
||
message.warning('请选择资源');
|
||
return null;
|
||
}
|
||
if (
|
||
selectedHasNonGift.value &&
|
||
(form.exchangeDays === '' || Number.isNaN(Number(form.exchangeDays)))
|
||
) {
|
||
message.warning('请输入有效天数');
|
||
return null;
|
||
}
|
||
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),
|
||
receiverAccounts: accounts.join(','),
|
||
secondaryType: resource.secondaryType,
|
||
sysOrigin: form.sysOrigin,
|
||
type: resource.kind === 'badge' ? 'BADGE' : 'PROPS',
|
||
};
|
||
|
||
return payload;
|
||
}
|
||
|
||
async function handleSendProps() {
|
||
const accounts = validateBeforeSend();
|
||
if (!accounts) {
|
||
return;
|
||
}
|
||
|
||
propsFormLoading.value = true;
|
||
try {
|
||
for (const resource of selectedResources.value) {
|
||
const invalidAccounts = await sendPropsGiveUser(
|
||
buildSendPayload(resource, accounts),
|
||
);
|
||
if (invalidAccounts) {
|
||
message.error(
|
||
`资源「${resource.name}」无效短账号: ${invalidAccounts},请处理后再将当前批次所有短账号重新发送。`,
|
||
);
|
||
return;
|
||
}
|
||
}
|
||
message.success(`发送成功,共发送 ${selectedResources.value.length} 个资源`);
|
||
form.receiverAccounts = '';
|
||
} finally {
|
||
propsFormLoading.value = false;
|
||
}
|
||
}
|
||
|
||
function resetCurrentForm() {
|
||
form.receiverAccounts = '';
|
||
form.secondaryType = '';
|
||
form.exchangeDays = '';
|
||
form.giftQuantity = 1;
|
||
resourceKeyword.value = '';
|
||
resourcePage.value = 1;
|
||
sourceOptions.value = [];
|
||
selectedResources.value = [];
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<Page title="资源赠送">
|
||
<div v-if="canUsePage" class="send-tool">
|
||
<div class="send-layout">
|
||
<section class="send-main">
|
||
<Form class="send-form" layout="vertical">
|
||
<div class="control-panel">
|
||
<div class="receiver-line">
|
||
<span class="control-label">接收人</span>
|
||
<Input
|
||
v-model:value="form.receiverAccounts"
|
||
allow-clear
|
||
class="receiver-input"
|
||
placeholder="输入短账号,逗号/空格分隔"
|
||
@blur="formatCurrentAccounts"
|
||
@press-enter="formatCurrentAccounts"
|
||
/>
|
||
<Tag
|
||
:color="
|
||
currentAccounts.length > MAX_BATCH_ACCOUNTS ? 'error' : 'blue'
|
||
"
|
||
class="receiver-count"
|
||
>
|
||
{{ currentAccounts.length }}/{{ MAX_BATCH_ACCOUNTS }}
|
||
</Tag>
|
||
<div v-if="currentAccounts.length > 0" class="recipient-strip">
|
||
<span v-if="recipientLoading" class="recipient-state">
|
||
检索中
|
||
</span>
|
||
<span
|
||
v-for="(recipient, index) in displayedRecipients"
|
||
:key="`${recipient.account}-${index}`"
|
||
:class="{
|
||
'recipient-chip--missing': recipient.status === 'missing',
|
||
}"
|
||
class="recipient-chip"
|
||
>
|
||
<img
|
||
:src="recipient.avatar || USER_AVATAR_FALLBACK"
|
||
alt=""
|
||
class="recipient-chip__avatar"
|
||
/>
|
||
<span class="recipient-chip__name">{{ recipient.name }}</span>
|
||
<span class="recipient-chip__account">
|
||
{{ recipient.account }}
|
||
</span>
|
||
</span>
|
||
<Tag
|
||
v-if="currentAccounts.length > MAX_BATCH_ACCOUNTS"
|
||
color="error"
|
||
>
|
||
超出 {{ currentAccounts.length - MAX_BATCH_ACCOUNTS }} 个
|
||
</Tag>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="type-tags">
|
||
<button
|
||
v-for="option in typeOptions"
|
||
:key="option.value"
|
||
:class="{ 'type-tag--active': form.secondaryType === option.value }"
|
||
class="type-tag"
|
||
type="button"
|
||
@click="selectType(option)"
|
||
>
|
||
<IconifyIcon :icon="option.icon" />
|
||
<span>{{ option.label }}</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="resource-section">
|
||
<div class="resource-head">
|
||
<div class="field-head">
|
||
<span>资源列表</span>
|
||
<small>
|
||
当前 {{ currentTypeLabel }},共 {{ filteredResources.length }} 个,已选
|
||
{{ selectedCount }} 个
|
||
</small>
|
||
</div>
|
||
<Input
|
||
v-model:value="resourceKeyword"
|
||
allow-clear
|
||
class="resource-search"
|
||
placeholder="搜索资源名称 / ID"
|
||
/>
|
||
</div>
|
||
|
||
<Spin :spinning="sourceLoading">
|
||
<div v-if="pagedResources.length > 0" class="resource-grid">
|
||
<button
|
||
v-for="resource in pagedResources"
|
||
:key="resource.key"
|
||
:class="{
|
||
'resource-icon-card--active':
|
||
selectedResourceKeys.has(resource.key),
|
||
}"
|
||
class="resource-icon-card"
|
||
type="button"
|
||
@click="toggleResource(resource)"
|
||
>
|
||
<span
|
||
v-if="selectedResourceKeys.has(resource.key)"
|
||
class="resource-icon-card__check"
|
||
>
|
||
<IconifyIcon icon="lucide:check" />
|
||
</span>
|
||
<RewardIcon :item="resource" :size="42" />
|
||
<span class="resource-icon-card__name">{{ resource.name }}</span>
|
||
<span class="resource-icon-card__price">
|
||
底价 {{ formatResourceAmount(resource.amount) }}
|
||
</span>
|
||
</button>
|
||
</div>
|
||
<Empty
|
||
v-else
|
||
:description="form.secondaryType ? '暂无可选资源' : '请选择类型'"
|
||
class="resource-empty"
|
||
/>
|
||
</Spin>
|
||
|
||
<div v-if="filteredResources.length > RESOURCE_PAGE_SIZE" class="pager">
|
||
<Pagination
|
||
:current="resourcePage"
|
||
:page-size="RESOURCE_PAGE_SIZE"
|
||
:total="filteredResources.length"
|
||
size="small"
|
||
@change="(page: number) => (resourcePage = page)"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</Form>
|
||
</section>
|
||
|
||
<aside class="send-summary">
|
||
<div class="summary-title">
|
||
<IconifyIcon icon="lucide:package-check" />
|
||
<span>赠送资源</span>
|
||
</div>
|
||
|
||
<div class="selected-panel">
|
||
<div class="selected-panel__head">
|
||
<span>已选资源</span>
|
||
<div class="selected-panel__actions">
|
||
<Tag color="blue">{{ selectedCount }}</Tag>
|
||
<button
|
||
v-if="selectedResources.length > 0"
|
||
class="selected-clear"
|
||
type="button"
|
||
@click="clearSelectedResources"
|
||
>
|
||
清空
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div v-if="selectedResources.length > 0" class="selected-list">
|
||
<div
|
||
v-for="resource in selectedResources"
|
||
:key="resource.key"
|
||
class="selected-item"
|
||
>
|
||
<RewardIcon :item="resource" :size="36" />
|
||
<div class="selected-item__body">
|
||
<strong>{{ resource.name }}</strong>
|
||
<span>
|
||
{{ resource.typeLabel }} · 底价
|
||
{{ formatResourceAmount(resource.amount) }}
|
||
</span>
|
||
</div>
|
||
<button
|
||
aria-label="移除资源"
|
||
class="selected-item__remove"
|
||
type="button"
|
||
@click="removeSelectedResource(resource)"
|
||
>
|
||
<IconifyIcon icon="lucide:x" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div v-else class="summary-empty">未选择资源</div>
|
||
</div>
|
||
|
||
<div v-if="showExchangeDays" class="summary-control summary-control--days">
|
||
<div class="summary-control__row">
|
||
<label class="summary-control__label" for="props-send-days">
|
||
有效天数
|
||
</label>
|
||
<InputNumber
|
||
id="props-send-days"
|
||
v-model:value="form.exchangeDays"
|
||
:controls="false"
|
||
:precision="0"
|
||
class="summary-days-input"
|
||
placeholder="正数赠送,负数回收"
|
||
/>
|
||
</div>
|
||
<div class="quick-days summary-quick-days">
|
||
<button
|
||
v-for="day in QUICK_DAYS"
|
||
:key="day"
|
||
:class="{ 'quick-day--active': Number(form.exchangeDays) === day }"
|
||
class="quick-day"
|
||
type="button"
|
||
@click="setQuickDays(day)"
|
||
>
|
||
{{ day > 0 ? `${day}天` : `${day}天` }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<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">
|
||
<div>
|
||
<dt>接收人数</dt>
|
||
<dd>{{ currentAccounts.length }}</dd>
|
||
</div>
|
||
<div>
|
||
<dt>资源类型</dt>
|
||
<dd>{{ selectedTypeSummary }}</dd>
|
||
</div>
|
||
<div>
|
||
<dt>动作</dt>
|
||
<dd>{{ sendActionText }}</dd>
|
||
</div>
|
||
</dl>
|
||
|
||
<Button
|
||
:loading="propsFormLoading"
|
||
block
|
||
class="summary-send"
|
||
size="large"
|
||
type="primary"
|
||
@click="handleSendProps"
|
||
>
|
||
<template #icon>
|
||
<IconifyIcon icon="lucide:send" />
|
||
</template>
|
||
发送
|
||
</Button>
|
||
<Button block @click="resetCurrentForm">
|
||
<template #icon>
|
||
<IconifyIcon icon="lucide:rotate-ccw" />
|
||
</template>
|
||
重置当前
|
||
</Button>
|
||
</aside>
|
||
</div>
|
||
</div>
|
||
<Empty v-else description="暂无赠送权限" />
|
||
</Page>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.send-tool {
|
||
display: grid;
|
||
}
|
||
|
||
.send-layout {
|
||
align-items: start;
|
||
display: grid;
|
||
gap: 12px;
|
||
grid-template-columns: minmax(0, 1fr) 320px;
|
||
}
|
||
|
||
.send-main,
|
||
.send-summary {
|
||
background: #fff;
|
||
border: 1px solid #e5e7eb;
|
||
border-radius: 8px;
|
||
}
|
||
|
||
.send-main {
|
||
padding: 12px 14px;
|
||
}
|
||
|
||
.send-form {
|
||
display: grid;
|
||
gap: 10px;
|
||
}
|
||
|
||
.control-panel,
|
||
.resource-section {
|
||
display: grid;
|
||
gap: 8px;
|
||
}
|
||
|
||
.control-panel {
|
||
background: #fbfcfe;
|
||
border: 1px solid #edf0f3;
|
||
border-radius: 8px;
|
||
padding: 10px;
|
||
}
|
||
|
||
.receiver-line {
|
||
align-items: center;
|
||
display: flex;
|
||
gap: 8px;
|
||
min-width: 0;
|
||
}
|
||
|
||
.control-label {
|
||
color: #111827;
|
||
font-weight: 600;
|
||
flex: 0 0 64px;
|
||
}
|
||
|
||
.receiver-input {
|
||
flex: 0 1 320px;
|
||
min-width: 220px;
|
||
}
|
||
|
||
.receiver-count {
|
||
flex: 0 0 auto;
|
||
margin-inline-end: 0;
|
||
}
|
||
|
||
.recipient-strip {
|
||
align-items: center;
|
||
display: flex;
|
||
flex: 1 1 auto;
|
||
gap: 6px;
|
||
min-width: 0;
|
||
overflow-x: auto;
|
||
padding: 1px 0;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.recipient-state {
|
||
color: #6b7280;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.recipient-chip {
|
||
align-items: center;
|
||
background: #fff;
|
||
border: 1px solid #d9e2ef;
|
||
border-radius: 999px;
|
||
display: inline-flex;
|
||
flex: 0 0 auto;
|
||
gap: 6px;
|
||
height: 30px;
|
||
max-width: 220px;
|
||
padding: 3px 8px 3px 3px;
|
||
}
|
||
|
||
.recipient-chip--missing {
|
||
border-color: #ffd8bf;
|
||
color: #ad4e00;
|
||
}
|
||
|
||
.recipient-chip__avatar {
|
||
border-radius: 50%;
|
||
height: 24px;
|
||
object-fit: cover;
|
||
width: 24px;
|
||
}
|
||
|
||
.recipient-chip__name,
|
||
.recipient-chip__account {
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.recipient-chip__name {
|
||
color: #111827;
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
max-width: 88px;
|
||
}
|
||
|
||
.recipient-chip__account {
|
||
color: #6b7280;
|
||
font-size: 11px;
|
||
max-width: 72px;
|
||
}
|
||
|
||
.quick-days,
|
||
.type-tags {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 6px;
|
||
}
|
||
|
||
.quick-day {
|
||
background: #fff;
|
||
border: 1px solid #d9dfe8;
|
||
border-radius: 6px;
|
||
color: #374151;
|
||
cursor: pointer;
|
||
font-size: 12px;
|
||
height: 30px;
|
||
line-height: 1;
|
||
padding: 0 10px;
|
||
}
|
||
|
||
.quick-day--active {
|
||
background: #e8f2ff;
|
||
border-color: #1677ff;
|
||
color: #0958d9;
|
||
}
|
||
|
||
.field-head,
|
||
.resource-head,
|
||
.selected-panel__head {
|
||
align-items: center;
|
||
color: #111827;
|
||
display: flex;
|
||
font-weight: 600;
|
||
justify-content: space-between;
|
||
}
|
||
|
||
.field-head small,
|
||
.resource-head small {
|
||
color: #6b7280;
|
||
font-size: 12px;
|
||
font-weight: 400;
|
||
}
|
||
|
||
.type-tag {
|
||
align-items: center;
|
||
background: #fff;
|
||
border: 1px solid #d9dfe8;
|
||
border-radius: 999px;
|
||
color: #374151;
|
||
cursor: pointer;
|
||
display: inline-flex;
|
||
font-size: 13px;
|
||
gap: 6px;
|
||
line-height: 1;
|
||
min-height: 30px;
|
||
padding: 7px 10px;
|
||
transition:
|
||
background 0.18s ease,
|
||
border-color 0.18s ease,
|
||
color 0.18s ease,
|
||
box-shadow 0.18s ease;
|
||
}
|
||
|
||
.type-tag:hover,
|
||
.type-tag--active {
|
||
background: #e8f2ff;
|
||
border-color: #1677ff;
|
||
color: #0958d9;
|
||
box-shadow: 0 6px 14px rgb(22 119 255 / 10%);
|
||
}
|
||
|
||
.resource-head {
|
||
gap: 12px;
|
||
}
|
||
|
||
.resource-search {
|
||
max-width: 300px;
|
||
}
|
||
|
||
.resource-grid {
|
||
align-content: start;
|
||
align-items: start;
|
||
border: 1px solid #edf0f3;
|
||
border-radius: 8px;
|
||
display: grid;
|
||
gap: 8px;
|
||
grid-auto-rows: 96px;
|
||
grid-template-columns: repeat(auto-fill, minmax(104px, 1fr));
|
||
max-height: calc(100vh - 286px);
|
||
min-height: 390px;
|
||
overflow: auto;
|
||
padding: 8px;
|
||
}
|
||
|
||
.resource-icon-card {
|
||
align-items: center;
|
||
background: #fff;
|
||
border: 1px solid #e5e7eb;
|
||
border-radius: 8px;
|
||
cursor: pointer;
|
||
display: grid;
|
||
gap: 5px;
|
||
height: 96px;
|
||
justify-items: center;
|
||
min-height: 96px;
|
||
min-width: 0;
|
||
overflow: hidden;
|
||
padding: 8px 6px;
|
||
position: relative;
|
||
text-align: center;
|
||
transition:
|
||
background 0.18s ease,
|
||
border-color 0.18s ease,
|
||
box-shadow 0.18s ease,
|
||
transform 0.18s ease;
|
||
}
|
||
|
||
.resource-icon-card:hover,
|
||
.resource-icon-card--active {
|
||
border-color: #1677ff;
|
||
box-shadow: 0 0 0 1px #1677ff inset;
|
||
}
|
||
|
||
.resource-icon-card--active {
|
||
background: #eef6ff;
|
||
}
|
||
|
||
.resource-icon-card__check {
|
||
align-items: center;
|
||
background: #1677ff;
|
||
border-radius: 999px;
|
||
color: #fff;
|
||
display: inline-flex;
|
||
height: 20px;
|
||
justify-content: center;
|
||
position: absolute;
|
||
right: 6px;
|
||
top: 6px;
|
||
width: 20px;
|
||
}
|
||
|
||
.resource-icon-card__name {
|
||
color: #111827;
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
line-height: 1.3;
|
||
max-width: 100%;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.resource-icon-card__price {
|
||
background: #fff7e6;
|
||
border-radius: 999px;
|
||
color: #8c5a00;
|
||
font-size: 11px;
|
||
line-height: 1.25;
|
||
max-width: 100%;
|
||
overflow: hidden;
|
||
padding: 2px 7px;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.resource-empty {
|
||
border: 1px dashed #d1d5db;
|
||
border-radius: 8px;
|
||
padding: 54px 0;
|
||
}
|
||
|
||
.pager {
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
margin-top: 8px;
|
||
}
|
||
|
||
.send-summary {
|
||
align-self: start;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 10px;
|
||
max-height: calc(100vh - 96px);
|
||
overflow: hidden;
|
||
padding: 14px;
|
||
position: sticky;
|
||
top: 12px;
|
||
}
|
||
|
||
.summary-title {
|
||
align-items: center;
|
||
color: #111827;
|
||
display: flex;
|
||
font-size: 16px;
|
||
font-weight: 700;
|
||
gap: 8px;
|
||
}
|
||
|
||
.summary-title svg {
|
||
color: #1677ff;
|
||
}
|
||
|
||
.selected-panel {
|
||
background: #f9fafb;
|
||
border: 1px solid #edf0f3;
|
||
border-radius: 8px;
|
||
display: grid;
|
||
gap: 8px;
|
||
min-height: 0;
|
||
padding: 10px;
|
||
}
|
||
|
||
.selected-panel__actions {
|
||
align-items: center;
|
||
display: inline-flex;
|
||
gap: 6px;
|
||
}
|
||
|
||
.selected-clear {
|
||
background: transparent;
|
||
border: 0;
|
||
color: #1677ff;
|
||
cursor: pointer;
|
||
font-size: 12px;
|
||
padding: 0;
|
||
}
|
||
|
||
.selected-list {
|
||
display: grid;
|
||
gap: 6px;
|
||
max-height: min(28vh, 220px);
|
||
overflow: auto;
|
||
}
|
||
|
||
.selected-item {
|
||
align-items: center;
|
||
background: #fff;
|
||
border: 1px solid #e5e7eb;
|
||
border-radius: 8px;
|
||
display: grid;
|
||
gap: 8px;
|
||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||
min-height: 56px;
|
||
padding: 6px;
|
||
}
|
||
|
||
.selected-item__body {
|
||
min-width: 0;
|
||
}
|
||
|
||
.selected-item__body strong,
|
||
.selected-item__body span {
|
||
display: block;
|
||
max-width: 100%;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.selected-item__body strong {
|
||
color: #111827;
|
||
font-size: 13px;
|
||
line-height: 1.2;
|
||
}
|
||
|
||
.selected-item__body span {
|
||
color: #6b7280;
|
||
font-size: 11px;
|
||
}
|
||
|
||
.selected-item__remove {
|
||
align-items: center;
|
||
background: transparent;
|
||
border: 0;
|
||
color: #6b7280;
|
||
cursor: pointer;
|
||
display: inline-flex;
|
||
height: 28px;
|
||
justify-content: center;
|
||
padding: 0;
|
||
width: 28px;
|
||
}
|
||
|
||
.selected-item__remove:hover {
|
||
color: #d4380d;
|
||
}
|
||
|
||
.summary-empty {
|
||
align-items: center;
|
||
color: #6b7280;
|
||
display: flex;
|
||
justify-content: center;
|
||
min-height: 44px;
|
||
}
|
||
|
||
.summary-control {
|
||
background: #fbfcfe;
|
||
border: 1px solid #edf0f3;
|
||
border-radius: 8px;
|
||
display: grid;
|
||
gap: 6px;
|
||
padding: 8px;
|
||
}
|
||
|
||
.summary-control--days {
|
||
gap: 6px;
|
||
}
|
||
|
||
.summary-control__row {
|
||
align-items: center;
|
||
display: grid;
|
||
gap: 8px;
|
||
grid-template-columns: 64px minmax(0, 1fr);
|
||
}
|
||
|
||
.summary-control__label {
|
||
color: #111827;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.summary-days-input {
|
||
width: 100%;
|
||
}
|
||
|
||
.summary-quick-days {
|
||
display: grid;
|
||
gap: 4px;
|
||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||
}
|
||
|
||
.summary-quick-days .quick-day {
|
||
height: 28px;
|
||
padding: 0 4px;
|
||
}
|
||
|
||
.summary-list {
|
||
display: grid;
|
||
gap: 0;
|
||
margin: 0;
|
||
}
|
||
|
||
.summary-list div {
|
||
align-items: center;
|
||
border-top: 1px solid #f0f2f5;
|
||
display: flex;
|
||
justify-content: space-between;
|
||
min-height: 30px;
|
||
}
|
||
|
||
.summary-list dt {
|
||
color: #6b7280;
|
||
}
|
||
|
||
.summary-list dd {
|
||
color: #111827;
|
||
font-weight: 600;
|
||
margin: 0;
|
||
max-width: 190px;
|
||
overflow: hidden;
|
||
text-align: right;
|
||
text-overflow: ellipsis;
|
||
white-space: normal;
|
||
}
|
||
|
||
.summary-send {
|
||
margin-top: 4px;
|
||
}
|
||
|
||
@media (max-width: 1120px) {
|
||
.send-layout {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
|
||
.send-summary {
|
||
position: static;
|
||
}
|
||
}
|
||
|
||
@media (max-width: 720px) {
|
||
.send-main {
|
||
padding: 14px;
|
||
}
|
||
|
||
.receiver-line {
|
||
align-items: stretch;
|
||
display: grid;
|
||
grid-template-columns: 1fr;
|
||
}
|
||
|
||
.control-label,
|
||
.receiver-input {
|
||
flex: none;
|
||
width: 100%;
|
||
}
|
||
|
||
.resource-head {
|
||
align-items: stretch;
|
||
display: grid;
|
||
}
|
||
|
||
.resource-search {
|
||
max-width: none;
|
||
}
|
||
|
||
.resource-grid {
|
||
grid-template-columns: repeat(auto-fill, minmax(92px, 1fr));
|
||
max-height: none;
|
||
}
|
||
}
|
||
</style>
|