feat: add skill item drops configuration for pokemon
Add `has_item_drop` flag to skills and `pokemon_skill_item_drops` table Enable configuring item drops for specific pokemon skills in editor Show skill item drops on pokemon and item detail pages
This commit is contained in:
@@ -17,12 +17,14 @@ Pokemon 可配置:
|
|||||||
- ID
|
- ID
|
||||||
- 名字
|
- 名字
|
||||||
- 特长(可多选,最多 2 个)
|
- 特长(可多选,最多 2 个)
|
||||||
|
- 特长掉落物品(按 Pokemon + 特长 配置,单选物品)
|
||||||
- 喜欢的环境(单选)
|
- 喜欢的环境(单选)
|
||||||
- 喜欢的东西(可多选,最多 6 个)
|
- 喜欢的东西(可多选,最多 6 个)
|
||||||
- 出现的栖息地(可多选)
|
- 出现的栖息地(可多选)
|
||||||
|
|
||||||
特长 可配置:
|
特长 可配置:
|
||||||
- 名称
|
- 名称
|
||||||
|
- 是否有掉落物
|
||||||
|
|
||||||
喜欢的环境 可配置:
|
喜欢的环境 可配置:
|
||||||
- 名称
|
- 名称
|
||||||
@@ -79,6 +81,7 @@ Pokemon 可配置:
|
|||||||
- 喜欢的东西(可多选,满足任意条件 / 满足全部条件)
|
- 喜欢的东西(可多选,满足任意条件 / 满足全部条件)
|
||||||
- Pokemon 详情页
|
- Pokemon 详情页
|
||||||
- 特长
|
- 特长
|
||||||
|
- 特长掉落物品
|
||||||
- 喜欢的环境
|
- 喜欢的环境
|
||||||
- 喜欢的东西
|
- 喜欢的东西
|
||||||
- 栖息地
|
- 栖息地
|
||||||
@@ -108,6 +111,7 @@ Pokemon 可配置:
|
|||||||
- 需要材料列表
|
- 需要材料列表
|
||||||
- 标签
|
- 标签
|
||||||
- 相关栖息地
|
- 相关栖息地
|
||||||
|
- 相关 Pokemon 掉落
|
||||||
- 材料单详情页
|
- 材料单详情页
|
||||||
- 基本信息
|
- 基本信息
|
||||||
- 入手方式
|
- 入手方式
|
||||||
|
|||||||
@@ -40,10 +40,12 @@ CREATE INDEX IF NOT EXISTS user_sessions_user_id_idx
|
|||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS skills (
|
CREATE TABLE IF NOT EXISTS skills (
|
||||||
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,
|
||||||
|
has_item_drop boolean NOT NULL DEFAULT false
|
||||||
);
|
);
|
||||||
|
|
||||||
ALTER TABLE skills DROP COLUMN IF EXISTS subcategory;
|
ALTER TABLE skills DROP COLUMN IF EXISTS subcategory;
|
||||||
|
ALTER TABLE skills ADD COLUMN IF NOT EXISTS has_item_drop boolean NOT NULL DEFAULT false;
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS skills_name_key ON skills(name);
|
CREATE UNIQUE INDEX IF NOT EXISTS skills_name_key ON skills(name);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS favorite_things (
|
CREATE TABLE IF NOT EXISTS favorite_things (
|
||||||
@@ -154,6 +156,14 @@ CREATE TABLE IF NOT EXISTS item_favorite_things (
|
|||||||
PRIMARY KEY (item_id, favorite_thing_id)
|
PRIMARY KEY (item_id, favorite_thing_id)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS pokemon_skill_item_drops (
|
||||||
|
pokemon_id integer NOT NULL,
|
||||||
|
skill_id integer NOT NULL,
|
||||||
|
item_id integer NOT NULL REFERENCES items(id),
|
||||||
|
PRIMARY KEY (pokemon_id, skill_id),
|
||||||
|
FOREIGN KEY (pokemon_id, skill_id) REFERENCES pokemon_skills(pokemon_id, skill_id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS recipe_materials (
|
CREATE TABLE IF NOT EXISTS recipe_materials (
|
||||||
recipe_id integer NOT NULL REFERENCES recipes(id) ON DELETE CASCADE,
|
recipe_id integer NOT NULL REFERENCES recipes(id) ON DELETE CASCADE,
|
||||||
item_id integer NOT NULL REFERENCES items(id),
|
item_id integer NOT NULL REFERENCES items(id),
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ type ConfigType =
|
|||||||
type ConfigDefinition = {
|
type ConfigDefinition = {
|
||||||
table: string;
|
table: string;
|
||||||
order: string;
|
order: string;
|
||||||
|
hasItemDrop?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type IdQuantity = {
|
type IdQuantity = {
|
||||||
@@ -27,12 +28,18 @@ type IdQuantity = {
|
|||||||
quantity: number;
|
quantity: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type SkillItemDrop = {
|
||||||
|
skillId: number;
|
||||||
|
itemId: number;
|
||||||
|
};
|
||||||
|
|
||||||
type PokemonPayload = {
|
type PokemonPayload = {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
environmentId: number;
|
environmentId: number;
|
||||||
skillIds: number[];
|
skillIds: number[];
|
||||||
favoriteThingIds: number[];
|
favoriteThingIds: number[];
|
||||||
|
skillItemDrops: SkillItemDrop[];
|
||||||
};
|
};
|
||||||
|
|
||||||
type ItemPayload = {
|
type ItemPayload = {
|
||||||
@@ -71,7 +78,7 @@ const timeOfDays = ['早晨', '中午', '傍晚', '晚上'];
|
|||||||
const weathers = ['晴天', '阴天', '雨天'];
|
const weathers = ['晴天', '阴天', '雨天'];
|
||||||
|
|
||||||
const configDefinitions: Record<ConfigType, ConfigDefinition> = {
|
const configDefinitions: Record<ConfigType, ConfigDefinition> = {
|
||||||
skills: { table: 'skills', order: 'name' },
|
skills: { table: 'skills', order: 'name', hasItemDrop: true },
|
||||||
environments: { table: 'environments', order: 'name' },
|
environments: { table: 'environments', order: 'name' },
|
||||||
'favorite-things': { table: 'favorite_things', order: 'name' },
|
'favorite-things': { table: 'favorite_things', order: 'name' },
|
||||||
'item-categories': { table: 'item_categories', order: 'name' },
|
'item-categories': { table: 'item_categories', order: 'name' },
|
||||||
@@ -88,6 +95,10 @@ 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 skillOptions(): Promise<Array<{ id: number; name: string; hasItemDrop: boolean }>> {
|
||||||
|
return query('SELECT id, name, has_item_drop AS "hasItemDrop" FROM skills ORDER BY name');
|
||||||
|
}
|
||||||
|
|
||||||
function auditSelect(entityAlias: string, createdAlias = 'created_user', updatedAlias = 'updated_user'): string {
|
function auditSelect(entityAlias: string, createdAlias = 'created_user', updatedAlias = 'updated_user'): string {
|
||||||
return `
|
return `
|
||||||
${entityAlias}.created_at AS "createdAt",
|
${entityAlias}.created_at AS "createdAt",
|
||||||
@@ -117,6 +128,10 @@ function configOrder(definition: ConfigDefinition): string {
|
|||||||
.join(', ');
|
.join(', ');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function configSelect(definition: ConfigDefinition): string {
|
||||||
|
return definition.hasItemDrop ? 'c.id, c.name, c.has_item_drop AS "hasItemDrop"' : 'c.id, c.name';
|
||||||
|
}
|
||||||
|
|
||||||
function validationError(message: string): ValidationError {
|
function validationError(message: string): ValidationError {
|
||||||
const error = new Error(message) as ValidationError;
|
const error = new Error(message) as ValidationError;
|
||||||
error.statusCode = 400;
|
error.statusCode = 400;
|
||||||
@@ -209,7 +224,7 @@ const pokemonProjection = `
|
|||||||
${auditSelect('p', 'pokemon_created_user', 'pokemon_updated_user')},
|
${auditSelect('p', 'pokemon_created_user', 'pokemon_updated_user')},
|
||||||
json_build_object('id', e.id, 'name', e.name) AS environment,
|
json_build_object('id', e.id, 'name', e.name) AS environment,
|
||||||
COALESCE((
|
COALESCE((
|
||||||
SELECT json_agg(json_build_object('id', s.id, 'name', s.name) ORDER BY s.name)
|
SELECT json_agg(json_build_object('id', s.id, 'name', s.name, 'hasItemDrop', s.has_item_drop) ORDER BY s.name)
|
||||||
FROM pokemon_skills ps
|
FROM pokemon_skills ps
|
||||||
JOIN skills s ON s.id = ps.skill_id
|
JOIN skills s ON s.id = ps.skill_id
|
||||||
WHERE ps.pokemon_id = p.id
|
WHERE ps.pokemon_id = p.id
|
||||||
@@ -235,7 +250,7 @@ export async function getOptions() {
|
|||||||
acquisitionMethods,
|
acquisitionMethods,
|
||||||
maps
|
maps
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
optionSelect('skills'),
|
skillOptions(),
|
||||||
optionSelect('environments'),
|
optionSelect('environments'),
|
||||||
optionSelect('favorite_things'),
|
optionSelect('favorite_things'),
|
||||||
optionSelect('item_categories'),
|
optionSelect('item_categories'),
|
||||||
@@ -264,7 +279,7 @@ export async function listConfig(type: ConfigType) {
|
|||||||
const definition = configDefinitions[type];
|
const definition = configDefinitions[type];
|
||||||
return query(
|
return query(
|
||||||
`
|
`
|
||||||
SELECT c.id, c.name, ${auditSelect('c')}
|
SELECT ${configSelect(definition)}, ${auditSelect('c')}
|
||||||
FROM ${definition.table} c
|
FROM ${definition.table} c
|
||||||
${auditJoins('c')}
|
${auditJoins('c')}
|
||||||
ORDER BY ${configOrder(definition)}
|
ORDER BY ${configOrder(definition)}
|
||||||
@@ -276,7 +291,7 @@ async function getConfigById(type: ConfigType, id: number) {
|
|||||||
const definition = configDefinitions[type];
|
const definition = configDefinitions[type];
|
||||||
return queryOne(
|
return queryOne(
|
||||||
`
|
`
|
||||||
SELECT c.id, c.name, ${auditSelect('c')}
|
SELECT ${configSelect(definition)}, ${auditSelect('c')}
|
||||||
FROM ${definition.table} c
|
FROM ${definition.table} c
|
||||||
${auditJoins('c')}
|
${auditJoins('c')}
|
||||||
WHERE c.id = $1
|
WHERE c.id = $1
|
||||||
@@ -288,16 +303,26 @@ async function getConfigById(type: ConfigType, id: number) {
|
|||||||
export async function createConfig(type: ConfigType, payload: Record<string, unknown>, userId: number) {
|
export async function createConfig(type: ConfigType, payload: Record<string, unknown>, userId: number) {
|
||||||
const definition = configDefinitions[type];
|
const definition = configDefinitions[type];
|
||||||
const name = cleanName(payload.name);
|
const name = cleanName(payload.name);
|
||||||
|
const hasItemDrop = definition.hasItemDrop ? Boolean(payload.hasItemDrop) : false;
|
||||||
|
|
||||||
const id = await withTransaction(async (client) => {
|
const id = await withTransaction(async (client) => {
|
||||||
const result = await client.query<{ id: number }>(
|
const result = definition.hasItemDrop
|
||||||
`
|
? await client.query<{ id: number }>(
|
||||||
INSERT INTO ${definition.table} (name, created_by_user_id, updated_by_user_id)
|
`
|
||||||
VALUES ($1, $2, $2)
|
INSERT INTO ${definition.table} (name, has_item_drop, created_by_user_id, updated_by_user_id)
|
||||||
RETURNING id
|
VALUES ($1, $2, $3, $3)
|
||||||
`,
|
RETURNING id
|
||||||
[name, userId]
|
`,
|
||||||
);
|
[name, hasItemDrop, userId]
|
||||||
|
)
|
||||||
|
: await client.query<{ id: number }>(
|
||||||
|
`
|
||||||
|
INSERT INTO ${definition.table} (name, created_by_user_id, updated_by_user_id)
|
||||||
|
VALUES ($1, $2, $2)
|
||||||
|
RETURNING id
|
||||||
|
`,
|
||||||
|
[name, userId]
|
||||||
|
);
|
||||||
|
|
||||||
const createdId = result.rows[0].id;
|
const createdId = result.rows[0].id;
|
||||||
await recordEditLog(client, type, createdId, 'create', userId);
|
await recordEditLog(client, type, createdId, 'create', userId);
|
||||||
@@ -310,21 +335,35 @@ export async function createConfig(type: ConfigType, payload: Record<string, unk
|
|||||||
export async function updateConfig(type: ConfigType, id: number, payload: Record<string, unknown>, userId: number) {
|
export async function updateConfig(type: ConfigType, id: number, payload: Record<string, unknown>, userId: number) {
|
||||||
const definition = configDefinitions[type];
|
const definition = configDefinitions[type];
|
||||||
const name = cleanName(payload.name);
|
const name = cleanName(payload.name);
|
||||||
|
const hasItemDrop = definition.hasItemDrop ? Boolean(payload.hasItemDrop) : false;
|
||||||
|
|
||||||
const updated = await withTransaction(async (client) => {
|
const updated = await withTransaction(async (client) => {
|
||||||
const result = await client.query(
|
const result = definition.hasItemDrop
|
||||||
`
|
? await client.query(
|
||||||
UPDATE ${definition.table}
|
`
|
||||||
SET name = $1, updated_by_user_id = $2, updated_at = now()
|
UPDATE ${definition.table}
|
||||||
WHERE id = $3
|
SET name = $1, has_item_drop = $2, updated_by_user_id = $3, updated_at = now()
|
||||||
`,
|
WHERE id = $4
|
||||||
[name, userId, id]
|
`,
|
||||||
);
|
[name, hasItemDrop, userId, id]
|
||||||
|
)
|
||||||
|
: await client.query(
|
||||||
|
`
|
||||||
|
UPDATE ${definition.table}
|
||||||
|
SET name = $1, updated_by_user_id = $2, updated_at = now()
|
||||||
|
WHERE id = $3
|
||||||
|
`,
|
||||||
|
[name, userId, id]
|
||||||
|
);
|
||||||
|
|
||||||
if (result.rowCount === 0) {
|
if (result.rowCount === 0) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (definition.hasItemDrop && !hasItemDrop) {
|
||||||
|
await client.query('DELETE FROM pokemon_skill_item_drops WHERE skill_id = $1', [id]);
|
||||||
|
}
|
||||||
|
|
||||||
await recordEditLog(client, type, id, 'update', userId);
|
await recordEditLog(client, type, id, 'update', userId);
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
@@ -399,30 +438,58 @@ export async function getPokemon(id: number) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const habitats = await query(
|
const [habitats, itemDrops] = await Promise.all([
|
||||||
`
|
query(
|
||||||
SELECT
|
`
|
||||||
h.id,
|
SELECT
|
||||||
h.name,
|
h.id,
|
||||||
hp.time_of_day,
|
h.name,
|
||||||
hp.weather,
|
hp.time_of_day,
|
||||||
hp.rarity,
|
hp.weather,
|
||||||
json_build_object('id', m.id, 'name', m.name) AS map
|
hp.rarity,
|
||||||
FROM habitat_pokemon hp
|
json_build_object('id', m.id, 'name', m.name) AS map
|
||||||
JOIN habitats h ON h.id = hp.habitat_id
|
FROM habitat_pokemon hp
|
||||||
JOIN maps m ON m.id = hp.map_id
|
JOIN habitats h ON h.id = hp.habitat_id
|
||||||
WHERE hp.pokemon_id = $1
|
JOIN maps m ON m.id = hp.map_id
|
||||||
ORDER BY h.name, hp.rarity, m.name
|
WHERE hp.pokemon_id = $1
|
||||||
`,
|
ORDER BY h.name, hp.rarity, m.name
|
||||||
[id]
|
`,
|
||||||
);
|
[id]
|
||||||
|
),
|
||||||
|
query<{ skillId: number; id: number; name: string }>(
|
||||||
|
`
|
||||||
|
SELECT psid.skill_id AS "skillId", i.id, i.name
|
||||||
|
FROM pokemon_skill_item_drops psid
|
||||||
|
JOIN skills s ON s.id = psid.skill_id
|
||||||
|
JOIN items i ON i.id = psid.item_id
|
||||||
|
WHERE psid.pokemon_id = $1
|
||||||
|
AND s.has_item_drop = true
|
||||||
|
ORDER BY psid.skill_id, i.name
|
||||||
|
`,
|
||||||
|
[id]
|
||||||
|
)
|
||||||
|
]);
|
||||||
|
|
||||||
return { ...pokemon, habitats };
|
const dropsBySkill = itemDrops.reduce((itemsBySkill, item) => {
|
||||||
|
itemsBySkill.set(item.skillId, { id: item.id, name: item.name });
|
||||||
|
return itemsBySkill;
|
||||||
|
}, new Map<number, { id: number; name: string }>());
|
||||||
|
|
||||||
|
const skills = Array.isArray(pokemon.skills)
|
||||||
|
? pokemon.skills.map((skill: { id: number; name: string }) => ({
|
||||||
|
...skill,
|
||||||
|
itemDrop: dropsBySkill.get(skill.id) ?? null
|
||||||
|
}))
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return { ...pokemon, skills, habitats };
|
||||||
}
|
}
|
||||||
|
|
||||||
function cleanPokemonPayload(payload: Record<string, unknown>): PokemonPayload {
|
function cleanPokemonPayload(payload: Record<string, unknown>): PokemonPayload {
|
||||||
const skillIds = cleanIds(payload.skillIds);
|
const skillIds = cleanIds(payload.skillIds);
|
||||||
const favoriteThingIds = cleanIds(payload.favoriteThingIds);
|
const favoriteThingIds = cleanIds(payload.favoriteThingIds);
|
||||||
|
const selectedSkillIds = new Set(skillIds);
|
||||||
|
const skillItemDrops = new Map<string, SkillItemDrop>();
|
||||||
|
|
||||||
if (skillIds.length > 2) {
|
if (skillIds.length > 2) {
|
||||||
throw validationError('特长最多选择 2 个');
|
throw validationError('特长最多选择 2 个');
|
||||||
@@ -431,16 +498,36 @@ function cleanPokemonPayload(payload: Record<string, unknown>): PokemonPayload {
|
|||||||
throw validationError('喜欢的东西最多选择 6 个');
|
throw validationError('喜欢的东西最多选择 6 个');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(payload.skillItemDrops)) {
|
||||||
|
for (const item of payload.skillItemDrops) {
|
||||||
|
const row = item as Record<string, unknown>;
|
||||||
|
const skillId = Number(row.skillId);
|
||||||
|
const itemId = Number(row.itemId);
|
||||||
|
|
||||||
|
if (!Number.isInteger(itemId) || itemId <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Number.isInteger(skillId) || skillId <= 0 || !selectedSkillIds.has(skillId)) {
|
||||||
|
throw validationError('掉落物品必须关联已选择的特长');
|
||||||
|
}
|
||||||
|
|
||||||
|
skillItemDrops.set(String(skillId), { skillId, itemId });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: requirePositiveInteger(payload.id, '请输入 Pokemon ID'),
|
id: requirePositiveInteger(payload.id, '请输入 Pokemon ID'),
|
||||||
name: cleanName(payload.name, '请输入 Pokemon 名字'),
|
name: cleanName(payload.name, '请输入 Pokemon 名字'),
|
||||||
environmentId: requirePositiveInteger(payload.environmentId, '请选择喜欢的环境'),
|
environmentId: requirePositiveInteger(payload.environmentId, '请选择喜欢的环境'),
|
||||||
skillIds,
|
skillIds,
|
||||||
favoriteThingIds
|
favoriteThingIds,
|
||||||
|
skillItemDrops: [...skillItemDrops.values()]
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function replacePokemonRelations(client: DbClient, pokemonId: number, payload: PokemonPayload): Promise<void> {
|
async function replacePokemonRelations(client: DbClient, pokemonId: number, payload: PokemonPayload): Promise<void> {
|
||||||
|
await client.query('DELETE FROM pokemon_skill_item_drops WHERE pokemon_id = $1', [pokemonId]);
|
||||||
await client.query('DELETE FROM pokemon_skills WHERE pokemon_id = $1', [pokemonId]);
|
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]);
|
await client.query('DELETE FROM pokemon_favorite_things WHERE pokemon_id = $1', [pokemonId]);
|
||||||
|
|
||||||
@@ -454,6 +541,25 @@ async function replacePokemonRelations(client: DbClient, pokemonId: number, payl
|
|||||||
favoriteThingId
|
favoriteThingId
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (payload.skillItemDrops.length > 0) {
|
||||||
|
const allowedDrops = await client.query<{ id: number }>(
|
||||||
|
'SELECT id FROM skills WHERE id = ANY($1::integer[]) AND has_item_drop = true',
|
||||||
|
[payload.skillItemDrops.map((drop) => drop.skillId)]
|
||||||
|
);
|
||||||
|
const allowedDropSkillIds = new Set(allowedDrops.rows.map((row) => row.id));
|
||||||
|
|
||||||
|
if (payload.skillItemDrops.some((drop) => !allowedDropSkillIds.has(drop.skillId))) {
|
||||||
|
throw validationError('该特长不能配置掉落物');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const drop of payload.skillItemDrops) {
|
||||||
|
await client.query(
|
||||||
|
'INSERT INTO pokemon_skill_item_drops (pokemon_id, skill_id, item_id) VALUES ($1, $2, $3)',
|
||||||
|
[pokemonId, drop.skillId, drop.itemId]
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createPokemon(payload: Record<string, unknown>, userId: number) {
|
export async function createPokemon(payload: Record<string, unknown>, userId: number) {
|
||||||
@@ -758,7 +864,7 @@ export async function getItem(id: number) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const [acquisitionMethods, recipe, relatedHabitats] = await Promise.all([
|
const [acquisitionMethods, recipe, relatedHabitats, droppedByPokemon] = await Promise.all([
|
||||||
query(
|
query(
|
||||||
`
|
`
|
||||||
SELECT am.id, am.name
|
SELECT am.id, am.name
|
||||||
@@ -804,10 +910,24 @@ export async function getItem(id: number) {
|
|||||||
ORDER BY h.name
|
ORDER BY h.name
|
||||||
`,
|
`,
|
||||||
[id]
|
[id]
|
||||||
|
),
|
||||||
|
query(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
json_build_object('id', p.id, 'name', p.name) AS pokemon,
|
||||||
|
json_build_object('id', s.id, 'name', s.name) AS skill
|
||||||
|
FROM pokemon_skill_item_drops psid
|
||||||
|
JOIN pokemon p ON p.id = psid.pokemon_id
|
||||||
|
JOIN skills s ON s.id = psid.skill_id
|
||||||
|
WHERE psid.item_id = $1
|
||||||
|
AND s.has_item_drop = true
|
||||||
|
ORDER BY p.id, s.name
|
||||||
|
`,
|
||||||
|
[id]
|
||||||
)
|
)
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return { ...item, acquisitionMethods, recipe, relatedHabitats };
|
return { ...item, acquisitionMethods, recipe, relatedHabitats, droppedByPokemon };
|
||||||
}
|
}
|
||||||
|
|
||||||
function cleanItemPayload(payload: Record<string, unknown>): ItemPayload {
|
function cleanItemPayload(payload: Record<string, unknown>): ItemPayload {
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ export interface NamedEntity {
|
|||||||
name: string;
|
name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Skill extends NamedEntity {
|
||||||
|
hasItemDrop: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface UserSummary {
|
export interface UserSummary {
|
||||||
id: number;
|
id: number;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
@@ -23,11 +27,12 @@ export interface Pokemon extends EditInfo {
|
|||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
environment: NamedEntity;
|
environment: NamedEntity;
|
||||||
skills: NamedEntity[];
|
skills: Skill[];
|
||||||
favorite_things: NamedEntity[];
|
favorite_things: NamedEntity[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PokemonDetail extends Pokemon {
|
export interface PokemonDetail extends Pokemon {
|
||||||
|
skills: Array<Skill & { itemDrop: NamedEntity | null }>;
|
||||||
habitats: Array<{
|
habitats: Array<{
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -71,6 +76,10 @@ export interface ItemDetail extends Item {
|
|||||||
acquisitionMethods: NamedEntity[];
|
acquisitionMethods: NamedEntity[];
|
||||||
recipe: RecipeDetail | null;
|
recipe: RecipeDetail | null;
|
||||||
relatedHabitats: Array<NamedEntity & { quantity: number }>;
|
relatedHabitats: Array<NamedEntity & { quantity: number }>;
|
||||||
|
droppedByPokemon: Array<{
|
||||||
|
pokemon: NamedEntity;
|
||||||
|
skill: NamedEntity;
|
||||||
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Recipe extends EditInfo {
|
export interface Recipe extends EditInfo {
|
||||||
@@ -85,7 +94,7 @@ export interface RecipeDetail extends Recipe {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface Options {
|
export interface Options {
|
||||||
skills: NamedEntity[];
|
skills: Skill[];
|
||||||
environments: NamedEntity[];
|
environments: NamedEntity[];
|
||||||
favoriteThings: NamedEntity[];
|
favoriteThings: NamedEntity[];
|
||||||
itemCategories: NamedEntity[];
|
itemCategories: NamedEntity[];
|
||||||
@@ -131,6 +140,7 @@ export interface PokemonPayload {
|
|||||||
environmentId: number;
|
environmentId: number;
|
||||||
skillIds: number[];
|
skillIds: number[];
|
||||||
favoriteThingIds: number[];
|
favoriteThingIds: number[];
|
||||||
|
skillItemDrops: Array<{ skillId: number; itemId: number }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ItemPayload {
|
export interface ItemPayload {
|
||||||
@@ -279,11 +289,11 @@ export const api = {
|
|||||||
me: () => getJson<{ user: AuthUser }>('/api/auth/me'),
|
me: () => getJson<{ user: AuthUser }>('/api/auth/me'),
|
||||||
logout: () => postEmpty('/api/auth/logout'),
|
logout: () => postEmpty('/api/auth/logout'),
|
||||||
options: () => getJson<Options>('/api/options'),
|
options: () => getJson<Options>('/api/options'),
|
||||||
config: (type: ConfigType) => getJson<NamedEntity[]>(`/api/admin/config/${type}`),
|
config: (type: ConfigType) => getJson<Array<Skill | NamedEntity>>(`/api/admin/config/${type}`),
|
||||||
createConfig: (type: ConfigType, payload: { name: string }) =>
|
createConfig: (type: ConfigType, payload: { name: string; hasItemDrop?: boolean }) =>
|
||||||
sendJson<NamedEntity>(`/api/admin/config/${type}`, 'POST', payload),
|
sendJson<Skill | NamedEntity>(`/api/admin/config/${type}`, 'POST', payload),
|
||||||
updateConfig: (type: ConfigType, id: number, payload: { name: string }) =>
|
updateConfig: (type: ConfigType, id: number, payload: { name: string; hasItemDrop?: boolean }) =>
|
||||||
sendJson<NamedEntity>(`/api/admin/config/${type}/${id}`, 'PUT', payload),
|
sendJson<Skill | NamedEntity>(`/api/admin/config/${type}/${id}`, 'PUT', payload),
|
||||||
deleteConfig: (type: ConfigType, id: number) => deleteJson(`/api/admin/config/${type}/${id}`),
|
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)}`),
|
||||||
|
|||||||
@@ -854,6 +854,20 @@ button:disabled,
|
|||||||
font-weight: 750;
|
font-weight: 750;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.config-flag {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 24px;
|
||||||
|
margin-left: 8px;
|
||||||
|
padding: 3px 7px;
|
||||||
|
border: 1px solid rgba(42, 117, 187, 0.24);
|
||||||
|
border-radius: var(--radius-small);
|
||||||
|
background: var(--surface-soft);
|
||||||
|
color: var(--pokemon-blue-deep);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 850;
|
||||||
|
}
|
||||||
|
|
||||||
.chips {
|
.chips {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -944,6 +958,14 @@ button:disabled,
|
|||||||
border-bottom: 0;
|
border-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.skill-drop-summary li {
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skill-drop-summary .chips {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
.appearance-list li {
|
.appearance-list li {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: max-content minmax(0, 1fr);
|
grid-template-columns: max-content minmax(0, 1fr);
|
||||||
@@ -1166,6 +1188,24 @@ button:disabled,
|
|||||||
width: 90px;
|
width: 90px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.skill-drop-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skill-drop-row {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-card);
|
||||||
|
background: var(--surface-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skill-drop-row label {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.check-row label {
|
.check-row label {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -12,11 +12,12 @@ import {
|
|||||||
type Item,
|
type Item,
|
||||||
type NamedEntity,
|
type NamedEntity,
|
||||||
type Pokemon,
|
type Pokemon,
|
||||||
type Recipe
|
type Recipe,
|
||||||
|
type Skill
|
||||||
} from '../services/api';
|
} from '../services/api';
|
||||||
|
|
||||||
type AdminTab = 'config' | 'pokemon' | 'items' | 'recipes' | 'habitats';
|
type AdminTab = 'config' | 'pokemon' | 'items' | 'recipes' | 'habitats';
|
||||||
type EditableConfig = NamedEntity;
|
type EditableConfig = (NamedEntity | Skill) & { hasItemDrop?: boolean };
|
||||||
|
|
||||||
const tabs: Array<{ key: AdminTab; label: string }> = [
|
const tabs: Array<{ key: AdminTab; label: string }> = [
|
||||||
{ key: 'config', label: '系统配置' },
|
{ key: 'config', label: '系统配置' },
|
||||||
@@ -26,8 +27,8 @@ const tabs: Array<{ key: AdminTab; label: string }> = [
|
|||||||
{ key: 'habitats', label: '栖息地' }
|
{ key: 'habitats', label: '栖息地' }
|
||||||
];
|
];
|
||||||
|
|
||||||
const configTypes: Array<{ key: ConfigType; label: string }> = [
|
const configTypes: Array<{ key: ConfigType; label: string; supportsItemDrop?: boolean }> = [
|
||||||
{ key: 'skills', label: '特长' },
|
{ key: 'skills', label: '特长', supportsItemDrop: true },
|
||||||
{ key: 'environments', label: '喜欢的环境' },
|
{ key: 'environments', label: '喜欢的环境' },
|
||||||
{ key: 'favorite-things', label: '喜欢的东西 / 标签' },
|
{ key: 'favorite-things', label: '喜欢的东西 / 标签' },
|
||||||
{ key: 'item-categories', label: '物品分类' },
|
{ key: 'item-categories', label: '物品分类' },
|
||||||
@@ -47,7 +48,7 @@ const currentUser = ref<AuthUser | null>(null);
|
|||||||
const busy = ref(false);
|
const busy = ref(false);
|
||||||
const contentLoading = ref(false);
|
const contentLoading = ref(false);
|
||||||
const message = ref('');
|
const message = ref('');
|
||||||
const configForm = ref({ id: 0, name: '' });
|
const configForm = ref({ id: 0, name: '', hasItemDrop: false });
|
||||||
|
|
||||||
const selectedConfig = computed(() => configTypes.find((item) => item.key === activeConfigType.value) ?? configTypes[0]);
|
const selectedConfig = computed(() => configTypes.find((item) => item.key === activeConfigType.value) ?? configTypes[0]);
|
||||||
const configTabs = computed<TabOption[]>(() => configTypes.map((item) => ({ value: item.key, label: item.label })));
|
const configTabs = computed<TabOption[]>(() => configTypes.map((item) => ({ value: item.key, label: item.label })));
|
||||||
@@ -86,17 +87,18 @@ async function loadConfig() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resetConfigForm() {
|
function resetConfigForm() {
|
||||||
configForm.value = { id: 0, name: '' };
|
configForm.value = { id: 0, name: '', hasItemDrop: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
function editConfig(item: EditableConfig) {
|
function editConfig(item: EditableConfig) {
|
||||||
configForm.value = { id: item.id, name: item.name };
|
configForm.value = { id: item.id, name: item.name, hasItemDrop: item.hasItemDrop === true };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveConfig() {
|
async function saveConfig() {
|
||||||
await run(async () => {
|
await run(async () => {
|
||||||
const payload = {
|
const payload = {
|
||||||
name: configForm.value.name
|
name: configForm.value.name,
|
||||||
|
hasItemDrop: selectedConfig.value.supportsItemDrop ? configForm.value.hasItemDrop : undefined
|
||||||
};
|
};
|
||||||
|
|
||||||
if (configForm.value.id) {
|
if (configForm.value.id) {
|
||||||
@@ -245,6 +247,12 @@ onMounted(() => {
|
|||||||
<label for="config-name">名称</label>
|
<label for="config-name">名称</label>
|
||||||
<input id="config-name" v-model="configForm.name" required />
|
<input id="config-name" v-model="configForm.name" required />
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="selectedConfig.supportsItemDrop" class="check-row">
|
||||||
|
<label>
|
||||||
|
<input v-model="configForm.hasItemDrop" type="checkbox" />
|
||||||
|
有掉落物
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button type="submit" class="link-button" :disabled="busy">{{ busy ? '保存中' : '保存' }}</button>
|
<button type="submit" class="link-button" :disabled="busy">{{ busy ? '保存中' : '保存' }}</button>
|
||||||
<button type="button" class="plain-button" :disabled="busy" @click="resetConfigForm">新建</button>
|
<button type="button" class="plain-button" :disabled="busy" @click="resetConfigForm">新建</button>
|
||||||
@@ -254,7 +262,7 @@ onMounted(() => {
|
|||||||
<h3 class="section-subtitle">{{ selectedConfig.label }}</h3>
|
<h3 class="section-subtitle">{{ selectedConfig.label }}</h3>
|
||||||
<ul v-if="configRows.length" class="row-list">
|
<ul v-if="configRows.length" class="row-list">
|
||||||
<li v-for="item in configRows" :key="item.id">
|
<li v-for="item in configRows" :key="item.id">
|
||||||
<span>{{ item.name }}</span>
|
<span>{{ item.name }}<span v-if="item.hasItemDrop" class="config-flag">有掉落物</span></span>
|
||||||
<span class="row-actions">
|
<span class="row-actions">
|
||||||
<button type="button" @click="editConfig(item)">编辑</button>
|
<button type="button" @click="editConfig(item)">编辑</button>
|
||||||
<button type="button" @click="removeConfig(item.id)">删除</button>
|
<button type="button" @click="removeConfig(item.id)">删除</button>
|
||||||
|
|||||||
@@ -118,12 +118,23 @@ onMounted(async () => {
|
|||||||
</DetailSection>
|
</DetailSection>
|
||||||
|
|
||||||
<DetailSection title="相关栖息地">
|
<DetailSection title="相关栖息地">
|
||||||
<ul class="row-list">
|
<ul v-if="item.relatedHabitats.length" class="row-list">
|
||||||
<li v-for="habitat in item.relatedHabitats" :key="habitat.id">
|
<li v-for="habitat in item.relatedHabitats" :key="habitat.id">
|
||||||
<RouterLink :to="`/habitats/${habitat.id}`">{{ habitat.name }}</RouterLink>
|
<RouterLink :to="`/habitats/${habitat.id}`">{{ habitat.name }}</RouterLink>
|
||||||
<span>× {{ habitat.quantity }}</span>
|
<span>× {{ habitat.quantity }}</span>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
<p v-else class="meta-line">无</p>
|
||||||
|
</DetailSection>
|
||||||
|
|
||||||
|
<DetailSection title="Pokemon 掉落">
|
||||||
|
<ul v-if="item.droppedByPokemon.length" class="row-list">
|
||||||
|
<li v-for="entry in item.droppedByPokemon" :key="`${entry.pokemon.id}-${entry.skill.id}`">
|
||||||
|
<RouterLink :to="`/pokemon/${entry.pokemon.id}`">#{{ entry.pokemon.id }} {{ entry.pokemon.name }}</RouterLink>
|
||||||
|
<span>{{ entry.skill.name }}掉落物</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p v-else class="meta-line">无</p>
|
||||||
</DetailSection>
|
</DetailSection>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ const habitatRows = computed<HabitatRow[]>(() => {
|
|||||||
maps: [...row.maps].sort((a, b) => a.localeCompare(b))
|
maps: [...row.maps].sort((a, b) => a.localeCompare(b))
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
const skillDropRows = computed(() => pokemon.value?.skills.filter((skill) => skill.itemDrop) ?? []);
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
pokemon.value = await api.pokemonDetail(String(route.params.id));
|
pokemon.value = await api.pokemonDetail(String(route.params.id));
|
||||||
@@ -154,6 +155,15 @@ onMounted(async () => {
|
|||||||
<EntityChips :items="pokemon.skills" />
|
<EntityChips :items="pokemon.skills" />
|
||||||
</DetailSection>
|
</DetailSection>
|
||||||
|
|
||||||
|
<DetailSection v-if="skillDropRows.length" title="特长掉落物">
|
||||||
|
<ul class="row-list skill-drop-summary">
|
||||||
|
<li v-for="skill in skillDropRows" :key="skill.id">
|
||||||
|
<span>{{ skill.name }}掉落物</span>
|
||||||
|
<RouterLink v-if="skill.itemDrop" :to="`/items/${skill.itemDrop.id}`">{{ skill.itemDrop.name }}</RouterLink>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</DetailSection>
|
||||||
|
|
||||||
<DetailSection title="喜欢的东西">
|
<DetailSection title="喜欢的东西">
|
||||||
<EntityChips :items="pokemon.favorite_things" />
|
<EntityChips :items="pokemon.favorite_things" />
|
||||||
</DetailSection>
|
</DetailSection>
|
||||||
|
|||||||
@@ -1,15 +1,21 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref } from 'vue';
|
import { computed, onMounted, ref, watch } from 'vue';
|
||||||
import { useRoute, useRouter } from 'vue-router';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
import PageHeader from '../components/PageHeader.vue';
|
import PageHeader from '../components/PageHeader.vue';
|
||||||
import Skeleton from '../components/Skeleton.vue';
|
import Skeleton from '../components/Skeleton.vue';
|
||||||
import StatusMessage from '../components/StatusMessage.vue';
|
import StatusMessage from '../components/StatusMessage.vue';
|
||||||
import TagsSelect from '../components/TagsSelect.vue';
|
import TagsSelect from '../components/TagsSelect.vue';
|
||||||
import { api, type ConfigType, type Options, type PokemonPayload } from '../services/api';
|
import { api, type ConfigType, type NamedEntity, type Options, type PokemonPayload } from '../services/api';
|
||||||
|
|
||||||
|
type SkillItemDropForm = {
|
||||||
|
skillId: string;
|
||||||
|
itemId: string;
|
||||||
|
};
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const options = ref<Options | null>(null);
|
const options = ref<Options | null>(null);
|
||||||
|
const itemOptions = ref<NamedEntity[]>([]);
|
||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
const busy = ref(false);
|
const busy = ref(false);
|
||||||
const message = ref('');
|
const message = ref('');
|
||||||
@@ -19,13 +25,17 @@ const pokemonForm = ref({
|
|||||||
name: '',
|
name: '',
|
||||||
environmentId: '',
|
environmentId: '',
|
||||||
skillIds: [] as string[],
|
skillIds: [] as string[],
|
||||||
favoriteThingIds: [] as string[]
|
favoriteThingIds: [] as string[],
|
||||||
|
skillItemDrops: [] as SkillItemDropForm[]
|
||||||
});
|
});
|
||||||
|
|
||||||
const routeId = computed(() => (typeof route.params.id === 'string' ? route.params.id : ''));
|
const routeId = computed(() => (typeof route.params.id === 'string' ? route.params.id : ''));
|
||||||
const isEditing = computed(() => routeId.value !== '');
|
const isEditing = computed(() => routeId.value !== '');
|
||||||
const pageTitle = computed(() => (isEditing.value ? `编辑 #${pokemonForm.value.id || routeId.value} ${pokemonForm.value.name}` : '新增 Pokemon'));
|
const pageTitle = computed(() => (isEditing.value ? `编辑 #${pokemonForm.value.id || routeId.value} ${pokemonForm.value.name}` : '新增 Pokemon'));
|
||||||
const cancelTo = computed(() => (isEditing.value ? `/pokemon/${routeId.value}` : '/pokemon'));
|
const cancelTo = computed(() => (isEditing.value ? `/pokemon/${routeId.value}` : '/pokemon'));
|
||||||
|
const selectedSkillDropRows = computed(() =>
|
||||||
|
pokemonForm.value.skillItemDrops.filter((row) => pokemonForm.value.skillIds.includes(row.skillId) && skillSupportsItemDrop(row.skillId))
|
||||||
|
);
|
||||||
|
|
||||||
function toIds(values: string[]): number[] {
|
function toIds(values: string[]): number[] {
|
||||||
return values.map(Number).filter((item) => Number.isInteger(item) && item > 0);
|
return values.map(Number).filter((item) => Number.isInteger(item) && item > 0);
|
||||||
@@ -36,7 +46,35 @@ function errorText(error: unknown, fallback: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadOptions() {
|
async function loadOptions() {
|
||||||
options.value = await api.options();
|
const [loadedOptions, loadedItems] = await Promise.all([api.options(), api.items({})]);
|
||||||
|
options.value = loadedOptions;
|
||||||
|
itemOptions.value = loadedItems.map((item) => ({ id: item.id, name: item.name }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncSkillItemDrops() {
|
||||||
|
const selectedSkillIds = new Set(pokemonForm.value.skillIds);
|
||||||
|
const rows = pokemonForm.value.skillItemDrops.filter((row) => selectedSkillIds.has(row.skillId) && skillSupportsItemDrop(row.skillId));
|
||||||
|
|
||||||
|
pokemonForm.value.skillIds.forEach((skillId) => {
|
||||||
|
if (skillSupportsItemDrop(skillId) && !rows.some((row) => row.skillId === skillId)) {
|
||||||
|
rows.push({ skillId, itemId: '' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
pokemonForm.value.skillItemDrops = rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
function skillName(skillId: string) {
|
||||||
|
return options.value?.skills.find((skill) => String(skill.id) === skillId)?.name ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function skillSupportsItemDrop(skillId: string) {
|
||||||
|
return options.value?.skills.some((skill) => String(skill.id) === skillId && skill.hasItemDrop) === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function skillDropLabel(skillId: string) {
|
||||||
|
const name = skillName(skillId);
|
||||||
|
return name ? `${name}掉落物` : '掉落物';
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadEditor() {
|
async function loadEditor() {
|
||||||
@@ -52,8 +90,13 @@ async function loadEditor() {
|
|||||||
name: pokemon.name,
|
name: pokemon.name,
|
||||||
environmentId: String(pokemon.environment.id),
|
environmentId: String(pokemon.environment.id),
|
||||||
skillIds: pokemon.skills.map((skill) => String(skill.id)),
|
skillIds: pokemon.skills.map((skill) => String(skill.id)),
|
||||||
favoriteThingIds: pokemon.favorite_things.map((thing) => String(thing.id))
|
favoriteThingIds: pokemon.favorite_things.map((thing) => String(thing.id)),
|
||||||
|
skillItemDrops: pokemon.skills.map((skill) => ({
|
||||||
|
skillId: String(skill.id),
|
||||||
|
itemId: skill.itemDrop ? String(skill.itemDrop.id) : ''
|
||||||
|
}))
|
||||||
};
|
};
|
||||||
|
syncSkillItemDrops();
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
message.value = errorText(error, '加载失败');
|
message.value = errorText(error, '加载失败');
|
||||||
@@ -109,7 +152,10 @@ async function savePokemon() {
|
|||||||
name: pokemonForm.value.name,
|
name: pokemonForm.value.name,
|
||||||
environmentId: Number(pokemonForm.value.environmentId),
|
environmentId: Number(pokemonForm.value.environmentId),
|
||||||
skillIds: toIds(pokemonForm.value.skillIds.slice(0, 2)),
|
skillIds: toIds(pokemonForm.value.skillIds.slice(0, 2)),
|
||||||
favoriteThingIds: toIds(pokemonForm.value.favoriteThingIds.slice(0, 6))
|
favoriteThingIds: toIds(pokemonForm.value.favoriteThingIds.slice(0, 6)),
|
||||||
|
skillItemDrops: selectedSkillDropRows.value
|
||||||
|
.map((row) => ({ skillId: Number(row.skillId), itemId: Number(row.itemId) }))
|
||||||
|
.filter((row) => Number.isInteger(row.skillId) && row.skillId > 0 && Number.isInteger(row.itemId) && row.itemId > 0)
|
||||||
};
|
};
|
||||||
const saved = isEditing.value ? await api.updatePokemon(routeId.value, payload) : await api.createPokemon(payload);
|
const saved = isEditing.value ? await api.updatePokemon(routeId.value, payload) : await api.createPokemon(payload);
|
||||||
await router.push(`/pokemon/${saved.id}`);
|
await router.push(`/pokemon/${saved.id}`);
|
||||||
@@ -123,6 +169,8 @@ async function savePokemon() {
|
|||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
void loadEditor();
|
void loadEditor();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
watch(() => pokemonForm.value.skillIds.slice(), syncSkillItemDrops);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -190,6 +238,23 @@ onMounted(() => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-if="selectedSkillDropRows.length" class="field">
|
||||||
|
<span class="field-label">特长掉落物</span>
|
||||||
|
<div class="skill-drop-list">
|
||||||
|
<div v-for="row in selectedSkillDropRows" :key="row.skillId" class="skill-drop-row">
|
||||||
|
<label :for="`pokemon-skill-drops-${row.skillId}`">{{ skillDropLabel(row.skillId) }}</label>
|
||||||
|
<TagsSelect
|
||||||
|
:id="`pokemon-skill-drops-${row.skillId}`"
|
||||||
|
v-model="row.itemId"
|
||||||
|
:options="itemOptions"
|
||||||
|
:multiple="false"
|
||||||
|
placeholder="选择掉落物品"
|
||||||
|
search-placeholder="搜索物品"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button type="submit" class="link-button" :disabled="busy">{{ busy ? '保存中' : '保存' }}</button>
|
<button type="submit" class="link-button" :disabled="busy">{{ busy ? '保存中' : '保存' }}</button>
|
||||||
<RouterLink class="plain-button" :to="cancelTo">取消</RouterLink>
|
<RouterLink class="plain-button" :to="cancelTo">取消</RouterLink>
|
||||||
|
|||||||
Reference in New Issue
Block a user