yumi-admin/apps/src/views/props/send-tool.vue
2026-05-19 18:25:02 +08:00

1417 lines
34 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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>