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
This commit is contained in:
2026-05-05 17:51:38 +08:00
parent 0e2743b469
commit 5b22d788d7
7 changed files with 196 additions and 5 deletions

1
.repomixignore Normal file
View File

@@ -0,0 +1 @@
data/**/*.csv

View File

@@ -232,7 +232,12 @@
- Import 不自动覆盖系统配置、语言、用户、角色、权限、系统文案或 Life 内容。 - Import 不自动覆盖系统配置、语言、用户、角色、权限、系统文案或 Life 内容。
- 导入数据引用的 System config、Languages、Users 或上传文件路径必须已存在;缺失依赖会导致导入失败并回滚。 - 导入数据引用的 System config、Languages、Users 或上传文件路径必须已存在;缺失依赖会导致导入失败并回滚。
- Import 完成后重置相关 identity sequence 到当前最大 ID 之后。 - Import 完成后重置相关 identity sequence 到当前最大 ID 之后。
- 前端导入和 Wipe 必须使用确认 Modal并要求输入固定确认词后才能执行 - Data Tools 额外支持 Items CSV 导入,用于在 Wipe Items 后按 CSV 顺序批量新增普通 ItemsCSV 导入只新增 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 ## Referral
@@ -627,6 +632,7 @@ Pokemon 详情页展示:
- 无材料单:`no_recipe` - 无材料单:`no_recipe`
- 标签:使用喜欢的东西配置,可多选 - 标签:使用喜欢的东西配置,可多选
- 图标图片:通过通用 Wiki 图片上传维护当前图标和历史上传记录 - 图标图片:通过通用 Wiki 图片上传维护当前图标和历史上传记录
- Data Tools 的 Items CSV 导入可为物品写入静态图标路径 `/pokopia/items/{image_file_name}`;静态图标展示 URL 为 `https://pokesprite.tootaio.com/pokopia/items/{image_file_name}`,用户后续仍可在编辑页切换为社区上传图片
- 翻译 - 翻译
- 排序 - 排序

View File

@@ -616,6 +616,7 @@ const lifeCommentPreviewLimit = 2;
const lifeReactionTypes = ['like', 'helpful', 'fun', 'thanks'] as const; 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 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' },
@@ -718,10 +719,17 @@ function sqlLiteral(value: string): string {
function uploadedImageJson(pathExpression: string): string { function uploadedImageJson(pathExpression: string): string {
return ` return `
CASE WHEN ${pathExpression} <> '' THEN json_build_object( CASE
'path', ${pathExpression}, WHEN ${pathExpression} LIKE ${sqlLiteral(`${itemStaticImagePathPrefix}%`)} THEN json_build_object(
'url', ${sqlLiteral(uploadPublicBaseUrl)} || ${pathExpression} 'path', ${pathExpression},
) ELSE NULL END '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() : ''; 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 { function cleanUploadImagePath(value: unknown, entityType: 'items' | 'habitats' | 'ancient-artifacts'): string {
const imagePath = cleanOptionalText(value); const imagePath = cleanOptionalText(value);
if (imagePath === '') { if (imagePath === '') {
@@ -1053,6 +1066,9 @@ function cleanItemOrArtifactImagePath(value: unknown): string {
if (imagePath === '') { if (imagePath === '') {
return ''; return '';
} }
if (isItemStaticImagePath(imagePath)) {
return imagePath;
}
if (!isUploadImagePath(imagePath) || (!imagePath.startsWith('items/') && !imagePath.startsWith('ancient-artifacts/'))) { if (!isUploadImagePath(imagePath) || (!imagePath.startsWith('items/') && !imagePath.startsWith('ancient-artifacts/'))) {
throw validationError('server.validation.imagePathInvalid'); throw validationError('server.validation.imagePathInvalid');
} }
@@ -7729,6 +7745,93 @@ const dataToolColumns = {
], ],
discussionCommentLikes: ['comment_id', 'user_id', 'created_at'] discussionCommentLikes: ['comment_id', 'user_id', 'created_at']
} as const; } as const;
const itemsCsvColumns = [
'name',
'category',
'description',
'image_file_name',
'not_registered_in_collection',
'cannot_grow_again_today'
] as const;
const itemsCsvCategoryAliases = new Map<string, string>(
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<string>();
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 { 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);
@@ -8198,6 +8301,50 @@ export async function importAdminData(payload: Record<string, unknown>): Promise
return getAdminDataToolsSummary(); return getAdminDataToolsSummary();
} }
export async function importAdminItemsCsv(payload: Record<string, unknown>, 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<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) => {

View File

@@ -83,6 +83,7 @@ import {
getRecipe, getRecipe,
globalSearch, globalSearch,
importAdminData, importAdminData,
importAdminItemsCsv,
isConfigType, isConfigType,
listAncientArtifacts, listAncientArtifacts,
listEntityDiscussionComments, listEntityDiscussionComments,
@@ -2151,6 +2152,11 @@ app.post('/api/admin/data-tools/import', async (request, reply) => {
return user ? importAdminData(request.body as Record<string, unknown>) : undefined; return user ? importAdminData(request.body as Record<string, unknown>) : 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<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;

View File

@@ -1166,6 +1166,7 @@ export const api = {
dataToolsSummary: () => getJson<DataToolsSummary>('/api/admin/data-tools/summary'), dataToolsSummary: () => getJson<DataToolsSummary>('/api/admin/data-tools/summary'),
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 }),
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) =>

View File

@@ -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() { function closeImportDataToolsModal() {
dataToolImportModalOpen.value = false; dataToolImportModalOpen.value = false;
pendingImportBundle.value = null; pendingImportBundle.value = null;
@@ -1925,6 +1942,11 @@ onMounted(() => {
</div> </div>
<p class="meta-line">{{ t('pages.admin.dataToolDependencyNote') }}</p> <p class="meta-line">{{ t('pages.admin.dataToolDependencyNote') }}</p>
<p class="meta-line">{{ t('pages.admin.dataToolImportMode') }}</p> <p class="meta-line">{{ t('pages.admin.dataToolImportMode') }}</p>
<div class="field">
<label for="data-tools-items-csv-file">{{ t('pages.admin.dataToolItemsCsvFile') }}</label>
<input id="data-tools-items-csv-file" type="file" accept="text/csv,.csv" :disabled="busy || !can('admin.data.import')" @change="selectImportItemsCsvFile" />
</div>
<p class="meta-line">{{ t('pages.admin.dataToolItemsCsvMode') }}</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')">

View File

@@ -1029,6 +1029,9 @@ export const systemWordingMessages = {
dataToolImportButton: 'Import', dataToolImportButton: 'Import',
dataToolImportFile: 'Data bundle', dataToolImportFile: 'Data bundle',
dataToolImportMode: 'Import replaces the scopes included in the 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', dataToolWipe: 'Wipe data',
dataToolWipeButton: 'Wipe', dataToolWipeButton: 'Wipe',
dataToolSelectScope: 'Select at least one data scope.', dataToolSelectScope: 'Select at least one data scope.',
@@ -1282,6 +1285,7 @@ export const systemWordingMessages = {
dataToolScopeRequired: 'Select at least one data scope', dataToolScopeRequired: 'Select at least one data scope',
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',
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',
@@ -2370,6 +2374,9 @@ export const systemWordingMessages = {
dataToolImportButton: '导入', dataToolImportButton: '导入',
dataToolImportFile: '数据包', dataToolImportFile: '数据包',
dataToolImportMode: '导入会替换数据包内包含的范围。', dataToolImportMode: '导入会替换数据包内包含的范围。',
dataToolItemsCsvFile: '物品 CSV',
dataToolItemsCsvMode: 'CSV 导入只会新增物品。替换列表时请先清空物品。',
dataToolItemsCsvImported: '物品 CSV 已导入。',
dataToolWipe: '清空数据', dataToolWipe: '清空数据',
dataToolWipeButton: '清空', dataToolWipeButton: '清空',
dataToolSelectScope: '请至少选择一个数据范围。', dataToolSelectScope: '请至少选择一个数据范围。',
@@ -2623,6 +2630,7 @@ export const systemWordingMessages = {
dataToolScopeRequired: '请至少选择一个数据范围', dataToolScopeRequired: '请至少选择一个数据范围',
dataToolScopeInvalid: '数据范围不合法', dataToolScopeInvalid: '数据范围不合法',
dataToolBundleInvalid: '数据包不合法', dataToolBundleInvalid: '数据包不合法',
dataToolItemsCsvInvalid: '物品 CSV 不合法',
pokemonImagePathInvalid: 'Pokemon 图片路径不合法', pokemonImagePathInvalid: 'Pokemon 图片路径不合法',
imagePathInvalid: '图片路径不合法', imagePathInvalid: '图片路径不合法',
imageUploadRequired: '请选择图片', imageUploadRequired: '请选择图片',