yumi-admin/apps/src/views/props/components/resource-batch-drawer.vue
2026-05-19 18:25:02 +08:00

1549 lines
40 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,
Switch,
Table,
Tag,
} from 'antdv-next';
import {
getAccessImgUrl,
OSS_FILE_BUCKETS,
simpleUploadFile,
} from '#/api/legacy/oss';
import { addPropsSource } from '#/api/legacy/props';
import { normalizeResourceCode } from '#/views/_shared/resource-code';
import {
BADGE_DISPLAY_TYPE_OPTIONS,
getPropsTypeName,
PROPS_RESOURCE_CONFIG_TYPES,
} from '../shared';
type AssetKind = 'cover' | 'expand' | 'source';
type RowStatus = 'error' | 'ready' | 'saved' | 'saving' | 'uploading';
type StructuredAssetKind = 'cover' | 'source';
interface BatchRow {
adminFree: boolean;
amount: number;
amountTouched: boolean;
code: string;
cover: string;
coverError: string;
coverFileName: string;
coverUploading: boolean;
errors: string[];
expand: string;
expandError: string;
expandFileName: string;
expandUploading: boolean;
key: string;
name: string;
saved: boolean;
saving: boolean;
sourceError: string;
sourceFileName: string;
sourceUploading: boolean;
sourceUrl: string;
status: RowStatus;
type: string;
typeTouched: boolean;
}
interface FindRowOptions {
adminFree?: boolean;
amount?: number | string;
amountTouched?: boolean;
code?: string;
type?: string;
typeTouched?: boolean;
}
interface StructuredResourceFile {
amount: number;
assetKind: StructuredAssetKind;
code: string;
name: string;
type: string;
}
interface UploadTask {
file: File;
fileName: string;
kind: AssetKind;
row: BatchRow;
}
const props = defineProps<{
initialSysOrigin?: number | string;
initialType?: number | string;
open: boolean;
sysOriginOptions: Array<{ label: string; value: string }>;
}>();
const emit = defineEmits<{
close: [];
success: [];
}>();
const batchForm = reactive({
adminFree: false,
amount: 0,
badgeDisplayType: 'SHORT',
sysOrigin: '',
type: '',
});
const rows = ref<BatchRow[]>([]);
const activeRowKey = ref('');
const dragging = ref(false);
const folderInputRef = ref<HTMLInputElement>();
const fileInputRef = ref<HTMLInputElement>();
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 = 2;
let activeUploadCount = 0;
const propsTypeOptions = PROPS_RESOURCE_CONFIG_TYPES.map((item) => ({
label: item.name,
value: item.value as any,
}));
const badgeDisplayTypeOptions = BADGE_DISPLAY_TYPE_OPTIONS.map((item) => ({
label: item.name,
value: item.value as any,
}));
const STRUCTURED_RESOURCE_TYPE_MAP: Record<string, string> = {
勋章: 'BADGE',
坐骑: 'RIDE',
头像框: 'AVATAR_FRAME',
};
const STRUCTURED_ASSET_KIND_MAP: Record<string, StructuredAssetKind> = {
animation: 'source',
cover: 'cover',
};
function isAmountRequiredByType(type: string) {
return !['CUSTOMIZE', 'FRAGMENTS'].includes(type);
}
function isSourceRequiredByType(type: string) {
return !['BADGE', 'CUSTOMIZE', 'FRAGMENTS'].includes(type);
}
function showExpandUploadByType(type: string) {
return type === 'CHAT_BUBBLE';
}
const isAmountRequired = computed(() => isAmountRequiredByType(batchForm.type));
const showExpandUpload = computed(() => showExpandUploadByType(batchForm.type));
const hasExpandColumn = computed(
() =>
showExpandUpload.value
|| rows.value.some((row) => showExpandUploadByType(getRowType(row))),
);
const columns = computed(() => {
const baseColumns = [
{ dataIndex: 'cover', key: 'cover', title: '封面', width: 116 },
{ dataIndex: 'sourceUrl', key: 'sourceUrl', title: '资源', width: 180 },
];
if (hasExpandColumn.value) {
baseColumns.push({
dataIndex: 'expand',
key: 'expand',
title: 'Android资源',
width: 180,
});
}
return [
...baseColumns,
{ dataIndex: 'type', key: 'type', title: '类型', width: 132 },
{ dataIndex: 'name', key: 'name', title: '名称', width: 180 },
{ dataIndex: 'code', key: 'code', title: '编码', width: 180 },
{ dataIndex: 'amount', key: 'amount', title: '底价', width: 116 },
{ dataIndex: 'adminFree', key: 'adminFree', title: 'Admin Free', width: 116 },
{ 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(() => {
return rows.value.reduce((total, row) => {
return total
+ (row.coverFileName ? 1 : 0)
+ (row.sourceFileName ? 1 : 0)
+ (row.expandFileName ? 1 : 0);
}, 0);
});
const uploadAssetFinished = computed(() => {
return rows.value.reduce((total, row) => {
return total
+ (row.coverFileName && !row.coverUploading ? 1 : 0)
+ (row.sourceFileName && !row.sourceUploading ? 1 : 0)
+ (row.expandFileName && !row.expandUploading ? 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 readyRows = computed(() =>
rows.value.filter((row) => isRowSubmittable(row)),
);
const submitTargetCount = computed(() => readyRows.value.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.type =
String(props.initialType || PROPS_RESOURCE_CONFIG_TYPES[0]?.value || 'AVATAR_FRAME');
batchForm.amount = 0;
batchForm.adminFree = false;
batchForm.badgeDisplayType = 'SHORT';
rows.value = [];
selectedRowKeys.value = [];
activeRowKey.value = '';
showOnlyErrors.value = false;
sequence.value = 0;
uploadQueue.length = 0;
}
watch(
() => props.open,
(open) => {
if (open) {
resetBatch();
}
},
{ immediate: true },
);
watch(
() => batchForm.amount,
() => {
rows.value.forEach((row) => {
if (!row.amountTouched) {
row.amount = normalizeAmount(batchForm.amount);
}
});
refreshAllRows();
},
);
watch(
() => [batchForm.type, batchForm.sysOrigin, batchForm.badgeDisplayType],
() => {
rows.value.forEach((row) => {
if (!row.typeTouched) {
row.type = batchForm.type;
}
});
refreshAllRows();
},
);
function normalizeAmount(value: unknown) {
const amount = Number(value ?? 0);
return Number.isFinite(amount) && amount >= 0 ? amount : 0;
}
function normalizeCode(value: string) {
return normalizeResourceCode(value);
}
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 isResourceFile(file: File) {
return /\.(mp4|pag|svga|zip)$/i.test(file.name);
}
function parseStructuredResourceFile(file: File): null | StructuredResourceFile {
if (!isImageFile(file) && !isResourceFile(file)) {
return null;
}
const fileName = getPathFileName(getFilePath(file));
const baseName = fileName.replace(/\.[^.]+$/, '').trim();
const parts = baseName
.split('_')
.map((part) => part.trim())
.filter(Boolean);
const type = STRUCTURED_RESOURCE_TYPE_MAP[parts[0] || ''];
const assetKind =
STRUCTURED_ASSET_KIND_MAP[String(parts[parts.length - 1] || '').toLowerCase()];
if (!type || !assetKind) {
return null;
}
if (assetKind === 'cover' && !isImageFile(file)) {
return null;
}
if (type === 'BADGE') {
const name = parts.slice(1, -1).join('_').trim();
if (!name) {
return null;
}
return {
amount: 0,
assetKind,
code: normalizeCode(name),
name,
type,
};
}
if (parts.length < 5) {
return null;
}
const amount = Number(parts[parts.length - 3]);
const duration = String(parts[parts.length - 2] || '');
const name = parts.slice(1, -3).join('_').trim();
if (!name || !/^\d+天$/.test(duration) || !Number.isFinite(amount) || amount < 0) {
return null;
}
return {
amount: normalizeAmount(amount),
assetKind,
code: normalizeCode(name),
name,
type,
};
}
function getRowType(row: BatchRow) {
return String(row.type || batchForm.type || '');
}
function findOrCreateRow(baseName: string, options: FindRowOptions = {}) {
const name = baseName.trim() || 'resource';
const type = String(options.type || batchForm.type || '');
const code = options.code || normalizeCode(name);
const matched = rows.value.find(
(row) =>
getRowType(row) === type
&& (String(row.code || '') === code || String(row.name || '') === name),
);
if (matched) {
if (options.adminFree !== undefined) {
matched.adminFree = options.adminFree;
}
if (options.amount !== undefined) {
matched.amount = normalizeAmount(options.amount);
matched.amountTouched = options.amountTouched ?? true;
}
if (options.code) {
matched.code = options.code;
}
if (options.type) {
matched.type = options.type;
matched.typeTouched = options.typeTouched ?? true;
}
matched.saved = false;
return matched;
}
const row: BatchRow = {
adminFree: options.adminFree ?? batchForm.adminFree,
amount:
options.amount === undefined
? normalizeAmount(batchForm.amount)
: normalizeAmount(options.amount),
amountTouched: options.amountTouched ?? false,
code,
cover: '',
coverError: '',
coverFileName: '',
coverUploading: false,
errors: [],
expand: '',
expandError: '',
expandFileName: '',
expandUploading: false,
key: `batch-${Date.now()}-${sequence.value++}`,
name,
saved: false,
saving: false,
sourceError: '',
sourceFileName: '',
sourceUploading: false,
sourceUrl: '',
status: 'error',
type,
typeTouched: options.typeTouched ?? false,
};
rows.value.push(row);
if (!activeRowKey.value) {
activeRowKey.value = row.key;
}
return row;
}
function getUploadBucket(kind: AssetKind, file: File) {
if (kind === 'cover' || kind === 'expand' || isImageFile(file)) {
return OSS_FILE_BUCKETS.svgaCover;
}
return OSS_FILE_BUCKETS.svgasource;
}
function setRowAsset(row: BatchRow, kind: AssetKind, file: File) {
const fileName = getPathFileName(getFilePath(file));
if (kind === 'cover') {
row.cover = '';
row.coverError = '';
row.coverFileName = fileName;
row.coverUploading = true;
} else if (kind === 'source') {
row.sourceUrl = '';
row.sourceError = '';
row.sourceFileName = fileName;
row.sourceUploading = true;
} else {
row.expand = '';
row.expandError = '';
row.expandFileName = fileName;
row.expandUploading = 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: BatchRow,
kind: AssetKind,
file: File,
fileName: string,
) {
try {
const result = await simpleUploadFile(file, getUploadBucket(kind, file));
const url = getAccessImgUrl(result.name);
if (kind === 'cover' && row.coverFileName === fileName) {
row.cover = url;
} else if (kind === 'source' && row.sourceFileName === fileName) {
row.sourceUrl = url;
} else if (kind === 'expand' && row.expandFileName === fileName) {
row.expand = url;
}
} catch (error) {
console.error(error);
if (kind === 'cover' && row.coverFileName === fileName) {
row.coverError = '封面上传失败';
} else if (kind === 'source' && row.sourceFileName === fileName) {
row.sourceError = '资源上传失败';
} else if (kind === 'expand' && row.expandFileName === fileName) {
row.expandError = 'Android资源上传失败';
}
} finally {
if (kind === 'cover' && row.coverFileName === fileName) {
row.coverUploading = false;
} else if (kind === 'source' && row.sourceFileName === fileName) {
row.sourceUploading = false;
} else if (kind === 'expand' && row.expandFileName === fileName) {
row.expandUploading = false;
}
refreshAllRows();
}
}
function processFiles(files: File[]) {
let accepted = 0;
let ignored = 0;
files.forEach((file) => {
const structuredFile = parseStructuredResourceFile(file);
if (!structuredFile) {
ignored += 1;
return;
}
const row = findOrCreateRow(structuredFile.name, {
adminFree: false,
amount: structuredFile.amount,
amountTouched: true,
code: structuredFile.code,
type: structuredFile.type,
typeTouched: true,
});
setRowAsset(row, structuredFile.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: BatchRow,
duplicateCodes = getDuplicateMap('code'),
duplicateNames = getDuplicateMap('name'),
) {
const errors: string[] = [];
if (!batchForm.sysOrigin) {
errors.push('请选择系统');
}
const rowType = getRowType(row);
if (!rowType) {
errors.push('请选择类型');
}
if (!String(row.name || '').trim()) {
errors.push('缺少名称');
}
if (!String(row.code || '').trim()) {
errors.push('缺少编码');
}
if (String(row.code || '').trim() && (duplicateCodes.get(String(row.code).trim()) || 0) > 1) {
errors.push('本批次编码重复');
}
if (String(row.name || '').trim() && (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.expandError) {
errors.push(row.expandError);
}
if (!row.cover && !row.coverUploading) {
errors.push('缺少封面');
}
if (isSourceRequiredByType(rowType) && !row.sourceUrl && !row.sourceUploading) {
errors.push('缺少资源');
}
if (showExpandUploadByType(rowType) && !row.expand && !row.expandUploading) {
errors.push('缺少 Android 资源');
}
if (
isAmountRequiredByType(rowType)
&& (!Number.isFinite(Number(row.amount)) || Number(row.amount) < 0)
) {
errors.push('底价不正确');
}
return errors;
}
function refreshRow(
row: BatchRow,
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.expandUploading) {
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: BatchRow) {
refreshRow(row);
return row.status === 'ready' && !row.saved;
}
function markAmountTouched(record: Record<string, any>) {
const row = record as BatchRow;
row.amountTouched = true;
row.saved = false;
refreshAllRows();
}
function markTypeTouched(record: Record<string, any>) {
const row = record as BatchRow;
row.typeTouched = true;
row.saved = false;
refreshAllRows();
}
function markRowEdited(record: Record<string, any>) {
const row = record as BatchRow;
row.saved = false;
refreshAllRows();
}
function getTargetRows() {
if (selectedRowKeys.value.length > 0) {
const selected = new Set(selectedRowKeys.value);
return rows.value.filter((row) => selected.has(row.key));
}
return visibleRows.value;
}
function applyDefaultAmount() {
const targetRows = getTargetRows();
targetRows.forEach((row) => {
row.amount = normalizeAmount(batchForm.amount);
row.amountTouched = true;
row.saved = false;
});
refreshAllRows();
}
function applyDefaultAdminFree() {
const targetRows = getTargetRows();
targetRows.forEach((row) => {
row.adminFree = batchForm.adminFree;
row.saved = false;
});
refreshAllRows();
}
function deleteRow(record: Record<string, any>) {
const row = record as BatchRow;
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 parseBoolean(value: string) {
return /^(1|true|yes|y|是|开启|打开|free)$/i.test(value.trim());
}
function importRowsFromText(text: string) {
const lines = text
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
let imported = 0;
lines.forEach((line) => {
const cells = line.split(/\t|,/).map((cell) => cell.trim());
if (cells.length === 0 || !cells[0]) {
return;
}
const row = findOrCreateRow(cells[0]);
row.name = cells[0];
row.code = cells[1] || normalizeCode(cells[0]);
if (cells[2] !== undefined && cells[2] !== '') {
row.amount = normalizeAmount(cells[2]);
row.amountTouched = true;
}
if (cells[3] !== undefined && cells[3] !== '') {
row.adminFree = parseBoolean(cells[3]);
}
row.saved = false;
imported += 1;
});
refreshAllRows();
if (imported > 0) {
message.success(`已导入 ${imported} 行数据`);
} else {
message.warning('未识别到可导入的数据');
}
}
async function importClipboard() {
try {
const text = await navigator.clipboard.readText();
importRowsFromText(text);
} catch {
message.warning('请复制 Excel 数据后,在表格区域粘贴');
}
}
function handlePaste(event: ClipboardEvent) {
const target = event.target as HTMLElement;
if (['INPUT', 'TEXTAREA'].includes(target.tagName) || target.isContentEditable) {
return;
}
const text = event.clipboardData?.getData('text/plain') || '';
if (!text.trim()) {
return;
}
event.preventDefault();
importRowsFromText(text);
}
function buildBadgeExpand() {
return JSON.stringify({
badgeDisplayType: batchForm.badgeDisplayType || 'SHORT',
});
}
function buildPayload(row: BatchRow) {
const rowType = getRowType(row);
return {
adminFree: row.adminFree,
amount: isAmountRequiredByType(rowType) ? normalizeAmount(row.amount) : 0,
code: String(row.code || '').trim(),
cover: row.cover,
expand: rowType === 'BADGE' ? buildBadgeExpand() : row.expand,
id: '',
name: String(row.name || '').trim(),
sourceUrl: row.sourceUrl,
sysOrigin: batchForm.sysOrigin,
type: rowType,
};
}
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 addPropsSource(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} 个失败`);
}
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: BatchRow) {
activeRowKey.value = row.key;
}
function getRowClassName(record: Record<string, any>) {
const row = record as BatchRow;
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 BatchRow;
return {
onClick: () => setActiveRow(row),
};
}
</script>
<template>
<Drawer
destroy-on-hidden
:open="open"
root-class="resource-batch-drawer-root"
size="100vw"
:styles="{
body: { padding: '0', overflow: 'hidden' },
footer: { padding: '0' },
}"
title="批量导入资源"
@close="emit('close')"
>
<div class="batch-drawer" tabindex="0" @paste="handlePaste">
<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.type"
option-label-prop="label"
:options="propsTypeOptions"
/>
</div>
<div v-if="batchForm.type === 'BADGE'" class="common-field">
<span class="common-label">展示形态</span>
<Select
v-model:value="batchForm.badgeDisplayType"
option-label-prop="label"
:options="badgeDisplayTypeOptions"
/>
</div>
<div v-if="isAmountRequired" class="common-field common-field--short">
<span class="common-label">默认底价</span>
<InputNumber
v-model:value="batchForm.amount"
:min="0"
style="width: 100%"
/>
</div>
<div class="common-switch">
<span class="common-label">Admin Free</span>
<Switch
v-model:checked="batchForm.adminFree"
checked-children=""
un-checked-children=""
/>
</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">坐骑_物品名_价格_1天_cover/animation</div>
<div class="rule-item">头像框_物品名_价格_1天_cover/animation</div>
<div class="rule-item">勋章_物品名_cover/animation</div>
<div class="rule-item">cover 写入封面animation 写入动态图资源</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 @click="importClipboard">粘贴 Excel</Button>
<Button
:disabled="rows.length === 0"
@click="showOnlyErrors = !showOnlyErrors"
>
{{ showOnlyErrors ? '查看全部' : '只看错误' }}
</Button>
<Button
:disabled="rows.length === 0"
@click="applyDefaultAmount"
>
批量设置底价
</Button>
<Button
:disabled="rows.length === 0"
@click="applyDefaultAdminFree"
>
批量设置 Admin Free
</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="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 === 'expand'">
<div class="file-cell">
<Tag :color="record.expand ? 'success' : 'default'">
{{ record.expandUploading ? '上传中' : record.expand ? '已上传' : '未上传' }}
</Tag>
<div class="file-name">{{ record.expandFileName || '-' }}</div>
</div>
</template>
<template v-else-if="column.key === 'type'">
<Select
v-model:value="record.type"
option-label-prop="label"
:options="propsTypeOptions"
size="small"
style="width: 118px"
@change="markTypeTouched(record)"
/>
</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 === 'amount'">
<InputNumber
v-model:value="record.amount"
:disabled="!isAmountRequiredByType(record.type)"
:min="0"
size="small"
style="width: 96px"
@change="markAmountTouched(record)"
/>
</template>
<template v-else-if="column.key === 'adminFree'">
<Switch
v-model:checked="record.adminFree"
checked-children=""
un-checked-children=""
@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="activeRow.cover"
/>
<div v-else class="preview-empty">未上传封面</div>
</div>
<div class="preview-name">{{ activeRow.name || '-' }}</div>
<div class="preview-meta">
类型{{ getPropsTypeName(getRowType(activeRow)) }}
</div>
<div class="preview-meta">编码{{ activeRow.code || '-' }}</div>
<div class="preview-meta">封面{{ activeRow.coverFileName || '-' }}</div>
<div class="preview-meta">资源{{ activeRow.sourceFileName || '-' }}</div>
<div v-if="showExpandUploadByType(getRowType(activeRow))" class="preview-meta">
Android{{ activeRow.expandFileName || '-' }}
</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;
outline: none;
}
.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: 140px;
}
.common-label {
color: #475569;
font-size: 12px;
line-height: 18px;
}
.common-switch {
align-items: center;
display: flex;
gap: 10px;
min-height: 32px;
}
.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;
transition:
background 0.2s ease,
border-color 0.2s ease;
}
.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>