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

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

View File

@@ -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);

View File

@@ -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(() => {
<template #default="{ item }">
<span class="reorderable-row-title">
{{ 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.hasItemDrop" class="config-flag">{{ t('pages.admin.hasItemDrop') }}</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>
<input id="config-name" v-model="configNameInput" :placeholder="configNamePlaceholder" :required="configNameRequired" />
</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">
<label for="config-description">{{ t('pages.admin.description') }}</label>
<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 { iconAdd, iconBack, iconEdit, iconHabitat, iconItem } from '../icons';
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';
const route = useRoute();
@@ -72,6 +72,7 @@ const possibleTagEvidenceSections = computed(() => [
{ 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 ?? [] }
]);
const acquisitionMethodGroups = computed(() => groupAcquisitionMethods(item.value?.acquisitionMethods ?? []));
const { data: moduleSettings } = useAsyncData<ModuleSettings>(
'module-settings',
@@ -185,6 +186,17 @@ function activeItemRouteId(): string | 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 () => {
try {
currentUser.value = (await api.me()).user;
@@ -341,7 +353,16 @@ watch(initialItem, applyInitialItem, { immediate: true });
</div>
<div class="entity-profile-group">
<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>
</div>
<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 { iconBack, iconEdit, iconRecipe } from '../icons';
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';
const route = useRoute();
@@ -41,6 +41,7 @@ const recipeSubtitle = computed(() => {
return categoryName ?? t('pages.recipes.detailSubtitle');
});
const acquisitionMethodGroups = computed(() => groupAcquisitionMethods(recipe.value?.acquisition_methods ?? []));
const { data: initialRecipe } = useAsyncData<RecipeDetail | null>(
`recipe-detail:${String(route.params.id)}:${locale.value}`,
@@ -75,6 +76,17 @@ function applyInitialRecipe(value: RecipeDetail | null | undefined) {
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() {
try {
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-group">
<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>
</div>
<div class="entity-profile-group">