feat: enhance habitat appearances and item relations
Replace item tags with favorite things to unify entity tagging Allow multiple maps, times, and weathers per habitat appearance Make item usage optional and translate API error messages to Chinese Add .dockerignore files for backend and frontend
This commit is contained in:
4
backend/.dockerignore
Normal file
4
backend/.dockerignore
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
*.log
|
||||||
|
.env
|
||||||
@@ -48,11 +48,6 @@ CREATE TABLE IF NOT EXISTS acquisition_methods (
|
|||||||
name text NOT NULL UNIQUE
|
name text NOT NULL UNIQUE
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS item_tags (
|
|
||||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
|
||||||
name text NOT NULL UNIQUE
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS recipes (
|
CREATE TABLE IF NOT EXISTS recipes (
|
||||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
name text NOT NULL UNIQUE
|
name text NOT NULL UNIQUE
|
||||||
@@ -68,23 +63,28 @@ CREATE TABLE IF NOT EXISTS items (
|
|||||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
name text NOT NULL UNIQUE,
|
name text NOT NULL UNIQUE,
|
||||||
category_id integer NOT NULL REFERENCES item_categories(id),
|
category_id integer NOT NULL REFERENCES item_categories(id),
|
||||||
usage_id integer NOT NULL REFERENCES item_usages(id),
|
usage_id integer REFERENCES item_usages(id),
|
||||||
recipe_id integer REFERENCES recipes(id),
|
recipe_id integer REFERENCES recipes(id),
|
||||||
dyeable boolean NOT NULL DEFAULT false,
|
dyeable boolean NOT NULL DEFAULT false,
|
||||||
dual_dyeable boolean NOT NULL DEFAULT false,
|
dual_dyeable boolean NOT NULL DEFAULT false,
|
||||||
pattern_editable boolean NOT NULL DEFAULT false
|
pattern_editable boolean NOT NULL DEFAULT false
|
||||||
);
|
);
|
||||||
|
|
||||||
|
ALTER TABLE items ALTER COLUMN usage_id DROP NOT NULL;
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS item_item_tags;
|
||||||
|
DROP TABLE IF EXISTS item_tags;
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS item_acquisition_methods (
|
CREATE TABLE IF NOT EXISTS item_acquisition_methods (
|
||||||
item_id integer NOT NULL REFERENCES items(id) ON DELETE CASCADE,
|
item_id integer NOT NULL REFERENCES items(id) ON DELETE CASCADE,
|
||||||
acquisition_method_id integer NOT NULL REFERENCES acquisition_methods(id),
|
acquisition_method_id integer NOT NULL REFERENCES acquisition_methods(id),
|
||||||
PRIMARY KEY (item_id, acquisition_method_id)
|
PRIMARY KEY (item_id, acquisition_method_id)
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS item_item_tags (
|
CREATE TABLE IF NOT EXISTS item_favorite_things (
|
||||||
item_id integer NOT NULL REFERENCES items(id) ON DELETE CASCADE,
|
item_id integer NOT NULL REFERENCES items(id) ON DELETE CASCADE,
|
||||||
item_tag_id integer NOT NULL REFERENCES item_tags(id),
|
favorite_thing_id integer NOT NULL REFERENCES favorite_things(id),
|
||||||
PRIMARY KEY (item_id, item_tag_id)
|
PRIMARY KEY (item_id, favorite_thing_id)
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS recipe_materials (
|
CREATE TABLE IF NOT EXISTS recipe_materials (
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { parseIdList, parseMatchMode, sqlForRelationFilter } from './filter.ts';
|
import { parseIdList, parseMatchMode, sqlForRelationFilter } from './filter.ts';
|
||||||
import { pool, query, queryOne } from './db.ts';
|
import { pool, query, queryOne } from './db.ts';
|
||||||
|
import type { PoolClient } from 'pg';
|
||||||
|
|
||||||
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 DbClient = PoolClient;
|
||||||
|
|
||||||
type ConfigType =
|
type ConfigType =
|
||||||
| 'skills'
|
| 'skills'
|
||||||
@@ -14,7 +15,6 @@ type ConfigType =
|
|||||||
| 'item-categories'
|
| 'item-categories'
|
||||||
| 'item-usages'
|
| 'item-usages'
|
||||||
| 'acquisition-methods'
|
| 'acquisition-methods'
|
||||||
| 'item-tags'
|
|
||||||
| 'maps';
|
| 'maps';
|
||||||
|
|
||||||
type ConfigDefinition = {
|
type ConfigDefinition = {
|
||||||
@@ -40,7 +40,7 @@ type PokemonPayload = {
|
|||||||
type ItemPayload = {
|
type ItemPayload = {
|
||||||
name: string;
|
name: string;
|
||||||
categoryId: number;
|
categoryId: number;
|
||||||
usageId: number;
|
usageId: number | null;
|
||||||
recipeId: number | null;
|
recipeId: number | null;
|
||||||
dyeable: boolean;
|
dyeable: boolean;
|
||||||
dualDyeable: boolean;
|
dualDyeable: boolean;
|
||||||
@@ -67,6 +67,11 @@ type HabitatPayload = {
|
|||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type ValidationError = Error & { statusCode: number };
|
||||||
|
|
||||||
|
const timeOfDays = ['早晨', '中午', '傍晚', '晚上'];
|
||||||
|
const weathers = ['晴天', '阴天', '雨天'];
|
||||||
|
|
||||||
const configDefinitions: Record<ConfigType, ConfigDefinition> = {
|
const configDefinitions: Record<ConfigType, ConfigDefinition> = {
|
||||||
skills: { table: 'skills', select: 'id, name, subcategory', order: 'name, subcategory', hasSubcategory: true },
|
skills: { table: 'skills', select: 'id, name, subcategory', order: 'name, subcategory', hasSubcategory: true },
|
||||||
environments: { table: 'environments', select: 'id, name', order: 'name' },
|
environments: { table: 'environments', select: 'id, name', order: 'name' },
|
||||||
@@ -74,7 +79,6 @@ const configDefinitions: Record<ConfigType, ConfigDefinition> = {
|
|||||||
'item-categories': { table: 'item_categories', 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' },
|
'item-usages': { table: 'item_usages', select: 'id, name', order: 'name' },
|
||||||
'acquisition-methods': { table: 'acquisition_methods', 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' }
|
maps: { table: 'maps', select: 'id, name', order: 'name' }
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -86,17 +90,23 @@ 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 {
|
function validationError(message: string): ValidationError {
|
||||||
|
const error = new Error(message) as ValidationError;
|
||||||
|
error.statusCode = 400;
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requirePositiveInteger(value: unknown, message: string): number {
|
||||||
const numberValue = Number(value);
|
const numberValue = Number(value);
|
||||||
if (!Number.isInteger(numberValue) || numberValue <= 0) {
|
if (!Number.isInteger(numberValue) || numberValue <= 0) {
|
||||||
throw new Error(`${fieldName} is required`);
|
throw validationError(message);
|
||||||
}
|
}
|
||||||
return numberValue;
|
return numberValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
function cleanName(value: unknown): string {
|
function cleanName(value: unknown, message = '请输入名称'): string {
|
||||||
if (typeof value !== 'string' || value.trim() === '') {
|
if (typeof value !== 'string' || value.trim() === '') {
|
||||||
throw new Error('Name is required');
|
throw validationError(message);
|
||||||
}
|
}
|
||||||
return value.trim();
|
return value.trim();
|
||||||
}
|
}
|
||||||
@@ -108,6 +118,10 @@ function cleanIds(value: unknown): number[] {
|
|||||||
return [...new Set(value.map((item) => Number(item)).filter((item) => Number.isInteger(item) && item > 0))];
|
return [...new Set(value.map((item) => Number(item)).filter((item) => Number.isInteger(item) && item > 0))];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cleanIdValues(value: unknown): number[] {
|
||||||
|
return cleanIds(Array.isArray(value) ? value : [value]);
|
||||||
|
}
|
||||||
|
|
||||||
function cleanQuantities(value: unknown): IdQuantity[] {
|
function cleanQuantities(value: unknown): IdQuantity[] {
|
||||||
if (!Array.isArray(value)) {
|
if (!Array.isArray(value)) {
|
||||||
return [];
|
return [];
|
||||||
@@ -124,6 +138,11 @@ function cleanQuantities(value: unknown): IdQuantity[] {
|
|||||||
.filter((item) => Number.isInteger(item.itemId) && item.itemId > 0 && Number.isInteger(item.quantity) && item.quantity > 0);
|
.filter((item) => Number.isInteger(item.itemId) && item.itemId > 0 && Number.isInteger(item.quantity) && item.quantity > 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cleanOptions(value: unknown, allowedValues: string[]): string[] {
|
||||||
|
const values = Array.isArray(value) ? value : [value];
|
||||||
|
return [...new Set(values.map((item) => String(item ?? '')).filter((item) => allowedValues.includes(item)))];
|
||||||
|
}
|
||||||
|
|
||||||
async function withTransaction<T>(callback: (client: DbClient) => Promise<T>): Promise<T> {
|
async function withTransaction<T>(callback: (client: DbClient) => Promise<T>): Promise<T> {
|
||||||
const client = await pool.connect();
|
const client = await pool.connect();
|
||||||
|
|
||||||
@@ -169,7 +188,6 @@ export async function getOptions() {
|
|||||||
itemCategories,
|
itemCategories,
|
||||||
itemUsages,
|
itemUsages,
|
||||||
acquisitionMethods,
|
acquisitionMethods,
|
||||||
itemTags,
|
|
||||||
maps
|
maps
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
query<{ id: number; name: string; subcategory: string | null }>(
|
query<{ id: number; name: string; subcategory: string | null }>(
|
||||||
@@ -180,7 +198,6 @@ export async function getOptions() {
|
|||||||
optionSelect('item_categories'),
|
optionSelect('item_categories'),
|
||||||
optionSelect('item_usages'),
|
optionSelect('item_usages'),
|
||||||
optionSelect('acquisition_methods'),
|
optionSelect('acquisition_methods'),
|
||||||
optionSelect('item_tags'),
|
|
||||||
optionSelect('maps')
|
optionSelect('maps')
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -191,7 +208,7 @@ export async function getOptions() {
|
|||||||
itemCategories,
|
itemCategories,
|
||||||
itemUsages,
|
itemUsages,
|
||||||
acquisitionMethods,
|
acquisitionMethods,
|
||||||
itemTags,
|
itemTags: favoriteThings,
|
||||||
maps
|
maps
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -321,16 +338,16 @@ function cleanPokemonPayload(payload: Record<string, unknown>): PokemonPayload {
|
|||||||
const favoriteThingIds = cleanIds(payload.favoriteThingIds);
|
const favoriteThingIds = cleanIds(payload.favoriteThingIds);
|
||||||
|
|
||||||
if (skillIds.length > 2) {
|
if (skillIds.length > 2) {
|
||||||
throw new Error('Pokemon can have at most 2 skills');
|
throw validationError('特长最多选择 2 个');
|
||||||
}
|
}
|
||||||
if (favoriteThingIds.length > 6) {
|
if (favoriteThingIds.length > 6) {
|
||||||
throw new Error('Pokemon can have at most 6 favorite things');
|
throw validationError('喜欢的东西最多选择 6 个');
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: requirePositiveInteger(payload.id, 'Pokemon ID'),
|
id: requirePositiveInteger(payload.id, '请输入 Pokemon ID'),
|
||||||
name: cleanName(payload.name),
|
name: cleanName(payload.name, '请输入 Pokemon 名字'),
|
||||||
environmentId: requirePositiveInteger(payload.environmentId, 'Environment'),
|
environmentId: requirePositiveInteger(payload.environmentId, '请选择喜欢的环境'),
|
||||||
skillIds,
|
skillIds,
|
||||||
favoriteThingIds
|
favoriteThingIds
|
||||||
};
|
};
|
||||||
@@ -457,33 +474,39 @@ export async function getHabitat(id: number) {
|
|||||||
|
|
||||||
function cleanHabitatPayload(payload: Record<string, unknown>): HabitatPayload {
|
function cleanHabitatPayload(payload: Record<string, unknown>): HabitatPayload {
|
||||||
const appearances = Array.isArray(payload.pokemonAppearances) ? payload.pokemonAppearances : [];
|
const appearances = Array.isArray(payload.pokemonAppearances) ? payload.pokemonAppearances : [];
|
||||||
|
const pokemonAppearances = new Map<string, HabitatPayload['pokemonAppearances'][number]>();
|
||||||
|
|
||||||
|
for (const item of appearances) {
|
||||||
|
const row = item as Record<string, unknown>;
|
||||||
|
const pokemonId = Number(row.pokemonId);
|
||||||
|
const mapIds = cleanIdValues(row.mapIds ?? row.mapId);
|
||||||
|
const selectedTimeOfDays = cleanOptions(row.timeOfDays ?? row.timeOfDay, timeOfDays);
|
||||||
|
const selectedWeathers = cleanOptions(row.weathers ?? row.weather, weathers);
|
||||||
|
const rarity = Number(row.rarity);
|
||||||
|
|
||||||
|
if (!Number.isInteger(pokemonId) || pokemonId <= 0 || !Number.isInteger(rarity) || rarity < 1 || rarity > 3) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const mapId of mapIds) {
|
||||||
|
for (const timeOfDay of selectedTimeOfDays) {
|
||||||
|
for (const weather of selectedWeathers) {
|
||||||
|
pokemonAppearances.set(`${pokemonId}:${mapId}:${timeOfDay}:${weather}`, {
|
||||||
|
pokemonId,
|
||||||
|
mapId,
|
||||||
|
timeOfDay,
|
||||||
|
weather,
|
||||||
|
rarity
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
name: cleanName(payload.name),
|
name: cleanName(payload.name, '请输入栖息地名字'),
|
||||||
recipeItems: cleanQuantities(payload.recipeItems),
|
recipeItems: cleanQuantities(payload.recipeItems),
|
||||||
pokemonAppearances: appearances
|
pokemonAppearances: [...pokemonAppearances.values()]
|
||||||
.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
|
|
||||||
)
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -548,7 +571,7 @@ const itemProjection = `
|
|||||||
i.id,
|
i.id,
|
||||||
i.name,
|
i.name,
|
||||||
json_build_object('id', c.id, 'name', c.name) AS category,
|
json_build_object('id', c.id, 'name', c.name) AS category,
|
||||||
json_build_object('id', u.id, 'name', u.name) AS usage,
|
CASE WHEN u.id IS NULL THEN NULL ELSE json_build_object('id', u.id, 'name', u.name) END AS usage,
|
||||||
json_build_object(
|
json_build_object(
|
||||||
'dyeable', i.dyeable,
|
'dyeable', i.dyeable,
|
||||||
'dualDyeable', i.dual_dyeable,
|
'dualDyeable', i.dual_dyeable,
|
||||||
@@ -556,13 +579,13 @@ const itemProjection = `
|
|||||||
) AS customization,
|
) AS customization,
|
||||||
COALESCE((
|
COALESCE((
|
||||||
SELECT json_agg(json_build_object('id', t.id, 'name', t.name) ORDER BY t.name)
|
SELECT json_agg(json_build_object('id', t.id, 'name', t.name) ORDER BY t.name)
|
||||||
FROM item_item_tags iit
|
FROM item_favorite_things ift
|
||||||
JOIN item_tags t ON t.id = iit.item_tag_id
|
JOIN favorite_things t ON t.id = ift.favorite_thing_id
|
||||||
WHERE iit.item_id = i.id
|
WHERE ift.item_id = i.id
|
||||||
), '[]'::json) AS tags
|
), '[]'::json) AS tags
|
||||||
FROM items i
|
FROM items i
|
||||||
JOIN item_categories c ON c.id = i.category_id
|
JOIN item_categories c ON c.id = i.category_id
|
||||||
JOIN item_usages u ON u.id = i.usage_id
|
LEFT JOIN item_usages u ON u.id = i.usage_id
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export async function listItems(paramsQuery: QueryParams) {
|
export async function listItems(paramsQuery: QueryParams) {
|
||||||
@@ -591,9 +614,9 @@ export async function listItems(paramsQuery: QueryParams) {
|
|||||||
const tagFilter = sqlForRelationFilter(
|
const tagFilter = sqlForRelationFilter(
|
||||||
tagIds,
|
tagIds,
|
||||||
'any',
|
'any',
|
||||||
'item_item_tags',
|
'item_favorite_things',
|
||||||
'item_id',
|
'item_id',
|
||||||
'item_tag_id',
|
'favorite_thing_id',
|
||||||
'i.id',
|
'i.id',
|
||||||
params
|
params
|
||||||
);
|
);
|
||||||
@@ -663,12 +686,15 @@ export async function getItem(id: number) {
|
|||||||
function cleanItemPayload(payload: Record<string, unknown>): ItemPayload {
|
function cleanItemPayload(payload: Record<string, unknown>): ItemPayload {
|
||||||
const recipeId = payload.recipeId === null || payload.recipeId === '' || payload.recipeId === undefined
|
const recipeId = payload.recipeId === null || payload.recipeId === '' || payload.recipeId === undefined
|
||||||
? null
|
? null
|
||||||
: requirePositiveInteger(payload.recipeId, 'Recipe');
|
: requirePositiveInteger(payload.recipeId, '请选择材料单');
|
||||||
|
const usageId = payload.usageId === null || payload.usageId === '' || payload.usageId === undefined
|
||||||
|
? null
|
||||||
|
: requirePositiveInteger(payload.usageId, '请选择用途');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
name: cleanName(payload.name),
|
name: cleanName(payload.name, '请输入物品名字'),
|
||||||
categoryId: requirePositiveInteger(payload.categoryId, 'Category'),
|
categoryId: requirePositiveInteger(payload.categoryId, '请选择分类'),
|
||||||
usageId: requirePositiveInteger(payload.usageId, 'Usage'),
|
usageId,
|
||||||
recipeId,
|
recipeId,
|
||||||
dyeable: Boolean(payload.dyeable),
|
dyeable: Boolean(payload.dyeable),
|
||||||
dualDyeable: Boolean(payload.dualDyeable),
|
dualDyeable: Boolean(payload.dualDyeable),
|
||||||
@@ -680,7 +706,7 @@ function cleanItemPayload(payload: Record<string, unknown>): ItemPayload {
|
|||||||
|
|
||||||
async function replaceItemRelations(client: DbClient, itemId: number, payload: ItemPayload): Promise<void> {
|
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_acquisition_methods WHERE item_id = $1', [itemId]);
|
||||||
await client.query('DELETE FROM item_item_tags WHERE item_id = $1', [itemId]);
|
await client.query('DELETE FROM item_favorite_things WHERE item_id = $1', [itemId]);
|
||||||
|
|
||||||
for (const methodId of payload.acquisitionMethodIds) {
|
for (const methodId of payload.acquisitionMethodIds) {
|
||||||
await client.query('INSERT INTO item_acquisition_methods (item_id, acquisition_method_id) VALUES ($1, $2)', [
|
await client.query('INSERT INTO item_acquisition_methods (item_id, acquisition_method_id) VALUES ($1, $2)', [
|
||||||
@@ -690,7 +716,10 @@ async function replaceItemRelations(client: DbClient, itemId: number, payload: I
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const tagId of payload.tagIds) {
|
for (const tagId of payload.tagIds) {
|
||||||
await client.query('INSERT INTO item_item_tags (item_id, item_tag_id) VALUES ($1, $2)', [itemId, tagId]);
|
await client.query('INSERT INTO item_favorite_things (item_id, favorite_thing_id) VALUES ($1, $2)', [
|
||||||
|
itemId,
|
||||||
|
tagId
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -805,7 +834,7 @@ export async function getRecipe(id: number) {
|
|||||||
|
|
||||||
function cleanRecipePayload(payload: Record<string, unknown>): RecipePayload {
|
function cleanRecipePayload(payload: Record<string, unknown>): RecipePayload {
|
||||||
return {
|
return {
|
||||||
name: cleanName(payload.name),
|
name: cleanName(payload.name, '请输入材料单名字'),
|
||||||
acquisitionMethodIds: cleanIds(payload.acquisitionMethodIds),
|
acquisitionMethodIds: cleanIds(payload.acquisitionMethodIds),
|
||||||
materials: cleanQuantities(payload.materials)
|
materials: cleanQuantities(payload.materials)
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ const app = Fastify({
|
|||||||
});
|
});
|
||||||
|
|
||||||
await app.register(cors, {
|
await app.register(cors, {
|
||||||
|
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
|
||||||
origin: process.env.FRONTEND_ORIGIN ?? true
|
origin: process.env.FRONTEND_ORIGIN ?? true
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -42,15 +43,15 @@ app.setErrorHandler(async (error, _request, reply) => {
|
|||||||
const pgError = error as Error & { code?: string; constraint?: string; detail?: string; statusCode?: number };
|
const pgError = error as Error & { code?: string; constraint?: string; detail?: string; statusCode?: number };
|
||||||
|
|
||||||
if (pgError.code === '23503') {
|
if (pgError.code === '23503') {
|
||||||
return reply.code(409).send({ message: 'Referenced data is missing or this item is in use' });
|
return reply.code(409).send({ message: '引用的数据不存在,或当前记录正在被使用' });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pgError.code === '23505') {
|
if (pgError.code === '23505') {
|
||||||
return reply.code(409).send({ message: 'A record with the same value already exists' });
|
return reply.code(409).send({ message: '同名或相同 ID 的记录已存在' });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pgError.code === '23514') {
|
if (pgError.code === '23514') {
|
||||||
return reply.code(400).send({ message: 'Invalid field value' });
|
return reply.code(400).send({ message: '字段值不合法' });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pgError.statusCode && pgError.statusCode < 500) {
|
if (pgError.statusCode && pgError.statusCode < 500) {
|
||||||
@@ -58,7 +59,7 @@ app.setErrorHandler(async (error, _request, reply) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
app.log.error(error);
|
app.log.error(error);
|
||||||
return reply.code(500).send({ message: 'Server error' });
|
return reply.code(500).send({ message: '服务器错误' });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/health', async () => ({ ok: true }));
|
app.get('/health', async () => ({ ok: true }));
|
||||||
|
|||||||
4
frontend/.dockerignore
Normal file
4
frontend/.dockerignore
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
*.log
|
||||||
|
.env
|
||||||
@@ -48,7 +48,7 @@ export interface Item {
|
|||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
category: NamedEntity;
|
category: NamedEntity;
|
||||||
usage: NamedEntity;
|
usage: NamedEntity | null;
|
||||||
customization: {
|
customization: {
|
||||||
dyeable: boolean;
|
dyeable: boolean;
|
||||||
dualDyeable: boolean;
|
dualDyeable: boolean;
|
||||||
@@ -91,7 +91,6 @@ export type ConfigType =
|
|||||||
| 'item-categories'
|
| 'item-categories'
|
||||||
| 'item-usages'
|
| 'item-usages'
|
||||||
| 'acquisition-methods'
|
| 'acquisition-methods'
|
||||||
| 'item-tags'
|
|
||||||
| 'maps';
|
| 'maps';
|
||||||
|
|
||||||
export interface PokemonPayload {
|
export interface PokemonPayload {
|
||||||
@@ -105,7 +104,7 @@ export interface PokemonPayload {
|
|||||||
export interface ItemPayload {
|
export interface ItemPayload {
|
||||||
name: string;
|
name: string;
|
||||||
categoryId: number;
|
categoryId: number;
|
||||||
usageId: number;
|
usageId: number | null;
|
||||||
recipeId: number | null;
|
recipeId: number | null;
|
||||||
dyeable: boolean;
|
dyeable: boolean;
|
||||||
dualDyeable: boolean;
|
dualDyeable: boolean;
|
||||||
@@ -125,9 +124,9 @@ export interface HabitatPayload {
|
|||||||
recipeItems: Array<{ itemId: number; quantity: number }>;
|
recipeItems: Array<{ itemId: number; quantity: number }>;
|
||||||
pokemonAppearances: Array<{
|
pokemonAppearances: Array<{
|
||||||
pokemonId: number;
|
pokemonId: number;
|
||||||
mapId: number;
|
mapIds: number[];
|
||||||
timeOfDay: string;
|
timeOfDays: string[];
|
||||||
weather: string;
|
weathers: string[];
|
||||||
rarity: number;
|
rarity: number;
|
||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
@@ -145,11 +144,24 @@ export function buildQuery(params: Record<string, string | number | undefined>):
|
|||||||
return query ? `?${query}` : '';
|
return query ? `?${query}` : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function getErrorMessage(response: Response): Promise<string> {
|
||||||
|
try {
|
||||||
|
const data = (await response.json()) as { message?: unknown };
|
||||||
|
if (typeof data.message === 'string' && data.message.trim() !== '') {
|
||||||
|
return data.message;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore invalid or empty error bodies and use the status fallback.
|
||||||
|
}
|
||||||
|
|
||||||
|
return `请求失败(${response.status})`;
|
||||||
|
}
|
||||||
|
|
||||||
async function getJson<T>(path: string): Promise<T> {
|
async function getJson<T>(path: string): Promise<T> {
|
||||||
const response = await fetch(`${apiBaseUrl}${path}`);
|
const response = await fetch(`${apiBaseUrl}${path}`);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`Request failed with ${response.status}`);
|
throw new Error(await getErrorMessage(response));
|
||||||
}
|
}
|
||||||
|
|
||||||
return response.json() as Promise<T>;
|
return response.json() as Promise<T>;
|
||||||
@@ -165,7 +177,7 @@ async function sendJson<T>(path: string, method: 'POST' | 'PUT', body: unknown):
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`Request failed with ${response.status}`);
|
throw new Error(await getErrorMessage(response));
|
||||||
}
|
}
|
||||||
|
|
||||||
return response.json() as Promise<T>;
|
return response.json() as Promise<T>;
|
||||||
@@ -177,7 +189,7 @@ async function deleteJson(path: string): Promise<void> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`Request failed with ${response.status}`);
|
throw new Error(await getErrorMessage(response));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
api,
|
api,
|
||||||
type ConfigType,
|
type ConfigType,
|
||||||
type Habitat,
|
type Habitat,
|
||||||
|
type HabitatDetail,
|
||||||
type HabitatPayload,
|
type HabitatPayload,
|
||||||
type Item,
|
type Item,
|
||||||
type ItemPayload,
|
type ItemPayload,
|
||||||
@@ -18,6 +19,13 @@ import {
|
|||||||
|
|
||||||
type AdminTab = 'config' | 'pokemon' | 'items' | 'recipes' | 'habitats';
|
type AdminTab = 'config' | 'pokemon' | 'items' | 'recipes' | 'habitats';
|
||||||
type EditableConfig = (NamedEntity | Skill) & { subcategory?: string | null };
|
type EditableConfig = (NamedEntity | Skill) & { subcategory?: string | null };
|
||||||
|
type HabitatAppearanceForm = {
|
||||||
|
pokemonId: string;
|
||||||
|
mapIds: string[];
|
||||||
|
timeOfDays: string[];
|
||||||
|
weathers: string[];
|
||||||
|
rarity: number;
|
||||||
|
};
|
||||||
|
|
||||||
const tabs: Array<{ key: AdminTab; label: string }> = [
|
const tabs: Array<{ key: AdminTab; label: string }> = [
|
||||||
{ key: 'config', label: '系统配置' },
|
{ key: 'config', label: '系统配置' },
|
||||||
@@ -27,6 +35,9 @@ const tabs: Array<{ key: AdminTab; label: string }> = [
|
|||||||
{ key: 'habitats', label: '栖息地' }
|
{ key: 'habitats', label: '栖息地' }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const timeOfDays = ['早晨', '中午', '傍晚', '晚上'];
|
||||||
|
const weathers = ['晴天', '阴天', '雨天'];
|
||||||
|
|
||||||
const configTypes: Array<{ key: ConfigType; label: string; hasSubcategory?: boolean }> = [
|
const configTypes: Array<{ key: ConfigType; label: string; hasSubcategory?: boolean }> = [
|
||||||
{ key: 'skills', label: '特长', hasSubcategory: true },
|
{ key: 'skills', label: '特长', hasSubcategory: true },
|
||||||
{ key: 'environments', label: '喜欢的环境' },
|
{ key: 'environments', label: '喜欢的环境' },
|
||||||
@@ -34,7 +45,6 @@ const configTypes: Array<{ key: ConfigType; label: string; hasSubcategory?: bool
|
|||||||
{ key: 'item-categories', label: '物品 / 材料单分类' },
|
{ key: 'item-categories', label: '物品 / 材料单分类' },
|
||||||
{ key: 'item-usages', label: '物品 / 材料单用途' },
|
{ key: 'item-usages', label: '物品 / 材料单用途' },
|
||||||
{ key: 'acquisition-methods', label: '入手方式' },
|
{ key: 'acquisition-methods', label: '入手方式' },
|
||||||
{ key: 'item-tags', label: '物品标签' },
|
|
||||||
{ key: 'maps', label: '地图' }
|
{ key: 'maps', label: '地图' }
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -73,7 +83,7 @@ const habitatForm = ref({
|
|||||||
id: 0,
|
id: 0,
|
||||||
name: '',
|
name: '',
|
||||||
recipeItems: [] as Array<{ itemId: string; quantity: number }>,
|
recipeItems: [] as Array<{ itemId: string; quantity: number }>,
|
||||||
pokemonAppearances: [] as Array<{ pokemonId: string; mapId: string; timeOfDay: string; weather: string; rarity: number }>
|
pokemonAppearances: [] as HabitatAppearanceForm[]
|
||||||
});
|
});
|
||||||
|
|
||||||
const selectedConfig = computed(() => configTypes.find((item) => item.key === activeConfigType.value) ?? configTypes[0]);
|
const selectedConfig = computed(() => configTypes.find((item) => item.key === activeConfigType.value) ?? configTypes[0]);
|
||||||
@@ -93,8 +103,8 @@ async function run(action: () => Promise<void>) {
|
|||||||
message.value = '';
|
message.value = '';
|
||||||
try {
|
try {
|
||||||
await action();
|
await action();
|
||||||
} catch {
|
} catch (error) {
|
||||||
message.value = '操作失败';
|
message.value = error instanceof Error && error.message ? error.message : '操作失败';
|
||||||
} finally {
|
} finally {
|
||||||
busy.value = false;
|
busy.value = false;
|
||||||
}
|
}
|
||||||
@@ -235,7 +245,7 @@ async function editItem(item: Item) {
|
|||||||
id: detail.id,
|
id: detail.id,
|
||||||
name: detail.name,
|
name: detail.name,
|
||||||
categoryId: String(detail.category.id),
|
categoryId: String(detail.category.id),
|
||||||
usageId: String(detail.usage.id),
|
usageId: detail.usage ? String(detail.usage.id) : '',
|
||||||
recipeId: detail.recipe ? String(detail.recipe.id) : '',
|
recipeId: detail.recipe ? String(detail.recipe.id) : '',
|
||||||
dyeable: detail.customization.dyeable,
|
dyeable: detail.customization.dyeable,
|
||||||
dualDyeable: detail.customization.dualDyeable,
|
dualDyeable: detail.customization.dualDyeable,
|
||||||
@@ -251,7 +261,7 @@ async function saveItem() {
|
|||||||
const payload: ItemPayload = {
|
const payload: ItemPayload = {
|
||||||
name: itemForm.value.name,
|
name: itemForm.value.name,
|
||||||
categoryId: Number(itemForm.value.categoryId),
|
categoryId: Number(itemForm.value.categoryId),
|
||||||
usageId: Number(itemForm.value.usageId),
|
usageId: itemForm.value.usageId ? Number(itemForm.value.usageId) : null,
|
||||||
recipeId: itemForm.value.recipeId ? Number(itemForm.value.recipeId) : null,
|
recipeId: itemForm.value.recipeId ? Number(itemForm.value.recipeId) : null,
|
||||||
dyeable: itemForm.value.dyeable,
|
dyeable: itemForm.value.dyeable,
|
||||||
dualDyeable: itemForm.value.dualDyeable,
|
dualDyeable: itemForm.value.dualDyeable,
|
||||||
@@ -331,13 +341,36 @@ function addHabitatRecipeItem() {
|
|||||||
function addPokemonAppearance() {
|
function addPokemonAppearance() {
|
||||||
habitatForm.value.pokemonAppearances.push({
|
habitatForm.value.pokemonAppearances.push({
|
||||||
pokemonId: '',
|
pokemonId: '',
|
||||||
mapId: '',
|
mapIds: [],
|
||||||
timeOfDay: '早晨',
|
timeOfDays: ['早晨'],
|
||||||
weather: '晴天',
|
weathers: ['晴天'],
|
||||||
rarity: 1
|
rarity: 1
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function groupPokemonAppearances(detail: HabitatDetail): HabitatAppearanceForm[] {
|
||||||
|
const rows = new Map<string, HabitatAppearanceForm>();
|
||||||
|
|
||||||
|
detail.pokemon.forEach((pokemon) => {
|
||||||
|
const key = `${pokemon.id}:${pokemon.rarity}`;
|
||||||
|
const row = rows.get(key) ?? {
|
||||||
|
pokemonId: String(pokemon.id),
|
||||||
|
mapIds: [],
|
||||||
|
timeOfDays: [],
|
||||||
|
weathers: [],
|
||||||
|
rarity: pokemon.rarity
|
||||||
|
};
|
||||||
|
|
||||||
|
const mapId = String(pokemon.map.id);
|
||||||
|
if (!row.mapIds.includes(mapId)) row.mapIds.push(mapId);
|
||||||
|
if (!row.timeOfDays.includes(pokemon.time_of_day)) row.timeOfDays.push(pokemon.time_of_day);
|
||||||
|
if (!row.weathers.includes(pokemon.weather)) row.weathers.push(pokemon.weather);
|
||||||
|
rows.set(key, row);
|
||||||
|
});
|
||||||
|
|
||||||
|
return [...rows.values()];
|
||||||
|
}
|
||||||
|
|
||||||
async function editHabitat(item: Habitat) {
|
async function editHabitat(item: Habitat) {
|
||||||
await run(async () => {
|
await run(async () => {
|
||||||
const detail = await api.habitatDetail(item.id);
|
const detail = await api.habitatDetail(item.id);
|
||||||
@@ -345,13 +378,7 @@ async function editHabitat(item: Habitat) {
|
|||||||
id: detail.id,
|
id: detail.id,
|
||||||
name: detail.name,
|
name: detail.name,
|
||||||
recipeItems: detail.recipe.map((recipeItem) => ({ itemId: String(recipeItem.id), quantity: recipeItem.quantity })),
|
recipeItems: detail.recipe.map((recipeItem) => ({ itemId: String(recipeItem.id), quantity: recipeItem.quantity })),
|
||||||
pokemonAppearances: detail.pokemon.map((pokemon) => ({
|
pokemonAppearances: groupPokemonAppearances(detail)
|
||||||
pokemonId: String(pokemon.id),
|
|
||||||
mapId: String(pokemon.map.id),
|
|
||||||
timeOfDay: pokemon.time_of_day,
|
|
||||||
weather: pokemon.weather,
|
|
||||||
rarity: pokemon.rarity
|
|
||||||
}))
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -364,12 +391,12 @@ async function saveHabitat() {
|
|||||||
pokemonAppearances: habitatForm.value.pokemonAppearances
|
pokemonAppearances: habitatForm.value.pokemonAppearances
|
||||||
.map((item) => ({
|
.map((item) => ({
|
||||||
pokemonId: Number(item.pokemonId),
|
pokemonId: Number(item.pokemonId),
|
||||||
mapId: Number(item.mapId),
|
mapIds: toIds(item.mapIds),
|
||||||
timeOfDay: item.timeOfDay,
|
timeOfDays: item.timeOfDays.filter((entry) => timeOfDays.includes(entry)),
|
||||||
weather: item.weather,
|
weathers: item.weathers.filter((entry) => weathers.includes(entry)),
|
||||||
rarity: Number(item.rarity)
|
rarity: Number(item.rarity)
|
||||||
}))
|
}))
|
||||||
.filter((item) => item.pokemonId > 0 && item.mapId > 0)
|
.filter((item) => item.pokemonId > 0 && item.mapIds.length > 0 && item.timeOfDays.length > 0 && item.weathers.length > 0)
|
||||||
};
|
};
|
||||||
if (habitatForm.value.id) {
|
if (habitatForm.value.id) {
|
||||||
await api.updateHabitat(habitatForm.value.id, payload);
|
await api.updateHabitat(habitatForm.value.id, payload);
|
||||||
@@ -411,7 +438,7 @@ onMounted(() => {
|
|||||||
<p v-if="message" class="status">{{ message }}</p>
|
<p v-if="message" class="status">{{ message }}</p>
|
||||||
|
|
||||||
<section v-if="activeTab === 'config'" class="admin-layout">
|
<section v-if="activeTab === 'config'" class="admin-layout">
|
||||||
<div class="detail-section">
|
<form class="detail-section" @submit.prevent="saveConfig">
|
||||||
<h2>系统配置</h2>
|
<h2>系统配置</h2>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="config-type">类型</label>
|
<label for="config-type">类型</label>
|
||||||
@@ -428,10 +455,10 @@ onMounted(() => {
|
|||||||
<input id="config-subcategory" v-model="configForm.subcategory" />
|
<input id="config-subcategory" v-model="configForm.subcategory" />
|
||||||
</div>
|
</div>
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button type="button" class="link-button" :disabled="busy" @click="saveConfig">保存</button>
|
<button type="submit" class="link-button" :disabled="busy">保存</button>
|
||||||
<button type="button" class="plain-button" @click="resetConfigForm">新建</button>
|
<button type="button" class="plain-button" @click="resetConfigForm">新建</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</form>
|
||||||
|
|
||||||
<div class="detail-section">
|
<div class="detail-section">
|
||||||
<h2>{{ selectedConfig.label }}</h2>
|
<h2>{{ selectedConfig.label }}</h2>
|
||||||
@@ -507,7 +534,7 @@ onMounted(() => {
|
|||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="item-usage">用途</label>
|
<label for="item-usage">用途</label>
|
||||||
<select id="item-usage" v-model="itemForm.usageId">
|
<select id="item-usage" v-model="itemForm.usageId">
|
||||||
<option value="">请选择</option>
|
<option value="">无</option>
|
||||||
<option v-for="item in options.itemUsages" :key="item.id" :value="item.id">{{ item.name }}</option>
|
<option v-for="item in options.itemUsages" :key="item.id" :value="item.id">{{ item.name }}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
@@ -620,20 +647,14 @@ onMounted(() => {
|
|||||||
<option value="">Pokemon</option>
|
<option value="">Pokemon</option>
|
||||||
<option v-for="item in pokemonRows" :key="item.id" :value="String(item.id)">#{{ item.id }} {{ item.name }}</option>
|
<option v-for="item in pokemonRows" :key="item.id" :value="String(item.id)">#{{ item.id }} {{ item.name }}</option>
|
||||||
</select>
|
</select>
|
||||||
<select v-model="row.mapId">
|
<select v-model="row.mapIds" multiple>
|
||||||
<option value="">地图</option>
|
|
||||||
<option v-for="item in options.maps" :key="item.id" :value="String(item.id)">{{ item.name }}</option>
|
<option v-for="item in options.maps" :key="item.id" :value="String(item.id)">{{ item.name }}</option>
|
||||||
</select>
|
</select>
|
||||||
<select v-model="row.timeOfDay">
|
<select v-model="row.timeOfDays" multiple>
|
||||||
<option>早晨</option>
|
<option v-for="item in timeOfDays" :key="item">{{ item }}</option>
|
||||||
<option>中午</option>
|
|
||||||
<option>傍晚</option>
|
|
||||||
<option>晚上</option>
|
|
||||||
</select>
|
</select>
|
||||||
<select v-model="row.weather">
|
<select v-model="row.weathers" multiple>
|
||||||
<option>晴天</option>
|
<option v-for="item in weathers" :key="item">{{ item }}</option>
|
||||||
<option>阴天</option>
|
|
||||||
<option>雨天</option>
|
|
||||||
</select>
|
</select>
|
||||||
<input v-model.number="row.rarity" type="number" min="1" max="3" />
|
<input v-model.number="row.rarity" type="number" min="1" max="3" />
|
||||||
<button type="button" @click="habitatForm.pokemonAppearances.splice(index, 1)">删除</button>
|
<button type="button" @click="habitatForm.pokemonAppearances.splice(index, 1)">删除</button>
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ onMounted(async () => {
|
|||||||
<section class="detail-section">
|
<section class="detail-section">
|
||||||
<h2>可能出现的宝可梦</h2>
|
<h2>可能出现的宝可梦</h2>
|
||||||
<ul class="row-list">
|
<ul class="row-list">
|
||||||
<li v-for="item in habitat.pokemon" :key="`${item.id}-${item.map.id}-${item.time_of_day}`">
|
<li v-for="item in habitat.pokemon" :key="`${item.id}-${item.map.id}-${item.time_of_day}-${item.weather}`">
|
||||||
<RouterLink :to="`/pokemon/${item.id}`">{{ item.name }}</RouterLink>
|
<RouterLink :to="`/pokemon/${item.id}`">{{ item.name }}</RouterLink>
|
||||||
<span>{{ item.time_of_day }} · {{ item.weather }} · {{ item.rarity }} 星 · {{ item.map.name }}</span>
|
<span>{{ item.time_of_day }} · {{ item.weather }} · {{ item.rarity }} 星 · {{ item.map.name }}</span>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ onMounted(async () => {
|
|||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="page-title">{{ item.name }}</h1>
|
<h1 class="page-title">{{ item.name }}</h1>
|
||||||
<p class="page-subtitle">{{ item.category.name }} · {{ item.usage.name }}</p>
|
<p class="page-subtitle">{{ item.usage ? `${item.category.name} · ${item.usage.name}` : item.category.name }}</p>
|
||||||
</div>
|
</div>
|
||||||
<RouterLink class="link-button" to="/items">返回列表</RouterLink>
|
<RouterLink class="link-button" to="/items">返回列表</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ watch([tab, itemQuery], loadItems);
|
|||||||
<div v-else-if="tab === 'items'" class="grid">
|
<div v-else-if="tab === 'items'" class="grid">
|
||||||
<RouterLink v-for="item in items" :key="item.id" class="entity-card" :to="`/items/${item.id}`">
|
<RouterLink v-for="item in items" :key="item.id" class="entity-card" :to="`/items/${item.id}`">
|
||||||
<h2>{{ item.name }}</h2>
|
<h2>{{ item.name }}</h2>
|
||||||
<p class="meta-line">{{ item.category.name }} · {{ item.usage.name }}</p>
|
<p class="meta-line">{{ item.usage ? `${item.category.name} · ${item.usage.name}` : item.category.name }}</p>
|
||||||
<EntityChips :items="item.tags" />
|
<EntityChips :items="item.tags" />
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ onMounted(async () => {
|
|||||||
<section class="detail-section">
|
<section class="detail-section">
|
||||||
<h2>栖息地</h2>
|
<h2>栖息地</h2>
|
||||||
<ul class="row-list">
|
<ul class="row-list">
|
||||||
<li v-for="habitat in pokemon.habitats" :key="`${habitat.id}-${habitat.map.id}-${habitat.time_of_day}`">
|
<li v-for="habitat in pokemon.habitats" :key="`${habitat.id}-${habitat.map.id}-${habitat.time_of_day}-${habitat.weather}`">
|
||||||
<RouterLink :to="`/habitats/${habitat.id}`">{{ habitat.name }}</RouterLink>
|
<RouterLink :to="`/habitats/${habitat.id}`">{{ habitat.name }}</RouterLink>
|
||||||
<span>{{ habitat.time_of_day }} · {{ habitat.weather }} · {{ habitat.rarity }} 星 · {{ habitat.map.name }}</span>
|
<span>{{ habitat.time_of_day }} · {{ habitat.weather }} · {{ habitat.rarity }} 星 · {{ habitat.map.name }}</span>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
Reference in New Issue
Block a user