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
This commit is contained in:
2026-05-13 15:32:07 +08:00
parent 5c72766781
commit c15905bafd
8 changed files with 147 additions and 18 deletions

View File

@@ -505,6 +505,7 @@
### 入手方式 ### 入手方式
- 名称 - 名称
- Category用于将入手方式分组展示未单独维护分类主数据默认可使用 `General`
- 可关联到物品和材料单。 - 可关联到物品和材料单。
### 地图 ### 地图
@@ -712,7 +713,7 @@ Items 与 Event Items 使用相同数据模型:
- Ancient Artifact 分类:仅在物品已配置 Ancient Artifact 分类时展示 - Ancient Artifact 分类:仅在物品已配置 Ancient Artifact 分类时展示
- 分类 - 分类
- 用途 - 用途
- 入手方式 - 入手方式:按 Category 分类表格展示,左侧为 Category右侧为该分类下的入手方式 value 逐行展示
- 客制化 - 客制化
- 标签 - 标签
- Possible Tags根据所有拥有支持 Trading 特长的 Pokemon Trading 观察推断该物品可能包含的隐藏标签 - Possible Tags根据所有拥有支持 Trading 特长的 Pokemon Trading 观察推断该物品可能包含的隐藏标签
@@ -786,7 +787,7 @@ Ancient Artifacts 详情页使用同一套 Item Details 视图展示同一条 `i
- 结果物品图片或默认材料单标记占位符;顶部概览卡片不显示 `Image` / `Details` 通用区块标题 - 结果物品图片或默认材料单标记占位符;顶部概览卡片不显示 `Image` / `Details` 通用区块标题
- 结果物品名称、分类和用途;`GET /api/recipes/:id``item` 字段返回展示所需的 `id``name``image``category``usage` - 结果物品名称、分类和用途;`GET /api/recipes/:id``item` 字段返回展示所需的 `id``name``image``category``usage`
- 入手方式 - 入手方式:按 Category 分类表格展示,左侧为 Category右侧为该分类下的入手方式 value 逐行展示
- 需要材料列表:展示材料物品图标;未配置图标时显示默认物品标记占位符 - 需要材料列表:展示材料物品图标;未配置图标时显示默认物品标记占位符
- 最后编辑信息 - 最后编辑信息
- 讨论 - 讨论

View File

@@ -1169,6 +1169,7 @@ CREATE TABLE IF NOT EXISTS pokemon_favorite_things (
CREATE TABLE IF NOT EXISTS acquisition_methods ( CREATE TABLE IF NOT EXISTS acquisition_methods (
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 text NOT NULL DEFAULT 'General',
sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0), sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0),
created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL, created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
updated_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 ALTER TABLE favorite_things
ADD COLUMN IF NOT EXISTS opposite_favorite_thing_id integer; 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 $$ DO $$
BEGIN BEGIN
IF NOT EXISTS ( IF NOT EXISTS (

View File

@@ -155,6 +155,7 @@ type ConfigType =
type ConfigDefinition = { type ConfigDefinition = {
table: string; table: string;
entityType: EntityType; entityType: EntityType;
hasCategory?: boolean;
hasItemDrop?: boolean; hasItemDrop?: boolean;
hasTrading?: boolean; hasTrading?: boolean;
hasChangeLog?: boolean; hasChangeLog?: boolean;
@@ -663,6 +664,7 @@ type DailyChecklistChangeSource = {
} & TranslationChangeSource; } & TranslationChangeSource;
type ConfigChangeSource = { type ConfigChangeSource = {
name: string; name: string;
category?: string;
description?: string; description?: string;
opposite?: { name: string } | null; opposite?: { name: string } | null;
hasItemDrop?: boolean; hasItemDrop?: boolean;
@@ -734,7 +736,7 @@ const configDefinitions: Record<ConfigType, ConfigDefinition> = {
skills: { table: 'skills', entityType: 'skills', hasItemDrop: true, hasTrading: true }, skills: { table: 'skills', entityType: 'skills', hasItemDrop: true, hasTrading: true },
environments: { table: 'environments', entityType: 'environments', hasDescription: true, oppositeColumn: 'opposite_environment_id' }, environments: { table: 'environments', entityType: 'environments', hasDescription: true, oppositeColumn: 'opposite_environment_id' },
'favorite-things': { table: 'favorite_things', entityType: 'favorite-things', oppositeColumn: 'opposite_favorite_thing_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' }, maps: { table: 'maps', entityType: 'maps' },
'game-versions': { table: 'game_versions', entityType: 'game-versions', hasChangeLog: true }, 'game-versions': { table: 'game_versions', entityType: 'game-versions', hasChangeLog: true },
'dish-flavors': { table: 'dish_flavors', entityType: 'dish-flavors' } 'dish-flavors': { table: 'dish_flavors', entityType: 'dish-flavors' }
@@ -1042,10 +1044,12 @@ async function deleteEntityTranslations(client: DbClient, entityType: EntityType
function optionSelect( function optionSelect(
tableName: string, tableName: string,
entityType: EntityType, entityType: EntityType,
locale: string locale: string,
): Promise<Array<{ id: number; name: string }>> { includeCategory = false
): Promise<Array<{ id: number; name: string; category?: string }>> {
const name = localizedName(entityType, 'o', locale); 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 { function systemListLabel(option: SystemListOption, locale: string): string {
@@ -1178,6 +1182,9 @@ function configSelect(definition: ConfigDefinition, locale: string): string {
if (definition.hasDescription) { if (definition.hasDescription) {
columns.push(`c.description`); columns.push(`c.description`);
} }
if (definition.hasCategory) {
columns.push(`c.category`);
}
if (definition.oppositeColumn) { if (definition.oppositeColumn) {
columns.push( columns.push(
` `
@@ -1247,6 +1254,11 @@ function cleanOptionalText(value: unknown): string {
return typeof value === 'string' ? value.trim() : ''; return typeof value === 'string' ? value.trim() : '';
} }
function cleanConfigCategory(value: unknown): string {
const category = cleanOptionalText(value);
return category || 'General';
}
function isStaticImageFileName(fileName: string): boolean { function isStaticImageFileName(fileName: string): boolean {
return Boolean(fileName) && !fileName.includes('/') && !fileName.includes('\\') && !fileName.includes('..') && /^[A-Za-z0-9._()-]+$/.test(fileName); return Boolean(fileName) && !fileName.includes('/') && !fileName.includes('\\') && !fileName.includes('..') && /^[A-Za-z0-9._()-]+$/.test(fileName);
} }
@@ -2745,6 +2757,7 @@ function configEditChanges(
before: ConfigChangeSource, before: ConfigChangeSource,
after: { after: {
name: string; name: string;
category?: string;
description: string; description: string;
translations: TranslationInput; translations: TranslationInput;
oppositeName: string; oppositeName: string;
@@ -2756,6 +2769,9 @@ function configEditChanges(
const changes: EditChange[] = []; const changes: EditChange[] = [];
pushChange(changes, 'Name', before.name, after.name); pushChange(changes, 'Name', before.name, after.name);
pushTranslationChanges(changes, before.translations, after.translations, ['name']); pushTranslationChanges(changes, before.translations, after.translations, ['name']);
if (definition.hasCategory) {
pushChange(changes, 'Category', before.category, after.category);
}
if (definition.hasDescription) { if (definition.hasDescription) {
pushChange(changes, 'Description', before.description, after.description); pushChange(changes, 'Description', before.description, after.description);
} }
@@ -2883,7 +2899,7 @@ export async function getOptions(locale = defaultLocale) {
skillOptions(locale), skillOptions(locale),
optionSelect('environments', 'environments', locale), optionSelect('environments', 'environments', locale),
optionSelect('favorite_things', 'favorite-things', locale), optionSelect('favorite_things', 'favorite-things', locale),
optionSelect('acquisition_methods', 'acquisition-methods', locale), optionSelect('acquisition_methods', 'acquisition-methods', locale, true),
optionSelect('maps', 'maps', locale), optionSelect('maps', 'maps', locale),
gameVersionOptions(locale), gameVersionOptions(locale),
optionSelect('dish_flavors', 'dish-flavors', locale), optionSelect('dish_flavors', 'dish-flavors', locale),
@@ -5581,6 +5597,7 @@ export async function createConfig(type: ConfigType, payload: Record<string, unk
const definition = configDefinitions[type]; const definition = configDefinitions[type];
const name = cleanName(payload.name); const name = cleanName(payload.name);
const translations = cleanTranslations(payload.translations, ['name']); const translations = cleanTranslations(payload.translations, ['name']);
const category = definition.hasCategory ? cleanConfigCategory(payload.category) : '';
const description = definition.hasDescription ? cleanOptionalText(payload.description) : ''; const description = definition.hasDescription ? cleanOptionalText(payload.description) : '';
const oppositeId = definition.oppositeColumn ? cleanOptionalPositiveInteger(payload.oppositeId) : null; const oppositeId = definition.oppositeColumn ? cleanOptionalPositiveInteger(payload.oppositeId) : null;
const hasItemDrop = definition.hasItemDrop ? Boolean(payload.hasItemDrop) : false; const hasItemDrop = definition.hasItemDrop ? Boolean(payload.hasItemDrop) : false;
@@ -5595,6 +5612,10 @@ export async function createConfig(type: ConfigType, payload: Record<string, unk
columns.push('description'); columns.push('description');
values.push(description); values.push(description);
} }
if (definition.hasCategory) {
columns.push('category');
values.push(category);
}
if (definition.hasItemDrop) { if (definition.hasItemDrop) {
columns.push('has_item_drop'); columns.push('has_item_drop');
values.push(hasItemDrop); values.push(hasItemDrop);
@@ -5653,6 +5674,7 @@ export async function updateConfig(
const definition = configDefinitions[type]; const definition = configDefinitions[type];
const name = cleanName(payload.name); const name = cleanName(payload.name);
const translations = cleanTranslations(payload.translations, ['name']); const translations = cleanTranslations(payload.translations, ['name']);
const category = definition.hasCategory ? cleanConfigCategory(payload.category) : '';
const description = definition.hasDescription ? cleanOptionalText(payload.description) : ''; const description = definition.hasDescription ? cleanOptionalText(payload.description) : '';
const oppositeId = definition.oppositeColumn ? cleanOptionalPositiveInteger(payload.oppositeId) : null; const oppositeId = definition.oppositeColumn ? cleanOptionalPositiveInteger(payload.oppositeId) : null;
const before = await getConfigById(type, id, defaultLocale); const before = await getConfigById(type, id, defaultLocale);
@@ -5675,6 +5697,10 @@ export async function updateConfig(
values.push(description); values.push(description);
assignments.push(`description = $${values.length}`); assignments.push(`description = $${values.length}`);
} }
if (definition.hasCategory) {
values.push(category);
assignments.push(`category = $${values.length}`);
}
if (definition.hasItemDrop) { if (definition.hasItemDrop) {
values.push(hasItemDrop); values.push(hasItemDrop);
assignments.push(`has_item_drop = $${values.length}`); assignments.push(`has_item_drop = $${values.length}`);
@@ -5735,6 +5761,7 @@ export async function updateConfig(
const changes = before const changes = before
? configEditChanges(definition, before as ConfigChangeSource, { ? configEditChanges(definition, before as ConfigChangeSource, {
name, name,
category,
description, description,
translations, translations,
oppositeName: oppositeId ? oppositeNames.get(oppositeId) ?? '' : '', oppositeName: oppositeId ? oppositeNames.get(oppositeId) ?? '' : '',
@@ -6813,7 +6840,7 @@ export async function getItem(id: number, locale = defaultLocale) {
] = await Promise.all([ ] = await Promise.all([
query( query(
` `
SELECT am.id, ${acquisitionMethodName} AS name SELECT am.id, ${acquisitionMethodName} AS name, am.category
FROM item_acquisition_methods iam FROM item_acquisition_methods iam
JOIN acquisition_methods am ON am.id = iam.acquisition_method_id JOIN acquisition_methods am ON am.id = iam.acquisition_method_id
WHERE iam.item_id = $1 WHERE iam.item_id = $1
@@ -6828,7 +6855,7 @@ export async function getItem(id: number, locale = defaultLocale) {
${resultItemName} AS name, ${resultItemName} AS name,
${auditSelect('r', 'recipe_created_user', 'recipe_updated_user')}, ${auditSelect('r', 'recipe_created_user', 'recipe_updated_user')},
COALESCE(( COALESCE((
SELECT json_agg(json_build_object('id', am.id, 'name', ${acquisitionMethodName}) ORDER BY ${orderByEntity('am')}) SELECT json_agg(json_build_object('id', am.id, 'name', ${acquisitionMethodName}, 'category', am.category) ORDER BY ${orderByEntity('am')})
FROM recipe_acquisition_methods ram FROM recipe_acquisition_methods ram
JOIN acquisition_methods am ON am.id = ram.acquisition_method_id JOIN acquisition_methods am ON am.id = ram.acquisition_method_id
WHERE ram.recipe_id = r.id WHERE ram.recipe_id = r.id
@@ -7496,7 +7523,7 @@ export async function getRecipe(id: number, locale = defaultLocale) {
${resultItemName} AS name, ${resultItemName} AS name,
${auditSelect('r', 'recipe_created_user', 'recipe_updated_user')}, ${auditSelect('r', 'recipe_created_user', 'recipe_updated_user')},
COALESCE(( COALESCE((
SELECT json_agg(json_build_object('id', am.id, 'name', ${acquisitionMethodName}) ORDER BY ${orderByEntity('am')}) SELECT json_agg(json_build_object('id', am.id, 'name', ${acquisitionMethodName}, 'category', am.category) ORDER BY ${orderByEntity('am')})
FROM recipe_acquisition_methods ram FROM recipe_acquisition_methods ram
JOIN acquisition_methods am ON am.id = ram.acquisition_method_id JOIN acquisition_methods am ON am.id = ram.acquisition_method_id
WHERE ram.recipe_id = r.id WHERE ram.recipe_id = r.id

View File

@@ -100,6 +100,7 @@ export interface NamedEntity {
id: number; id: number;
name: string; name: string;
baseName?: string; baseName?: string;
category?: string;
description?: string; description?: string;
opposite?: NamedEntity | null; opposite?: NamedEntity | null;
translations?: TranslationMap; translations?: TranslationMap;
@@ -1626,7 +1627,7 @@ export const api = {
config: (type: ConfigType) => getJson<Array<Skill | GameVersion | NamedEntity>>(`/api/admin/config/${type}`), config: (type: ConfigType) => getJson<Array<Skill | GameVersion | NamedEntity>>(`/api/admin/config/${type}`),
createConfig: ( createConfig: (
type: ConfigType, 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<Skill | GameVersion | NamedEntity>(`/api/admin/config/${type}`, 'POST', payload), sendJson<Skill | GameVersion | NamedEntity>(`/api/admin/config/${type}`, 'POST', payload),
reorderConfig: (type: ConfigType, ids: number[]) => reorderConfig: (type: ConfigType, ids: number[]) =>
@@ -1634,7 +1635,7 @@ export const api = {
updateConfig: ( updateConfig: (
type: ConfigType, type: ConfigType,
id: number, 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<Skill | GameVersion | NamedEntity>(`/api/admin/config/${type}/${id}`, 'PUT', payload), sendJson<Skill | GameVersion | 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}`),

View File

@@ -5738,6 +5738,50 @@ button:disabled,
font-variant-numeric: tabular-nums; 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 { .entity-profile-title-link {
justify-self: center; justify-self: center;
color: var(--pokemon-blue-deep); color: var(--pokemon-blue-deep);

View File

@@ -96,6 +96,7 @@ type AdminGroup = 'content' | 'configuration' | 'localization' | 'access';
type AdminNavItem = { key: AdminTab; label: string; permission: string | string[] }; type AdminNavItem = { key: AdminTab; label: string; permission: string | string[] };
type AdminNavGroup = { key: AdminGroup; label: string; items: AdminNavItem[] }; type AdminNavGroup = { key: AdminGroup; label: string; items: AdminNavItem[] };
type EditableConfig = (NamedEntity | Skill | GameVersion) & { type EditableConfig = (NamedEntity | Skill | GameVersion) & {
category?: string;
description?: string; description?: string;
opposite?: NamedEntity | null; opposite?: NamedEntity | null;
hasItemDrop?: boolean; hasItemDrop?: boolean;
@@ -210,6 +211,7 @@ const configTypes = computed<
supportsItemDrop?: boolean; supportsItemDrop?: boolean;
supportsTrading?: boolean; supportsTrading?: boolean;
supportsChangeLog?: boolean; supportsChangeLog?: boolean;
supportsCategory?: boolean;
supportsDescription?: boolean; supportsDescription?: boolean;
supportsOpposite?: boolean; supportsOpposite?: boolean;
}> }>
@@ -218,7 +220,7 @@ const configTypes = computed<
{ key: 'skills', label: t('config.skills'), supportsItemDrop: true, supportsTrading: true }, { key: 'skills', label: t('config.skills'), supportsItemDrop: true, supportsTrading: true },
{ key: 'environments', label: t('config.environments'), supportsDescription: true, supportsOpposite: true }, { key: 'environments', label: t('config.environments'), supportsDescription: true, supportsOpposite: true },
{ key: 'favorite-things', label: t('config.favoriteThings'), 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: 'maps', label: t('config.maps') },
{ key: 'game-versions', label: t('config.gameVersions'), supportsChangeLog: true }, { key: 'game-versions', label: t('config.gameVersions'), supportsChangeLog: true },
{ key: 'dish-flavors', label: t('config.dishFlavors') } { key: 'dish-flavors', label: t('config.dishFlavors') }
@@ -254,6 +256,7 @@ const message = ref('');
const configForm = ref({ const configForm = ref({
id: 0, id: 0,
name: '', name: '',
category: 'General',
description: '', description: '',
oppositeId: '', oppositeId: '',
translations: {} as TranslationMap, translations: {} as TranslationMap,
@@ -632,7 +635,7 @@ async function loadModuleSettings() {
} }
function resetConfigForm() { 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() { function resetChecklistForm() {
@@ -740,6 +743,7 @@ function editConfig(item: EditableConfig) {
configForm.value = { configForm.value = {
id: item.id, id: item.id,
name: item.baseName ?? item.name, name: item.baseName ?? item.name,
category: item.category ?? 'General',
description: item.description ?? '', description: item.description ?? '',
oppositeId: item.opposite ? String(item.opposite.id) : '', oppositeId: item.opposite ? String(item.opposite.id) : '',
translations: item.translations ?? {}, translations: item.translations ?? {},
@@ -1143,6 +1147,7 @@ async function saveConfig() {
const payload = { const payload = {
name: configBaseNameForSave(), name: configBaseNameForSave(),
translations: configForm.value.translations, translations: configForm.value.translations,
category: selectedConfig.value.supportsCategory ? configForm.value.category : undefined,
description: selectedConfig.value.supportsDescription ? configForm.value.description : undefined, description: selectedConfig.value.supportsDescription ? configForm.value.description : undefined,
oppositeId: selectedConfig.value.supportsOpposite && configForm.value.oppositeId ? Number(configForm.value.oppositeId) : null, oppositeId: selectedConfig.value.supportsOpposite && configForm.value.oppositeId ? Number(configForm.value.oppositeId) : null,
hasItemDrop: selectedConfig.value.supportsItemDrop ? configForm.value.hasItemDrop : undefined, hasItemDrop: selectedConfig.value.supportsItemDrop ? configForm.value.hasItemDrop : undefined,
@@ -2231,6 +2236,7 @@ onMounted(() => {
<template #default="{ item }"> <template #default="{ item }">
<span class="reorderable-row-title"> <span class="reorderable-row-title">
{{ item.name }} {{ item.name }}
<span v-if="selectedConfig.supportsCategory" class="config-flag">{{ item.category ?? 'General' }}</span>
<span v-if="item.opposite" class="config-flag">{{ t('pages.admin.opposite') }}: {{ item.opposite.name }}</span> <span v-if="item.opposite" class="config-flag">{{ t('pages.admin.opposite') }}: {{ item.opposite.name }}</span>
<span v-if="item.hasItemDrop" class="config-flag">{{ t('pages.admin.hasItemDrop') }}</span> <span v-if="item.hasItemDrop" class="config-flag">{{ t('pages.admin.hasItemDrop') }}</span>
<span v-if="tradingModuleEnabled && item.hasTrading" class="config-flag">{{ t('pages.admin.hasTrading') }}</span> <span v-if="tradingModuleEnabled && item.hasTrading" class="config-flag">{{ t('pages.admin.hasTrading') }}</span>
@@ -3064,6 +3070,10 @@ onMounted(() => {
<label for="config-name">{{ t('common.name') }}</label> <label for="config-name">{{ t('common.name') }}</label>
<input id="config-name" v-model="configNameInput" :placeholder="configNamePlaceholder" :required="configNameRequired" /> <input id="config-name" v-model="configNameInput" :placeholder="configNamePlaceholder" :required="configNameRequired" />
</div> </div>
<div v-if="selectedConfig.supportsCategory" class="field">
<label for="config-category">{{ t('pages.admin.category') }}</label>
<input id="config-category" v-model="configForm.category" />
</div>
<div v-if="selectedConfig.supportsDescription" class="field"> <div v-if="selectedConfig.supportsDescription" class="field">
<label for="config-description">{{ t('pages.admin.description') }}</label> <label for="config-description">{{ t('pages.admin.description') }}</label>
<textarea id="config-description" v-model="configForm.description"></textarea> <textarea id="config-description" v-model="configForm.description"></textarea>

View File

@@ -13,7 +13,7 @@ import Skeleton from '../components/Skeleton.vue';
import Tabs, { type TabOption } from '../components/Tabs.vue'; import Tabs, { type TabOption } from '../components/Tabs.vue';
import { iconAdd, iconBack, iconEdit, iconHabitat, iconItem } from '../icons'; import { iconAdd, iconBack, iconEdit, iconHabitat, iconItem } from '../icons';
import { applySeo, resolvedSeoHead, resolveSeo } from '../seo'; import { applySeo, resolvedSeoHead, resolveSeo } from '../seo';
import { api, type AuthUser, type ItemDetail, type ModuleSettings } from '../services/api'; import { api, type AuthUser, type ItemDetail, type ModuleSettings, type NamedEntity } from '../services/api';
import ItemEdit from './ItemEdit.vue'; import ItemEdit from './ItemEdit.vue';
const route = useRoute(); const route = useRoute();
@@ -72,6 +72,7 @@ const possibleTagEvidenceSections = computed(() => [
{ key: 'likes', title: t('pages.pokemon.tradingLikes'), rows: item.value?.possibleTags?.evidence.likes ?? [] }, { key: 'likes', title: t('pages.pokemon.tradingLikes'), rows: item.value?.possibleTags?.evidence.likes ?? [] },
{ key: 'neutral', title: t('pages.pokemon.tradingNeutral'), rows: item.value?.possibleTags?.evidence.neutral ?? [] } { key: 'neutral', title: t('pages.pokemon.tradingNeutral'), rows: item.value?.possibleTags?.evidence.neutral ?? [] }
]); ]);
const acquisitionMethodGroups = computed(() => groupAcquisitionMethods(item.value?.acquisitionMethods ?? []));
const { data: moduleSettings } = useAsyncData<ModuleSettings>( const { data: moduleSettings } = useAsyncData<ModuleSettings>(
'module-settings', 'module-settings',
@@ -185,6 +186,17 @@ function activeItemRouteId(): string | null {
: null; : null;
} }
function groupAcquisitionMethods(methods: NamedEntity[]) {
const groups = new Map<string, NamedEntity[]>();
for (const method of methods) {
const category = method.category?.trim() || t('common.none');
groups.set(category, [...(groups.get(category) ?? []), method]);
}
return [...groups.entries()].map(([category, values]) => ({ category, values }));
}
onMounted(async () => { onMounted(async () => {
try { try {
currentUser.value = (await api.me()).user; currentUser.value = (await api.me()).user;
@@ -341,7 +353,16 @@ watch(initialItem, applyInitialItem, { immediate: true });
</div> </div>
<div class="entity-profile-group"> <div class="entity-profile-group">
<h3 class="section-subtitle">{{ t('pages.items.acquisitionMethods') }}</h3> <h3 class="section-subtitle">{{ t('pages.items.acquisitionMethods') }}</h3>
<EntityChips v-if="item.acquisitionMethods.length" :items="item.acquisitionMethods" /> <table v-if="acquisitionMethodGroups.length" class="entity-method-table">
<tbody>
<tr v-for="group in acquisitionMethodGroups" :key="group.category">
<th scope="row">{{ group.category }}</th>
<td>
<span v-for="method in group.values" :key="method.id">{{ method.name }}</span>
</td>
</tr>
</tbody>
</table>
<p v-else class="meta-line">{{ t('common.none') }}</p> <p v-else class="meta-line">{{ t('common.none') }}</p>
</div> </div>
<div class="entity-profile-group"> <div class="entity-profile-group">

View File

@@ -12,7 +12,7 @@ import Skeleton from '../components/Skeleton.vue';
import Tabs, { type TabOption } from '../components/Tabs.vue'; import Tabs, { type TabOption } from '../components/Tabs.vue';
import { iconBack, iconEdit, iconRecipe } from '../icons'; import { iconBack, iconEdit, iconRecipe } from '../icons';
import { applySeo, resolvedSeoHead, resolveSeo } from '../seo'; import { applySeo, resolvedSeoHead, resolveSeo } from '../seo';
import { api, type AuthUser, type RecipeDetail } from '../services/api'; import { api, type AuthUser, type NamedEntity, type RecipeDetail } from '../services/api';
import RecipeEdit from './RecipeEdit.vue'; import RecipeEdit from './RecipeEdit.vue';
const route = useRoute(); const route = useRoute();
@@ -41,6 +41,7 @@ const recipeSubtitle = computed(() => {
return categoryName ?? t('pages.recipes.detailSubtitle'); return categoryName ?? t('pages.recipes.detailSubtitle');
}); });
const acquisitionMethodGroups = computed(() => groupAcquisitionMethods(recipe.value?.acquisition_methods ?? []));
const { data: initialRecipe } = useAsyncData<RecipeDetail | null>( const { data: initialRecipe } = useAsyncData<RecipeDetail | null>(
`recipe-detail:${String(route.params.id)}:${locale.value}`, `recipe-detail:${String(route.params.id)}:${locale.value}`,
@@ -75,6 +76,17 @@ function applyInitialRecipe(value: RecipeDetail | null | undefined) {
initialRecipeLoaded.value = true; initialRecipeLoaded.value = true;
} }
function groupAcquisitionMethods(methods: NamedEntity[]) {
const groups = new Map<string, NamedEntity[]>();
for (const method of methods) {
const category = method.category?.trim() || t('common.none');
groups.set(category, [...(groups.get(category) ?? []), method]);
}
return [...groups.entries()].map(([category, values]) => ({ category, values }));
}
async function loadRecipeDetail() { async function loadRecipeDetail() {
try { try {
const nextRecipe = await api.recipeDetail(String(route.params.id)); const nextRecipe = await api.recipeDetail(String(route.params.id));
@@ -214,7 +226,16 @@ watch(initialRecipe, applyInitialRecipe, { immediate: true });
<div class="entity-profile-groups"> <div class="entity-profile-groups">
<div class="entity-profile-group"> <div class="entity-profile-group">
<h3 class="section-subtitle">{{ t('pages.items.acquisitionMethods') }}</h3> <h3 class="section-subtitle">{{ t('pages.items.acquisitionMethods') }}</h3>
<EntityChips v-if="recipe.acquisition_methods.length" :items="recipe.acquisition_methods" /> <table v-if="acquisitionMethodGroups.length" class="entity-method-table">
<tbody>
<tr v-for="group in acquisitionMethodGroups" :key="group.category">
<th scope="row">{{ group.category }}</th>
<td>
<span v-for="method in group.values" :key="method.id">{{ method.name }}</span>
</td>
</tr>
</tbody>
</table>
<p v-else class="meta-line">{{ t('common.none') }}</p> <p v-else class="meta-line">{{ t('common.none') }}</p>
</div> </div>
<div class="entity-profile-group"> <div class="entity-profile-group">