feat(admin): add habitats CSV import to data tools
Support importing habitats from CSV files to batch create entries Add validation, API endpoint, and admin UI for the import process
This commit is contained in:
@@ -237,7 +237,11 @@
|
|||||||
- Items CSV 的 `category` 必须匹配系统固定物品分类;支持 `Misc.` 匹配内置 `Misc`,其他值按固定分类英文名匹配。
|
- Items CSV 的 `category` 必须匹配系统固定物品分类;支持 `Misc.` 匹配内置 `Misc`,其他值按固定分类英文名匹配。
|
||||||
- Items CSV 导入时,`description` 写入物品介绍;若 `not_registered_in_collection` 为 true,追加 `Note: Not registered in collection`;若 `cannot_grow_again_today` 为 true,追加 `Note: Cannot have Grow used on it again today`;原介绍非空时 Note 前使用换行分隔。
|
- Items CSV 导入时,`description` 写入物品介绍;若 `not_registered_in_collection` 为 true,追加 `Note: Not registered in collection`;若 `cannot_grow_again_today` 为 true,追加 `Note: Cannot have Grow used on it again today`;原介绍非空时 Note 前使用换行分隔。
|
||||||
- Items CSV 导入时,图片路径保存为 `/pokopia/items/{image_file_name}`,API 对外图片 URL 解析为 `https://pokesprite.tootaio.com/pokopia/items/{image_file_name}`。
|
- Items CSV 导入时,图片路径保存为 `/pokopia/items/{image_file_name}`,API 对外图片 URL 解析为 `https://pokesprite.tootaio.com/pokopia/items/{image_file_name}`。
|
||||||
- 前端 JSON bundle Import 和 Wipe 必须使用确认 Modal,并要求输入固定确认词后才能执行;Items CSV 导入只新增物品,不执行删除,可直接从 CSV 文件选择触发。
|
- Data Tools 额外支持 Habitats CSV 导入,用于在 Wipe Habitats 后按 CSV 顺序批量新增 Habitats;CSV 导入只新增 Habitats,不自动 Wipe,不创建配方项、Pokemon 出现配置或翻译。
|
||||||
|
- Habitats CSV 必须包含 `id`、`name`、`image_file_name` 列。
|
||||||
|
- Habitats CSV 的 `id` 仅用于识别导入行与 Event 标记,不写入数据库主键;`id` 前缀为 `E` 或 `E-` 时导入为 Event Habitat,否则导入为 Main Game Habitat。
|
||||||
|
- Habitats CSV 导入时,图片路径保存为 `/pokopia/habitats/{image_file_name}`,API 对外图片 URL 解析为 `https://pokesprite.tootaio.com/pokopia/habitats/{image_file_name}`。
|
||||||
|
- 前端 JSON bundle Import 和 Wipe 必须使用确认 Modal,并要求输入固定确认词后才能执行;Items CSV 和 Habitats CSV 导入只新增对应内容,不执行删除,可直接从 CSV 文件选择触发。
|
||||||
|
|
||||||
## Referral
|
## Referral
|
||||||
|
|
||||||
|
|||||||
@@ -628,6 +628,7 @@ const lifeReactionTypes = ['like', 'helpful', 'fun', 'thanks'] as const;
|
|||||||
const pokemonTypeIconIds = new Set(Array.from({ length: 19 }, (_value, index) => index + 1));
|
const pokemonTypeIconIds = new Set(Array.from({ length: 19 }, (_value, index) => index + 1));
|
||||||
const pokemonSpriteBaseUrl = 'https://pokesprite.tootaio.com';
|
const pokemonSpriteBaseUrl = 'https://pokesprite.tootaio.com';
|
||||||
const itemStaticImagePathPrefix = '/pokopia/items/';
|
const itemStaticImagePathPrefix = '/pokopia/items/';
|
||||||
|
const habitatStaticImagePathPrefix = '/pokopia/habitats/';
|
||||||
const pokemonSpriteRequestTimeoutMs = 2500;
|
const pokemonSpriteRequestTimeoutMs = 2500;
|
||||||
const pokemonStatLabels: Array<{ key: keyof PokemonStats; label: string }> = [
|
const pokemonStatLabels: Array<{ key: keyof PokemonStats; label: string }> = [
|
||||||
{ key: 'hp', label: 'HP' },
|
{ key: 'hp', label: 'HP' },
|
||||||
@@ -731,7 +732,8 @@ function sqlLiteral(value: string): string {
|
|||||||
function uploadedImageJson(pathExpression: string): string {
|
function uploadedImageJson(pathExpression: string): string {
|
||||||
return `
|
return `
|
||||||
CASE
|
CASE
|
||||||
WHEN ${pathExpression} LIKE ${sqlLiteral(`${itemStaticImagePathPrefix}%`)} THEN json_build_object(
|
WHEN ${pathExpression} LIKE ${sqlLiteral(`${itemStaticImagePathPrefix}%`)}
|
||||||
|
OR ${pathExpression} LIKE ${sqlLiteral(`${habitatStaticImagePathPrefix}%`)} THEN json_build_object(
|
||||||
'path', ${pathExpression},
|
'path', ${pathExpression},
|
||||||
'url', ${sqlLiteral(pokemonSpriteBaseUrl)} || ${pathExpression}
|
'url', ${sqlLiteral(pokemonSpriteBaseUrl)} || ${pathExpression}
|
||||||
)
|
)
|
||||||
@@ -1061,16 +1063,26 @@ function cleanOptionalText(value: unknown): string {
|
|||||||
return typeof value === 'string' ? value.trim() : '';
|
return typeof value === 'string' ? value.trim() : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function isItemStaticImagePath(value: string): boolean {
|
function isStaticImageFileName(fileName: string): boolean {
|
||||||
const fileName = value.startsWith(itemStaticImagePathPrefix) ? value.slice(itemStaticImagePathPrefix.length) : '';
|
|
||||||
return Boolean(fileName) && !fileName.includes('/') && !fileName.includes('\\') && !fileName.includes('..') && /^[A-Za-z0-9._()-]+$/.test(fileName);
|
return Boolean(fileName) && !fileName.includes('/') && !fileName.includes('\\') && !fileName.includes('..') && /^[A-Za-z0-9._()-]+$/.test(fileName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isItemStaticImagePath(value: string): boolean {
|
||||||
|
return isStaticImageFileName(value.startsWith(itemStaticImagePathPrefix) ? value.slice(itemStaticImagePathPrefix.length) : '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function isHabitatStaticImagePath(value: string): boolean {
|
||||||
|
return isStaticImageFileName(value.startsWith(habitatStaticImagePathPrefix) ? value.slice(habitatStaticImagePathPrefix.length) : '');
|
||||||
|
}
|
||||||
|
|
||||||
function cleanUploadImagePath(value: unknown, entityType: 'items' | 'habitats' | 'ancient-artifacts'): string {
|
function cleanUploadImagePath(value: unknown, entityType: 'items' | 'habitats' | 'ancient-artifacts'): string {
|
||||||
const imagePath = cleanOptionalText(value);
|
const imagePath = cleanOptionalText(value);
|
||||||
if (imagePath === '') {
|
if (imagePath === '') {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
if (entityType === 'habitats' && isHabitatStaticImagePath(imagePath)) {
|
||||||
|
return imagePath;
|
||||||
|
}
|
||||||
if (!isUploadImagePath(imagePath) || !imagePath.startsWith(`${entityType}/`)) {
|
if (!isUploadImagePath(imagePath) || !imagePath.startsWith(`${entityType}/`)) {
|
||||||
throw validationError('server.validation.imagePathInvalid');
|
throw validationError('server.validation.imagePathInvalid');
|
||||||
}
|
}
|
||||||
@@ -8028,6 +8040,7 @@ const itemsCsvColumns = [
|
|||||||
'not_registered_in_collection',
|
'not_registered_in_collection',
|
||||||
'cannot_grow_again_today'
|
'cannot_grow_again_today'
|
||||||
] as const;
|
] as const;
|
||||||
|
const habitatsCsvColumns = ['id', 'name', 'image_file_name'] as const;
|
||||||
const itemsCsvCategoryAliases = new Map<string, string>(
|
const itemsCsvCategoryAliases = new Map<string, string>(
|
||||||
itemCategoryOptions.flatMap((option) => [
|
itemCategoryOptions.flatMap((option) => [
|
||||||
[option.key, option.key],
|
[option.key, option.key],
|
||||||
@@ -8086,6 +8099,27 @@ function itemsCsvImagePath(value: string): string {
|
|||||||
return imagePath;
|
return imagePath;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function habitatsCsvId(value: string): { normalizedId: string; isEventItem: boolean } {
|
||||||
|
const id = value.trim();
|
||||||
|
const eventMatch = id.match(/^E-?(\d+)$/i);
|
||||||
|
if (eventMatch) {
|
||||||
|
return { normalizedId: `E${eventMatch[1]}`, isEventItem: true };
|
||||||
|
}
|
||||||
|
if (!/^\d+$/.test(id)) {
|
||||||
|
throw validationError('server.validation.dataToolHabitatsCsvInvalid');
|
||||||
|
}
|
||||||
|
return { normalizedId: id, isEventItem: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
function habitatsCsvImagePath(value: string): string {
|
||||||
|
const fileName = value.trim();
|
||||||
|
const imagePath = `${habitatStaticImagePathPrefix}${fileName}`;
|
||||||
|
if (!isHabitatStaticImagePath(imagePath)) {
|
||||||
|
throw validationError('server.validation.dataToolHabitatsCsvInvalid');
|
||||||
|
}
|
||||||
|
return imagePath;
|
||||||
|
}
|
||||||
|
|
||||||
function cleanItemsCsvRows(value: unknown): CsvRow[] {
|
function cleanItemsCsvRows(value: unknown): CsvRow[] {
|
||||||
if (typeof value !== 'string' || value.trim() === '') {
|
if (typeof value !== 'string' || value.trim() === '') {
|
||||||
throw validationError('server.validation.dataToolItemsCsvInvalid');
|
throw validationError('server.validation.dataToolItemsCsvInvalid');
|
||||||
@@ -8108,6 +8142,32 @@ function cleanItemsCsvRows(value: unknown): CsvRow[] {
|
|||||||
return rows;
|
return rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cleanHabitatsCsvRows(value: unknown): CsvRow[] {
|
||||||
|
if (typeof value !== 'string' || value.trim() === '') {
|
||||||
|
throw validationError('server.validation.dataToolHabitatsCsvInvalid');
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = parseCsv(value, 'habitats.csv');
|
||||||
|
if (!rows.length || rows.some((row) => habitatsCsvColumns.some((column) => !(column in row)))) {
|
||||||
|
throw validationError('server.validation.dataToolHabitatsCsvInvalid');
|
||||||
|
}
|
||||||
|
|
||||||
|
const ids = new Set<string>();
|
||||||
|
const names = new Set<string>();
|
||||||
|
for (const row of rows) {
|
||||||
|
const id = habitatsCsvId(csvText(row, 'id')).normalizedId;
|
||||||
|
const name = csvText(row, 'name');
|
||||||
|
habitatsCsvImagePath(csvText(row, 'image_file_name'));
|
||||||
|
if (ids.has(id) || !name || names.has(name)) {
|
||||||
|
throw validationError('server.validation.dataToolHabitatsCsvInvalid');
|
||||||
|
}
|
||||||
|
ids.add(id);
|
||||||
|
names.add(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
function isDataToolScope(value: unknown): value is DataToolScope {
|
function isDataToolScope(value: unknown): value is DataToolScope {
|
||||||
return typeof value === 'string' && dataToolScopes.includes(value as DataToolScope);
|
return typeof value === 'string' && dataToolScopes.includes(value as DataToolScope);
|
||||||
}
|
}
|
||||||
@@ -8630,6 +8690,49 @@ export async function importAdminItemsCsv(payload: Record<string, unknown>, user
|
|||||||
return getAdminDataToolsSummary();
|
return getAdminDataToolsSummary();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function importAdminHabitatsCsv(payload: Record<string, unknown>, userId: number): Promise<{ scopes: DataToolScopeSummary[] }> {
|
||||||
|
const rows = cleanHabitatsCsvRows(payload.csv);
|
||||||
|
const names = rows.map((row) => csvText(row, 'name'));
|
||||||
|
|
||||||
|
await withTransaction(async (client) => {
|
||||||
|
const existing = await client.query<{ name: string }>('SELECT name FROM habitats WHERE name = ANY($1::text[])', [names]);
|
||||||
|
if (existing.rowCount && existing.rowCount > 0) {
|
||||||
|
throw validationError('server.validation.dataToolHabitatsCsvInvalid');
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstSortOrder = await nextSortOrder(client, 'habitats');
|
||||||
|
for (const [index, row] of rows.entries()) {
|
||||||
|
const { isEventItem } = habitatsCsvId(csvText(row, 'id'));
|
||||||
|
const result = await client.query<{ id: number }>(
|
||||||
|
`
|
||||||
|
INSERT INTO habitats (
|
||||||
|
name,
|
||||||
|
is_event_item,
|
||||||
|
image_path,
|
||||||
|
sort_order,
|
||||||
|
created_by_user_id,
|
||||||
|
updated_by_user_id
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $5)
|
||||||
|
RETURNING id
|
||||||
|
`,
|
||||||
|
[
|
||||||
|
csvText(row, 'name'),
|
||||||
|
isEventItem,
|
||||||
|
habitatsCsvImagePath(csvText(row, 'image_file_name')),
|
||||||
|
firstSortOrder + index * 10,
|
||||||
|
userId
|
||||||
|
]
|
||||||
|
);
|
||||||
|
await recordEditLog(client, 'habitats', result.rows[0].id, 'create', userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
await resetIdentity(client, 'habitats');
|
||||||
|
});
|
||||||
|
|
||||||
|
return getAdminDataToolsSummary();
|
||||||
|
}
|
||||||
|
|
||||||
export async function wipeAdminData(payload: Record<string, unknown>): Promise<{ scopes: DataToolScopeSummary[] }> {
|
export async function wipeAdminData(payload: Record<string, unknown>): Promise<{ scopes: DataToolScopeSummary[] }> {
|
||||||
const scopes = cleanDataToolScopes(payload.scopes);
|
const scopes = cleanDataToolScopes(payload.scopes);
|
||||||
await withTransaction(async (client) => {
|
await withTransaction(async (client) => {
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ import {
|
|||||||
getRecipe,
|
getRecipe,
|
||||||
globalSearch,
|
globalSearch,
|
||||||
importAdminData,
|
importAdminData,
|
||||||
|
importAdminHabitatsCsv,
|
||||||
importAdminItemsCsv,
|
importAdminItemsCsv,
|
||||||
isConfigType,
|
isConfigType,
|
||||||
listAncientArtifacts,
|
listAncientArtifacts,
|
||||||
@@ -2157,6 +2158,11 @@ app.post('/api/admin/data-tools/import-items-csv', async (request, reply) => {
|
|||||||
return user ? importAdminItemsCsv(request.body as Record<string, unknown>, user.id) : undefined;
|
return user ? importAdminItemsCsv(request.body as Record<string, unknown>, user.id) : undefined;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.post('/api/admin/data-tools/import-habitats-csv', async (request, reply) => {
|
||||||
|
const user = await requirePermissionWithRateLimits(request, reply, 'admin.data.import', 'adminWrite');
|
||||||
|
return user ? importAdminHabitatsCsv(request.body as Record<string, unknown>, user.id) : undefined;
|
||||||
|
});
|
||||||
|
|
||||||
app.post('/api/admin/data-tools/wipe', async (request, reply) => {
|
app.post('/api/admin/data-tools/wipe', async (request, reply) => {
|
||||||
const user = await requirePermissionWithRateLimits(request, reply, 'admin.data.import', 'adminWrite');
|
const user = await requirePermissionWithRateLimits(request, reply, 'admin.data.import', 'adminWrite');
|
||||||
return user ? wipeAdminData(request.body as Record<string, unknown>) : undefined;
|
return user ? wipeAdminData(request.body as Record<string, unknown>) : undefined;
|
||||||
|
|||||||
@@ -1195,6 +1195,7 @@ export const api = {
|
|||||||
exportDataTools: (scopes: DataToolScope[]) => sendJson<DataToolsBundle>('/api/admin/data-tools/export', 'POST', { scopes }),
|
exportDataTools: (scopes: DataToolScope[]) => sendJson<DataToolsBundle>('/api/admin/data-tools/export', 'POST', { scopes }),
|
||||||
importDataTools: (bundle: DataToolsBundle) => sendJson<DataToolsSummary>('/api/admin/data-tools/import', 'POST', { bundle }),
|
importDataTools: (bundle: DataToolsBundle) => sendJson<DataToolsSummary>('/api/admin/data-tools/import', 'POST', { bundle }),
|
||||||
importItemsCsvDataTools: (csv: string) => sendJson<DataToolsSummary>('/api/admin/data-tools/import-items-csv', 'POST', { csv }),
|
importItemsCsvDataTools: (csv: string) => sendJson<DataToolsSummary>('/api/admin/data-tools/import-items-csv', 'POST', { csv }),
|
||||||
|
importHabitatsCsvDataTools: (csv: string) => sendJson<DataToolsSummary>('/api/admin/data-tools/import-habitats-csv', 'POST', { csv }),
|
||||||
wipeDataTools: (scopes: DataToolScope[]) => sendJson<DataToolsSummary>('/api/admin/data-tools/wipe', 'POST', { scopes }),
|
wipeDataTools: (scopes: DataToolScope[]) => sendJson<DataToolsSummary>('/api/admin/data-tools/wipe', 'POST', { scopes }),
|
||||||
register: (payload: RegisterPayload) => sendJson<{ message: string }>('/api/auth/register', 'POST', payload),
|
register: (payload: RegisterPayload) => sendJson<{ message: string }>('/api/auth/register', 'POST', payload),
|
||||||
verifyEmail: (token: string) =>
|
verifyEmail: (token: string) =>
|
||||||
|
|||||||
@@ -1623,6 +1623,23 @@ async function selectImportItemsCsvFile(event: Event) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function selectImportHabitatsCsvFile(event: Event) {
|
||||||
|
const input = event.target instanceof HTMLInputElement ? event.target : null;
|
||||||
|
const file = input?.files?.[0];
|
||||||
|
if (input) {
|
||||||
|
input.value = '';
|
||||||
|
}
|
||||||
|
if (!file) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await run(async () => {
|
||||||
|
const csv = await file.text();
|
||||||
|
dataToolsSummary.value = await api.importHabitatsCsvDataTools(csv);
|
||||||
|
message.value = t('pages.admin.dataToolHabitatsCsvImported');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function closeImportDataToolsModal() {
|
function closeImportDataToolsModal() {
|
||||||
dataToolImportModalOpen.value = false;
|
dataToolImportModalOpen.value = false;
|
||||||
pendingImportBundle.value = null;
|
pendingImportBundle.value = null;
|
||||||
@@ -1959,6 +1976,11 @@ onMounted(() => {
|
|||||||
<input id="data-tools-items-csv-file" type="file" accept="text/csv,.csv" :disabled="busy || !can('admin.data.import')" @change="selectImportItemsCsvFile" />
|
<input id="data-tools-items-csv-file" type="file" accept="text/csv,.csv" :disabled="busy || !can('admin.data.import')" @change="selectImportItemsCsvFile" />
|
||||||
</div>
|
</div>
|
||||||
<p class="meta-line">{{ t('pages.admin.dataToolItemsCsvMode') }}</p>
|
<p class="meta-line">{{ t('pages.admin.dataToolItemsCsvMode') }}</p>
|
||||||
|
<div class="field">
|
||||||
|
<label for="data-tools-habitats-csv-file">{{ t('pages.admin.dataToolHabitatsCsvFile') }}</label>
|
||||||
|
<input id="data-tools-habitats-csv-file" type="file" accept="text/csv,.csv" :disabled="busy || !can('admin.data.import')" @change="selectImportHabitatsCsvFile" />
|
||||||
|
</div>
|
||||||
|
<p class="meta-line">{{ t('pages.admin.dataToolHabitatsCsvMode') }}</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="data-tool-panel data-tool-panel--danger" :aria-label="t('pages.admin.dataToolWipe')">
|
<section class="data-tool-panel data-tool-panel--danger" :aria-label="t('pages.admin.dataToolWipe')">
|
||||||
|
|||||||
@@ -1049,6 +1049,9 @@ export const systemWordingMessages = {
|
|||||||
dataToolItemsCsvFile: 'Items CSV',
|
dataToolItemsCsvFile: 'Items CSV',
|
||||||
dataToolItemsCsvMode: 'CSV import adds Items only. Wipe Items first when replacing the list.',
|
dataToolItemsCsvMode: 'CSV import adds Items only. Wipe Items first when replacing the list.',
|
||||||
dataToolItemsCsvImported: 'Items CSV imported.',
|
dataToolItemsCsvImported: 'Items CSV imported.',
|
||||||
|
dataToolHabitatsCsvFile: 'Habitats CSV',
|
||||||
|
dataToolHabitatsCsvMode: 'CSV import adds Habitats only. Wipe Habitats first when replacing the list.',
|
||||||
|
dataToolHabitatsCsvImported: 'Habitats CSV imported.',
|
||||||
dataToolWipe: 'Wipe data',
|
dataToolWipe: 'Wipe data',
|
||||||
dataToolWipeButton: 'Wipe',
|
dataToolWipeButton: 'Wipe',
|
||||||
dataToolSelectScope: 'Select at least one data scope.',
|
dataToolSelectScope: 'Select at least one data scope.',
|
||||||
@@ -1304,6 +1307,7 @@ export const systemWordingMessages = {
|
|||||||
dataToolScopeInvalid: 'Data scope is invalid',
|
dataToolScopeInvalid: 'Data scope is invalid',
|
||||||
dataToolBundleInvalid: 'Data bundle is invalid',
|
dataToolBundleInvalid: 'Data bundle is invalid',
|
||||||
dataToolItemsCsvInvalid: 'Items CSV is invalid',
|
dataToolItemsCsvInvalid: 'Items CSV is invalid',
|
||||||
|
dataToolHabitatsCsvInvalid: 'Habitats CSV is invalid',
|
||||||
pokemonImagePathInvalid: 'Pokemon image path is invalid',
|
pokemonImagePathInvalid: 'Pokemon image path is invalid',
|
||||||
imagePathInvalid: 'Image path is invalid',
|
imagePathInvalid: 'Image path is invalid',
|
||||||
imageUploadRequired: 'Please select an image',
|
imageUploadRequired: 'Please select an image',
|
||||||
@@ -2412,6 +2416,9 @@ export const systemWordingMessages = {
|
|||||||
dataToolItemsCsvFile: '物品 CSV',
|
dataToolItemsCsvFile: '物品 CSV',
|
||||||
dataToolItemsCsvMode: 'CSV 导入只会新增物品。替换列表时请先清空物品。',
|
dataToolItemsCsvMode: 'CSV 导入只会新增物品。替换列表时请先清空物品。',
|
||||||
dataToolItemsCsvImported: '物品 CSV 已导入。',
|
dataToolItemsCsvImported: '物品 CSV 已导入。',
|
||||||
|
dataToolHabitatsCsvFile: '栖息地 CSV',
|
||||||
|
dataToolHabitatsCsvMode: 'CSV 导入只会新增栖息地。替换列表时请先清空栖息地。',
|
||||||
|
dataToolHabitatsCsvImported: '栖息地 CSV 已导入。',
|
||||||
dataToolWipe: '清空数据',
|
dataToolWipe: '清空数据',
|
||||||
dataToolWipeButton: '清空',
|
dataToolWipeButton: '清空',
|
||||||
dataToolSelectScope: '请至少选择一个数据范围。',
|
dataToolSelectScope: '请至少选择一个数据范围。',
|
||||||
@@ -2665,9 +2672,10 @@ export const systemWordingMessages = {
|
|||||||
pokemonDataIdMismatch: '官方 Pokemon 数据 ID 与当前 Pokemon 不一致',
|
pokemonDataIdMismatch: '官方 Pokemon 数据 ID 与当前 Pokemon 不一致',
|
||||||
dataToolScopeRequired: '请至少选择一个数据范围',
|
dataToolScopeRequired: '请至少选择一个数据范围',
|
||||||
dataToolScopeInvalid: '数据范围不合法',
|
dataToolScopeInvalid: '数据范围不合法',
|
||||||
dataToolBundleInvalid: '数据包不合法',
|
dataToolBundleInvalid: '数据包不合法',
|
||||||
dataToolItemsCsvInvalid: '物品 CSV 不合法',
|
dataToolItemsCsvInvalid: '物品 CSV 不合法',
|
||||||
pokemonImagePathInvalid: 'Pokemon 图片路径不合法',
|
dataToolHabitatsCsvInvalid: '栖息地 CSV 不合法',
|
||||||
|
pokemonImagePathInvalid: 'Pokemon 图片路径不合法',
|
||||||
imagePathInvalid: '图片路径不合法',
|
imagePathInvalid: '图片路径不合法',
|
||||||
imageUploadRequired: '请选择图片',
|
imageUploadRequired: '请选择图片',
|
||||||
imageUploadTypeInvalid: '不支持这种图片类型',
|
imageUploadTypeInvalid: '不支持这种图片类型',
|
||||||
|
|||||||
Reference in New Issue
Block a user