feat(admin): implement management UI and CRUD APIs for all entities
Add full CRUD operations for Pokemon, Habitats, Items, Recipes, and Configs Switch package manager from npm to pnpm across the project Remove static seed data in favor of UI-driven data management
This commit is contained in:
@@ -2,7 +2,7 @@ FROM node:22-alpine
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package.json ./
|
COPY package.json ./
|
||||||
RUN npm install
|
RUN corepack enable && pnpm install
|
||||||
COPY . .
|
COPY . .
|
||||||
EXPOSE 3001
|
EXPOSE 3001
|
||||||
CMD ["npm", "run", "start"]
|
CMD ["pnpm", "run", "start"]
|
||||||
|
|||||||
@@ -1,147 +1 @@
|
|||||||
INSERT INTO environments (id, name) VALUES
|
-- Intentionally empty. Project data is created through the management UI.
|
||||||
(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));
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
"name": "@pokopia/backend",
|
"name": "@pokopia/backend",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
"packageManager": "pnpm@10.33.2",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "tsx watch src/server.ts",
|
"dev": "tsx watch src/server.ts",
|
||||||
|
|||||||
@@ -1,10 +1,83 @@
|
|||||||
import { parseIdList, parseMatchMode, sqlForRelationFilter } from './filter.ts';
|
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 QueryValue = string | string[] | undefined;
|
||||||
|
|
||||||
type QueryParams = Record<string, QueryValue>;
|
type QueryParams = Record<string, QueryValue>;
|
||||||
|
|
||||||
|
type DbClient = Awaited<ReturnType<typeof pool.connect>>;
|
||||||
|
|
||||||
|
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<ConfigType, ConfigDefinition> = {
|
||||||
|
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 {
|
function asString(value: QueryValue): string | undefined {
|
||||||
return Array.isArray(value) ? value[0] : value;
|
return Array.isArray(value) ? value[0] : value;
|
||||||
}
|
}
|
||||||
@@ -13,6 +86,60 @@ function optionSelect(tableName: string): Promise<Array<{ id: number; name: stri
|
|||||||
return query(`SELECT id, name FROM ${tableName} ORDER BY name`);
|
return query(`SELECT id, name FROM ${tableName} ORDER BY name`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function requirePositiveInteger(value: unknown, fieldName: string): number {
|
||||||
|
const numberValue = Number(value);
|
||||||
|
if (!Number.isInteger(numberValue) || numberValue <= 0) {
|
||||||
|
throw new Error(`${fieldName} is required`);
|
||||||
|
}
|
||||||
|
return numberValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanName(value: unknown): string {
|
||||||
|
if (typeof value !== 'string' || value.trim() === '') {
|
||||||
|
throw new Error('Name is required');
|
||||||
|
}
|
||||||
|
return value.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanIds(value: unknown): number[] {
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return [...new Set(value.map((item) => 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<IdQuantity>;
|
||||||
|
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<T>(callback: (client: DbClient) => Promise<T>): Promise<T> {
|
||||||
|
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 = `
|
const pokemonProjection = `
|
||||||
SELECT
|
SELECT
|
||||||
p.id,
|
p.id,
|
||||||
@@ -35,7 +162,16 @@ const pokemonProjection = `
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
export async function getOptions() {
|
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 }>(
|
query<{ id: number; name: string; subcategory: string | null }>(
|
||||||
'SELECT id, name, subcategory FROM skills ORDER BY name, subcategory'
|
'SELECT id, name, subcategory FROM skills ORDER BY name, subcategory'
|
||||||
),
|
),
|
||||||
@@ -43,7 +179,9 @@ export async function getOptions() {
|
|||||||
optionSelect('favorite_things'),
|
optionSelect('favorite_things'),
|
||||||
optionSelect('item_categories'),
|
optionSelect('item_categories'),
|
||||||
optionSelect('item_usages'),
|
optionSelect('item_usages'),
|
||||||
optionSelect('item_tags')
|
optionSelect('acquisition_methods'),
|
||||||
|
optionSelect('item_tags'),
|
||||||
|
optionSelect('maps')
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -52,10 +190,57 @@ export async function getOptions() {
|
|||||||
favoriteThings,
|
favoriteThings,
|
||||||
itemCategories,
|
itemCategories,
|
||||||
itemUsages,
|
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<string, unknown>) {
|
||||||
|
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<string, unknown>) {
|
||||||
|
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) {
|
export async function listPokemon(paramsQuery: QueryParams) {
|
||||||
const params: unknown[] = [];
|
const params: unknown[] = [];
|
||||||
const conditions: string[] = [];
|
const conditions: string[] = [];
|
||||||
@@ -131,6 +316,80 @@ export async function getPokemon(id: number) {
|
|||||||
return { ...pokemon, habitats };
|
return { ...pokemon, habitats };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cleanPokemonPayload(payload: Record<string, unknown>): 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<void> {
|
||||||
|
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<string, unknown>) {
|
||||||
|
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<string, unknown>) {
|
||||||
|
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() {
|
export async function listHabitats() {
|
||||||
return query(`
|
return query(`
|
||||||
SELECT
|
SELECT
|
||||||
@@ -196,6 +455,94 @@ export async function getHabitat(id: number) {
|
|||||||
return { ...habitat, pokemon };
|
return { ...habitat, pokemon };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cleanHabitatPayload(payload: Record<string, unknown>): 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<string, unknown>;
|
||||||
|
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<void> {
|
||||||
|
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<string, unknown>) {
|
||||||
|
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<string, unknown>) {
|
||||||
|
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 = `
|
const itemProjection = `
|
||||||
SELECT
|
SELECT
|
||||||
i.id,
|
i.id,
|
||||||
@@ -313,6 +660,108 @@ export async function getItem(id: number) {
|
|||||||
return { ...item, acquisitionMethods, recipe, relatedHabitats };
|
return { ...item, acquisitionMethods, recipe, relatedHabitats };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cleanItemPayload(payload: Record<string, unknown>): 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<void> {
|
||||||
|
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<string, unknown>) {
|
||||||
|
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<string, unknown>) {
|
||||||
|
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() {
|
export async function listRecipes() {
|
||||||
return query(`
|
return query(`
|
||||||
SELECT
|
SELECT
|
||||||
@@ -353,3 +802,64 @@ export async function getRecipe(id: number) {
|
|||||||
[id]
|
[id]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cleanRecipePayload(payload: Record<string, unknown>): RecipePayload {
|
||||||
|
return {
|
||||||
|
name: cleanName(payload.name),
|
||||||
|
acquisitionMethodIds: cleanIds(payload.acquisitionMethodIds),
|
||||||
|
materials: cleanQuantities(payload.materials)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function replaceRecipeRelations(client: DbClient, recipeId: number, payload: RecipePayload): Promise<void> {
|
||||||
|
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<string, unknown>) {
|
||||||
|
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<string, unknown>) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,15 +2,32 @@ import cors from '@fastify/cors';
|
|||||||
import Fastify from 'fastify';
|
import Fastify from 'fastify';
|
||||||
import { initializeDatabase, pool } from './db.ts';
|
import { initializeDatabase, pool } from './db.ts';
|
||||||
import {
|
import {
|
||||||
|
createConfig,
|
||||||
|
createHabitat,
|
||||||
|
createItem,
|
||||||
|
createPokemon,
|
||||||
|
createRecipe,
|
||||||
|
deleteConfig,
|
||||||
|
deleteHabitat,
|
||||||
|
deleteItem,
|
||||||
|
deletePokemon,
|
||||||
|
deleteRecipe,
|
||||||
getHabitat,
|
getHabitat,
|
||||||
getItem,
|
getItem,
|
||||||
getOptions,
|
getOptions,
|
||||||
getPokemon,
|
getPokemon,
|
||||||
getRecipe,
|
getRecipe,
|
||||||
|
isConfigType,
|
||||||
|
listConfig,
|
||||||
listHabitats,
|
listHabitats,
|
||||||
listItems,
|
listItems,
|
||||||
listPokemon,
|
listPokemon,
|
||||||
listRecipes
|
listRecipes,
|
||||||
|
updateConfig,
|
||||||
|
updateHabitat,
|
||||||
|
updateItem,
|
||||||
|
updatePokemon,
|
||||||
|
updateRecipe
|
||||||
} from './queries.ts';
|
} from './queries.ts';
|
||||||
|
|
||||||
const app = Fastify({
|
const app = Fastify({
|
||||||
@@ -21,6 +38,29 @@ await app.register(cors, {
|
|||||||
origin: process.env.FRONTEND_ORIGIN ?? true
|
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('/health', async () => ({ ok: true }));
|
||||||
|
|
||||||
app.get('/api/options', async () => getOptions());
|
app.get('/api/options', async () => getOptions());
|
||||||
@@ -38,6 +78,25 @@ app.get('/api/pokemon/:id', async (request, reply) => {
|
|||||||
return pokemon;
|
return pokemon;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.post('/api/pokemon', async (request, reply) => reply.code(201).send(await createPokemon(request.body as Record<string, unknown>)));
|
||||||
|
|
||||||
|
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<string, unknown>);
|
||||||
|
|
||||||
|
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', async () => listHabitats());
|
||||||
|
|
||||||
app.get('/api/habitats/:id', async (request, reply) => {
|
app.get('/api/habitats/:id', async (request, reply) => {
|
||||||
@@ -51,6 +110,25 @@ app.get('/api/habitats/:id', async (request, reply) => {
|
|||||||
return habitat;
|
return habitat;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.post('/api/habitats', async (request, reply) => reply.code(201).send(await createHabitat(request.body as Record<string, unknown>)));
|
||||||
|
|
||||||
|
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<string, unknown>);
|
||||||
|
|
||||||
|
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<string, string | string[] | undefined>));
|
app.get('/api/items', async (request) => listItems(request.query as Record<string, string | string[] | undefined>));
|
||||||
|
|
||||||
app.get('/api/items/:id', async (request, reply) => {
|
app.get('/api/items/:id', async (request, reply) => {
|
||||||
@@ -64,6 +142,25 @@ app.get('/api/items/:id', async (request, reply) => {
|
|||||||
return item;
|
return item;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.post('/api/items', async (request, reply) => reply.code(201).send(await createItem(request.body as Record<string, unknown>)));
|
||||||
|
|
||||||
|
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<string, unknown>);
|
||||||
|
|
||||||
|
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', async () => listRecipes());
|
||||||
|
|
||||||
app.get('/api/recipes/:id', async (request, reply) => {
|
app.get('/api/recipes/:id', async (request, reply) => {
|
||||||
@@ -77,6 +174,59 @@ app.get('/api/recipes/:id', async (request, reply) => {
|
|||||||
return recipe;
|
return recipe;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.post('/api/recipes', async (request, reply) => reply.code(201).send(await createRecipe(request.body as Record<string, unknown>)));
|
||||||
|
|
||||||
|
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<string, unknown>);
|
||||||
|
|
||||||
|
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<string, unknown>));
|
||||||
|
});
|
||||||
|
|
||||||
|
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<string, unknown>);
|
||||||
|
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);
|
const port = Number(process.env.BACKEND_PORT ?? 3001);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ FROM node:22-alpine
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package.json ./
|
COPY package.json ./
|
||||||
RUN npm install
|
RUN corepack enable && pnpm install
|
||||||
COPY . .
|
COPY . .
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
CMD ["npm", "run", "dev"]
|
CMD ["pnpm", "run", "dev"]
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
"name": "@pokopia/frontend",
|
"name": "@pokopia/frontend",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
"packageManager": "pnpm@10.33.2",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --host 0.0.0.0 --port 3000",
|
"dev": "vite --host 0.0.0.0 --port 3000",
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
const navItems = [
|
const navItems = [
|
||||||
{ label: 'Pokemon', to: '/pokemon' },
|
{ label: 'Pokemon', to: '/pokemon' },
|
||||||
{ label: '栖息地', to: '/habitats' },
|
{ label: '栖息地', to: '/habitats' },
|
||||||
{ label: '物品 / 材料单', to: '/items' }
|
{ label: '物品 / 材料单', to: '/items' },
|
||||||
|
{ label: '管理', to: '/admin' }
|
||||||
];
|
];
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import HabitatDetail from '../views/HabitatDetail.vue';
|
|||||||
import ItemsList from '../views/ItemsList.vue';
|
import ItemsList from '../views/ItemsList.vue';
|
||||||
import ItemDetail from '../views/ItemDetail.vue';
|
import ItemDetail from '../views/ItemDetail.vue';
|
||||||
import RecipeDetail from '../views/RecipeDetail.vue';
|
import RecipeDetail from '../views/RecipeDetail.vue';
|
||||||
|
import AdminView from '../views/AdminView.vue';
|
||||||
|
|
||||||
export const router = createRouter({
|
export const router = createRouter({
|
||||||
history: createWebHistory(),
|
history: createWebHistory(),
|
||||||
@@ -17,7 +18,8 @@ export const router = createRouter({
|
|||||||
{ path: '/habitats/:id', component: HabitatDetail },
|
{ path: '/habitats/:id', component: HabitatDetail },
|
||||||
{ path: '/items', component: ItemsList },
|
{ path: '/items', component: ItemsList },
|
||||||
{ path: '/items/:id', component: ItemDetail },
|
{ path: '/items/:id', component: ItemDetail },
|
||||||
{ path: '/recipes/:id', component: RecipeDetail }
|
{ path: '/recipes/:id', component: RecipeDetail },
|
||||||
|
{ path: '/admin', component: AdminView }
|
||||||
],
|
],
|
||||||
scrollBehavior: () => ({ top: 0 })
|
scrollBehavior: () => ({ top: 0 })
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -79,7 +79,57 @@ export interface Options {
|
|||||||
favoriteThings: NamedEntity[];
|
favoriteThings: NamedEntity[];
|
||||||
itemCategories: NamedEntity[];
|
itemCategories: NamedEntity[];
|
||||||
itemUsages: NamedEntity[];
|
itemUsages: NamedEntity[];
|
||||||
|
acquisitionMethods: NamedEntity[];
|
||||||
itemTags: 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, string | number | undefined>): string {
|
export function buildQuery(params: Record<string, string | number | undefined>): string {
|
||||||
@@ -105,16 +155,63 @@ async function getJson<T>(path: string): Promise<T> {
|
|||||||
return response.json() as Promise<T>;
|
return response.json() as Promise<T>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function sendJson<T>(path: string, method: 'POST' | 'PUT', body: unknown): Promise<T> {
|
||||||
|
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<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteJson(path: string): Promise<void> {
|
||||||
|
const response = await fetch(`${apiBaseUrl}${path}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Request failed with ${response.status}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const api = {
|
export const api = {
|
||||||
options: () => getJson<Options>('/api/options'),
|
options: () => getJson<Options>('/api/options'),
|
||||||
|
config: (type: ConfigType) => getJson<Array<Skill | NamedEntity>>(`/api/admin/config/${type}`),
|
||||||
|
createConfig: (type: ConfigType, payload: { name: string; subcategory?: string | null }) =>
|
||||||
|
sendJson<Skill | NamedEntity>(`/api/admin/config/${type}`, 'POST', payload),
|
||||||
|
updateConfig: (type: ConfigType, id: number, payload: { name: string; subcategory?: string | null }) =>
|
||||||
|
sendJson<Skill | NamedEntity>(`/api/admin/config/${type}/${id}`, 'PUT', payload),
|
||||||
|
deleteConfig: (type: ConfigType, id: number) => deleteJson(`/api/admin/config/${type}/${id}`),
|
||||||
pokemon: (params: Record<string, string | number | undefined>) =>
|
pokemon: (params: Record<string, string | number | undefined>) =>
|
||||||
getJson<Pokemon[]>(`/api/pokemon${buildQuery(params)}`),
|
getJson<Pokemon[]>(`/api/pokemon${buildQuery(params)}`),
|
||||||
pokemonDetail: (id: string | number) => getJson<PokemonDetail>(`/api/pokemon/${id}`),
|
pokemonDetail: (id: string | number) => getJson<PokemonDetail>(`/api/pokemon/${id}`),
|
||||||
|
createPokemon: (payload: PokemonPayload) => sendJson<PokemonDetail>('/api/pokemon', 'POST', payload),
|
||||||
|
updatePokemon: (id: string | number, payload: PokemonPayload) =>
|
||||||
|
sendJson<PokemonDetail>(`/api/pokemon/${id}`, 'PUT', payload),
|
||||||
|
deletePokemon: (id: string | number) => deleteJson(`/api/pokemon/${id}`),
|
||||||
habitats: () => getJson<Habitat[]>('/api/habitats'),
|
habitats: () => getJson<Habitat[]>('/api/habitats'),
|
||||||
habitatDetail: (id: string | number) => getJson<HabitatDetail>(`/api/habitats/${id}`),
|
habitatDetail: (id: string | number) => getJson<HabitatDetail>(`/api/habitats/${id}`),
|
||||||
|
createHabitat: (payload: HabitatPayload) => sendJson<HabitatDetail>('/api/habitats', 'POST', payload),
|
||||||
|
updateHabitat: (id: string | number, payload: HabitatPayload) =>
|
||||||
|
sendJson<HabitatDetail>(`/api/habitats/${id}`, 'PUT', payload),
|
||||||
|
deleteHabitat: (id: string | number) => deleteJson(`/api/habitats/${id}`),
|
||||||
items: (params: Record<string, string | number | undefined>) =>
|
items: (params: Record<string, string | number | undefined>) =>
|
||||||
getJson<Item[]>(`/api/items${buildQuery(params)}`),
|
getJson<Item[]>(`/api/items${buildQuery(params)}`),
|
||||||
itemDetail: (id: string | number) => getJson<ItemDetail>(`/api/items/${id}`),
|
itemDetail: (id: string | number) => getJson<ItemDetail>(`/api/items/${id}`),
|
||||||
|
createItem: (payload: ItemPayload) => sendJson<ItemDetail>('/api/items', 'POST', payload),
|
||||||
|
updateItem: (id: string | number, payload: ItemPayload) => sendJson<ItemDetail>(`/api/items/${id}`, 'PUT', payload),
|
||||||
|
deleteItem: (id: string | number) => deleteJson(`/api/items/${id}`),
|
||||||
recipes: () => getJson<Recipe[]>('/api/recipes'),
|
recipes: () => getJson<Recipe[]>('/api/recipes'),
|
||||||
recipeDetail: (id: string | number) => getJson<RecipeDetail>(`/api/recipes/${id}`)
|
recipeDetail: (id: string | number) => getJson<RecipeDetail>(`/api/recipes/${id}`),
|
||||||
|
createRecipe: (payload: RecipePayload) => sendJson<RecipeDetail>('/api/recipes', 'POST', payload),
|
||||||
|
updateRecipe: (id: string | number, payload: RecipePayload) =>
|
||||||
|
sendJson<RecipeDetail>(`/api/recipes/${id}`, 'PUT', payload),
|
||||||
|
deleteRecipe: (id: string | number) => deleteJson(`/api/recipes/${id}`)
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -283,6 +283,76 @@ select {
|
|||||||
font-weight: 800;
|
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) {
|
@media (max-width: 760px) {
|
||||||
.topbar {
|
.topbar {
|
||||||
align-items: start;
|
align-items: start;
|
||||||
@@ -295,7 +365,12 @@ select {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.toolbar,
|
.toolbar,
|
||||||
.detail-grid {
|
.detail-grid,
|
||||||
|
.admin-layout {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.appearance-row {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
663
frontend/src/views/AdminView.vue
Normal file
663
frontend/src/views/AdminView.vue
Normal file
@@ -0,0 +1,663 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref } from 'vue';
|
||||||
|
import {
|
||||||
|
api,
|
||||||
|
type ConfigType,
|
||||||
|
type Habitat,
|
||||||
|
type HabitatPayload,
|
||||||
|
type Item,
|
||||||
|
type ItemPayload,
|
||||||
|
type NamedEntity,
|
||||||
|
type Options,
|
||||||
|
type Pokemon,
|
||||||
|
type PokemonPayload,
|
||||||
|
type Recipe,
|
||||||
|
type RecipePayload,
|
||||||
|
type Skill
|
||||||
|
} from '../services/api';
|
||||||
|
|
||||||
|
type AdminTab = 'config' | 'pokemon' | 'items' | 'recipes' | 'habitats';
|
||||||
|
type EditableConfig = (NamedEntity | Skill) & { subcategory?: string | null };
|
||||||
|
|
||||||
|
const tabs: Array<{ key: AdminTab; label: string }> = [
|
||||||
|
{ key: 'config', label: '系统配置' },
|
||||||
|
{ key: 'pokemon', label: 'Pokemon' },
|
||||||
|
{ key: 'items', label: '物品' },
|
||||||
|
{ key: 'recipes', label: '材料单' },
|
||||||
|
{ key: 'habitats', label: '栖息地' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const configTypes: Array<{ key: ConfigType; label: string; hasSubcategory?: boolean }> = [
|
||||||
|
{ key: 'skills', label: '特长', hasSubcategory: true },
|
||||||
|
{ key: 'environments', label: '喜欢的环境' },
|
||||||
|
{ key: 'favorite-things', label: '喜欢的东西' },
|
||||||
|
{ key: 'item-categories', label: '物品 / 材料单分类' },
|
||||||
|
{ key: 'item-usages', label: '物品 / 材料单用途' },
|
||||||
|
{ key: 'acquisition-methods', label: '入手方式' },
|
||||||
|
{ key: 'item-tags', label: '物品标签' },
|
||||||
|
{ key: 'maps', label: '地图' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const activeTab = ref<AdminTab>('config');
|
||||||
|
const activeConfigType = ref<ConfigType>('skills');
|
||||||
|
const options = ref<Options | null>(null);
|
||||||
|
const configRows = ref<EditableConfig[]>([]);
|
||||||
|
const pokemonRows = ref<Pokemon[]>([]);
|
||||||
|
const itemRows = ref<Item[]>([]);
|
||||||
|
const recipeRows = ref<Recipe[]>([]);
|
||||||
|
const habitatRows = ref<Habitat[]>([]);
|
||||||
|
const busy = ref(false);
|
||||||
|
const message = ref('');
|
||||||
|
|
||||||
|
const configForm = ref({ id: 0, name: '', subcategory: '' });
|
||||||
|
const pokemonForm = ref({ id: '', name: '', environmentId: '', skillIds: [] as string[], favoriteThingIds: [] as string[] });
|
||||||
|
const itemForm = ref({
|
||||||
|
id: 0,
|
||||||
|
name: '',
|
||||||
|
categoryId: '',
|
||||||
|
usageId: '',
|
||||||
|
recipeId: '',
|
||||||
|
dyeable: false,
|
||||||
|
dualDyeable: false,
|
||||||
|
patternEditable: false,
|
||||||
|
acquisitionMethodIds: [] as string[],
|
||||||
|
tagIds: [] as string[]
|
||||||
|
});
|
||||||
|
const recipeForm = ref({
|
||||||
|
id: 0,
|
||||||
|
name: '',
|
||||||
|
acquisitionMethodIds: [] as string[],
|
||||||
|
materials: [] as Array<{ itemId: string; quantity: number }>
|
||||||
|
});
|
||||||
|
const habitatForm = ref({
|
||||||
|
id: 0,
|
||||||
|
name: '',
|
||||||
|
recipeItems: [] as Array<{ itemId: string; quantity: number }>,
|
||||||
|
pokemonAppearances: [] as Array<{ pokemonId: string; mapId: string; timeOfDay: string; weather: string; rarity: number }>
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectedConfig = computed(() => configTypes.find((item) => item.key === activeConfigType.value) ?? configTypes[0]);
|
||||||
|
|
||||||
|
function toIds(values: string[]): number[] {
|
||||||
|
return values.map(Number).filter((item) => Number.isInteger(item) && item > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toQuantityRows(rows: Array<{ itemId: string; quantity: number }>) {
|
||||||
|
return rows
|
||||||
|
.map((item) => ({ itemId: Number(item.itemId), quantity: Number(item.quantity) }))
|
||||||
|
.filter((item) => Number.isInteger(item.itemId) && item.itemId > 0 && Number.isInteger(item.quantity) && item.quantity > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run(action: () => Promise<void>) {
|
||||||
|
busy.value = true;
|
||||||
|
message.value = '';
|
||||||
|
try {
|
||||||
|
await action();
|
||||||
|
} catch {
|
||||||
|
message.value = '操作失败';
|
||||||
|
} finally {
|
||||||
|
busy.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadOptions() {
|
||||||
|
options.value = await api.options();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadConfig() {
|
||||||
|
configRows.value = (await api.config(activeConfigType.value)) as EditableConfig[];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPokemon() {
|
||||||
|
pokemonRows.value = await api.pokemon({});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadItems() {
|
||||||
|
itemRows.value = await api.items({});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadRecipes() {
|
||||||
|
recipeRows.value = await api.recipes();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadHabitats() {
|
||||||
|
habitatRows.value = await api.habitats();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCurrentTab() {
|
||||||
|
await loadOptions();
|
||||||
|
if (activeTab.value === 'config') await loadConfig();
|
||||||
|
if (activeTab.value === 'pokemon') await loadPokemon();
|
||||||
|
if (activeTab.value === 'items') {
|
||||||
|
await Promise.all([loadItems(), loadRecipes()]);
|
||||||
|
}
|
||||||
|
if (activeTab.value === 'recipes') {
|
||||||
|
await Promise.all([loadRecipes(), loadItems()]);
|
||||||
|
}
|
||||||
|
if (activeTab.value === 'habitats') {
|
||||||
|
await Promise.all([loadHabitats(), loadPokemon(), loadItems()]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setTab(tab: AdminTab) {
|
||||||
|
activeTab.value = tab;
|
||||||
|
void run(loadCurrentTab);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetConfigForm() {
|
||||||
|
configForm.value = { id: 0, name: '', subcategory: '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
function editConfig(item: EditableConfig) {
|
||||||
|
configForm.value = { id: item.id, name: item.name, subcategory: item.subcategory ?? '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveConfig() {
|
||||||
|
await run(async () => {
|
||||||
|
const payload = { name: configForm.value.name, subcategory: configForm.value.subcategory || null };
|
||||||
|
if (configForm.value.id) {
|
||||||
|
await api.updateConfig(activeConfigType.value, configForm.value.id, payload);
|
||||||
|
} else {
|
||||||
|
await api.createConfig(activeConfigType.value, payload);
|
||||||
|
}
|
||||||
|
resetConfigForm();
|
||||||
|
await loadCurrentTab();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeConfig(id: number) {
|
||||||
|
await run(async () => {
|
||||||
|
await api.deleteConfig(activeConfigType.value, id);
|
||||||
|
await loadCurrentTab();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetPokemonForm() {
|
||||||
|
pokemonForm.value = { id: '', name: '', environmentId: '', skillIds: [], favoriteThingIds: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
function editPokemon(item: Pokemon) {
|
||||||
|
pokemonForm.value = {
|
||||||
|
id: String(item.id),
|
||||||
|
name: item.name,
|
||||||
|
environmentId: String(item.environment.id),
|
||||||
|
skillIds: item.skills.map((skill) => String(skill.id)),
|
||||||
|
favoriteThingIds: item.favorite_things.map((thing) => String(thing.id))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function savePokemon() {
|
||||||
|
await run(async () => {
|
||||||
|
const payload: PokemonPayload = {
|
||||||
|
id: Number(pokemonForm.value.id),
|
||||||
|
name: pokemonForm.value.name,
|
||||||
|
environmentId: Number(pokemonForm.value.environmentId),
|
||||||
|
skillIds: toIds(pokemonForm.value.skillIds.slice(0, 2)),
|
||||||
|
favoriteThingIds: toIds(pokemonForm.value.favoriteThingIds.slice(0, 6))
|
||||||
|
};
|
||||||
|
const exists = pokemonRows.value.some((item) => item.id === payload.id);
|
||||||
|
if (exists) {
|
||||||
|
await api.updatePokemon(payload.id, payload);
|
||||||
|
} else {
|
||||||
|
await api.createPokemon(payload);
|
||||||
|
}
|
||||||
|
resetPokemonForm();
|
||||||
|
await loadPokemon();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removePokemon(id: number) {
|
||||||
|
await run(async () => {
|
||||||
|
await api.deletePokemon(id);
|
||||||
|
await loadPokemon();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetItemForm() {
|
||||||
|
itemForm.value = {
|
||||||
|
id: 0,
|
||||||
|
name: '',
|
||||||
|
categoryId: '',
|
||||||
|
usageId: '',
|
||||||
|
recipeId: '',
|
||||||
|
dyeable: false,
|
||||||
|
dualDyeable: false,
|
||||||
|
patternEditable: false,
|
||||||
|
acquisitionMethodIds: [],
|
||||||
|
tagIds: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function editItem(item: Item) {
|
||||||
|
await run(async () => {
|
||||||
|
const detail = await api.itemDetail(item.id);
|
||||||
|
itemForm.value = {
|
||||||
|
id: detail.id,
|
||||||
|
name: detail.name,
|
||||||
|
categoryId: String(detail.category.id),
|
||||||
|
usageId: String(detail.usage.id),
|
||||||
|
recipeId: detail.recipe ? String(detail.recipe.id) : '',
|
||||||
|
dyeable: detail.customization.dyeable,
|
||||||
|
dualDyeable: detail.customization.dualDyeable,
|
||||||
|
patternEditable: detail.customization.patternEditable,
|
||||||
|
acquisitionMethodIds: detail.acquisitionMethods.map((method) => String(method.id)),
|
||||||
|
tagIds: detail.tags.map((tag) => String(tag.id))
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveItem() {
|
||||||
|
await run(async () => {
|
||||||
|
const payload: ItemPayload = {
|
||||||
|
name: itemForm.value.name,
|
||||||
|
categoryId: Number(itemForm.value.categoryId),
|
||||||
|
usageId: Number(itemForm.value.usageId),
|
||||||
|
recipeId: itemForm.value.recipeId ? Number(itemForm.value.recipeId) : null,
|
||||||
|
dyeable: itemForm.value.dyeable,
|
||||||
|
dualDyeable: itemForm.value.dualDyeable,
|
||||||
|
patternEditable: itemForm.value.patternEditable,
|
||||||
|
acquisitionMethodIds: toIds(itemForm.value.acquisitionMethodIds),
|
||||||
|
tagIds: toIds(itemForm.value.tagIds)
|
||||||
|
};
|
||||||
|
if (itemForm.value.id) {
|
||||||
|
await api.updateItem(itemForm.value.id, payload);
|
||||||
|
} else {
|
||||||
|
await api.createItem(payload);
|
||||||
|
}
|
||||||
|
resetItemForm();
|
||||||
|
await loadItems();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeItem(id: number) {
|
||||||
|
await run(async () => {
|
||||||
|
await api.deleteItem(id);
|
||||||
|
await loadItems();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetRecipeForm() {
|
||||||
|
recipeForm.value = { id: 0, name: '', acquisitionMethodIds: [], materials: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
function addRecipeMaterial() {
|
||||||
|
recipeForm.value.materials.push({ itemId: '', quantity: 1 });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function editRecipe(item: Recipe) {
|
||||||
|
await run(async () => {
|
||||||
|
const detail = await api.recipeDetail(item.id);
|
||||||
|
recipeForm.value = {
|
||||||
|
id: detail.id,
|
||||||
|
name: detail.name,
|
||||||
|
acquisitionMethodIds: detail.acquisition_methods.map((method) => String(method.id)),
|
||||||
|
materials: detail.materials.map((material) => ({ itemId: String(material.id), quantity: material.quantity }))
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveRecipe() {
|
||||||
|
await run(async () => {
|
||||||
|
const payload: RecipePayload = {
|
||||||
|
name: recipeForm.value.name,
|
||||||
|
acquisitionMethodIds: toIds(recipeForm.value.acquisitionMethodIds),
|
||||||
|
materials: toQuantityRows(recipeForm.value.materials)
|
||||||
|
};
|
||||||
|
if (recipeForm.value.id) {
|
||||||
|
await api.updateRecipe(recipeForm.value.id, payload);
|
||||||
|
} else {
|
||||||
|
await api.createRecipe(payload);
|
||||||
|
}
|
||||||
|
resetRecipeForm();
|
||||||
|
await loadRecipes();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeRecipe(id: number) {
|
||||||
|
await run(async () => {
|
||||||
|
await api.deleteRecipe(id);
|
||||||
|
await loadRecipes();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetHabitatForm() {
|
||||||
|
habitatForm.value = { id: 0, name: '', recipeItems: [], pokemonAppearances: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
function addHabitatRecipeItem() {
|
||||||
|
habitatForm.value.recipeItems.push({ itemId: '', quantity: 1 });
|
||||||
|
}
|
||||||
|
|
||||||
|
function addPokemonAppearance() {
|
||||||
|
habitatForm.value.pokemonAppearances.push({
|
||||||
|
pokemonId: '',
|
||||||
|
mapId: '',
|
||||||
|
timeOfDay: '早晨',
|
||||||
|
weather: '晴天',
|
||||||
|
rarity: 1
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function editHabitat(item: Habitat) {
|
||||||
|
await run(async () => {
|
||||||
|
const detail = await api.habitatDetail(item.id);
|
||||||
|
habitatForm.value = {
|
||||||
|
id: detail.id,
|
||||||
|
name: detail.name,
|
||||||
|
recipeItems: detail.recipe.map((recipeItem) => ({ itemId: String(recipeItem.id), quantity: recipeItem.quantity })),
|
||||||
|
pokemonAppearances: detail.pokemon.map((pokemon) => ({
|
||||||
|
pokemonId: String(pokemon.id),
|
||||||
|
mapId: String(pokemon.map.id),
|
||||||
|
timeOfDay: pokemon.time_of_day,
|
||||||
|
weather: pokemon.weather,
|
||||||
|
rarity: pokemon.rarity
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveHabitat() {
|
||||||
|
await run(async () => {
|
||||||
|
const payload: HabitatPayload = {
|
||||||
|
name: habitatForm.value.name,
|
||||||
|
recipeItems: toQuantityRows(habitatForm.value.recipeItems),
|
||||||
|
pokemonAppearances: habitatForm.value.pokemonAppearances
|
||||||
|
.map((item) => ({
|
||||||
|
pokemonId: Number(item.pokemonId),
|
||||||
|
mapId: Number(item.mapId),
|
||||||
|
timeOfDay: item.timeOfDay,
|
||||||
|
weather: item.weather,
|
||||||
|
rarity: Number(item.rarity)
|
||||||
|
}))
|
||||||
|
.filter((item) => item.pokemonId > 0 && item.mapId > 0)
|
||||||
|
};
|
||||||
|
if (habitatForm.value.id) {
|
||||||
|
await api.updateHabitat(habitatForm.value.id, payload);
|
||||||
|
} else {
|
||||||
|
await api.createHabitat(payload);
|
||||||
|
}
|
||||||
|
resetHabitatForm();
|
||||||
|
await loadHabitats();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeHabitat(id: number) {
|
||||||
|
await run(async () => {
|
||||||
|
await api.deleteHabitat(id);
|
||||||
|
await loadHabitats();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
void run(loadCurrentTab);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section>
|
||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
|
<h1 class="page-title">管理</h1>
|
||||||
|
<p class="page-subtitle">维护 Wiki 数据和系统配置。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tabs" role="tablist" aria-label="管理模块">
|
||||||
|
<button v-for="tab in tabs" :key="tab.key" :class="{ active: activeTab === tab.key }" type="button" @click="setTab(tab.key)">
|
||||||
|
{{ tab.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="message" class="status">{{ message }}</p>
|
||||||
|
|
||||||
|
<section v-if="activeTab === 'config'" class="admin-layout">
|
||||||
|
<div class="detail-section">
|
||||||
|
<h2>系统配置</h2>
|
||||||
|
<div class="field">
|
||||||
|
<label for="config-type">类型</label>
|
||||||
|
<select id="config-type" v-model="activeConfigType" @change="run(loadConfig)">
|
||||||
|
<option v-for="item in configTypes" :key="item.key" :value="item.key">{{ item.label }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="config-name">名称</label>
|
||||||
|
<input id="config-name" v-model="configForm.name" />
|
||||||
|
</div>
|
||||||
|
<div v-if="selectedConfig.hasSubcategory" class="field">
|
||||||
|
<label for="config-subcategory">二级分类</label>
|
||||||
|
<input id="config-subcategory" v-model="configForm.subcategory" />
|
||||||
|
</div>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="button" class="link-button" :disabled="busy" @click="saveConfig">保存</button>
|
||||||
|
<button type="button" class="plain-button" @click="resetConfigForm">新建</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="detail-section">
|
||||||
|
<h2>{{ selectedConfig.label }}</h2>
|
||||||
|
<ul class="row-list">
|
||||||
|
<li v-for="item in configRows" :key="item.id">
|
||||||
|
<span>{{ item.name }}<span v-if="item.subcategory"> · {{ item.subcategory }}</span></span>
|
||||||
|
<span class="row-actions">
|
||||||
|
<button type="button" @click="editConfig(item)">编辑</button>
|
||||||
|
<button type="button" @click="removeConfig(item.id)">删除</button>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-if="activeTab === 'pokemon' && options" class="admin-layout">
|
||||||
|
<form class="detail-section" @submit.prevent="savePokemon">
|
||||||
|
<h2>Pokemon</h2>
|
||||||
|
<div class="field"><label for="pokemon-id">ID</label><input id="pokemon-id" v-model="pokemonForm.id" type="number" /></div>
|
||||||
|
<div class="field"><label for="pokemon-name">名字</label><input id="pokemon-name" v-model="pokemonForm.name" /></div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="pokemon-environment">喜欢的环境</label>
|
||||||
|
<select id="pokemon-environment" v-model="pokemonForm.environmentId">
|
||||||
|
<option value="">请选择</option>
|
||||||
|
<option v-for="item in options.environments" :key="item.id" :value="item.id">{{ item.name }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="pokemon-skills">特长</label>
|
||||||
|
<select id="pokemon-skills" v-model="pokemonForm.skillIds" multiple @change="pokemonForm.skillIds = pokemonForm.skillIds.slice(0, 2)">
|
||||||
|
<option v-for="item in options.skills" :key="item.id" :value="String(item.id)">
|
||||||
|
{{ item.name }}{{ item.subcategory ? ` · ${item.subcategory}` : '' }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="pokemon-things">喜欢的东西</label>
|
||||||
|
<select id="pokemon-things" v-model="pokemonForm.favoriteThingIds" multiple @change="pokemonForm.favoriteThingIds = pokemonForm.favoriteThingIds.slice(0, 6)">
|
||||||
|
<option v-for="item in options.favoriteThings" :key="item.id" :value="String(item.id)">{{ item.name }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="submit" class="link-button" :disabled="busy">保存</button>
|
||||||
|
<button type="button" class="plain-button" @click="resetPokemonForm">新建</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="detail-section">
|
||||||
|
<h2>Pokemon 列表</h2>
|
||||||
|
<ul class="row-list">
|
||||||
|
<li v-for="item in pokemonRows" :key="item.id">
|
||||||
|
<span>#{{ item.id }} {{ item.name }}</span>
|
||||||
|
<span class="row-actions">
|
||||||
|
<button type="button" @click="editPokemon(item)">编辑</button>
|
||||||
|
<button type="button" @click="removePokemon(item.id)">删除</button>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-if="activeTab === 'items' && options" class="admin-layout">
|
||||||
|
<form class="detail-section" @submit.prevent="saveItem">
|
||||||
|
<h2>物品</h2>
|
||||||
|
<div class="field"><label for="item-name">名称</label><input id="item-name" v-model="itemForm.name" /></div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="item-category">分类</label>
|
||||||
|
<select id="item-category" v-model="itemForm.categoryId">
|
||||||
|
<option value="">请选择</option>
|
||||||
|
<option v-for="item in options.itemCategories" :key="item.id" :value="item.id">{{ item.name }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="item-usage">用途</label>
|
||||||
|
<select id="item-usage" v-model="itemForm.usageId">
|
||||||
|
<option value="">请选择</option>
|
||||||
|
<option v-for="item in options.itemUsages" :key="item.id" :value="item.id">{{ item.name }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="item-recipe">材料单</label>
|
||||||
|
<select id="item-recipe" v-model="itemForm.recipeId">
|
||||||
|
<option value="">无</option>
|
||||||
|
<option v-for="item in recipeRows" :key="item.id" :value="item.id">{{ item.name }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="check-row">
|
||||||
|
<label><input v-model="itemForm.dyeable" type="checkbox" /> 可染色</label>
|
||||||
|
<label><input v-model="itemForm.dualDyeable" type="checkbox" /> 可双区染色</label>
|
||||||
|
<label><input v-model="itemForm.patternEditable" type="checkbox" /> 可改花纹</label>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="item-methods">入手方式</label>
|
||||||
|
<select id="item-methods" v-model="itemForm.acquisitionMethodIds" multiple>
|
||||||
|
<option v-for="item in options.acquisitionMethods" :key="item.id" :value="String(item.id)">{{ item.name }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="item-tags">标签</label>
|
||||||
|
<select id="item-tags" v-model="itemForm.tagIds" multiple>
|
||||||
|
<option v-for="item in options.itemTags" :key="item.id" :value="String(item.id)">{{ item.name }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="submit" class="link-button" :disabled="busy">保存</button>
|
||||||
|
<button type="button" class="plain-button" @click="resetItemForm">新建</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="detail-section">
|
||||||
|
<h2>物品列表</h2>
|
||||||
|
<ul class="row-list">
|
||||||
|
<li v-for="item in itemRows" :key="item.id">
|
||||||
|
<span>{{ item.name }}</span>
|
||||||
|
<span class="row-actions">
|
||||||
|
<button type="button" @click="editItem(item)">编辑</button>
|
||||||
|
<button type="button" @click="removeItem(item.id)">删除</button>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-if="activeTab === 'recipes' && options" class="admin-layout">
|
||||||
|
<form class="detail-section" @submit.prevent="saveRecipe">
|
||||||
|
<h2>材料单</h2>
|
||||||
|
<div class="field"><label for="recipe-name">名称</label><input id="recipe-name" v-model="recipeForm.name" /></div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="recipe-methods">入手方式</label>
|
||||||
|
<select id="recipe-methods" v-model="recipeForm.acquisitionMethodIds" multiple>
|
||||||
|
<option v-for="item in options.acquisitionMethods" :key="item.id" :value="String(item.id)">{{ item.name }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>需要材料</label>
|
||||||
|
<div v-for="(row, index) in recipeForm.materials" :key="index" class="inline-row">
|
||||||
|
<select v-model="row.itemId">
|
||||||
|
<option value="">请选择</option>
|
||||||
|
<option v-for="item in itemRows" :key="item.id" :value="String(item.id)">{{ item.name }}</option>
|
||||||
|
</select>
|
||||||
|
<input v-model.number="row.quantity" type="number" min="1" />
|
||||||
|
<button type="button" @click="recipeForm.materials.splice(index, 1)">删除</button>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="plain-button" @click="addRecipeMaterial">添加材料</button>
|
||||||
|
</div>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="submit" class="link-button" :disabled="busy">保存</button>
|
||||||
|
<button type="button" class="plain-button" @click="resetRecipeForm">新建</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="detail-section">
|
||||||
|
<h2>材料单列表</h2>
|
||||||
|
<ul class="row-list">
|
||||||
|
<li v-for="item in recipeRows" :key="item.id">
|
||||||
|
<span>{{ item.name }}</span>
|
||||||
|
<span class="row-actions">
|
||||||
|
<button type="button" @click="editRecipe(item)">编辑</button>
|
||||||
|
<button type="button" @click="removeRecipe(item.id)">删除</button>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-if="activeTab === 'habitats' && options" class="admin-layout">
|
||||||
|
<form class="detail-section" @submit.prevent="saveHabitat">
|
||||||
|
<h2>栖息地</h2>
|
||||||
|
<div class="field"><label for="habitat-name">名称</label><input id="habitat-name" v-model="habitatForm.name" /></div>
|
||||||
|
<div class="field">
|
||||||
|
<label>配方</label>
|
||||||
|
<div v-for="(row, index) in habitatForm.recipeItems" :key="index" class="inline-row">
|
||||||
|
<select v-model="row.itemId">
|
||||||
|
<option value="">请选择</option>
|
||||||
|
<option v-for="item in itemRows" :key="item.id" :value="String(item.id)">{{ item.name }}</option>
|
||||||
|
</select>
|
||||||
|
<input v-model.number="row.quantity" type="number" min="1" />
|
||||||
|
<button type="button" @click="habitatForm.recipeItems.splice(index, 1)">删除</button>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="plain-button" @click="addHabitatRecipeItem">添加物品</button>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>可出现的宝可梦</label>
|
||||||
|
<div v-for="(row, index) in habitatForm.pokemonAppearances" :key="index" class="appearance-row">
|
||||||
|
<select v-model="row.pokemonId">
|
||||||
|
<option value="">Pokemon</option>
|
||||||
|
<option v-for="item in pokemonRows" :key="item.id" :value="String(item.id)">#{{ item.id }} {{ item.name }}</option>
|
||||||
|
</select>
|
||||||
|
<select v-model="row.mapId">
|
||||||
|
<option value="">地图</option>
|
||||||
|
<option v-for="item in options.maps" :key="item.id" :value="String(item.id)">{{ item.name }}</option>
|
||||||
|
</select>
|
||||||
|
<select v-model="row.timeOfDay">
|
||||||
|
<option>早晨</option>
|
||||||
|
<option>中午</option>
|
||||||
|
<option>傍晚</option>
|
||||||
|
<option>晚上</option>
|
||||||
|
</select>
|
||||||
|
<select v-model="row.weather">
|
||||||
|
<option>晴天</option>
|
||||||
|
<option>阴天</option>
|
||||||
|
<option>雨天</option>
|
||||||
|
</select>
|
||||||
|
<input v-model.number="row.rarity" type="number" min="1" max="3" />
|
||||||
|
<button type="button" @click="habitatForm.pokemonAppearances.splice(index, 1)">删除</button>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="plain-button" @click="addPokemonAppearance">添加 Pokemon</button>
|
||||||
|
</div>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="submit" class="link-button" :disabled="busy">保存</button>
|
||||||
|
<button type="button" class="plain-button" @click="resetHabitatForm">新建</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="detail-section">
|
||||||
|
<h2>栖息地列表</h2>
|
||||||
|
<ul class="row-list">
|
||||||
|
<li v-for="item in habitatRows" :key="item.id">
|
||||||
|
<span>{{ item.name }}</span>
|
||||||
|
<span class="row-actions">
|
||||||
|
<button type="button" @click="editHabitat(item)">编辑</button>
|
||||||
|
<button type="button" @click="removeHabitat(item.id)">删除</button>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
Reference in New Issue
Block a user