资源批量添加
This commit is contained in:
parent
cf291546de
commit
4eb2554c20
@ -26,11 +26,13 @@ import { addPropsSource } from '#/api/legacy/props';
|
||||
|
||||
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;
|
||||
@ -55,6 +57,25 @@ interface BatchRow {
|
||||
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;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
@ -97,23 +118,112 @@ const badgeDisplayTypeOptions = BADGE_DISPLAY_TYPE_OPTIONS.map((item) => ({
|
||||
value: item.value as any,
|
||||
}));
|
||||
|
||||
const isImageSourceType = computed(() =>
|
||||
['CHAT_BUBBLE', 'LAYOUT', 'THEME'].includes(batchForm.type),
|
||||
const STRUCTURED_RESOURCE_TYPE_MAP: Record<string, string> = {
|
||||
勋章: 'BADGE',
|
||||
坐骑: 'RIDE',
|
||||
头像框: 'AVATAR_FRAME',
|
||||
};
|
||||
const STRUCTURED_ASSET_KIND_MAP: Record<string, StructuredAssetKind> = {
|
||||
animation: 'source',
|
||||
cover: 'cover',
|
||||
};
|
||||
const RESOURCE_NAME_TRANSLATION_ENTRIES = ([
|
||||
['头像框', 'avatar frame'],
|
||||
['座驾', 'ride'],
|
||||
['坐骑', 'mount'],
|
||||
['勋章', 'badge'],
|
||||
['徽章', 'badge'],
|
||||
['管理员', 'admin'],
|
||||
['圣诞', 'christmas'],
|
||||
['新年', 'new year'],
|
||||
['情人节', 'valentine'],
|
||||
['万圣节', 'halloween'],
|
||||
['锦鲤', 'koi'],
|
||||
['熊猫', 'panda'],
|
||||
['独角兽', 'unicorn'],
|
||||
['天使', 'angel'],
|
||||
['恶魔', 'devil'],
|
||||
['皇冠', 'crown'],
|
||||
['国王', 'king'],
|
||||
['女王', 'queen'],
|
||||
['玫瑰', 'rose'],
|
||||
['星光', 'starlight'],
|
||||
['星星', 'star'],
|
||||
['月亮', 'moon'],
|
||||
['太阳', 'sun'],
|
||||
['彩虹', 'rainbow'],
|
||||
['火焰', 'flame'],
|
||||
['冰雪', 'snow'],
|
||||
['蓝色', 'blue'],
|
||||
['红色', 'red'],
|
||||
['紫色', 'purple'],
|
||||
['粉色', 'pink'],
|
||||
['金色', 'gold'],
|
||||
['银色', 'silver'],
|
||||
['黑色', 'black'],
|
||||
['白色', 'white'],
|
||||
['绿色', 'green'],
|
||||
['黄色', 'yellow'],
|
||||
['钻石', 'diamond'],
|
||||
['水晶', 'crystal'],
|
||||
['幸运', 'lucky'],
|
||||
['浪漫', 'romantic'],
|
||||
['荣耀', 'glory'],
|
||||
['守护', 'guard'],
|
||||
['贵族', 'noble'],
|
||||
['梦幻', 'dream'],
|
||||
['闪耀', 'shining'],
|
||||
['限定', 'limited'],
|
||||
['豪华', 'deluxe'],
|
||||
['高级', 'premium'],
|
||||
['经典', 'classic'],
|
||||
['热气球', 'hot air balloon'],
|
||||
['跑车', 'sports car'],
|
||||
['飞船', 'spaceship'],
|
||||
['游艇', 'yacht'],
|
||||
['摩托', 'motorcycle'],
|
||||
['龙', 'dragon'],
|
||||
['凤', 'phoenix'],
|
||||
['虎', 'tiger'],
|
||||
['兔', 'rabbit'],
|
||||
['猫', 'cat'],
|
||||
['狗', 'dog'],
|
||||
['鱼', 'fish'],
|
||||
] as Array<[string, string]>).toSorted(
|
||||
(current, next) => next[0].length - current[0].length,
|
||||
);
|
||||
const isAmountRequired = computed(
|
||||
() => !['CUSTOMIZE', 'FRAGMENTS'].includes(batchForm.type),
|
||||
|
||||
function isImageSourceTypeByType(type: string) {
|
||||
return ['CHAT_BUBBLE', 'LAYOUT', 'THEME'].includes(type);
|
||||
}
|
||||
|
||||
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 isImageSourceType = computed(() => isImageSourceTypeByType(batchForm.type));
|
||||
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 isSourceRequired = computed(
|
||||
() => !['BADGE', 'CUSTOMIZE', 'FRAGMENTS'].includes(batchForm.type),
|
||||
);
|
||||
const showExpandUpload = computed(() => batchForm.type === 'CHAT_BUBBLE');
|
||||
|
||||
const columns = computed(() => {
|
||||
const baseColumns = [
|
||||
{ dataIndex: 'cover', key: 'cover', title: '封面', width: 116 },
|
||||
{ dataIndex: 'sourceUrl', key: 'sourceUrl', title: '资源', width: 180 },
|
||||
];
|
||||
if (showExpandUpload.value) {
|
||||
if (hasExpandColumn.value) {
|
||||
baseColumns.push({
|
||||
dataIndex: 'expand',
|
||||
key: 'expand',
|
||||
@ -123,6 +233,7 @@ const columns = computed(() => {
|
||||
}
|
||||
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 },
|
||||
@ -228,6 +339,11 @@ watch(
|
||||
watch(
|
||||
() => [batchForm.type, batchForm.sysOrigin, batchForm.badgeDisplayType],
|
||||
() => {
|
||||
rows.value.forEach((row) => {
|
||||
if (!row.typeTouched) {
|
||||
row.type = batchForm.type;
|
||||
}
|
||||
});
|
||||
refreshAllRows();
|
||||
},
|
||||
);
|
||||
@ -247,7 +363,33 @@ function normalizeBaseName(value: string) {
|
||||
}
|
||||
|
||||
function normalizeCode(value: string) {
|
||||
return normalizeBaseName(value).replaceAll(/\s+/g, '_');
|
||||
const baseName = normalizeBaseName(value);
|
||||
const translated = translateResourceName(baseName);
|
||||
return slugifyResourceCode(translated) || slugifyResourceCode(baseName) || 'resource';
|
||||
}
|
||||
|
||||
function translateResourceName(value: string) {
|
||||
let result = value.trim();
|
||||
RESOURCE_NAME_TRANSLATION_ENTRIES.forEach(([source, target]) => {
|
||||
result = result.replaceAll(source, ` ${target} `);
|
||||
});
|
||||
return [...result]
|
||||
.map((char) => {
|
||||
if (/[\u4E00-\u9FFF]/.test(char)) {
|
||||
return ` u${char.codePointAt(0)?.toString(16) || ''} `;
|
||||
}
|
||||
return char;
|
||||
})
|
||||
.join('');
|
||||
}
|
||||
|
||||
function slugifyResourceCode(value: string) {
|
||||
return value
|
||||
.normalize('NFKC')
|
||||
.replaceAll(/[^\d A-Za-z]+/g, ' ')
|
||||
.trim()
|
||||
.replaceAll(/\s+/g, '_')
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
function getPathFileName(path: string) {
|
||||
@ -311,17 +453,93 @@ function getFileBaseName(file: File) {
|
||||
return normalizeBaseName(getPathFileName(getFilePath(file)));
|
||||
}
|
||||
|
||||
function findOrCreateRow(baseName: string) {
|
||||
const code = normalizeCode(baseName);
|
||||
const matched = rows.value.find((row) => row.code === code || row.name === baseName);
|
||||
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 name = parts.slice(1, -3).join('_').trim();
|
||||
if (!name || !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: batchForm.adminFree,
|
||||
amount: normalizeAmount(batchForm.amount),
|
||||
amountTouched: false,
|
||||
adminFree: options.adminFree ?? batchForm.adminFree,
|
||||
amount:
|
||||
options.amount === undefined
|
||||
? normalizeAmount(batchForm.amount)
|
||||
: normalizeAmount(options.amount),
|
||||
amountTouched: options.amountTouched ?? false,
|
||||
code,
|
||||
cover: '',
|
||||
coverError: '',
|
||||
@ -333,7 +551,7 @@ function findOrCreateRow(baseName: string) {
|
||||
expandFileName: '',
|
||||
expandUploading: false,
|
||||
key: `batch-${Date.now()}-${sequence.value++}`,
|
||||
name: baseName,
|
||||
name,
|
||||
saved: false,
|
||||
saving: false,
|
||||
sourceError: '',
|
||||
@ -341,6 +559,8 @@ function findOrCreateRow(baseName: string) {
|
||||
sourceUploading: false,
|
||||
sourceUrl: '',
|
||||
status: 'error',
|
||||
type,
|
||||
typeTouched: options.typeTouched ?? false,
|
||||
};
|
||||
rows.value.push(row);
|
||||
if (!activeRowKey.value) {
|
||||
@ -419,6 +639,20 @@ async function uploadAsset(
|
||||
function processFiles(files: File[]) {
|
||||
let accepted = 0;
|
||||
files.forEach((file) => {
|
||||
const structuredFile = parseStructuredResourceFile(file);
|
||||
if (structuredFile) {
|
||||
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;
|
||||
return;
|
||||
}
|
||||
const kind = classifyFile(file);
|
||||
if (!kind) {
|
||||
return;
|
||||
@ -515,7 +749,8 @@ function getRowErrors(
|
||||
if (!batchForm.sysOrigin) {
|
||||
errors.push('请选择系统');
|
||||
}
|
||||
if (!batchForm.type) {
|
||||
const rowType = getRowType(row);
|
||||
if (!rowType) {
|
||||
errors.push('请选择类型');
|
||||
}
|
||||
if (!String(row.name || '').trim()) {
|
||||
@ -542,14 +777,14 @@ function getRowErrors(
|
||||
if (!row.cover && !row.coverUploading) {
|
||||
errors.push('缺少封面');
|
||||
}
|
||||
if (isSourceRequired.value && !row.sourceUrl && !row.sourceUploading) {
|
||||
if (isSourceRequiredByType(rowType) && !row.sourceUrl && !row.sourceUploading) {
|
||||
errors.push('缺少资源');
|
||||
}
|
||||
if (showExpandUpload.value && !row.expand && !row.expandUploading) {
|
||||
if (showExpandUploadByType(rowType) && !row.expand && !row.expandUploading) {
|
||||
errors.push('缺少 Android 资源');
|
||||
}
|
||||
if (
|
||||
isAmountRequired.value
|
||||
isAmountRequiredByType(rowType)
|
||||
&& (!Number.isFinite(Number(row.amount)) || Number(row.amount) < 0)
|
||||
) {
|
||||
errors.push('底价不正确');
|
||||
@ -594,6 +829,13 @@ function markAmountTouched(record: Record<string, any>) {
|
||||
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;
|
||||
@ -722,17 +964,18 @@ function buildBadgeExpand() {
|
||||
}
|
||||
|
||||
function buildPayload(row: BatchRow) {
|
||||
const rowType = getRowType(row);
|
||||
return {
|
||||
adminFree: row.adminFree,
|
||||
amount: isAmountRequired.value ? normalizeAmount(row.amount) : 0,
|
||||
amount: isAmountRequiredByType(rowType) ? normalizeAmount(row.amount) : 0,
|
||||
code: String(row.code || '').trim(),
|
||||
cover: row.cover,
|
||||
expand: batchForm.type === 'BADGE' ? buildBadgeExpand() : row.expand,
|
||||
expand: rowType === 'BADGE' ? buildBadgeExpand() : row.expand,
|
||||
id: '',
|
||||
name: String(row.name || '').trim(),
|
||||
sourceUrl: row.sourceUrl,
|
||||
sysOrigin: batchForm.sysOrigin,
|
||||
type: batchForm.type,
|
||||
type: rowType,
|
||||
};
|
||||
}
|
||||
|
||||
@ -907,7 +1150,7 @@ function customRow(record: Record<string, any>) {
|
||||
@drop.prevent="handleDrop"
|
||||
>
|
||||
<div class="dropzone-title">拖拽文件到这里</div>
|
||||
<div class="dropzone-desc">封面和资源按文件名自动配对</div>
|
||||
<div class="dropzone-desc">选择文件夹后按命名规则自动配对</div>
|
||||
<Space wrap>
|
||||
<Button type="primary" @click.stop="fileInputRef?.click()">
|
||||
选择文件
|
||||
@ -918,9 +1161,10 @@ function customRow(record: Record<string, any>) {
|
||||
|
||||
<div class="import-rules">
|
||||
<div class="side-title">识别规则</div>
|
||||
<div class="rule-item">cover/ 或文件名 _cover 识别为封面</div>
|
||||
<div class="rule-item">resource/、source/ 或 svga/pag/mp4/zip 识别为资源</div>
|
||||
<div class="rule-item">聊天气泡 android/ 或 _android 识别为 Android 资源</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">
|
||||
@ -1012,6 +1256,16 @@ function customRow(record: Record<string, any>) {
|
||||
<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"
|
||||
@ -1029,7 +1283,7 @@ function customRow(record: Record<string, any>) {
|
||||
<template v-else-if="column.key === 'amount'">
|
||||
<InputNumber
|
||||
v-model:value="record.amount"
|
||||
:disabled="!isAmountRequired"
|
||||
:disabled="!isAmountRequiredByType(record.type)"
|
||||
:min="0"
|
||||
size="small"
|
||||
style="width: 96px"
|
||||
@ -1070,10 +1324,13 @@ function customRow(record: Record<string, any>) {
|
||||
<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="showExpandUpload" class="preview-meta">
|
||||
<div v-if="showExpandUploadByType(getRowType(activeRow))" class="preview-meta">
|
||||
Android:{{ activeRow.expandFileName || '-' }}
|
||||
</div>
|
||||
<div class="side-title side-title--spaced">校验结果</div>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user