Compare commits

..

No commits in common. "abbf6d9556b407fbfe8d4288ddc8c9e615d06ee9" and "cf291546de18b37530441ddf6ca47549c669d083" have entirely different histories.

View File

@ -26,13 +26,11 @@ import { addPropsSource } from '#/api/legacy/props';
import { import {
BADGE_DISPLAY_TYPE_OPTIONS, BADGE_DISPLAY_TYPE_OPTIONS,
getPropsTypeName,
PROPS_RESOURCE_CONFIG_TYPES, PROPS_RESOURCE_CONFIG_TYPES,
} from '../shared'; } from '../shared';
type AssetKind = 'cover' | 'expand' | 'source'; type AssetKind = 'cover' | 'expand' | 'source';
type RowStatus = 'error' | 'ready' | 'saved' | 'saving' | 'uploading'; type RowStatus = 'error' | 'ready' | 'saved' | 'saving' | 'uploading';
type StructuredAssetKind = 'cover' | 'source';
interface BatchRow { interface BatchRow {
adminFree: boolean; adminFree: boolean;
@ -57,25 +55,6 @@ interface BatchRow {
sourceUploading: boolean; sourceUploading: boolean;
sourceUrl: string; sourceUrl: string;
status: RowStatus; 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<{ const props = defineProps<{
@ -118,107 +97,23 @@ const badgeDisplayTypeOptions = BADGE_DISPLAY_TYPE_OPTIONS.map((item) => ({
value: item.value as any, value: item.value as any,
})); }));
const STRUCTURED_RESOURCE_TYPE_MAP: Record<string, string> = { const isImageSourceType = computed(() =>
勋章: 'BADGE', ['CHAT_BUBBLE', 'LAYOUT', 'THEME'].includes(batchForm.type),
坐骑: '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(
function isAmountRequiredByType(type: string) { () => !['CUSTOMIZE', 'FRAGMENTS'].includes(batchForm.type),
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 isSourceRequired = computed(
() => !['BADGE', 'CUSTOMIZE', 'FRAGMENTS'].includes(batchForm.type),
);
const showExpandUpload = computed(() => batchForm.type === 'CHAT_BUBBLE');
const columns = computed(() => { const columns = computed(() => {
const baseColumns = [ const baseColumns = [
{ dataIndex: 'cover', key: 'cover', title: '封面', width: 116 }, { dataIndex: 'cover', key: 'cover', title: '封面', width: 116 },
{ dataIndex: 'sourceUrl', key: 'sourceUrl', title: '资源', width: 180 }, { dataIndex: 'sourceUrl', key: 'sourceUrl', title: '资源', width: 180 },
]; ];
if (hasExpandColumn.value) { if (showExpandUpload.value) {
baseColumns.push({ baseColumns.push({
dataIndex: 'expand', dataIndex: 'expand',
key: 'expand', key: 'expand',
@ -228,7 +123,6 @@ const columns = computed(() => {
} }
return [ return [
...baseColumns, ...baseColumns,
{ dataIndex: 'type', key: 'type', title: '类型', width: 132 },
{ dataIndex: 'name', key: 'name', title: '名称', width: 180 }, { dataIndex: 'name', key: 'name', title: '名称', width: 180 },
{ dataIndex: 'code', key: 'code', title: '编码', width: 180 }, { dataIndex: 'code', key: 'code', title: '编码', width: 180 },
{ dataIndex: 'amount', key: 'amount', title: '底价', width: 116 }, { dataIndex: 'amount', key: 'amount', title: '底价', width: 116 },
@ -334,11 +228,6 @@ watch(
watch( watch(
() => [batchForm.type, batchForm.sysOrigin, batchForm.badgeDisplayType], () => [batchForm.type, batchForm.sysOrigin, batchForm.badgeDisplayType],
() => { () => {
rows.value.forEach((row) => {
if (!row.typeTouched) {
row.type = batchForm.type;
}
});
refreshAllRows(); refreshAllRows();
}, },
); );
@ -358,33 +247,7 @@ function normalizeBaseName(value: string) {
} }
function normalizeCode(value: string) { function normalizeCode(value: string) {
const baseName = normalizeBaseName(value); return normalizeBaseName(value).replaceAll(/\s+/g, '_');
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) { function getPathFileName(path: string) {
@ -411,94 +274,54 @@ function isResourceFile(file: File) {
return /\.(mp4|pag|svga|zip)$/i.test(file.name); return /\.(mp4|pag|svga|zip)$/i.test(file.name);
} }
function parseStructuredResourceFile(file: File): null | StructuredResourceFile { function classifyFile(file: File): AssetKind | null {
if (!isImageFile(file) && !isResourceFile(file)) { const path = getFilePath(file);
const lowerPath = path.toLowerCase();
const image = isImageFile(file);
if (!image && !isResourceFile(file)) {
return null; return null;
} }
const fileName = getPathFileName(getFilePath(file)); if (
const baseName = fileName.replace(/\.[^.]+$/, '').trim(); image
const parts = baseName && showExpandUpload.value
.split('_') && /(^|\/)(android|expand)(\/|$)|[_-](android|expand)\./i.test(lowerPath)
.map((part) => part.trim()) ) {
.filter(Boolean); return 'expand';
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)) { if (
return null; image
&& /(^|\/)(cover|covers|封面)(\/|$)|[_-](cover|封面)\./i.test(lowerPath)
) {
return 'cover';
} }
if (type === 'BADGE') { if (isResourceFile(file)) {
const name = parts.slice(1, -1).join('_').trim(); return 'source';
if (!name) {
return null;
}
return {
amount: 0,
assetKind,
code: normalizeCode(name),
name,
type,
};
} }
if (parts.length < 5) { if (
return null; image
&& isImageSourceType.value
&& /(^|\/)(source|resource|resources|ios|资源)(\/|$)|[_-](source|resource|ios|资源)\./i.test(lowerPath)
) {
return 'source';
} }
const amount = Number(parts[parts.length - 3]); return image ? 'cover' : 'source';
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) { function getFileBaseName(file: File) {
return String(row.type || batchForm.type || ''); return normalizeBaseName(getPathFileName(getFilePath(file)));
} }
function findOrCreateRow(baseName: string, options: FindRowOptions = {}) { function findOrCreateRow(baseName: string) {
const name = baseName.trim() || 'resource'; const code = normalizeCode(baseName);
const type = String(options.type || batchForm.type || ''); const matched = rows.value.find((row) => row.code === code || row.name === baseName);
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 (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; matched.saved = false;
return matched; return matched;
} }
const row: BatchRow = { const row: BatchRow = {
adminFree: options.adminFree ?? batchForm.adminFree, adminFree: batchForm.adminFree,
amount: amount: normalizeAmount(batchForm.amount),
options.amount === undefined amountTouched: false,
? normalizeAmount(batchForm.amount)
: normalizeAmount(options.amount),
amountTouched: options.amountTouched ?? false,
code, code,
cover: '', cover: '',
coverError: '', coverError: '',
@ -510,7 +333,7 @@ function findOrCreateRow(baseName: string, options: FindRowOptions = {}) {
expandFileName: '', expandFileName: '',
expandUploading: false, expandUploading: false,
key: `batch-${Date.now()}-${sequence.value++}`, key: `batch-${Date.now()}-${sequence.value++}`,
name, name: baseName,
saved: false, saved: false,
saving: false, saving: false,
sourceError: '', sourceError: '',
@ -518,8 +341,6 @@ function findOrCreateRow(baseName: string, options: FindRowOptions = {}) {
sourceUploading: false, sourceUploading: false,
sourceUrl: '', sourceUrl: '',
status: 'error', status: 'error',
type,
typeTouched: options.typeTouched ?? false,
}; };
rows.value.push(row); rows.value.push(row);
if (!activeRowKey.value) { if (!activeRowKey.value) {
@ -597,34 +418,21 @@ async function uploadAsset(
function processFiles(files: File[]) { function processFiles(files: File[]) {
let accepted = 0; let accepted = 0;
let ignored = 0;
files.forEach((file) => { files.forEach((file) => {
const structuredFile = parseStructuredResourceFile(file); const kind = classifyFile(file);
if (!structuredFile) { if (!kind) {
ignored += 1;
return; return;
} }
const row = findOrCreateRow(structuredFile.name, { const row = findOrCreateRow(getFileBaseName(file));
adminFree: false, setRowAsset(row, kind, file);
amount: structuredFile.amount,
amountTouched: true,
code: structuredFile.code,
type: structuredFile.type,
typeTouched: true,
});
setRowAsset(row, structuredFile.assetKind, file);
accepted += 1; accepted += 1;
}); });
if (accepted === 0 && files.length > 0) { if (accepted === 0 && files.length > 0) {
message.warning('未识别到符合命名规则的资源文件'); message.warning('未识别到可导入的封面或资源文件');
return; return;
} }
if (accepted > 0) { if (accepted > 0) {
message.success( message.success(`已导入 ${accepted} 个文件`);
ignored > 0
? `已导入 ${accepted} 个文件,忽略 ${ignored} 个非规则文件`
: `已导入 ${accepted} 个文件`,
);
} }
} }
@ -707,8 +515,7 @@ function getRowErrors(
if (!batchForm.sysOrigin) { if (!batchForm.sysOrigin) {
errors.push('请选择系统'); errors.push('请选择系统');
} }
const rowType = getRowType(row); if (!batchForm.type) {
if (!rowType) {
errors.push('请选择类型'); errors.push('请选择类型');
} }
if (!String(row.name || '').trim()) { if (!String(row.name || '').trim()) {
@ -735,14 +542,14 @@ function getRowErrors(
if (!row.cover && !row.coverUploading) { if (!row.cover && !row.coverUploading) {
errors.push('缺少封面'); errors.push('缺少封面');
} }
if (isSourceRequiredByType(rowType) && !row.sourceUrl && !row.sourceUploading) { if (isSourceRequired.value && !row.sourceUrl && !row.sourceUploading) {
errors.push('缺少资源'); errors.push('缺少资源');
} }
if (showExpandUploadByType(rowType) && !row.expand && !row.expandUploading) { if (showExpandUpload.value && !row.expand && !row.expandUploading) {
errors.push('缺少 Android 资源'); errors.push('缺少 Android 资源');
} }
if ( if (
isAmountRequiredByType(rowType) isAmountRequired.value
&& (!Number.isFinite(Number(row.amount)) || Number(row.amount) < 0) && (!Number.isFinite(Number(row.amount)) || Number(row.amount) < 0)
) { ) {
errors.push('底价不正确'); errors.push('底价不正确');
@ -787,13 +594,6 @@ function markAmountTouched(record: Record<string, any>) {
refreshAllRows(); 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>) { function markRowEdited(record: Record<string, any>) {
const row = record as BatchRow; const row = record as BatchRow;
row.saved = false; row.saved = false;
@ -922,18 +722,17 @@ function buildBadgeExpand() {
} }
function buildPayload(row: BatchRow) { function buildPayload(row: BatchRow) {
const rowType = getRowType(row);
return { return {
adminFree: row.adminFree, adminFree: row.adminFree,
amount: isAmountRequiredByType(rowType) ? normalizeAmount(row.amount) : 0, amount: isAmountRequired.value ? normalizeAmount(row.amount) : 0,
code: String(row.code || '').trim(), code: String(row.code || '').trim(),
cover: row.cover, cover: row.cover,
expand: rowType === 'BADGE' ? buildBadgeExpand() : row.expand, expand: batchForm.type === 'BADGE' ? buildBadgeExpand() : row.expand,
id: '', id: '',
name: String(row.name || '').trim(), name: String(row.name || '').trim(),
sourceUrl: row.sourceUrl, sourceUrl: row.sourceUrl,
sysOrigin: batchForm.sysOrigin, sysOrigin: batchForm.sysOrigin,
type: rowType, type: batchForm.type,
}; };
} }
@ -1108,7 +907,7 @@ function customRow(record: Record<string, any>) {
@drop.prevent="handleDrop" @drop.prevent="handleDrop"
> >
<div class="dropzone-title">拖拽文件到这里</div> <div class="dropzone-title">拖拽文件到这里</div>
<div class="dropzone-desc">选择文件夹后按命名规则自动配对</div> <div class="dropzone-desc">封面和资源按文件名自动配对</div>
<Space wrap> <Space wrap>
<Button type="primary" @click.stop="fileInputRef?.click()"> <Button type="primary" @click.stop="fileInputRef?.click()">
选择文件 选择文件
@ -1119,10 +918,9 @@ function customRow(record: Record<string, any>) {
<div class="import-rules"> <div class="import-rules">
<div class="side-title">识别规则</div> <div class="side-title">识别规则</div>
<div class="rule-item">坐骑_物品名_价格_1天_cover/animation</div> <div class="rule-item">cover/ 或文件名 _cover 识别为封面</div>
<div class="rule-item">头像框_物品名_价格_1天_cover/animation</div> <div class="rule-item">resource/source/ svga/pag/mp4/zip 识别为资源</div>
<div class="rule-item">勋章_物品名_cover/animation</div> <div class="rule-item">聊天气泡 android/ _android 识别为 Android 资源</div>
<div class="rule-item">cover 写入封面animation 写入动态图资源</div>
</div> </div>
<div class="import-progress"> <div class="import-progress">
@ -1214,16 +1012,6 @@ function customRow(record: Record<string, any>) {
<div class="file-name">{{ record.expandFileName || '-' }}</div> <div class="file-name">{{ record.expandFileName || '-' }}</div>
</div> </div>
</template> </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'"> <template v-else-if="column.key === 'name'">
<Input <Input
v-model:value="record.name" v-model:value="record.name"
@ -1241,7 +1029,7 @@ function customRow(record: Record<string, any>) {
<template v-else-if="column.key === 'amount'"> <template v-else-if="column.key === 'amount'">
<InputNumber <InputNumber
v-model:value="record.amount" v-model:value="record.amount"
:disabled="!isAmountRequiredByType(record.type)" :disabled="!isAmountRequired"
:min="0" :min="0"
size="small" size="small"
style="width: 96px" style="width: 96px"
@ -1282,13 +1070,10 @@ function customRow(record: Record<string, any>) {
<div v-else class="preview-empty">未上传封面</div> <div v-else class="preview-empty">未上传封面</div>
</div> </div>
<div class="preview-name">{{ activeRow.name || '-' }}</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.code || '-' }}</div>
<div class="preview-meta">封面{{ activeRow.coverFileName || '-' }}</div> <div class="preview-meta">封面{{ activeRow.coverFileName || '-' }}</div>
<div class="preview-meta">资源{{ activeRow.sourceFileName || '-' }}</div> <div class="preview-meta">资源{{ activeRow.sourceFileName || '-' }}</div>
<div v-if="showExpandUploadByType(getRowType(activeRow))" class="preview-meta"> <div v-if="showExpandUpload" class="preview-meta">
Android{{ activeRow.expandFileName || '-' }} Android{{ activeRow.expandFileName || '-' }}
</div> </div>
<div class="side-title side-title--spaced">校验结果</div> <div class="side-title side-title--spaced">校验结果</div>