From c15905bafddbd7579378b470fb1214f7ec2d9fa5 Mon Sep 17 00:00:00 2001 From: Kingsmai Date: Wed, 13 May 2026 15:32:07 +0800 Subject: [PATCH] feat(acquisition): add category grouping for acquisition methods Add category field to acquisition_methods table with 'General' default Group acquisition methods by category in item and recipe detail views Enable category management in admin configuration --- DESIGN.md | 5 ++-- backend/db/schema.sql | 4 +++ backend/src/queries.ts | 43 ++++++++++++++++++++++------ frontend/src/services/api.ts | 5 ++-- frontend/src/styles/main.css | 44 +++++++++++++++++++++++++++++ frontend/src/views/AdminView.vue | 14 +++++++-- frontend/src/views/ItemDetail.vue | 25 ++++++++++++++-- frontend/src/views/RecipeDetail.vue | 25 ++++++++++++++-- 8 files changed, 147 insertions(+), 18 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index f7d083c..6a3338b 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -505,6 +505,7 @@ ### 入手方式 - 名称 +- Category:用于将入手方式分组展示;未单独维护分类主数据,默认可使用 `General` - 可关联到物品和材料单。 ### 地图 @@ -712,7 +713,7 @@ Items 与 Event Items 使用相同数据模型: - Ancient Artifact 分类:仅在物品已配置 Ancient Artifact 分类时展示 - 分类 - 用途 -- 入手方式 +- 入手方式:按 Category 分类表格展示,左侧为 Category,右侧为该分类下的入手方式 value 逐行展示 - 客制化 - 标签 - Possible Tags:根据所有拥有支持 Trading 特长的 Pokemon Trading 观察推断该物品可能包含的隐藏标签 @@ -786,7 +787,7 @@ Ancient Artifacts 详情页使用同一套 Item Details 视图展示同一条 `i - 结果物品图片或默认材料单标记占位符;顶部概览卡片不显示 `Image` / `Details` 通用区块标题 - 结果物品名称、分类和用途;`GET /api/recipes/:id` 的 `item` 字段返回展示所需的 `id`、`name`、`image`、`category`、`usage` -- 入手方式 +- 入手方式:按 Category 分类表格展示,左侧为 Category,右侧为该分类下的入手方式 value 逐行展示 - 需要材料列表:展示材料物品图标;未配置图标时显示默认物品标记占位符 - 最后编辑信息 - 讨论 diff --git a/backend/db/schema.sql b/backend/db/schema.sql index fc8ad10..960d773 100644 --- a/backend/db/schema.sql +++ b/backend/db/schema.sql @@ -1169,6 +1169,7 @@ CREATE TABLE IF NOT EXISTS pokemon_favorite_things ( CREATE TABLE IF NOT EXISTS acquisition_methods ( id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, name text NOT NULL UNIQUE, + category text NOT NULL DEFAULT 'General', sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0), created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL, updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL, @@ -1565,6 +1566,9 @@ ALTER TABLE environments ALTER TABLE favorite_things ADD COLUMN IF NOT EXISTS opposite_favorite_thing_id integer; +ALTER TABLE acquisition_methods + ADD COLUMN IF NOT EXISTS category text NOT NULL DEFAULT 'General'; + DO $$ BEGIN IF NOT EXISTS ( diff --git a/backend/src/queries.ts b/backend/src/queries.ts index c98c8ac..c6600f0 100644 --- a/backend/src/queries.ts +++ b/backend/src/queries.ts @@ -155,6 +155,7 @@ type ConfigType = type ConfigDefinition = { table: string; entityType: EntityType; + hasCategory?: boolean; hasItemDrop?: boolean; hasTrading?: boolean; hasChangeLog?: boolean; @@ -663,6 +664,7 @@ type DailyChecklistChangeSource = { } & TranslationChangeSource; type ConfigChangeSource = { name: string; + category?: string; description?: string; opposite?: { name: string } | null; hasItemDrop?: boolean; @@ -734,7 +736,7 @@ const configDefinitions: Record = { skills: { table: 'skills', entityType: 'skills', hasItemDrop: true, hasTrading: true }, environments: { table: 'environments', entityType: 'environments', hasDescription: true, oppositeColumn: 'opposite_environment_id' }, 'favorite-things': { table: 'favorite_things', entityType: 'favorite-things', oppositeColumn: 'opposite_favorite_thing_id' }, - 'acquisition-methods': { table: 'acquisition_methods', entityType: 'acquisition-methods' }, + 'acquisition-methods': { table: 'acquisition_methods', entityType: 'acquisition-methods', hasCategory: true }, maps: { table: 'maps', entityType: 'maps' }, 'game-versions': { table: 'game_versions', entityType: 'game-versions', hasChangeLog: true }, 'dish-flavors': { table: 'dish_flavors', entityType: 'dish-flavors' } @@ -1042,10 +1044,12 @@ async function deleteEntityTranslations(client: DbClient, entityType: EntityType function optionSelect( tableName: string, entityType: EntityType, - locale: string -): Promise> { + locale: string, + includeCategory = false +): Promise> { const name = localizedName(entityType, 'o', locale); - return query(`SELECT o.id, ${name} AS name FROM ${tableName} o ORDER BY ${orderByEntity('o')}`); + const columns = includeCategory ? `o.id, ${name} AS name, o.category` : `o.id, ${name} AS name`; + return query(`SELECT ${columns} FROM ${tableName} o ORDER BY ${orderByEntity('o')}`); } function systemListLabel(option: SystemListOption, locale: string): string { @@ -1178,6 +1182,9 @@ function configSelect(definition: ConfigDefinition, locale: string): string { if (definition.hasDescription) { columns.push(`c.description`); } + if (definition.hasCategory) { + columns.push(`c.category`); + } if (definition.oppositeColumn) { columns.push( ` @@ -1247,6 +1254,11 @@ function cleanOptionalText(value: unknown): string { return typeof value === 'string' ? value.trim() : ''; } +function cleanConfigCategory(value: unknown): string { + const category = cleanOptionalText(value); + return category || 'General'; +} + function isStaticImageFileName(fileName: string): boolean { return Boolean(fileName) && !fileName.includes('/') && !fileName.includes('\\') && !fileName.includes('..') && /^[A-Za-z0-9._()-]+$/.test(fileName); } @@ -2745,6 +2757,7 @@ function configEditChanges( before: ConfigChangeSource, after: { name: string; + category?: string; description: string; translations: TranslationInput; oppositeName: string; @@ -2756,6 +2769,9 @@ function configEditChanges( const changes: EditChange[] = []; pushChange(changes, 'Name', before.name, after.name); pushTranslationChanges(changes, before.translations, after.translations, ['name']); + if (definition.hasCategory) { + pushChange(changes, 'Category', before.category, after.category); + } if (definition.hasDescription) { pushChange(changes, 'Description', before.description, after.description); } @@ -2883,7 +2899,7 @@ export async function getOptions(locale = defaultLocale) { skillOptions(locale), optionSelect('environments', 'environments', locale), optionSelect('favorite_things', 'favorite-things', locale), - optionSelect('acquisition_methods', 'acquisition-methods', locale), + optionSelect('acquisition_methods', 'acquisition-methods', locale, true), optionSelect('maps', 'maps', locale), gameVersionOptions(locale), optionSelect('dish_flavors', 'dish-flavors', locale), @@ -5581,6 +5597,7 @@ export async function createConfig(type: ConfigType, payload: Record getJson>(`/api/admin/config/${type}`), createConfig: ( type: ConfigType, - payload: { name: string; translations?: TranslationMap; description?: string; oppositeId?: number | null; hasItemDrop?: boolean; hasTrading?: boolean; changeLog?: string } + payload: { name: string; translations?: TranslationMap; category?: string; description?: string; oppositeId?: number | null; hasItemDrop?: boolean; hasTrading?: boolean; changeLog?: string } ) => sendJson(`/api/admin/config/${type}`, 'POST', payload), reorderConfig: (type: ConfigType, ids: number[]) => @@ -1634,7 +1635,7 @@ export const api = { updateConfig: ( type: ConfigType, id: number, - payload: { name: string; translations?: TranslationMap; description?: string; oppositeId?: number | null; hasItemDrop?: boolean; hasTrading?: boolean; changeLog?: string } + payload: { name: string; translations?: TranslationMap; category?: string; description?: string; oppositeId?: number | null; hasItemDrop?: boolean; hasTrading?: boolean; changeLog?: string } ) => sendJson(`/api/admin/config/${type}/${id}`, 'PUT', payload), deleteConfig: (type: ConfigType, id: number) => deleteJson(`/api/admin/config/${type}/${id}`), diff --git a/frontend/src/styles/main.css b/frontend/src/styles/main.css index 0add4c7..1e88631 100644 --- a/frontend/src/styles/main.css +++ b/frontend/src/styles/main.css @@ -5738,6 +5738,50 @@ button:disabled, font-variant-numeric: tabular-nums; } +.entity-method-table { + width: 100%; + overflow: hidden; + border: 1px solid var(--line); + border-collapse: separate; + border-spacing: 0; + border-radius: var(--radius-card); + background: var(--surface); +} + +.entity-method-table th, +.entity-method-table td { + padding: 10px 12px; + border-top: 1px solid var(--line); + text-align: left; + vertical-align: top; +} + +.entity-method-table tr:first-child th, +.entity-method-table tr:first-child td { + border-top: 0; +} + +.entity-method-table th { + width: 32%; + background: var(--surface-soft); + color: var(--muted); + font-size: 0.8rem; + font-weight: 900; + line-height: 1.25; + overflow-wrap: anywhere; +} + +.entity-method-table td { + color: var(--ink); + font-weight: 850; + line-height: 1.35; +} + +.entity-method-table td span { + display: block; + overflow-wrap: anywhere; +} + .entity-profile-title-link { justify-self: center; color: var(--pokemon-blue-deep); diff --git a/frontend/src/views/AdminView.vue b/frontend/src/views/AdminView.vue index e941204..66e1b46 100644 --- a/frontend/src/views/AdminView.vue +++ b/frontend/src/views/AdminView.vue @@ -96,6 +96,7 @@ type AdminGroup = 'content' | 'configuration' | 'localization' | 'access'; type AdminNavItem = { key: AdminTab; label: string; permission: string | string[] }; type AdminNavGroup = { key: AdminGroup; label: string; items: AdminNavItem[] }; type EditableConfig = (NamedEntity | Skill | GameVersion) & { + category?: string; description?: string; opposite?: NamedEntity | null; hasItemDrop?: boolean; @@ -210,6 +211,7 @@ const configTypes = computed< supportsItemDrop?: boolean; supportsTrading?: boolean; supportsChangeLog?: boolean; + supportsCategory?: boolean; supportsDescription?: boolean; supportsOpposite?: boolean; }> @@ -218,7 +220,7 @@ const configTypes = computed< { key: 'skills', label: t('config.skills'), supportsItemDrop: true, supportsTrading: true }, { key: 'environments', label: t('config.environments'), supportsDescription: true, supportsOpposite: true }, { key: 'favorite-things', label: t('config.favoriteThings'), supportsOpposite: true }, - { key: 'acquisition-methods', label: t('config.acquisitionMethods') }, + { key: 'acquisition-methods', label: t('config.acquisitionMethods'), supportsCategory: true }, { key: 'maps', label: t('config.maps') }, { key: 'game-versions', label: t('config.gameVersions'), supportsChangeLog: true }, { key: 'dish-flavors', label: t('config.dishFlavors') } @@ -254,6 +256,7 @@ const message = ref(''); const configForm = ref({ id: 0, name: '', + category: 'General', description: '', oppositeId: '', translations: {} as TranslationMap, @@ -632,7 +635,7 @@ async function loadModuleSettings() { } function resetConfigForm() { - configForm.value = { id: 0, name: '', description: '', oppositeId: '', translations: {}, hasItemDrop: false, hasTrading: false, changeLog: '' }; + configForm.value = { id: 0, name: '', category: 'General', description: '', oppositeId: '', translations: {}, hasItemDrop: false, hasTrading: false, changeLog: '' }; } function resetChecklistForm() { @@ -740,6 +743,7 @@ function editConfig(item: EditableConfig) { configForm.value = { id: item.id, name: item.baseName ?? item.name, + category: item.category ?? 'General', description: item.description ?? '', oppositeId: item.opposite ? String(item.opposite.id) : '', translations: item.translations ?? {}, @@ -1143,6 +1147,7 @@ async function saveConfig() { const payload = { name: configBaseNameForSave(), translations: configForm.value.translations, + category: selectedConfig.value.supportsCategory ? configForm.value.category : undefined, description: selectedConfig.value.supportsDescription ? configForm.value.description : undefined, oppositeId: selectedConfig.value.supportsOpposite && configForm.value.oppositeId ? Number(configForm.value.oppositeId) : null, hasItemDrop: selectedConfig.value.supportsItemDrop ? configForm.value.hasItemDrop : undefined, @@ -2231,6 +2236,7 @@ onMounted(() => {