资源批量添加

This commit is contained in:
zhx 2026-05-19 13:15:13 +08:00
parent cf291546de
commit 4eb2554c20

View File

@ -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>