From 5b22d788d793edb40ab0d1173bf0b463452e232a Mon Sep 17 00:00:00 2001 From: xiaomai Date: Tue, 5 May 2026 17:51:38 +0800 Subject: [PATCH] feat(admin): add items CSV import to data tools Allow bulk importing items via CSV in the admin data tools Support static image paths for items imported from CSV --- .repomixignore | 1 + DESIGN.md | 8 +- backend/src/queries.ts | 155 ++++++++++++++++++++++++++++++- backend/src/server.ts | 6 ++ frontend/src/services/api.ts | 1 + frontend/src/views/AdminView.vue | 22 +++++ system-wordings.ts | 8 ++ 7 files changed, 196 insertions(+), 5 deletions(-) create mode 100644 .repomixignore diff --git a/.repomixignore b/.repomixignore new file mode 100644 index 0000000..f4df7ba --- /dev/null +++ b/.repomixignore @@ -0,0 +1 @@ +data/**/*.csv \ No newline at end of file diff --git a/DESIGN.md b/DESIGN.md index 6e66440..acfae26 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -232,7 +232,12 @@ - Import 不自动覆盖系统配置、语言、用户、角色、权限、系统文案或 Life 内容。 - 导入数据引用的 System config、Languages、Users 或上传文件路径必须已存在;缺失依赖会导致导入失败并回滚。 - Import 完成后重置相关 identity sequence 到当前最大 ID 之后。 - - 前端导入和 Wipe 必须使用确认 Modal,并要求输入固定确认词后才能执行。 + - Data Tools 额外支持 Items CSV 导入,用于在 Wipe Items 后按 CSV 顺序批量新增普通 Items;CSV 导入只新增 Items,不自动 Wipe,不创建 Recipes、入手方式、标签或翻译。 + - Items CSV 必须包含 `name`、`category`、`description`、`image_file_name`、`not_registered_in_collection`、`cannot_grow_again_today` 列。 + - 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 文件选择触发。 ## Referral @@ -627,6 +632,7 @@ Pokemon 详情页展示: - 无材料单:`no_recipe` - 标签:使用喜欢的东西配置,可多选 - 图标图片:通过通用 Wiki 图片上传维护当前图标和历史上传记录 +- Data Tools 的 Items CSV 导入可为物品写入静态图标路径 `/pokopia/items/{image_file_name}`;静态图标展示 URL 为 `https://pokesprite.tootaio.com/pokopia/items/{image_file_name}`,用户后续仍可在编辑页切换为社区上传图片 - 翻译 - 排序 diff --git a/backend/src/queries.ts b/backend/src/queries.ts index 34fdcb3..4ce3503 100644 --- a/backend/src/queries.ts +++ b/backend/src/queries.ts @@ -616,6 +616,7 @@ const lifeCommentPreviewLimit = 2; 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 pokemonSpriteRequestTimeoutMs = 2500; const pokemonStatLabels: Array<{ key: keyof PokemonStats; label: string }> = [ { key: 'hp', label: 'HP' }, @@ -718,10 +719,17 @@ function sqlLiteral(value: string): string { function uploadedImageJson(pathExpression: string): string { return ` - CASE WHEN ${pathExpression} <> '' THEN json_build_object( - 'path', ${pathExpression}, - 'url', ${sqlLiteral(uploadPublicBaseUrl)} || ${pathExpression} - ) ELSE NULL END + CASE + WHEN ${pathExpression} LIKE ${sqlLiteral(`${itemStaticImagePathPrefix}%`)} THEN json_build_object( + 'path', ${pathExpression}, + 'url', ${sqlLiteral(pokemonSpriteBaseUrl)} || ${pathExpression} + ) + WHEN ${pathExpression} <> '' THEN json_build_object( + 'path', ${pathExpression}, + 'url', ${sqlLiteral(uploadPublicBaseUrl)} || ${pathExpression} + ) + ELSE NULL + END `; } @@ -1037,6 +1045,11 @@ 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) : ''; + return Boolean(fileName) && !fileName.includes('/') && !fileName.includes('\\') && !fileName.includes('..') && /^[A-Za-z0-9._()-]+$/.test(fileName); +} + function cleanUploadImagePath(value: unknown, entityType: 'items' | 'habitats' | 'ancient-artifacts'): string { const imagePath = cleanOptionalText(value); if (imagePath === '') { @@ -1053,6 +1066,9 @@ function cleanItemOrArtifactImagePath(value: unknown): string { if (imagePath === '') { return ''; } + if (isItemStaticImagePath(imagePath)) { + return imagePath; + } if (!isUploadImagePath(imagePath) || (!imagePath.startsWith('items/') && !imagePath.startsWith('ancient-artifacts/'))) { throw validationError('server.validation.imagePathInvalid'); } @@ -7729,6 +7745,93 @@ const dataToolColumns = { ], discussionCommentLikes: ['comment_id', 'user_id', 'created_at'] } as const; +const itemsCsvColumns = [ + 'name', + 'category', + 'description', + 'image_file_name', + 'not_registered_in_collection', + 'cannot_grow_again_today' +] as const; +const itemsCsvCategoryAliases = new Map( + itemCategoryOptions.flatMap((option) => [ + [option.key, option.key], + [option.labels.en.toLowerCase(), option.key], + [option.labels.en.toLowerCase().replaceAll(' ', '-'), option.key], + [option.labels.en.toLowerCase().replace(/\.$/, ''), option.key] + ]) +); + +itemsCsvCategoryAliases.set('misc.', 'misc'); + +function normalizeItemsCsvCategory(value: string): string { + return value.trim().toLowerCase().replace(/\s+/g, ' '); +} + +function itemsCsvCategoryKey(value: string): string { + const categoryKey = itemsCsvCategoryAliases.get(normalizeItemsCsvCategory(value)); + if (!categoryKey) { + throw validationError('server.validation.dataToolItemsCsvInvalid'); + } + return categoryKey; +} + +function itemsCsvBoolean(row: CsvRow, fieldName: string): boolean { + const value = csvText(row, fieldName).toLowerCase(); + if (value === '' || value === 'false' || value === '0' || value === 'no') { + return false; + } + if (value === 'true' || value === '1' || value === 'yes') { + return true; + } + throw validationError('server.validation.dataToolItemsCsvInvalid'); +} + +function appendItemsCsvNote(details: string, note: string): string { + return details ? `${details}\n${note}` : note; +} + +function itemsCsvDetails(row: CsvRow): string { + let details = csvText(row, 'description'); + if (itemsCsvBoolean(row, 'not_registered_in_collection')) { + details = appendItemsCsvNote(details, 'Note: Not registered in collection'); + } + if (itemsCsvBoolean(row, 'cannot_grow_again_today')) { + details = appendItemsCsvNote(details, 'Note: Cannot have Grow used on it again today'); + } + return details; +} + +function itemsCsvImagePath(value: string): string { + const fileName = value.trim(); + const imagePath = `${itemStaticImagePathPrefix}${fileName}`; + if (!isItemStaticImagePath(imagePath)) { + throw validationError('server.validation.dataToolItemsCsvInvalid'); + } + return imagePath; +} + +function cleanItemsCsvRows(value: unknown): CsvRow[] { + if (typeof value !== 'string' || value.trim() === '') { + throw validationError('server.validation.dataToolItemsCsvInvalid'); + } + + const rows = parseCsv(value, 'items.csv'); + if (!rows.length || rows.some((row) => itemsCsvColumns.some((column) => !(column in row)))) { + throw validationError('server.validation.dataToolItemsCsvInvalid'); + } + + const names = new Set(); + for (const row of rows) { + const name = csvText(row, 'name'); + if (!name || names.has(name)) { + throw validationError('server.validation.dataToolItemsCsvInvalid'); + } + names.add(name); + } + + return rows; +} function isDataToolScope(value: unknown): value is DataToolScope { return typeof value === 'string' && dataToolScopes.includes(value as DataToolScope); @@ -8198,6 +8301,50 @@ export async function importAdminData(payload: Record): Promise return getAdminDataToolsSummary(); } +export async function importAdminItemsCsv(payload: Record, userId: number): Promise<{ scopes: DataToolScopeSummary[] }> { + const rows = cleanItemsCsvRows(payload.csv); + const names = rows.map((row) => csvText(row, 'name')); + + await withTransaction(async (client) => { + const existing = await client.query<{ name: string }>('SELECT name FROM items WHERE name = ANY($1::text[])', [names]); + if (existing.rowCount && existing.rowCount > 0) { + throw validationError('server.validation.dataToolItemsCsvInvalid'); + } + + const firstSortOrder = await nextSortOrder(client, 'items'); + for (const [index, row] of rows.entries()) { + const result = await client.query<{ id: number }>( + ` + INSERT INTO items ( + name, + details, + category_key, + image_path, + sort_order, + created_by_user_id, + updated_by_user_id + ) + VALUES ($1, $2, $3, $4, $5, $6, $6) + RETURNING id + `, + [ + csvText(row, 'name'), + itemsCsvDetails(row), + itemsCsvCategoryKey(csvText(row, 'category')), + itemsCsvImagePath(csvText(row, 'image_file_name')), + firstSortOrder + index * 10, + userId + ] + ); + await recordEditLog(client, 'items', result.rows[0].id, 'create', userId); + } + + await resetIdentity(client, 'items'); + }); + + 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 aba4f50..dcf425b 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -83,6 +83,7 @@ import { getRecipe, globalSearch, importAdminData, + importAdminItemsCsv, isConfigType, listAncientArtifacts, listEntityDiscussionComments, @@ -2151,6 +2152,11 @@ app.post('/api/admin/data-tools/import', async (request, reply) => { return user ? importAdminData(request.body as Record) : undefined; }); +app.post('/api/admin/data-tools/import-items-csv', async (request, reply) => { + const user = await requirePermissionWithRateLimits(request, reply, 'admin.data.import', 'adminWrite'); + return user ? importAdminItemsCsv(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 3ba1739..f7491f5 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -1166,6 +1166,7 @@ export const api = { dataToolsSummary: () => getJson('/api/admin/data-tools/summary'), 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 }), 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 0129236..5e44b13 100644 --- a/frontend/src/views/AdminView.vue +++ b/frontend/src/views/AdminView.vue @@ -1594,6 +1594,23 @@ async function selectImportDataToolsFile(event: Event) { } } +async function selectImportItemsCsvFile(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.importItemsCsvDataTools(csv); + message.value = t('pages.admin.dataToolItemsCsvImported'); + }); +} + function closeImportDataToolsModal() { dataToolImportModalOpen.value = false; pendingImportBundle.value = null; @@ -1925,6 +1942,11 @@ onMounted(() => {

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

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

+
+ + +
+

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

diff --git a/system-wordings.ts b/system-wordings.ts index 74aeae5..da91dea 100644 --- a/system-wordings.ts +++ b/system-wordings.ts @@ -1029,6 +1029,9 @@ export const systemWordingMessages = { dataToolImportButton: 'Import', dataToolImportFile: 'Data bundle', dataToolImportMode: 'Import replaces the scopes included in the bundle.', + dataToolItemsCsvFile: 'Items CSV', + dataToolItemsCsvMode: 'CSV import adds Items only. Wipe Items first when replacing the list.', + dataToolItemsCsvImported: 'Items CSV imported.', dataToolWipe: 'Wipe data', dataToolWipeButton: 'Wipe', dataToolSelectScope: 'Select at least one data scope.', @@ -1282,6 +1285,7 @@ export const systemWordingMessages = { dataToolScopeRequired: 'Select at least one data scope', dataToolScopeInvalid: 'Data scope is invalid', dataToolBundleInvalid: 'Data bundle is invalid', + dataToolItemsCsvInvalid: 'Items CSV is invalid', pokemonImagePathInvalid: 'Pokemon image path is invalid', imagePathInvalid: 'Image path is invalid', imageUploadRequired: 'Please select an image', @@ -2370,6 +2374,9 @@ export const systemWordingMessages = { dataToolImportButton: '导入', dataToolImportFile: '数据包', dataToolImportMode: '导入会替换数据包内包含的范围。', + dataToolItemsCsvFile: '物品 CSV', + dataToolItemsCsvMode: 'CSV 导入只会新增物品。替换列表时请先清空物品。', + dataToolItemsCsvImported: '物品 CSV 已导入。', dataToolWipe: '清空数据', dataToolWipeButton: '清空', dataToolSelectScope: '请至少选择一个数据范围。', @@ -2623,6 +2630,7 @@ export const systemWordingMessages = { dataToolScopeRequired: '请至少选择一个数据范围', dataToolScopeInvalid: '数据范围不合法', dataToolBundleInvalid: '数据包不合法', + dataToolItemsCsvInvalid: '物品 CSV 不合法', pokemonImagePathInvalid: 'Pokemon 图片路径不合法', imagePathInvalid: '图片路径不合法', imageUploadRequired: '请选择图片',