diff --git a/backend/Dockerfile b/backend/Dockerfile index aff12c2..529b2e7 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -2,7 +2,7 @@ FROM node:22-alpine WORKDIR /app COPY package.json ./ -RUN npm install +RUN corepack enable && pnpm install COPY . . EXPOSE 3001 -CMD ["npm", "run", "start"] +CMD ["pnpm", "run", "start"] diff --git a/backend/db/seed.sql b/backend/db/seed.sql index 1282dfd..f3f3546 100644 --- a/backend/db/seed.sql +++ b/backend/db/seed.sql @@ -1,147 +1 @@ -INSERT INTO environments (id, name) VALUES - (1, '森林'), - (2, '水边'), - (3, '草原') -ON CONFLICT (id) DO NOTHING; - -INSERT INTO skills (id, name, subcategory) VALUES - (1, '采集', NULL), - (2, '乱撒', '棉花'), - (3, '浇水', NULL), - (4, '搬运', NULL) -ON CONFLICT (id) DO NOTHING; - -INSERT INTO favorite_things (id, name) VALUES - (1, '莓果'), - (2, '花朵'), - (3, '木材'), - (4, '清水'), - (5, '棉花') -ON CONFLICT (id) DO NOTHING; - -INSERT INTO pokemon (id, name, environment_id) VALUES - (1, '妙蛙种子', 1), - (4, '小火龙', 3), - (7, '杰尼龟', 2), - (25, '皮卡丘', 3) -ON CONFLICT (id) DO NOTHING; - -INSERT INTO pokemon_skills (pokemon_id, skill_id) VALUES - (1, 1), - (1, 3), - (4, 4), - (7, 3), - (25, 1), - (25, 2) -ON CONFLICT DO NOTHING; - -INSERT INTO pokemon_favorite_things (pokemon_id, favorite_thing_id) VALUES - (1, 1), - (1, 2), - (4, 3), - (7, 4), - (25, 1), - (25, 5) -ON CONFLICT DO NOTHING; - -INSERT INTO item_categories (id, name) VALUES - (1, '家具'), - (2, '材料'), - (3, '装饰') -ON CONFLICT (id) DO NOTHING; - -INSERT INTO item_usages (id, name) VALUES - (1, '栖息地配方'), - (2, '建造'), - (3, '装饰') -ON CONFLICT (id) DO NOTHING; - -INSERT INTO acquisition_methods (id, name) VALUES - (1, '采集'), - (2, '制作'), - (3, '探索') -ON CONFLICT (id) DO NOTHING; - -INSERT INTO item_tags (id, name) VALUES - (1, '自然'), - (2, '木质'), - (3, '柔软'), - (4, '水域') -ON CONFLICT (id) DO NOTHING; - -INSERT INTO recipes (id, name) VALUES - (1, '木质长椅材料单'), - (2, '棉花垫材料单') -ON CONFLICT (id) DO NOTHING; - -INSERT INTO items (id, name, category_id, usage_id, recipe_id, dyeable, dual_dyeable, pattern_editable) VALUES - (1, '原木', 2, 2, NULL, false, false, false), - (2, '棉花', 2, 2, NULL, true, false, false), - (3, '木质长椅', 1, 3, 1, true, true, false), - (4, '清水瓶', 3, 1, NULL, false, false, true), - (5, '棉花垫', 1, 3, 2, true, false, true) -ON CONFLICT (id) DO NOTHING; - -INSERT INTO item_acquisition_methods (item_id, acquisition_method_id) VALUES - (1, 1), - (2, 1), - (3, 2), - (4, 3), - (5, 2) -ON CONFLICT DO NOTHING; - -INSERT INTO item_item_tags (item_id, item_tag_id) VALUES - (1, 1), - (1, 2), - (2, 3), - (3, 2), - (4, 4), - (5, 3) -ON CONFLICT DO NOTHING; - -INSERT INTO recipe_acquisition_methods (recipe_id, acquisition_method_id) VALUES - (1, 2), - (2, 2) -ON CONFLICT DO NOTHING; - -INSERT INTO recipe_materials (recipe_id, item_id, quantity) VALUES - (1, 1, 6), - (2, 2, 4) -ON CONFLICT DO NOTHING; - -INSERT INTO maps (id, name) VALUES - (1, '起始平原'), - (2, '微风森林'), - (3, '湖畔小径') -ON CONFLICT (id) DO NOTHING; - -INSERT INTO habitats (id, name) VALUES - (1, '绿荫营地'), - (2, '湖边小窝') -ON CONFLICT (id) DO NOTHING; - -INSERT INTO habitat_recipe_items (habitat_id, item_id, quantity) VALUES - (1, 1, 8), - (1, 3, 1), - (2, 2, 3), - (2, 4, 2) -ON CONFLICT DO NOTHING; - -INSERT INTO habitat_pokemon (habitat_id, pokemon_id, map_id, time_of_day, weather, rarity) VALUES - (1, 1, 2, '早晨', '晴天', 1), - (1, 25, 1, '傍晚', '阴天', 2), - (2, 7, 3, '中午', '雨天', 1), - (2, 1, 3, '早晨', '雨天', 2) -ON CONFLICT DO NOTHING; - -SELECT setval(pg_get_serial_sequence('environments', 'id'), (SELECT max(id) FROM environments)); -SELECT setval(pg_get_serial_sequence('skills', 'id'), (SELECT max(id) FROM skills)); -SELECT setval(pg_get_serial_sequence('favorite_things', 'id'), (SELECT max(id) FROM favorite_things)); -SELECT setval(pg_get_serial_sequence('item_categories', 'id'), (SELECT max(id) FROM item_categories)); -SELECT setval(pg_get_serial_sequence('item_usages', 'id'), (SELECT max(id) FROM item_usages)); -SELECT setval(pg_get_serial_sequence('acquisition_methods', 'id'), (SELECT max(id) FROM acquisition_methods)); -SELECT setval(pg_get_serial_sequence('item_tags', 'id'), (SELECT max(id) FROM item_tags)); -SELECT setval(pg_get_serial_sequence('recipes', 'id'), (SELECT max(id) FROM recipes)); -SELECT setval(pg_get_serial_sequence('items', 'id'), (SELECT max(id) FROM items)); -SELECT setval(pg_get_serial_sequence('maps', 'id'), (SELECT max(id) FROM maps)); -SELECT setval(pg_get_serial_sequence('habitats', 'id'), (SELECT max(id) FROM habitats)); +-- Intentionally empty. Project data is created through the management UI. diff --git a/backend/package.json b/backend/package.json index 0022d21..8bdbc46 100644 --- a/backend/package.json +++ b/backend/package.json @@ -2,6 +2,7 @@ "name": "@pokopia/backend", "version": "0.1.0", "private": true, + "packageManager": "pnpm@10.33.2", "type": "module", "scripts": { "dev": "tsx watch src/server.ts", diff --git a/backend/src/queries.ts b/backend/src/queries.ts index 5585b3c..d27ec79 100644 --- a/backend/src/queries.ts +++ b/backend/src/queries.ts @@ -1,10 +1,83 @@ import { parseIdList, parseMatchMode, sqlForRelationFilter } from './filter.ts'; -import { query, queryOne } from './db.ts'; +import { pool, query, queryOne } from './db.ts'; type QueryValue = string | string[] | undefined; type QueryParams = Record; +type DbClient = Awaited>; + +type ConfigType = + | 'skills' + | 'environments' + | 'favorite-things' + | 'item-categories' + | 'item-usages' + | 'acquisition-methods' + | 'item-tags' + | 'maps'; + +type ConfigDefinition = { + table: string; + select: string; + order: string; + hasSubcategory?: boolean; +}; + +type IdQuantity = { + itemId: number; + quantity: number; +}; + +type PokemonPayload = { + id: number; + name: string; + environmentId: number; + skillIds: number[]; + favoriteThingIds: number[]; +}; + +type ItemPayload = { + name: string; + categoryId: number; + usageId: number; + recipeId: number | null; + dyeable: boolean; + dualDyeable: boolean; + patternEditable: boolean; + acquisitionMethodIds: number[]; + tagIds: number[]; +}; + +type RecipePayload = { + name: string; + acquisitionMethodIds: number[]; + materials: IdQuantity[]; +}; + +type HabitatPayload = { + name: string; + recipeItems: IdQuantity[]; + pokemonAppearances: Array<{ + pokemonId: number; + mapId: number; + timeOfDay: string; + weather: string; + rarity: number; + }>; +}; + +const configDefinitions: Record = { + skills: { table: 'skills', select: 'id, name, subcategory', order: 'name, subcategory', hasSubcategory: true }, + environments: { table: 'environments', select: 'id, name', order: 'name' }, + 'favorite-things': { table: 'favorite_things', select: 'id, name', order: 'name' }, + 'item-categories': { table: 'item_categories', select: 'id, name', order: 'name' }, + 'item-usages': { table: 'item_usages', select: 'id, name', order: 'name' }, + 'acquisition-methods': { table: 'acquisition_methods', select: 'id, name', order: 'name' }, + 'item-tags': { table: 'item_tags', select: 'id, name', order: 'name' }, + maps: { table: 'maps', select: 'id, name', order: 'name' } +}; + function asString(value: QueryValue): string | undefined { return Array.isArray(value) ? value[0] : value; } @@ -13,6 +86,60 @@ function optionSelect(tableName: string): Promise Number(item)).filter((item) => Number.isInteger(item) && item > 0))]; +} + +function cleanQuantities(value: unknown): IdQuantity[] { + if (!Array.isArray(value)) { + return []; + } + + return value + .map((item) => { + const row = item as Partial; + return { + itemId: Number(row.itemId), + quantity: Number(row.quantity) + }; + }) + .filter((item) => Number.isInteger(item.itemId) && item.itemId > 0 && Number.isInteger(item.quantity) && item.quantity > 0); +} + +async function withTransaction(callback: (client: DbClient) => Promise): Promise { + const client = await pool.connect(); + + try { + await client.query('BEGIN'); + const result = await callback(client); + await client.query('COMMIT'); + return result; + } catch (error) { + await client.query('ROLLBACK'); + throw error; + } finally { + client.release(); + } +} + const pokemonProjection = ` SELECT p.id, @@ -35,7 +162,16 @@ const pokemonProjection = ` `; export async function getOptions() { - const [skills, environments, favoriteThings, itemCategories, itemUsages, itemTags] = await Promise.all([ + const [ + skills, + environments, + favoriteThings, + itemCategories, + itemUsages, + acquisitionMethods, + itemTags, + maps + ] = await Promise.all([ query<{ id: number; name: string; subcategory: string | null }>( 'SELECT id, name, subcategory FROM skills ORDER BY name, subcategory' ), @@ -43,7 +179,9 @@ export async function getOptions() { optionSelect('favorite_things'), optionSelect('item_categories'), optionSelect('item_usages'), - optionSelect('item_tags') + optionSelect('acquisition_methods'), + optionSelect('item_tags'), + optionSelect('maps') ]); return { @@ -52,10 +190,57 @@ export async function getOptions() { favoriteThings, itemCategories, itemUsages, - itemTags + acquisitionMethods, + itemTags, + maps }; } +export function isConfigType(type: string): type is ConfigType { + return Object.hasOwn(configDefinitions, type); +} + +export async function listConfig(type: ConfigType) { + const definition = configDefinitions[type]; + return query(`SELECT ${definition.select} FROM ${definition.table} ORDER BY ${definition.order}`); +} + +export async function createConfig(type: ConfigType, payload: Record) { + const definition = configDefinitions[type]; + const name = cleanName(payload.name); + const subcategory = typeof payload.subcategory === 'string' && payload.subcategory.trim() ? payload.subcategory.trim() : null; + + if (definition.hasSubcategory) { + return queryOne( + `INSERT INTO ${definition.table} (name, subcategory) VALUES ($1, $2) RETURNING ${definition.select}`, + [name, subcategory] + ); + } + + return queryOne(`INSERT INTO ${definition.table} (name) VALUES ($1) RETURNING ${definition.select}`, [name]); +} + +export async function updateConfig(type: ConfigType, id: number, payload: Record) { + const definition = configDefinitions[type]; + const name = cleanName(payload.name); + const subcategory = typeof payload.subcategory === 'string' && payload.subcategory.trim() ? payload.subcategory.trim() : null; + + if (definition.hasSubcategory) { + return queryOne( + `UPDATE ${definition.table} SET name = $1, subcategory = $2 WHERE id = $3 RETURNING ${definition.select}`, + [name, subcategory, id] + ); + } + + return queryOne(`UPDATE ${definition.table} SET name = $1 WHERE id = $2 RETURNING ${definition.select}`, [name, id]); +} + +export async function deleteConfig(type: ConfigType, id: number) { + const definition = configDefinitions[type]; + const result = await pool.query(`DELETE FROM ${definition.table} WHERE id = $1`, [id]); + return (result.rowCount ?? 0) > 0; +} + export async function listPokemon(paramsQuery: QueryParams) { const params: unknown[] = []; const conditions: string[] = []; @@ -131,6 +316,80 @@ export async function getPokemon(id: number) { return { ...pokemon, habitats }; } +function cleanPokemonPayload(payload: Record): PokemonPayload { + const skillIds = cleanIds(payload.skillIds); + const favoriteThingIds = cleanIds(payload.favoriteThingIds); + + if (skillIds.length > 2) { + throw new Error('Pokemon can have at most 2 skills'); + } + if (favoriteThingIds.length > 6) { + throw new Error('Pokemon can have at most 6 favorite things'); + } + + return { + id: requirePositiveInteger(payload.id, 'Pokemon ID'), + name: cleanName(payload.name), + environmentId: requirePositiveInteger(payload.environmentId, 'Environment'), + skillIds, + favoriteThingIds + }; +} + +async function replacePokemonRelations(client: DbClient, pokemonId: number, payload: PokemonPayload): Promise { + await client.query('DELETE FROM pokemon_skills WHERE pokemon_id = $1', [pokemonId]); + await client.query('DELETE FROM pokemon_favorite_things WHERE pokemon_id = $1', [pokemonId]); + + for (const skillId of payload.skillIds) { + await client.query('INSERT INTO pokemon_skills (pokemon_id, skill_id) VALUES ($1, $2)', [pokemonId, skillId]); + } + + for (const favoriteThingId of payload.favoriteThingIds) { + await client.query('INSERT INTO pokemon_favorite_things (pokemon_id, favorite_thing_id) VALUES ($1, $2)', [ + pokemonId, + favoriteThingId + ]); + } +} + +export async function createPokemon(payload: Record) { + const cleanPayload = cleanPokemonPayload(payload); + + const id = await withTransaction(async (client) => { + await client.query('INSERT INTO pokemon (id, name, environment_id) VALUES ($1, $2, $3)', [ + cleanPayload.id, + cleanPayload.name, + cleanPayload.environmentId + ]); + await replacePokemonRelations(client, cleanPayload.id, cleanPayload); + return cleanPayload.id; + }); + return getPokemon(id); +} + +export async function updatePokemon(id: number, payload: Record) { + const cleanPayload = cleanPokemonPayload({ ...payload, id }); + + const updated = await withTransaction(async (client) => { + const result = await client.query('UPDATE pokemon SET name = $1, environment_id = $2 WHERE id = $3', [ + cleanPayload.name, + cleanPayload.environmentId, + id + ]); + if (result.rowCount === 0) { + return false; + } + await replacePokemonRelations(client, id, cleanPayload); + return true; + }); + return updated ? getPokemon(id) : null; +} + +export async function deletePokemon(id: number) { + const result = await pool.query('DELETE FROM pokemon WHERE id = $1', [id]); + return (result.rowCount ?? 0) > 0; +} + export async function listHabitats() { return query(` SELECT @@ -196,6 +455,94 @@ export async function getHabitat(id: number) { return { ...habitat, pokemon }; } +function cleanHabitatPayload(payload: Record): HabitatPayload { + const appearances = Array.isArray(payload.pokemonAppearances) ? payload.pokemonAppearances : []; + + return { + name: cleanName(payload.name), + recipeItems: cleanQuantities(payload.recipeItems), + pokemonAppearances: appearances + .map((item) => { + const row = item as Record; + return { + pokemonId: Number(row.pokemonId), + mapId: Number(row.mapId), + timeOfDay: String(row.timeOfDay ?? ''), + weather: String(row.weather ?? ''), + rarity: Number(row.rarity) + }; + }) + .filter( + (item) => + Number.isInteger(item.pokemonId) && + item.pokemonId > 0 && + Number.isInteger(item.mapId) && + item.mapId > 0 && + ['早晨', '中午', '傍晚', '晚上'].includes(item.timeOfDay) && + ['晴天', '阴天', '雨天'].includes(item.weather) && + Number.isInteger(item.rarity) && + item.rarity >= 1 && + item.rarity <= 3 + ) + }; +} + +async function replaceHabitatRelations(client: DbClient, habitatId: number, payload: HabitatPayload): Promise { + await client.query('DELETE FROM habitat_recipe_items WHERE habitat_id = $1', [habitatId]); + await client.query('DELETE FROM habitat_pokemon WHERE habitat_id = $1', [habitatId]); + + for (const item of payload.recipeItems) { + await client.query('INSERT INTO habitat_recipe_items (habitat_id, item_id, quantity) VALUES ($1, $2, $3)', [ + habitatId, + item.itemId, + item.quantity + ]); + } + + for (const item of payload.pokemonAppearances) { + await client.query( + ` + INSERT INTO habitat_pokemon (habitat_id, pokemon_id, map_id, time_of_day, weather, rarity) + VALUES ($1, $2, $3, $4, $5, $6) + `, + [habitatId, item.pokemonId, item.mapId, item.timeOfDay, item.weather, item.rarity] + ); + } +} + +export async function createHabitat(payload: Record) { + const cleanPayload = cleanHabitatPayload(payload); + + const id = await withTransaction(async (client) => { + const result = await client.query<{ id: number }>('INSERT INTO habitats (name) VALUES ($1) RETURNING id', [ + cleanPayload.name + ]); + const habitatId = result.rows[0].id; + await replaceHabitatRelations(client, habitatId, cleanPayload); + return habitatId; + }); + return getHabitat(id); +} + +export async function updateHabitat(id: number, payload: Record) { + const cleanPayload = cleanHabitatPayload(payload); + + const updated = await withTransaction(async (client) => { + const result = await client.query('UPDATE habitats SET name = $1 WHERE id = $2', [cleanPayload.name, id]); + if (result.rowCount === 0) { + return false; + } + await replaceHabitatRelations(client, id, cleanPayload); + return true; + }); + return updated ? getHabitat(id) : null; +} + +export async function deleteHabitat(id: number) { + const result = await pool.query('DELETE FROM habitats WHERE id = $1', [id]); + return (result.rowCount ?? 0) > 0; +} + const itemProjection = ` SELECT i.id, @@ -313,6 +660,108 @@ export async function getItem(id: number) { return { ...item, acquisitionMethods, recipe, relatedHabitats }; } +function cleanItemPayload(payload: Record): ItemPayload { + const recipeId = payload.recipeId === null || payload.recipeId === '' || payload.recipeId === undefined + ? null + : requirePositiveInteger(payload.recipeId, 'Recipe'); + + return { + name: cleanName(payload.name), + categoryId: requirePositiveInteger(payload.categoryId, 'Category'), + usageId: requirePositiveInteger(payload.usageId, 'Usage'), + recipeId, + dyeable: Boolean(payload.dyeable), + dualDyeable: Boolean(payload.dualDyeable), + patternEditable: Boolean(payload.patternEditable), + acquisitionMethodIds: cleanIds(payload.acquisitionMethodIds), + tagIds: cleanIds(payload.tagIds) + }; +} + +async function replaceItemRelations(client: DbClient, itemId: number, payload: ItemPayload): Promise { + await client.query('DELETE FROM item_acquisition_methods WHERE item_id = $1', [itemId]); + await client.query('DELETE FROM item_item_tags WHERE item_id = $1', [itemId]); + + for (const methodId of payload.acquisitionMethodIds) { + await client.query('INSERT INTO item_acquisition_methods (item_id, acquisition_method_id) VALUES ($1, $2)', [ + itemId, + methodId + ]); + } + + for (const tagId of payload.tagIds) { + await client.query('INSERT INTO item_item_tags (item_id, item_tag_id) VALUES ($1, $2)', [itemId, tagId]); + } +} + +export async function createItem(payload: Record) { + const cleanPayload = cleanItemPayload(payload); + + const id = await withTransaction(async (client) => { + const result = await client.query<{ id: number }>( + ` + INSERT INTO items (name, category_id, usage_id, recipe_id, dyeable, dual_dyeable, pattern_editable) + VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING id + `, + [ + cleanPayload.name, + cleanPayload.categoryId, + cleanPayload.usageId, + cleanPayload.recipeId, + cleanPayload.dyeable, + cleanPayload.dualDyeable, + cleanPayload.patternEditable + ] + ); + const itemId = result.rows[0].id; + await replaceItemRelations(client, itemId, cleanPayload); + return itemId; + }); + return getItem(id); +} + +export async function updateItem(id: number, payload: Record) { + const cleanPayload = cleanItemPayload(payload); + + const updated = await withTransaction(async (client) => { + const result = await client.query( + ` + UPDATE items + SET name = $1, + category_id = $2, + usage_id = $3, + recipe_id = $4, + dyeable = $5, + dual_dyeable = $6, + pattern_editable = $7 + WHERE id = $8 + `, + [ + cleanPayload.name, + cleanPayload.categoryId, + cleanPayload.usageId, + cleanPayload.recipeId, + cleanPayload.dyeable, + cleanPayload.dualDyeable, + cleanPayload.patternEditable, + id + ] + ); + if (result.rowCount === 0) { + return false; + } + await replaceItemRelations(client, id, cleanPayload); + return true; + }); + return updated ? getItem(id) : null; +} + +export async function deleteItem(id: number) { + const result = await pool.query('DELETE FROM items WHERE id = $1', [id]); + return (result.rowCount ?? 0) > 0; +} + export async function listRecipes() { return query(` SELECT @@ -353,3 +802,64 @@ export async function getRecipe(id: number) { [id] ); } + +function cleanRecipePayload(payload: Record): RecipePayload { + return { + name: cleanName(payload.name), + acquisitionMethodIds: cleanIds(payload.acquisitionMethodIds), + materials: cleanQuantities(payload.materials) + }; +} + +async function replaceRecipeRelations(client: DbClient, recipeId: number, payload: RecipePayload): Promise { + await client.query('DELETE FROM recipe_acquisition_methods WHERE recipe_id = $1', [recipeId]); + await client.query('DELETE FROM recipe_materials WHERE recipe_id = $1', [recipeId]); + + for (const methodId of payload.acquisitionMethodIds) { + await client.query('INSERT INTO recipe_acquisition_methods (recipe_id, acquisition_method_id) VALUES ($1, $2)', [ + recipeId, + methodId + ]); + } + + for (const material of payload.materials) { + await client.query('INSERT INTO recipe_materials (recipe_id, item_id, quantity) VALUES ($1, $2, $3)', [ + recipeId, + material.itemId, + material.quantity + ]); + } +} + +export async function createRecipe(payload: Record) { + const cleanPayload = cleanRecipePayload(payload); + + const id = await withTransaction(async (client) => { + const result = await client.query<{ id: number }>('INSERT INTO recipes (name) VALUES ($1) RETURNING id', [ + cleanPayload.name + ]); + const recipeId = result.rows[0].id; + await replaceRecipeRelations(client, recipeId, cleanPayload); + return recipeId; + }); + return getRecipe(id); +} + +export async function updateRecipe(id: number, payload: Record) { + const cleanPayload = cleanRecipePayload(payload); + + const updated = await withTransaction(async (client) => { + const result = await client.query('UPDATE recipes SET name = $1 WHERE id = $2', [cleanPayload.name, id]); + if (result.rowCount === 0) { + return false; + } + await replaceRecipeRelations(client, id, cleanPayload); + return true; + }); + return updated ? getRecipe(id) : null; +} + +export async function deleteRecipe(id: number) { + const result = await pool.query('DELETE FROM recipes WHERE id = $1', [id]); + return (result.rowCount ?? 0) > 0; +} diff --git a/backend/src/server.ts b/backend/src/server.ts index 48daad0..ebbf494 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -2,15 +2,32 @@ import cors from '@fastify/cors'; import Fastify from 'fastify'; import { initializeDatabase, pool } from './db.ts'; import { + createConfig, + createHabitat, + createItem, + createPokemon, + createRecipe, + deleteConfig, + deleteHabitat, + deleteItem, + deletePokemon, + deleteRecipe, getHabitat, getItem, getOptions, getPokemon, getRecipe, + isConfigType, + listConfig, listHabitats, listItems, listPokemon, - listRecipes + listRecipes, + updateConfig, + updateHabitat, + updateItem, + updatePokemon, + updateRecipe } from './queries.ts'; const app = Fastify({ @@ -21,6 +38,29 @@ await app.register(cors, { origin: process.env.FRONTEND_ORIGIN ?? true }); +app.setErrorHandler(async (error, _request, reply) => { + const pgError = error as Error & { code?: string; constraint?: string; detail?: string; statusCode?: number }; + + if (pgError.code === '23503') { + return reply.code(409).send({ message: 'Referenced data is missing or this item is in use' }); + } + + if (pgError.code === '23505') { + return reply.code(409).send({ message: 'A record with the same value already exists' }); + } + + if (pgError.code === '23514') { + return reply.code(400).send({ message: 'Invalid field value' }); + } + + if (pgError.statusCode && pgError.statusCode < 500) { + return reply.code(pgError.statusCode).send({ message: pgError.message }); + } + + app.log.error(error); + return reply.code(500).send({ message: 'Server error' }); +}); + app.get('/health', async () => ({ ok: true })); app.get('/api/options', async () => getOptions()); @@ -38,6 +78,25 @@ app.get('/api/pokemon/:id', async (request, reply) => { return pokemon; }); +app.post('/api/pokemon', async (request, reply) => reply.code(201).send(await createPokemon(request.body as Record))); + +app.put('/api/pokemon/:id', async (request, reply) => { + const { id } = request.params as { id: string }; + const pokemon = await updatePokemon(Number(id), request.body as Record); + + if (!pokemon) { + return reply.code(404).send({ message: 'Not found' }); + } + + return pokemon; +}); + +app.delete('/api/pokemon/:id', async (request, reply) => { + const { id } = request.params as { id: string }; + const deleted = await deletePokemon(Number(id)); + return deleted ? reply.code(204).send() : reply.code(404).send({ message: 'Not found' }); +}); + app.get('/api/habitats', async () => listHabitats()); app.get('/api/habitats/:id', async (request, reply) => { @@ -51,6 +110,25 @@ app.get('/api/habitats/:id', async (request, reply) => { return habitat; }); +app.post('/api/habitats', async (request, reply) => reply.code(201).send(await createHabitat(request.body as Record))); + +app.put('/api/habitats/:id', async (request, reply) => { + const { id } = request.params as { id: string }; + const habitat = await updateHabitat(Number(id), request.body as Record); + + if (!habitat) { + return reply.code(404).send({ message: 'Not found' }); + } + + return habitat; +}); + +app.delete('/api/habitats/:id', async (request, reply) => { + const { id } = request.params as { id: string }; + const deleted = await deleteHabitat(Number(id)); + return deleted ? reply.code(204).send() : reply.code(404).send({ message: 'Not found' }); +}); + app.get('/api/items', async (request) => listItems(request.query as Record)); app.get('/api/items/:id', async (request, reply) => { @@ -64,6 +142,25 @@ app.get('/api/items/:id', async (request, reply) => { return item; }); +app.post('/api/items', async (request, reply) => reply.code(201).send(await createItem(request.body as Record))); + +app.put('/api/items/:id', async (request, reply) => { + const { id } = request.params as { id: string }; + const item = await updateItem(Number(id), request.body as Record); + + if (!item) { + return reply.code(404).send({ message: 'Not found' }); + } + + return item; +}); + +app.delete('/api/items/:id', async (request, reply) => { + const { id } = request.params as { id: string }; + const deleted = await deleteItem(Number(id)); + return deleted ? reply.code(204).send() : reply.code(404).send({ message: 'Not found' }); +}); + app.get('/api/recipes', async () => listRecipes()); app.get('/api/recipes/:id', async (request, reply) => { @@ -77,6 +174,59 @@ app.get('/api/recipes/:id', async (request, reply) => { return recipe; }); +app.post('/api/recipes', async (request, reply) => reply.code(201).send(await createRecipe(request.body as Record))); + +app.put('/api/recipes/:id', async (request, reply) => { + const { id } = request.params as { id: string }; + const recipe = await updateRecipe(Number(id), request.body as Record); + + if (!recipe) { + return reply.code(404).send({ message: 'Not found' }); + } + + return recipe; +}); + +app.delete('/api/recipes/:id', async (request, reply) => { + const { id } = request.params as { id: string }; + const deleted = await deleteRecipe(Number(id)); + return deleted ? reply.code(204).send() : reply.code(404).send({ message: 'Not found' }); +}); + +app.get('/api/admin/config/:type', async (request, reply) => { + const { type } = request.params as { type: string }; + if (!isConfigType(type)) { + return reply.code(404).send({ message: 'Not found' }); + } + return listConfig(type); +}); + +app.post('/api/admin/config/:type', async (request, reply) => { + const { type } = request.params as { type: string }; + if (!isConfigType(type)) { + return reply.code(404).send({ message: 'Not found' }); + } + return reply.code(201).send(await createConfig(type, request.body as Record)); +}); + +app.put('/api/admin/config/:type/:id', async (request, reply) => { + const { type, id } = request.params as { type: string; id: string }; + if (!isConfigType(type)) { + return reply.code(404).send({ message: 'Not found' }); + } + const config = await updateConfig(type, Number(id), request.body as Record); + return config ? config : reply.code(404).send({ message: 'Not found' }); +}); + +app.delete('/api/admin/config/:type/:id', async (request, reply) => { + const { type, id } = request.params as { type: string; id: string }; + if (!isConfigType(type)) { + return reply.code(404).send({ message: 'Not found' }); + } + const deleted = await deleteConfig(type, Number(id)); + return deleted ? reply.code(204).send() : reply.code(404).send({ message: 'Not found' }); +}); + const port = Number(process.env.BACKEND_PORT ?? 3001); try { diff --git a/frontend/Dockerfile b/frontend/Dockerfile index db943be..151842a 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -2,7 +2,7 @@ FROM node:22-alpine WORKDIR /app COPY package.json ./ -RUN npm install +RUN corepack enable && pnpm install COPY . . EXPOSE 3000 -CMD ["npm", "run", "dev"] +CMD ["pnpm", "run", "dev"] diff --git a/frontend/package.json b/frontend/package.json index 89b4d9e..0430955 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -2,6 +2,7 @@ "name": "@pokopia/frontend", "version": "0.1.0", "private": true, + "packageManager": "pnpm@10.33.2", "type": "module", "scripts": { "dev": "vite --host 0.0.0.0 --port 3000", diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 05f40aa..35e4e6c 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -2,7 +2,8 @@ const navItems = [ { label: 'Pokemon', to: '/pokemon' }, { label: '栖息地', to: '/habitats' }, - { label: '物品 / 材料单', to: '/items' } + { label: '物品 / 材料单', to: '/items' }, + { label: '管理', to: '/admin' } ]; diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 999c50b..d275111 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -6,6 +6,7 @@ import HabitatDetail from '../views/HabitatDetail.vue'; import ItemsList from '../views/ItemsList.vue'; import ItemDetail from '../views/ItemDetail.vue'; import RecipeDetail from '../views/RecipeDetail.vue'; +import AdminView from '../views/AdminView.vue'; export const router = createRouter({ history: createWebHistory(), @@ -17,7 +18,8 @@ export const router = createRouter({ { path: '/habitats/:id', component: HabitatDetail }, { path: '/items', component: ItemsList }, { path: '/items/:id', component: ItemDetail }, - { path: '/recipes/:id', component: RecipeDetail } + { path: '/recipes/:id', component: RecipeDetail }, + { path: '/admin', component: AdminView } ], scrollBehavior: () => ({ top: 0 }) }); diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index c5bfaa8..d680d9a 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -79,7 +79,57 @@ export interface Options { favoriteThings: NamedEntity[]; itemCategories: NamedEntity[]; itemUsages: NamedEntity[]; + acquisitionMethods: NamedEntity[]; itemTags: NamedEntity[]; + maps: NamedEntity[]; +} + +export type ConfigType = + | 'skills' + | 'environments' + | 'favorite-things' + | 'item-categories' + | 'item-usages' + | 'acquisition-methods' + | 'item-tags' + | 'maps'; + +export interface PokemonPayload { + id: number; + name: string; + environmentId: number; + skillIds: number[]; + favoriteThingIds: number[]; +} + +export interface ItemPayload { + name: string; + categoryId: number; + usageId: number; + recipeId: number | null; + dyeable: boolean; + dualDyeable: boolean; + patternEditable: boolean; + acquisitionMethodIds: number[]; + tagIds: number[]; +} + +export interface RecipePayload { + name: string; + acquisitionMethodIds: number[]; + materials: Array<{ itemId: number; quantity: number }>; +} + +export interface HabitatPayload { + name: string; + recipeItems: Array<{ itemId: number; quantity: number }>; + pokemonAppearances: Array<{ + pokemonId: number; + mapId: number; + timeOfDay: string; + weather: string; + rarity: number; + }>; } export function buildQuery(params: Record): string { @@ -105,16 +155,63 @@ async function getJson(path: string): Promise { return response.json() as Promise; } +async function sendJson(path: string, method: 'POST' | 'PUT', body: unknown): Promise { + const response = await fetch(`${apiBaseUrl}${path}`, { + method, + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(body) + }); + + if (!response.ok) { + throw new Error(`Request failed with ${response.status}`); + } + + return response.json() as Promise; +} + +async function deleteJson(path: string): Promise { + const response = await fetch(`${apiBaseUrl}${path}`, { + method: 'DELETE' + }); + + if (!response.ok) { + throw new Error(`Request failed with ${response.status}`); + } +} + export const api = { options: () => getJson('/api/options'), + config: (type: ConfigType) => getJson>(`/api/admin/config/${type}`), + createConfig: (type: ConfigType, payload: { name: string; subcategory?: string | null }) => + sendJson(`/api/admin/config/${type}`, 'POST', payload), + updateConfig: (type: ConfigType, id: number, payload: { name: string; subcategory?: string | null }) => + sendJson(`/api/admin/config/${type}/${id}`, 'PUT', payload), + deleteConfig: (type: ConfigType, id: number) => deleteJson(`/api/admin/config/${type}/${id}`), pokemon: (params: Record) => getJson(`/api/pokemon${buildQuery(params)}`), pokemonDetail: (id: string | number) => getJson(`/api/pokemon/${id}`), + createPokemon: (payload: PokemonPayload) => sendJson('/api/pokemon', 'POST', payload), + updatePokemon: (id: string | number, payload: PokemonPayload) => + sendJson(`/api/pokemon/${id}`, 'PUT', payload), + deletePokemon: (id: string | number) => deleteJson(`/api/pokemon/${id}`), habitats: () => getJson('/api/habitats'), habitatDetail: (id: string | number) => getJson(`/api/habitats/${id}`), + createHabitat: (payload: HabitatPayload) => sendJson('/api/habitats', 'POST', payload), + updateHabitat: (id: string | number, payload: HabitatPayload) => + sendJson(`/api/habitats/${id}`, 'PUT', payload), + deleteHabitat: (id: string | number) => deleteJson(`/api/habitats/${id}`), items: (params: Record) => getJson(`/api/items${buildQuery(params)}`), itemDetail: (id: string | number) => getJson(`/api/items/${id}`), + createItem: (payload: ItemPayload) => sendJson('/api/items', 'POST', payload), + updateItem: (id: string | number, payload: ItemPayload) => sendJson(`/api/items/${id}`, 'PUT', payload), + deleteItem: (id: string | number) => deleteJson(`/api/items/${id}`), recipes: () => getJson('/api/recipes'), - recipeDetail: (id: string | number) => getJson(`/api/recipes/${id}`) + recipeDetail: (id: string | number) => getJson(`/api/recipes/${id}`), + createRecipe: (payload: RecipePayload) => sendJson('/api/recipes', 'POST', payload), + updateRecipe: (id: string | number, payload: RecipePayload) => + sendJson(`/api/recipes/${id}`, 'PUT', payload), + deleteRecipe: (id: string | number) => deleteJson(`/api/recipes/${id}`) }; diff --git a/frontend/src/styles/main.css b/frontend/src/styles/main.css index 6fba57e..b2f736b 100644 --- a/frontend/src/styles/main.css +++ b/frontend/src/styles/main.css @@ -283,6 +283,76 @@ select { font-weight: 800; } +.admin-layout { + display: grid; + grid-template-columns: minmax(320px, 420px) 1fr; + gap: 14px; + align-items: start; +} + +.form-actions, +.row-actions, +.check-row, +.inline-row, +.appearance-row { + display: flex; + gap: 8px; +} + +.form-actions, +.check-row { + flex-wrap: wrap; + align-items: center; +} + +.row-actions { + flex: 0 0 auto; +} + +.row-actions button, +.plain-button, +.inline-row button, +.appearance-row button { + min-height: 34px; + padding: 6px 10px; + border: 1px solid #c7c0b2; + border-radius: 8px; + background: #fffdfa; + color: #4e5c52; + cursor: pointer; +} + +.plain-button { + width: fit-content; +} + +.inline-row { + align-items: center; +} + +.inline-row select { + flex: 1; +} + +.inline-row input { + width: 90px; +} + +.appearance-row { + display: grid; + grid-template-columns: minmax(130px, 1.2fr) minmax(120px, 1fr) repeat(3, minmax(80px, 0.7fr)) auto; + align-items: center; +} + +.appearance-row input { + min-width: 64px; +} + +button:disabled { + cursor: not-allowed; + opacity: 0.6; +} + @media (max-width: 760px) { .topbar { align-items: start; @@ -295,7 +365,12 @@ select { } .toolbar, - .detail-grid { + .detail-grid, + .admin-layout { + grid-template-columns: 1fr; + } + + .appearance-row { grid-template-columns: 1fr; } } diff --git a/frontend/src/views/AdminView.vue b/frontend/src/views/AdminView.vue new file mode 100644 index 0000000..eca1635 --- /dev/null +++ b/frontend/src/views/AdminView.vue @@ -0,0 +1,663 @@ + + +