1270 lines
32 KiB
Vue
1270 lines
32 KiB
Vue
<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>
|