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 分类时展示
- 分类
- 用途
- 入手方式
- 入手方式:按 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 逐行展示
- 需要材料列表:展示材料物品图标;未配置图标时显示默认物品标记占位符
- 最后编辑信息
- 讨论

View File

@@ -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 (

View File

@@ -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<ConfigType, ConfigDefinition> = {
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<Array<{ id: number; name: string }>> {
locale: string,
includeCategory = false
): Promise<Array<{ id: number; name: string; category?: string }>> {
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<string, unk
const definition = configDefinitions[type];
const name = cleanName(payload.name);
const translations = cleanTranslations(payload.translations, ['name']);
const category = definition.hasCategory ? cleanConfigCategory(payload.category) : '';
const description = definition.hasDescription ? cleanOptionalText(payload.description) : '';
const oppositeId = definition.oppositeColumn ? cleanOptionalPositiveInteger(payload.oppositeId) : null;
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');
values.push(description);
}
if (definition.hasCategory) {
columns.push('category');
values.push(category);
}
if (definition.hasItemDrop) {
columns.push('has_item_drop');
values.push(hasItemDrop);
@@ -5653,6 +5674,7 @@ export async function updateConfig(
const definition = configDefinitions[type];
const name = cleanName(payload.name);
const translations = cleanTranslations(payload.translations, ['name']);
const category = definition.hasCategory ? cleanConfigCategory(payload.category) : '';
const description = definition.hasDescription ? cleanOptionalText(payload.description) : '';
const oppositeId = definition.oppositeColumn ? cleanOptionalPositiveInteger(payload.oppositeId) : null;
const before = await getConfigById(type, id, defaultLocale);
@@ -5675,6 +5697,10 @@ export async function updateConfig(
values.push(description);
assignments.push(`description = $${values.length}`);
}
if (definition.hasCategory) {
values.push(category);
assignments.push(`category = $${values.length}`);
}
if (definition.hasItemDrop) {
values.push(hasItemDrop);
assignments.push(`has_item_drop = $${values.length}`);
@@ -5735,6 +5761,7 @@ export async function updateConfig(
const changes = before
? configEditChanges(definition, before as ConfigChangeSource, {
name,
category,
description,
translations,
oppositeName: oppositeId ? oppositeNames.get(oppositeId) ?? '' : '',
@@ -6813,7 +6840,7 @@ export async function getItem(id: number, locale = defaultLocale) {
] = await Promise.all([
query(
`
SELECT am.id, ${acquisitionMethodName} AS name
SELECT am.id, ${acquisitionMethodName} AS name, am.category
FROM item_acquisition_methods iam
JOIN acquisition_methods am ON am.id = iam.acquisition_method_id
WHERE iam.item_id = $1
@@ -6828,7 +6855,7 @@ export async function getItem(id: number, locale = defaultLocale) {
${resultItemName} AS name,
${auditSelect('r', 'recipe_created_user', 'recipe_updated_user')},
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
JOIN acquisition_methods am ON am.id = ram.acquisition_method_id
WHERE ram.recipe_id = r.id
@@ -7496,7 +7523,7 @@ export async function getRecipe(id: number, locale = defaultLocale) {
${resultItemName} AS name,
${auditSelect('r', 'recipe_created_user', 'recipe_updated_user')},
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
JOIN acquisition_methods am ON am.id = ram.acquisition_method_id
WHERE ram.recipe_id = r.id

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">