diff --git a/DESIGN.md b/DESIGN.md index 4ecabb0..23ed2f5 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -17,12 +17,14 @@ Pokemon 可配置: - ID - 名字 - 特长(可多选,最多 2 个) +- 特长掉落物品(按 Pokemon + 特长 配置,单选物品) - 喜欢的环境(单选) - 喜欢的东西(可多选,最多 6 个) - 出现的栖息地(可多选) 特长 可配置: - 名称 +- 是否有掉落物 喜欢的环境 可配置: - 名称 @@ -79,6 +81,7 @@ Pokemon 可配置: - 喜欢的东西(可多选,满足任意条件 / 满足全部条件) - Pokemon 详情页 - 特长 + - 特长掉落物品 - 喜欢的环境 - 喜欢的东西 - 栖息地 @@ -108,6 +111,7 @@ Pokemon 可配置: - 需要材料列表 - 标签 - 相关栖息地 + - 相关 Pokemon 掉落 - 材料单详情页 - 基本信息 - 入手方式 diff --git a/backend/db/schema.sql b/backend/db/schema.sql index 21548ba..fba4281 100644 --- a/backend/db/schema.sql +++ b/backend/db/schema.sql @@ -40,10 +40,12 @@ CREATE INDEX IF NOT EXISTS user_sessions_user_id_idx CREATE TABLE IF NOT EXISTS skills ( 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 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 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) ); +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 ( recipe_id integer NOT NULL REFERENCES recipes(id) ON DELETE CASCADE, item_id integer NOT NULL REFERENCES items(id), diff --git a/backend/src/queries.ts b/backend/src/queries.ts index 1a8c8d9..d77f1e5 100644 --- a/backend/src/queries.ts +++ b/backend/src/queries.ts @@ -20,6 +20,7 @@ type ConfigType = type ConfigDefinition = { table: string; order: string; + hasItemDrop?: boolean; }; type IdQuantity = { @@ -27,12 +28,18 @@ type IdQuantity = { quantity: number; }; +type SkillItemDrop = { + skillId: number; + itemId: number; +}; + type PokemonPayload = { id: number; name: string; environmentId: number; skillIds: number[]; favoriteThingIds: number[]; + skillItemDrops: SkillItemDrop[]; }; type ItemPayload = { @@ -71,7 +78,7 @@ const timeOfDays = ['早晨', '中午', '傍晚', '晚上']; const weathers = ['晴天', '阴天', '雨天']; const configDefinitions: Record = { - skills: { table: 'skills', order: 'name' }, + skills: { table: 'skills', order: 'name', hasItemDrop: true }, environments: { table: 'environments', order: 'name' }, 'favorite-things': { table: 'favorite_things', order: 'name' }, 'item-categories': { table: 'item_categories', order: 'name' }, @@ -88,6 +95,10 @@ function optionSelect(tableName: string): Promise> { + 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 { return ` ${entityAlias}.created_at AS "createdAt", @@ -117,6 +128,10 @@ function configOrder(definition: ConfigDefinition): string { .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 { const error = new Error(message) as ValidationError; error.statusCode = 400; @@ -209,7 +224,7 @@ const pokemonProjection = ` ${auditSelect('p', 'pokemon_created_user', 'pokemon_updated_user')}, json_build_object('id', e.id, 'name', e.name) AS environment, 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 JOIN skills s ON s.id = ps.skill_id WHERE ps.pokemon_id = p.id @@ -235,7 +250,7 @@ export async function getOptions() { acquisitionMethods, maps ] = await Promise.all([ - optionSelect('skills'), + skillOptions(), optionSelect('environments'), optionSelect('favorite_things'), optionSelect('item_categories'), @@ -264,7 +279,7 @@ export async function listConfig(type: ConfigType) { const definition = configDefinitions[type]; return query( ` - SELECT c.id, c.name, ${auditSelect('c')} + SELECT ${configSelect(definition)}, ${auditSelect('c')} FROM ${definition.table} c ${auditJoins('c')} ORDER BY ${configOrder(definition)} @@ -276,7 +291,7 @@ async function getConfigById(type: ConfigType, id: number) { const definition = configDefinitions[type]; return queryOne( ` - SELECT c.id, c.name, ${auditSelect('c')} + SELECT ${configSelect(definition)}, ${auditSelect('c')} FROM ${definition.table} c ${auditJoins('c')} WHERE c.id = $1 @@ -288,16 +303,26 @@ async function getConfigById(type: ConfigType, id: number) { export async function createConfig(type: ConfigType, payload: Record, userId: number) { const definition = configDefinitions[type]; const name = cleanName(payload.name); + const hasItemDrop = definition.hasItemDrop ? Boolean(payload.hasItemDrop) : false; const id = await withTransaction(async (client) => { - const result = 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 result = definition.hasItemDrop + ? await client.query<{ id: number }>( + ` + INSERT INTO ${definition.table} (name, has_item_drop, created_by_user_id, updated_by_user_id) + VALUES ($1, $2, $3, $3) + RETURNING id + `, + [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; await recordEditLog(client, type, createdId, 'create', userId); @@ -310,21 +335,35 @@ export async function createConfig(type: ConfigType, payload: Record, userId: number) { const definition = configDefinitions[type]; const name = cleanName(payload.name); + const hasItemDrop = definition.hasItemDrop ? Boolean(payload.hasItemDrop) : false; const updated = await withTransaction(async (client) => { - const result = await client.query( - ` - UPDATE ${definition.table} - SET name = $1, updated_by_user_id = $2, updated_at = now() - WHERE id = $3 - `, - [name, userId, id] - ); + const result = definition.hasItemDrop + ? await client.query( + ` + UPDATE ${definition.table} + SET name = $1, has_item_drop = $2, updated_by_user_id = $3, updated_at = now() + WHERE id = $4 + `, + [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) { 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); return true; }); @@ -399,30 +438,58 @@ export async function getPokemon(id: number) { return null; } - const habitats = await query( - ` - SELECT - h.id, - h.name, - hp.time_of_day, - hp.weather, - hp.rarity, - json_build_object('id', m.id, 'name', m.name) AS map - FROM habitat_pokemon hp - JOIN habitats h ON h.id = hp.habitat_id - JOIN maps m ON m.id = hp.map_id - WHERE hp.pokemon_id = $1 - ORDER BY h.name, hp.rarity, m.name - `, - [id] - ); + const [habitats, itemDrops] = await Promise.all([ + query( + ` + SELECT + h.id, + h.name, + hp.time_of_day, + hp.weather, + hp.rarity, + json_build_object('id', m.id, 'name', m.name) AS map + FROM habitat_pokemon hp + JOIN habitats h ON h.id = hp.habitat_id + JOIN maps m ON m.id = hp.map_id + WHERE hp.pokemon_id = $1 + ORDER BY h.name, hp.rarity, m.name + `, + [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()); + + 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): PokemonPayload { const skillIds = cleanIds(payload.skillIds); const favoriteThingIds = cleanIds(payload.favoriteThingIds); + const selectedSkillIds = new Set(skillIds); + const skillItemDrops = new Map(); if (skillIds.length > 2) { throw validationError('特长最多选择 2 个'); @@ -431,16 +498,36 @@ function cleanPokemonPayload(payload: Record): PokemonPayload { throw validationError('喜欢的东西最多选择 6 个'); } + if (Array.isArray(payload.skillItemDrops)) { + for (const item of payload.skillItemDrops) { + const row = item as Record; + 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 { id: requirePositiveInteger(payload.id, '请输入 Pokemon ID'), name: cleanName(payload.name, '请输入 Pokemon 名字'), environmentId: requirePositiveInteger(payload.environmentId, '请选择喜欢的环境'), skillIds, - favoriteThingIds + favoriteThingIds, + skillItemDrops: [...skillItemDrops.values()] }; } async function replacePokemonRelations(client: DbClient, pokemonId: number, payload: PokemonPayload): Promise { + 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_favorite_things WHERE pokemon_id = $1', [pokemonId]); @@ -454,6 +541,25 @@ async function replacePokemonRelations(client: DbClient, pokemonId: number, payl 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, userId: number) { @@ -758,7 +864,7 @@ export async function getItem(id: number) { return null; } - const [acquisitionMethods, recipe, relatedHabitats] = await Promise.all([ + const [acquisitionMethods, recipe, relatedHabitats, droppedByPokemon] = await Promise.all([ query( ` SELECT am.id, am.name @@ -804,10 +910,24 @@ export async function getItem(id: number) { ORDER BY h.name `, [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): ItemPayload { diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index fba7394..8cac8c3 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -7,6 +7,10 @@ export interface NamedEntity { name: string; } +export interface Skill extends NamedEntity { + hasItemDrop: boolean; +} + export interface UserSummary { id: number; displayName: string; @@ -23,11 +27,12 @@ export interface Pokemon extends EditInfo { id: number; name: string; environment: NamedEntity; - skills: NamedEntity[]; + skills: Skill[]; favorite_things: NamedEntity[]; } export interface PokemonDetail extends Pokemon { + skills: Array; habitats: Array<{ id: number; name: string; @@ -71,6 +76,10 @@ export interface ItemDetail extends Item { acquisitionMethods: NamedEntity[]; recipe: RecipeDetail | null; relatedHabitats: Array; + droppedByPokemon: Array<{ + pokemon: NamedEntity; + skill: NamedEntity; + }>; } export interface Recipe extends EditInfo { @@ -85,7 +94,7 @@ export interface RecipeDetail extends Recipe { } export interface Options { - skills: NamedEntity[]; + skills: Skill[]; environments: NamedEntity[]; favoriteThings: NamedEntity[]; itemCategories: NamedEntity[]; @@ -131,6 +140,7 @@ export interface PokemonPayload { environmentId: number; skillIds: number[]; favoriteThingIds: number[]; + skillItemDrops: Array<{ skillId: number; itemId: number }>; } export interface ItemPayload { @@ -279,11 +289,11 @@ export const api = { me: () => getJson<{ user: AuthUser }>('/api/auth/me'), logout: () => postEmpty('/api/auth/logout'), options: () => getJson('/api/options'), - config: (type: ConfigType) => getJson(`/api/admin/config/${type}`), - createConfig: (type: ConfigType, payload: { name: string }) => - sendJson(`/api/admin/config/${type}`, 'POST', payload), - updateConfig: (type: ConfigType, id: number, payload: { name: string }) => - sendJson(`/api/admin/config/${type}/${id}`, 'PUT', payload), + config: (type: ConfigType) => getJson>(`/api/admin/config/${type}`), + createConfig: (type: ConfigType, payload: { name: string; hasItemDrop?: boolean }) => + sendJson(`/api/admin/config/${type}`, 'POST', payload), + updateConfig: (type: ConfigType, id: number, payload: { name: string; hasItemDrop?: boolean }) => + sendJson(`/api/admin/config/${type}/${id}`, 'PUT', payload), deleteConfig: (type: ConfigType, id: number) => deleteJson(`/api/admin/config/${type}/${id}`), pokemon: (params: Record) => getJson(`/api/pokemon${buildQuery(params)}`), diff --git a/frontend/src/styles/main.css b/frontend/src/styles/main.css index 576cbdb..1151872 100644 --- a/frontend/src/styles/main.css +++ b/frontend/src/styles/main.css @@ -854,6 +854,20 @@ button:disabled, 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 { display: flex; flex-wrap: wrap; @@ -944,6 +958,14 @@ button:disabled, border-bottom: 0; } +.skill-drop-summary li { + align-items: flex-start; +} + +.skill-drop-summary .chips { + justify-content: flex-end; +} + .appearance-list li { display: grid; grid-template-columns: max-content minmax(0, 1fr); @@ -1166,6 +1188,24 @@ button:disabled, 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 { display: inline-flex; align-items: center; diff --git a/frontend/src/views/AdminView.vue b/frontend/src/views/AdminView.vue index 3c4512a..4e2ee76 100644 --- a/frontend/src/views/AdminView.vue +++ b/frontend/src/views/AdminView.vue @@ -12,11 +12,12 @@ import { type Item, type NamedEntity, type Pokemon, - type Recipe + type Recipe, + type Skill } from '../services/api'; type AdminTab = 'config' | 'pokemon' | 'items' | 'recipes' | 'habitats'; -type EditableConfig = NamedEntity; +type EditableConfig = (NamedEntity | Skill) & { hasItemDrop?: boolean }; const tabs: Array<{ key: AdminTab; label: string }> = [ { key: 'config', label: '系统配置' }, @@ -26,8 +27,8 @@ const tabs: Array<{ key: AdminTab; label: string }> = [ { key: 'habitats', label: '栖息地' } ]; -const configTypes: Array<{ key: ConfigType; label: string }> = [ - { key: 'skills', label: '特长' }, +const configTypes: Array<{ key: ConfigType; label: string; supportsItemDrop?: boolean }> = [ + { key: 'skills', label: '特长', supportsItemDrop: true }, { key: 'environments', label: '喜欢的环境' }, { key: 'favorite-things', label: '喜欢的东西 / 标签' }, { key: 'item-categories', label: '物品分类' }, @@ -47,7 +48,7 @@ const currentUser = ref(null); const busy = ref(false); const contentLoading = ref(false); 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 configTabs = computed(() => configTypes.map((item) => ({ value: item.key, label: item.label }))); @@ -86,17 +87,18 @@ async function loadConfig() { } function resetConfigForm() { - configForm.value = { id: 0, name: '' }; + configForm.value = { id: 0, name: '', hasItemDrop: false }; } 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() { await run(async () => { const payload = { - name: configForm.value.name + name: configForm.value.name, + hasItemDrop: selectedConfig.value.supportsItemDrop ? configForm.value.hasItemDrop : undefined }; if (configForm.value.id) { @@ -245,6 +247,12 @@ onMounted(() => { +
+ +
@@ -254,7 +262,7 @@ onMounted(() => {

{{ selectedConfig.label }}

  • - {{ item.name }} + {{ item.name }}有掉落物 diff --git a/frontend/src/views/ItemDetail.vue b/frontend/src/views/ItemDetail.vue index d870554..550467c 100644 --- a/frontend/src/views/ItemDetail.vue +++ b/frontend/src/views/ItemDetail.vue @@ -118,12 +118,23 @@ onMounted(async () => { -
      +
      • {{ habitat.name }} × {{ habitat.quantity }}
      +

      + + + +
        +
      • + #{{ entry.pokemon.id }} {{ entry.pokemon.name }} + {{ entry.skill.name }}掉落物 +
      • +
      +

diff --git a/frontend/src/views/PokemonDetail.vue b/frontend/src/views/PokemonDetail.vue index 12ac337..79ad5e1 100644 --- a/frontend/src/views/PokemonDetail.vue +++ b/frontend/src/views/PokemonDetail.vue @@ -74,6 +74,7 @@ const habitatRows = computed(() => { maps: [...row.maps].sort((a, b) => a.localeCompare(b)) })); }); +const skillDropRows = computed(() => pokemon.value?.skills.filter((skill) => skill.itemDrop) ?? []); onMounted(async () => { pokemon.value = await api.pokemonDetail(String(route.params.id)); @@ -154,6 +155,15 @@ onMounted(async () => { + +
    +
  • + {{ skill.name }}掉落物 + {{ skill.itemDrop.name }} +
  • +
+
+ diff --git a/frontend/src/views/PokemonEdit.vue b/frontend/src/views/PokemonEdit.vue index 095f77c..26fddc0 100644 --- a/frontend/src/views/PokemonEdit.vue +++ b/frontend/src/views/PokemonEdit.vue @@ -1,15 +1,21 @@