yumi-admin/apps/src/views/operate/components/gift-batch-drawer.vue
2026-05-19 19:39:29 +08:00

1270 lines
32 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, reactive, ref, watch } from 'vue';
import {
Button,
Drawer,
Empty,
Image,
Input,
InputNumber,
message,
Progress,
Select,
Space,
Table,
Tag,
} from 'antdv-next';
import { addGift } from '#/api/legacy/gift';
import { getAccessImgUrl, simpleUploadFile } from '#/api/legacy/oss';
import { regionConfigTable } from '#/api/legacy/system';
import { normalizeResourceCode } from '#/views/_shared/resource-code';
import { GIFT_CONFIG_TAB_OPTIONS } from '../constants';
type AssetKind = 'cover' | 'source';
type RowStatus = 'error' | 'ready' | 'saved' | 'saving' | 'uploading';
interface BatchGiftRow {
code: string;
cover: string;
coverError: string;
coverFileName: string;
coverUploading: boolean;
errors: string[];
giftCandy: number;
giftIntegral: number;
key: string;
name: string;
saved: boolean;
saving: boolean;
sort: number;
sourceError: string;
sourceFileName: string;
sourceUploading: boolean;
sourceUrl: string;
status: RowStatus;
}
interface ParsedGiftFile {
amount: number;
assetKind: AssetKind;
code: string;
name: string;
}
interface UploadTask {
file: File;
fileName: string;
kind: AssetKind;
row: BatchGiftRow;
}
const props = defineProps<{
initialSysOrigin?: number | string;
open: boolean;
sysOriginOptions: Array<{ label: string; value: string }>;
}>();
const emit = defineEmits<{
close: [];
success: [];
}>();
const batchForm = reactive({
giftTab: 'ORDINARY',
regionList: [] as Array<number | string>,
sysOrigin: '',
});
const rows = ref<BatchGiftRow[]>([]);
const activeRowKey = ref('');
const dragging = ref(false);
const folderInputRef = ref<HTMLInputElement>();
const fileInputRef = ref<HTMLInputElement>();
const regions = ref<Array<Record<string, any>>>([]);
const selectedRowKeys = ref<string[]>([]);
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 = 1;
let activeUploadCount = 0;
const giftTabOptions = GIFT_CONFIG_TAB_OPTIONS.map((item) => ({
label: item.name,
value: item.value as any,
}));
const regionOptions = computed(() =>
regions.value.map((item) => ({
label: String(item.regionName || item.name || item.id || '-'),
value: item.id as any,
})),
);
const columns = [
{ dataIndex: 'cover', key: 'cover', title: '封面', width: 116 },
{ dataIndex: 'sourceUrl', key: 'sourceUrl', title: '资源', width: 180 },
{ dataIndex: 'name', key: 'name', title: '名称', width: 180 },
{ dataIndex: 'code', key: 'code', title: '编码', width: 180 },
{ dataIndex: 'giftCandy', key: 'giftCandy', title: '金币', width: 116 },
{ dataIndex: 'giftIntegral', key: 'giftIntegral', title: '积分', width: 116 },
{ dataIndex: 'sort', key: 'sort', title: '排序', width: 96 },
{ dataIndex: 'status', key: 'status', title: '状态', width: 120 },
{ dataIndex: 'actions', key: 'actions', title: '操作', width: 92 },
];
const visibleRows = computed(() =>
showOnlyErrors.value
? rows.value.filter((row) => row.errors.length > 0)
: rows.value,
);
const activeRow = computed(() => {
if (activeRowKey.value) {
const matched = rows.value.find((row) => row.key === activeRowKey.value);
if (matched) {
return matched;
}
}
return rows.value[0] || null;
});
const uploadAssetTotal = computed(() =>
rows.value.reduce(
(total, row) =>
total + (row.coverFileName ? 1 : 0) + (row.sourceFileName ? 1 : 0),
0,
),
);
const uploadAssetFinished = computed(() =>
rows.value.reduce(
(total, row) =>
total
+ (row.coverFileName && !row.coverUploading ? 1 : 0)
+ (row.sourceFileName && !row.sourceUploading ? 1 : 0),
0,
),
);
const uploadPercent = computed(() => {
if (uploadAssetTotal.value === 0) {
return 0;
}
return Math.round((uploadAssetFinished.value / uploadAssetTotal.value) * 100);
});
const errorCount = computed(() =>
rows.value.filter((row) => row.errors.length > 0).length,
);
const submitTargetCount = computed(
() => rows.value.filter((row) => isRowSubmittable(row)).length,
);
const rowSelection = computed(() => ({
selectedRowKeys: selectedRowKeys.value,
onChange: (keys: Array<number | string>) => {
selectedRowKeys.value = keys.map(String);
},
}));
function resetBatch() {
batchForm.sysOrigin =
String(props.initialSysOrigin || props.sysOriginOptions[0]?.value || 'LIKEI');
batchForm.giftTab = 'ORDINARY';
batchForm.regionList = [];
rows.value = [];
selectedRowKeys.value = [];
activeRowKey.value = '';
showOnlyErrors.value = false;
sequence.value = 0;
uploadQueue.length = 0;
}
watch(
() => props.open,
(open) => {
if (!open) {
return;
}
resetBatch();
void loadRegions();
},
{ immediate: true },
);
watch(
() => batchForm.sysOrigin,
() => {
if (props.open) {
void loadRegions();
}
},
);
watch(
() => [batchForm.sysOrigin, batchForm.regionList.length],
() => {
refreshAllRows();
},
);
async function loadRegions() {
if (!batchForm.sysOrigin) {
regions.value = [];
batchForm.regionList = [];
return;
}
regions.value = (await regionConfigTable({ sysOrigin: batchForm.sysOrigin })) || [];
batchForm.regionList = regions.value.map((item) => item.id);
refreshAllRows();
}
function normalizeAmount(value: unknown) {
const amount = Number(value ?? 0);
return Number.isFinite(amount) && amount >= 0 ? amount : 0;
}
function getPathFileName(path: string) {
const segments = path.split('/');
return segments[segments.length - 1] || path;
}
function getFilePath(file: File) {
return (
droppedFilePathMap.get(file)
|| (file as any).webkitRelativePath
|| file.name
);
}
function isImageFile(file: File) {
return (
file.type.startsWith('image/')
|| /\.(apng|avif|gif|jpe?g|png|svg|webp)$/i.test(file.name)
);
}
function isSourceFile(file: File) {
return /\.(mp4|pag|svga|zip)$/i.test(file.name);
}
function parseGiftFile(file: File): null | ParsedGiftFile {
const fileName = getPathFileName(getFilePath(file));
const baseName = fileName.replace(/\.[^.]+$/, '').trim();
const parts = baseName
.split('_')
.map((part) => part.trim())
.filter(Boolean);
if (parts[0] !== '礼物') {
return null;
}
const assetToken = String(parts[parts.length - 1] || '').toLowerCase();
let assetKind: '' | AssetKind = '';
if (assetToken === 'cover') {
assetKind = 'cover';
} else if (assetToken === 'animation') {
assetKind = 'source';
}
if (!assetKind) {
return null;
}
if (assetKind === 'cover' && !isImageFile(file)) {
return null;
}
if (assetKind === 'source' && !isSourceFile(file)) {
return null;
}
const nameParts = parts.slice(1, -1);
const maybeDuration = String(nameParts[nameParts.length - 1] || '');
if (/^\d+天$/.test(maybeDuration)) {
nameParts.pop();
}
let amount = 0;
const maybeAmount = String(nameParts[nameParts.length - 1] || '');
if (/^\d+(\.\d+)?$/.test(maybeAmount)) {
amount = normalizeAmount(nameParts.pop());
}
const name = nameParts.join('_').trim();
if (!name) {
return null;
}
return {
amount,
assetKind,
code: normalizeResourceCode(name),
name,
};
}
function findOrCreateRow(parsed: ParsedGiftFile) {
const matched = rows.value.find(
(row) => row.code === parsed.code || row.name === parsed.name,
);
if (matched) {
matched.giftCandy = parsed.amount;
matched.giftIntegral = parsed.amount;
matched.saved = false;
return matched;
}
const amount = normalizeAmount(parsed.amount);
const row: BatchGiftRow = {
code: parsed.code,
cover: '',
coverError: '',
coverFileName: '',
coverUploading: false,
errors: [],
giftCandy: amount,
giftIntegral: amount,
key: `gift-batch-${Date.now()}-${sequence.value++}`,
name: parsed.name,
saved: false,
saving: false,
sort: rows.value.length + 1,
sourceError: '',
sourceFileName: '',
sourceUploading: false,
sourceUrl: '',
status: 'error',
};
rows.value.push(row);
if (!activeRowKey.value) {
activeRowKey.value = row.key;
}
return row;
}
function setRowAsset(row: BatchGiftRow, kind: AssetKind, file: File) {
const fileName = getPathFileName(getFilePath(file));
if (kind === 'cover') {
row.cover = '';
row.coverError = '';
row.coverFileName = fileName;
row.coverUploading = true;
} else {
row.sourceUrl = '';
row.sourceError = '';
row.sourceFileName = fileName;
row.sourceUploading = true;
}
row.saved = false;
refreshAllRows();
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(
row: BatchGiftRow,
kind: AssetKind,
file: File,
fileName: string,
) {
try {
const result = await simpleUploadFile(file, 'gifts');
if (kind === 'cover' && row.coverFileName === fileName) {
row.cover = result.name;
} else if (kind === 'source' && row.sourceFileName === fileName) {
row.sourceUrl = result.name;
}
} catch (error) {
console.error(error);
if (kind === 'cover' && row.coverFileName === fileName) {
row.coverError = '封面上传失败';
} else if (kind === 'source' && row.sourceFileName === fileName) {
row.sourceError = '资源上传失败';
}
} finally {
if (kind === 'cover' && row.coverFileName === fileName) {
row.coverUploading = false;
} else if (kind === 'source' && row.sourceFileName === fileName) {
row.sourceUploading = false;
}
refreshAllRows();
}
}
function processFiles(files: File[]) {
let accepted = 0;
let ignored = 0;
files.forEach((file) => {
const parsed = parseGiftFile(file);
if (!parsed) {
ignored += 1;
return;
}
const row = findOrCreateRow(parsed);
setRowAsset(row, parsed.assetKind, file);
accepted += 1;
});
if (accepted === 0 && files.length > 0) {
message.warning('未识别到符合 礼物_ 命名规则的文件');
return;
}
if (accepted > 0) {
message.success(
ignored > 0
? `已导入 ${accepted} 个文件,忽略 ${ignored} 个非规则文件`
: `已导入 ${accepted} 个文件`,
);
}
}
function handleFileInput(event: Event) {
const target = event.target as HTMLInputElement;
const files = target.files ? [...target.files] : [];
processFiles(files);
target.value = '';
}
async function handleDrop(event: DragEvent) {
dragging.value = false;
const files = await collectDroppedFiles(event.dataTransfer);
processFiles(files);
}
async function collectDroppedFiles(dataTransfer?: DataTransfer | null) {
if (!dataTransfer) {
return [];
}
const items = [...(dataTransfer.items || [])];
const entries = items
.map((item) => (item as any).webkitGetAsEntry?.())
.filter(Boolean);
if (entries.length === 0) {
return [...(dataTransfer.files || [])];
}
const files: File[] = [];
await Promise.all(entries.map((entry) => readEntry(entry, files)));
return files;
}
async function readEntry(entry: any, result: File[], parentPath = ''): Promise<void> {
const currentPath = `${parentPath}${entry.name}`;
if (entry.isFile) {
await new Promise<void>((resolve) => {
entry.file((file: File) => {
droppedFilePathMap.set(file, currentPath);
result.push(file);
resolve();
}, resolve);
});
return;
}
if (!entry.isDirectory) {
return;
}
const reader = entry.createReader();
while (true) {
const entries = await new Promise<any[]>((resolve, reject) => {
reader.readEntries(resolve, reject);
});
if (entries.length === 0) {
break;
}
await Promise.all(
entries.map((child) => readEntry(child, result, `${currentPath}/`)),
);
}
}
function getDuplicateMap(field: 'code' | 'name') {
const map = new Map<string, number>();
rows.value.forEach((row) => {
const value = String(row[field] || '').trim();
if (!value) {
return;
}
map.set(value, (map.get(value) || 0) + 1);
});
return map;
}
function getRowErrors(
row: BatchGiftRow,
duplicateCodes = getDuplicateMap('code'),
duplicateNames = getDuplicateMap('name'),
) {
const errors: string[] = [];
if (!batchForm.sysOrigin) {
errors.push('请选择平台');
}
if (batchForm.regionList.length === 0) {
errors.push('请选择区域');
}
if (!String(row.name || '').trim()) {
errors.push('缺少名称');
}
if (!String(row.code || '').trim()) {
errors.push('缺少编码');
}
if ((duplicateCodes.get(String(row.code).trim()) || 0) > 1) {
errors.push('本批次编码重复');
}
if ((duplicateNames.get(String(row.name).trim()) || 0) > 1) {
errors.push('本批次名称重复');
}
if (row.coverError) {
errors.push(row.coverError);
}
if (row.sourceError) {
errors.push(row.sourceError);
}
if (!row.cover && !row.coverUploading) {
errors.push('缺少封面');
}
if (!Number.isFinite(Number(row.giftCandy)) || Number(row.giftCandy) < 0) {
errors.push('金币不正确');
}
if (!Number.isFinite(Number(row.giftIntegral)) || Number(row.giftIntegral) < 0) {
errors.push('积分不正确');
}
if (!Number.isFinite(Number(row.sort))) {
errors.push('排序不正确');
}
return errors;
}
function refreshRow(
row: BatchGiftRow,
duplicateCodes = getDuplicateMap('code'),
duplicateNames = getDuplicateMap('name'),
) {
row.errors = getRowErrors(row, duplicateCodes, duplicateNames);
if (row.saving) {
row.status = 'saving';
} else if (row.coverUploading || row.sourceUploading) {
row.status = 'uploading';
} else if (row.saved) {
row.status = 'saved';
} else if (row.errors.length > 0) {
row.status = 'error';
} else {
row.status = 'ready';
}
}
function refreshAllRows() {
const duplicateCodes = getDuplicateMap('code');
const duplicateNames = getDuplicateMap('name');
rows.value.forEach((row) => refreshRow(row, duplicateCodes, duplicateNames));
}
function isRowSubmittable(row: BatchGiftRow) {
refreshRow(row);
return row.status === 'ready' && !row.saved;
}
function markRowEdited(record: Record<string, any>) {
const row = record as BatchGiftRow;
row.saved = false;
refreshAllRows();
}
function deleteRow(record: Record<string, any>) {
const row = record as BatchGiftRow;
rows.value = rows.value.filter((item) => item.key !== row.key);
selectedRowKeys.value = selectedRowKeys.value.filter((key) => key !== row.key);
if (activeRowKey.value === row.key) {
activeRowKey.value = rows.value[0]?.key || '';
}
refreshAllRows();
}
function deleteSelectedRows() {
if (selectedRowKeys.value.length === 0) {
message.warning('请先选择礼物');
return;
}
const selected = new Set(selectedRowKeys.value);
rows.value = rows.value.filter((row) => !selected.has(row.key));
selectedRowKeys.value = [];
if (!rows.value.some((row) => row.key === activeRowKey.value)) {
activeRowKey.value = rows.value[0]?.key || '';
}
refreshAllRows();
}
function clearRows() {
rows.value = [];
selectedRowKeys.value = [];
activeRowKey.value = '';
}
function getStatusText(status: RowStatus) {
const map: Record<RowStatus, string> = {
error: '待处理',
ready: '可提交',
saved: '已保存',
saving: '保存中',
uploading: '上传中',
};
return map[status];
}
function getStatusColor(status: RowStatus) {
const map: Record<RowStatus, string> = {
error: 'error',
ready: 'success',
saved: 'default',
saving: 'processing',
uploading: 'processing',
};
return map[status];
}
function setActiveRow(row: BatchGiftRow) {
activeRowKey.value = row.key;
}
function getRowClassName(record: Record<string, any>) {
const row = record as BatchGiftRow;
if (row.key === activeRowKey.value) {
return row.errors.length > 0
? 'batch-row batch-row--active batch-row--error'
: 'batch-row batch-row--active';
}
return row.errors.length > 0 ? 'batch-row batch-row--error' : 'batch-row';
}
function customRow(record: Record<string, any>) {
const row = record as BatchGiftRow;
return {
onClick: () => setActiveRow(row),
};
}
function buildPayload(row: BatchGiftRow) {
return {
account: '',
explanationGift: false,
expiredTime: '',
giftCandy: Number(row.giftCandy || 0),
giftCode: String(row.code || '').trim(),
giftIntegral: Number(row.giftIntegral || 0),
giftName: String(row.name || '').trim(),
giftPhoto: row.cover,
giftSourceUrl: row.sourceUrl,
giftTab: batchForm.giftTab,
regionList: [...batchForm.regionList],
sort: Number(row.sort || 0),
specialList: [],
standardId: '',
sysOrigin: batchForm.sysOrigin,
type: 'GOLD',
};
}
async function submitBatch() {
refreshAllRows();
if (rows.value.some((row) => row.status === 'uploading')) {
message.warning('还有文件正在上传,请稍后提交');
return;
}
const targetRows = rows.value.filter((row) => row.status === 'ready');
if (targetRows.length === 0) {
message.warning('暂无可提交礼物');
return;
}
submitting.value = true;
let successCount = 0;
let failedCount = 0;
for (const row of targetRows) {
row.saving = true;
refreshRow(row);
try {
await addGift(buildPayload(row));
row.saved = true;
row.errors = [];
successCount += 1;
} catch (error) {
console.error(error);
row.saved = false;
row.errors = ['保存失败'];
failedCount += 1;
} finally {
row.saving = false;
refreshRow(row);
}
}
submitting.value = false;
if (failedCount === 0) {
message.success(`已提交 ${successCount} 个礼物`);
emit('success');
emit('close');
return;
}
message.error(`已提交 ${successCount} 个礼物,${failedCount} 个失败`);
}
</script>
<template>
<Drawer
destroy-on-hidden
:open="open"
root-class="gift-batch-drawer-root"
size="100vw"
:styles="{
body: { padding: '0', overflow: 'hidden' },
footer: { padding: '0' },
}"
title="批量导入礼物"
@close="emit('close')"
>
<div class="batch-drawer">
<div class="batch-common">
<div class="common-field">
<span class="common-label">平台</span>
<SysOriginSelect
v-model:value="batchForm.sysOrigin"
:options="sysOriginOptions"
/>
</div>
<div class="common-field">
<span class="common-label">礼物类型</span>
<Select
v-model:value="batchForm.giftTab"
option-label-prop="label"
:options="giftTabOptions"
/>
</div>
<div class="common-field common-field--wide">
<span class="common-label">区域</span>
<Select
v-model:value="batchForm.regionList"
mode="multiple"
option-label-prop="label"
:options="regionOptions"
/>
</div>
<div class="common-meta">收费类型金币</div>
</div>
<div class="batch-workspace">
<aside class="import-panel">
<input
ref="fileInputRef"
accept="image/*,.svga,.pag,.mp4,.zip"
class="hidden-input"
multiple
type="file"
@change="handleFileInput"
/>
<input
ref="folderInputRef"
accept="image/*,.svga,.pag,.mp4,.zip"
class="hidden-input"
multiple
type="file"
webkitdirectory
@change="handleFileInput"
/>
<div
class="dropzone"
:class="{ 'dropzone--active': dragging }"
@click="fileInputRef?.click()"
@dragenter.prevent="dragging = true"
@dragleave.prevent="dragging = false"
@dragover.prevent="dragging = true"
@drop.prevent="handleDrop"
>
<div class="dropzone-title">拖拽文件到这里</div>
<div class="dropzone-desc">只解析 礼物_ 开头的文件</div>
<Space wrap>
<Button type="primary" @click.stop="fileInputRef?.click()">
选择文件
</Button>
<Button @click.stop="folderInputRef?.click()">选择文件夹</Button>
</Space>
</div>
<div class="import-rules">
<div class="side-title">识别规则</div>
<div class="rule-item">礼物_物品名_价格_cover/animation</div>
<div class="rule-item">礼物_物品名_价格_1天_cover/animation</div>
<div class="rule-item">cover 写入封面animation 写入资源</div>
<div class="rule-item">默认金币不填写有效期</div>
</div>
<div class="import-progress">
<div class="progress-head">
<span>导入进度</span>
<span>{{ uploadAssetFinished }}/{{ uploadAssetTotal }}</span>
</div>
<Progress :percent="uploadPercent" size="small" />
<div class="progress-meta">
已识别 {{ rows.length }} 错误 {{ errorCount }}
</div>
</div>
</aside>
<main class="table-panel">
<div class="table-toolbar">
<Space wrap>
<Button
:disabled="rows.length === 0"
@click="showOnlyErrors = !showOnlyErrors"
>
{{ showOnlyErrors ? '查看全部' : '只看错误' }}
</Button>
<Button
danger
:disabled="selectedRowKeys.length === 0"
@click="deleteSelectedRows"
>
删除选中
</Button>
</Space>
<span class="selection-text">
已选 {{ selectedRowKeys.length }}
</span>
</div>
<Table
bordered
:columns="columns"
:custom-row="customRow"
:data-source="visibleRows"
:pagination="false"
:row-class-name="getRowClassName"
row-key="key"
:row-selection="rowSelection"
:scroll="{ x: 1120, y: 'calc(100vh - 360px)' }"
size="small"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'cover'">
<div class="asset-cell">
<Image
v-if="record.cover"
class="asset-cover"
:src="getAccessImgUrl(record.cover)"
/>
<div v-else class="asset-empty">
{{ record.coverUploading ? '上传中' : '缺少封面' }}
</div>
<div class="file-name">{{ record.coverFileName || '-' }}</div>
</div>
</template>
<template v-else-if="column.key === 'sourceUrl'">
<div class="file-cell">
<Tag :color="record.sourceUrl ? 'success' : 'default'">
{{ record.sourceUploading ? '上传中' : record.sourceUrl ? '已上传' : '未上传' }}
</Tag>
<div class="file-name">{{ record.sourceFileName || '-' }}</div>
</div>
</template>
<template v-else-if="column.key === 'name'">
<Input
v-model:value="record.name"
size="small"
@blur="markRowEdited(record)"
/>
</template>
<template v-else-if="column.key === 'code'">
<Input
v-model:value="record.code"
size="small"
@blur="markRowEdited(record)"
/>
</template>
<template v-else-if="column.key === 'giftCandy'">
<InputNumber
v-model:value="record.giftCandy"
:min="0"
size="small"
style="width: 96px"
@change="markRowEdited(record)"
/>
</template>
<template v-else-if="column.key === 'giftIntegral'">
<InputNumber
v-model:value="record.giftIntegral"
:min="0"
size="small"
style="width: 96px"
@change="markRowEdited(record)"
/>
</template>
<template v-else-if="column.key === 'sort'">
<InputNumber
v-model:value="record.sort"
size="small"
style="width: 76px"
@change="markRowEdited(record)"
/>
</template>
<template v-else-if="column.key === 'status'">
<Tag :color="getStatusColor(record.status)">
{{ getStatusText(record.status) }}
</Tag>
</template>
<template v-else-if="column.key === 'actions'">
<Button danger size="small" type="link" @click.stop="deleteRow(record)">
删除
</Button>
</template>
</template>
</Table>
</main>
<aside class="preview-panel">
<template v-if="activeRow">
<div class="side-title">礼物预览</div>
<div class="preview-cover">
<Image
v-if="activeRow.cover"
class="preview-image"
:src="getAccessImgUrl(activeRow.cover)"
/>
<div v-else class="preview-empty">未上传封面</div>
</div>
<div class="preview-name">{{ activeRow.name || '-' }}</div>
<div class="preview-meta">编码{{ activeRow.code || '-' }}</div>
<div class="preview-meta">封面{{ activeRow.coverFileName || '-' }}</div>
<div class="preview-meta">资源{{ activeRow.sourceFileName || '-' }}</div>
<div class="side-title side-title--spaced">校验结果</div>
<div v-if="activeRow.errors.length === 0" class="success-text">
可提交
</div>
<div v-else class="error-list">
<Tag
v-for="error in activeRow.errors"
:key="error"
color="error"
>
{{ error }}
</Tag>
</div>
</template>
<Empty v-else description="暂无礼物" />
</aside>
</div>
</div>
<template #footer>
<div class="batch-footer">
<div class="footer-stats">
<span>已识别 {{ rows.length }} </span>
<span>错误 {{ errorCount }} </span>
<span>可提交 {{ submitTargetCount }} </span>
</div>
<Space>
<Button :disabled="rows.length === 0 || submitting" @click="clearRows">
清空
</Button>
<Button :disabled="submitting" @click="emit('close')">取消</Button>
<Button
:disabled="submitTargetCount === 0"
:loading="submitting"
type="primary"
@click="submitBatch"
>
提交 {{ submitTargetCount }}
</Button>
</Space>
</div>
</template>
</Drawer>
</template>
<style scoped>
.batch-drawer {
background: #f5f7fb;
display: flex;
flex-direction: column;
height: calc(100vh - 55px);
min-width: 0;
}
.batch-common {
align-items: flex-end;
background: #fff;
border-bottom: 1px solid #e5e7eb;
display: flex;
flex-wrap: wrap;
gap: 12px;
padding: 14px 20px;
}
.common-field {
display: grid;
gap: 6px;
min-width: 180px;
}
.common-field--short {
min-width: 120px;
}
.common-field--wide {
min-width: 320px;
}
.common-label,
.common-meta {
color: #475569;
font-size: 12px;
line-height: 18px;
}
.common-meta {
min-height: 32px;
padding-top: 7px;
}
.batch-workspace {
display: grid;
flex: 1;
gap: 12px;
grid-template-columns: 260px minmax(620px, 1fr) 300px;
min-height: 0;
padding: 12px;
}
.import-panel,
.preview-panel,
.table-panel {
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 8px;
min-height: 0;
}
.import-panel,
.preview-panel {
display: flex;
flex-direction: column;
gap: 14px;
overflow: auto;
padding: 14px;
}
.table-panel {
display: flex;
flex-direction: column;
overflow: hidden;
}
.hidden-input {
display: none;
}
.dropzone {
align-items: center;
background: #f8fafc;
border: 1px dashed #94a3b8;
border-radius: 8px;
cursor: pointer;
display: flex;
flex-direction: column;
gap: 10px;
min-height: 180px;
padding: 20px 14px;
text-align: center;
}
.dropzone:hover,
.dropzone--active {
background: #eef6ff;
border-color: #1677ff;
}
.dropzone-title {
color: #111827;
font-size: 16px;
font-weight: 600;
}
.dropzone-desc {
color: #64748b;
font-size: 13px;
}
.side-title {
color: #111827;
font-weight: 600;
}
.side-title--spaced {
margin-top: 6px;
}
.import-rules {
display: grid;
gap: 8px;
}
.rule-item,
.progress-meta,
.preview-meta {
color: #64748b;
font-size: 12px;
line-height: 20px;
}
.progress-head {
align-items: center;
color: #334155;
display: flex;
font-size: 13px;
justify-content: space-between;
margin-bottom: 4px;
}
.table-toolbar {
align-items: center;
border-bottom: 1px solid #e5e7eb;
display: flex;
gap: 12px;
justify-content: space-between;
padding: 12px;
}
.selection-text {
color: #64748b;
flex-shrink: 0;
font-size: 12px;
}
.asset-cell,
.file-cell {
display: grid;
gap: 6px;
min-width: 0;
}
.asset-cover,
.asset-empty {
height: 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 {
align-items: center;
background: #f8fafc;
border: 1px dashed #cbd5e1;
border-radius: 6px;
color: #94a3b8;
display: flex;
font-size: 12px;
justify-content: center;
text-align: center;
}
.file-name {
color: #64748b;
font-size: 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.preview-cover,
.preview-empty {
align-items: center;
background: #f8fafc;
border: 1px solid #e5e7eb;
border-radius: 8px;
display: flex;
justify-content: center;
min-height: 180px;
overflow: hidden;
}
.preview-empty {
color: #94a3b8;
}
.preview-image {
max-height: 220px;
object-fit: contain;
width: 100%;
}
.preview-name {
color: #111827;
font-size: 16px;
font-weight: 600;
overflow-wrap: anywhere;
}
.success-text {
color: #16a34a;
}
.error-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.batch-footer {
align-items: center;
background: #fff;
border-top: 1px solid #e5e7eb;
display: flex;
justify-content: space-between;
padding: 12px 20px;
}
.footer-stats {
color: #334155;
display: flex;
flex-wrap: wrap;
gap: 16px;
font-size: 13px;
}
:deep(.batch-row--error > td) {
background: #fff7f7;
}
:deep(.batch-row--active > td) {
background: #eef6ff;
}
@media (width <= 1180px) {
.batch-workspace {
grid-template-columns: 240px minmax(520px, 1fr);
}
.preview-panel {
display: none;
}
}
</style>