From 91a001e3f90f08d885a498c1f5c309aba29fbe86 Mon Sep 17 00:00:00 2001 From: xiaomai Date: Wed, 6 May 2026 07:06:08 +0800 Subject: [PATCH] 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 --- DESIGN.md | 6 +- backend/src/queries.ts | 109 ++++++++++++++++++++++++++++++- backend/src/server.ts | 6 ++ frontend/src/services/api.ts | 1 + frontend/src/views/AdminView.vue | 22 +++++++ system-wordings.ts | 14 +++- 6 files changed, 151 insertions(+), 7 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index 68d8937..4f8b120 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -237,7 +237,11 @@ - 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 导入时,图片路径保存为 `/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 diff --git a/backend/src/queries.ts b/backend/src/queries.ts index f03edf3..0d30169 100644 --- a/backend/src/queries.ts +++ b/backend/src/queries.ts @@ -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 pokemonSpriteBaseUrl = 'https://pokesprite.tootaio.com'; const itemStaticImagePathPrefix = '/pokopia/items/'; +const habitatStaticImagePathPrefix = '/pokopia/habitats/'; const pokemonSpriteRequestTimeoutMs = 2500; const pokemonStatLabels: Array<{ key: keyof PokemonStats; label: string }> = [ { key: 'hp', label: 'HP' }, @@ -731,7 +732,8 @@ function sqlLiteral(value: string): string { function uploadedImageJson(pathExpression: string): string { return ` 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}, 'url', ${sqlLiteral(pokemonSpriteBaseUrl)} || ${pathExpression} ) @@ -1061,16 +1063,26 @@ function cleanOptionalText(value: unknown): string { return typeof value === 'string' ? value.trim() : ''; } -function isItemStaticImagePath(value: string): boolean { - const fileName = value.startsWith(itemStaticImagePathPrefix) ? value.slice(itemStaticImagePathPrefix.length) : ''; +function isStaticImageFileName(fileName: string): boolean { 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 { const imagePath = cleanOptionalText(value); if (imagePath === '') { return ''; } + if (entityType === 'habitats' && isHabitatStaticImagePath(imagePath)) { + return imagePath; + } if (!isUploadImagePath(imagePath) || !imagePath.startsWith(`${entityType}/`)) { throw validationError('server.validation.imagePathInvalid'); } @@ -8028,6 +8040,7 @@ const itemsCsvColumns = [ 'not_registered_in_collection', 'cannot_grow_again_today' ] as const; +const habitatsCsvColumns = ['id', 'name', 'image_file_name'] as const; const itemsCsvCategoryAliases = new Map( itemCategoryOptions.flatMap((option) => [ [option.key, option.key], @@ -8086,6 +8099,27 @@ function itemsCsvImagePath(value: string): string { 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[] { if (typeof value !== 'string' || value.trim() === '') { throw validationError('server.validation.dataToolItemsCsvInvalid'); @@ -8108,6 +8142,32 @@ function cleanItemsCsvRows(value: unknown): CsvRow[] { 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(); + const names = new Set(); + 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 { return typeof value === 'string' && dataToolScopes.includes(value as DataToolScope); } @@ -8630,6 +8690,49 @@ export async function importAdminItemsCsv(payload: Record, user return getAdminDataToolsSummary(); } +export async function importAdminHabitatsCsv(payload: Record, 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): Promise<{ scopes: DataToolScopeSummary[] }> { const scopes = cleanDataToolScopes(payload.scopes); await withTransaction(async (client) => { diff --git a/backend/src/server.ts b/backend/src/server.ts index dcf425b..1d134af 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -83,6 +83,7 @@ import { getRecipe, globalSearch, importAdminData, + importAdminHabitatsCsv, importAdminItemsCsv, isConfigType, listAncientArtifacts, @@ -2157,6 +2158,11 @@ app.post('/api/admin/data-tools/import-items-csv', async (request, reply) => { return user ? importAdminItemsCsv(request.body as Record, 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, user.id) : undefined; +}); + app.post('/api/admin/data-tools/wipe', async (request, reply) => { const user = await requirePermissionWithRateLimits(request, reply, 'admin.data.import', 'adminWrite'); return user ? wipeAdminData(request.body as Record) : undefined; diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 4e6a057..da80301 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -1195,6 +1195,7 @@ export const api = { exportDataTools: (scopes: DataToolScope[]) => sendJson('/api/admin/data-tools/export', 'POST', { scopes }), importDataTools: (bundle: DataToolsBundle) => sendJson('/api/admin/data-tools/import', 'POST', { bundle }), importItemsCsvDataTools: (csv: string) => sendJson('/api/admin/data-tools/import-items-csv', 'POST', { csv }), + importHabitatsCsvDataTools: (csv: string) => sendJson('/api/admin/data-tools/import-habitats-csv', 'POST', { csv }), wipeDataTools: (scopes: DataToolScope[]) => sendJson('/api/admin/data-tools/wipe', 'POST', { scopes }), register: (payload: RegisterPayload) => sendJson<{ message: string }>('/api/auth/register', 'POST', payload), verifyEmail: (token: string) => diff --git a/frontend/src/views/AdminView.vue b/frontend/src/views/AdminView.vue index 3587875..73ac2cd 100644 --- a/frontend/src/views/AdminView.vue +++ b/frontend/src/views/AdminView.vue @@ -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() { dataToolImportModalOpen.value = false; pendingImportBundle.value = null; @@ -1959,6 +1976,11 @@ onMounted(() => {

{{ t('pages.admin.dataToolItemsCsvMode') }}

+
+ + +
+

{{ t('pages.admin.dataToolHabitatsCsvMode') }}

diff --git a/system-wordings.ts b/system-wordings.ts index 07b202e..2f4c3d9 100644 --- a/system-wordings.ts +++ b/system-wordings.ts @@ -1049,6 +1049,9 @@ export const systemWordingMessages = { dataToolItemsCsvFile: 'Items CSV', dataToolItemsCsvMode: 'CSV import adds Items only. Wipe Items first when replacing the list.', 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', dataToolWipeButton: 'Wipe', dataToolSelectScope: 'Select at least one data scope.', @@ -1304,6 +1307,7 @@ export const systemWordingMessages = { dataToolScopeInvalid: 'Data scope is invalid', dataToolBundleInvalid: 'Data bundle is invalid', dataToolItemsCsvInvalid: 'Items CSV is invalid', + dataToolHabitatsCsvInvalid: 'Habitats CSV is invalid', pokemonImagePathInvalid: 'Pokemon image path is invalid', imagePathInvalid: 'Image path is invalid', imageUploadRequired: 'Please select an image', @@ -2412,6 +2416,9 @@ export const systemWordingMessages = { dataToolItemsCsvFile: '物品 CSV', dataToolItemsCsvMode: 'CSV 导入只会新增物品。替换列表时请先清空物品。', dataToolItemsCsvImported: '物品 CSV 已导入。', + dataToolHabitatsCsvFile: '栖息地 CSV', + dataToolHabitatsCsvMode: 'CSV 导入只会新增栖息地。替换列表时请先清空栖息地。', + dataToolHabitatsCsvImported: '栖息地 CSV 已导入。', dataToolWipe: '清空数据', dataToolWipeButton: '清空', dataToolSelectScope: '请至少选择一个数据范围。', @@ -2665,9 +2672,10 @@ export const systemWordingMessages = { pokemonDataIdMismatch: '官方 Pokemon 数据 ID 与当前 Pokemon 不一致', dataToolScopeRequired: '请至少选择一个数据范围', dataToolScopeInvalid: '数据范围不合法', - dataToolBundleInvalid: '数据包不合法', - dataToolItemsCsvInvalid: '物品 CSV 不合法', - pokemonImagePathInvalid: 'Pokemon 图片路径不合法', + dataToolBundleInvalid: '数据包不合法', + dataToolItemsCsvInvalid: '物品 CSV 不合法', + dataToolHabitatsCsvInvalid: '栖息地 CSV 不合法', + pokemonImagePathInvalid: 'Pokemon 图片路径不合法', imagePathInvalid: '图片路径不合法', imageUploadRequired: '请选择图片', imageUploadTypeInvalid: '不支持这种图片类型',