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:
@@ -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 逐行展示
|
||||
- 需要材料列表:展示材料物品图标;未配置图标时显示默认物品标记占位符
|
||||
- 最后编辑信息
|
||||
- 讨论
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}`),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user