1549 lines
40 KiB
Vue
1549 lines
40 KiB
Vue
<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>
|