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:
1
.repomixignore
Normal file
1
.repomixignore
Normal file
@@ -0,0 +1 @@
|
||||
data/**/*.csv
|
||||
@@ -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}`,用户后续仍可在编辑页切换为社区上传图片
|
||||
- 翻译
|
||||
- 排序
|
||||
|
||||
|
||||
@@ -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<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 {
|
||||
return typeof value === 'string' && dataToolScopes.includes(value as DataToolScope);
|
||||
@@ -8198,6 +8301,50 @@ export async function importAdminData(payload: Record<string, unknown>): Promise
|
||||
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[] }> {
|
||||
const scopes = cleanDataToolScopes(payload.scopes);
|
||||
await withTransaction(async (client) => {
|
||||
|
||||
@@ -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<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) => {
|
||||
const user = await requirePermissionWithRateLimits(request, reply, 'admin.data.import', 'adminWrite');
|
||||
return user ? wipeAdminData(request.body as Record<string, unknown>) : undefined;
|
||||
|
||||
@@ -1166,6 +1166,7 @@ export const api = {
|
||||
dataToolsSummary: () => getJson<DataToolsSummary>('/api/admin/data-tools/summary'),
|
||||
exportDataTools: (scopes: DataToolScope[]) => sendJson<DataToolsBundle>('/api/admin/data-tools/export', 'POST', { scopes }),
|
||||
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 }),
|
||||
register: (payload: RegisterPayload) => sendJson<{ message: string }>('/api/auth/register', 'POST', payload),
|
||||
verifyEmail: (token: string) =>
|
||||
|
||||
@@ -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(() => {
|
||||
</div>
|
||||
<p class="meta-line">{{ t('pages.admin.dataToolDependencyNote') }}</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 class="data-tool-panel data-tool-panel--danger" :aria-label="t('pages.admin.dataToolWipe')">
|
||||
|
||||
@@ -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: '请选择图片',
|
||||
|
||||
Reference in New Issue
Block a user