Files
pokopiawiki.tootaio.com/frontend/src/views/ItemDetail.vue
xiaomai 2919708cee refactor(items): merge ancient artifacts into items data model
Migrate ancient artifacts to items table using a category key.
Consolidate detail and edit views into ItemDetail and ItemEdit.
Update API, search, and data tools to reflect unified structure.
2026-05-05 10:46:14 +08:00

352 lines
15 KiB
Vue

<script setup lang="ts">
import { Icon } from '@iconify/vue';
import { computed, onMounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute, useRouter } from 'vue-router';
import DetailSection from '../components/DetailSection.vue';
import EditHistoryPanel from '../components/EditHistoryPanel.vue';
import EntityDiscussionPanel from '../components/EntityDiscussionPanel.vue';
import EntityChips from '../components/EntityChips.vue';
import PageHeader from '../components/PageHeader.vue';
import PokeBallMark from '../components/PokeBallMark.vue';
import Skeleton from '../components/Skeleton.vue';
import Tabs, { type TabOption } from '../components/Tabs.vue';
import { iconAdd, iconBack, iconEdit, iconHabitat, iconItem } from '../icons';
import { applySeo } from '../seo';
import { api, getAuthToken, type AuthUser, type ItemDetail } from '../services/api';
import ItemEdit from './ItemEdit.vue';
const route = useRoute();
const router = useRouter();
const { locale, t } = useI18n();
const item = ref<ItemDetail | null>(null);
const currentUser = ref<AuthUser | null>(null);
const detailTab = ref('details');
const isAncientArtifactRoute = computed(() => route.name === 'ancient-artifact-detail' || route.name === 'ancient-artifact-edit');
const showEditor = computed(() => route.name === 'item-edit' || route.name === 'ancient-artifact-edit');
const canUpdateItem = computed(() => currentUser.value?.permissions.includes('items.update') === true);
const canCreateRecipe = computed(() => currentUser.value?.permissions.includes('recipes.create') === true);
const detailTabs = computed<TabOption[]>(() => [
{ value: 'details', label: t('common.details') },
{ value: 'discussion', label: t('discussion.title') },
{ value: 'history', label: t('history.editHistory') }
]);
const itemSubtitle = computed(() => {
if (!item.value) {
return '';
}
return item.value.usage ? `${item.value.category.name} · ${item.value.usage.name}` : item.value.category.name;
});
const detailKicker = computed(() =>
isAncientArtifactRoute.value
? t('pages.ancientArtifacts.detailKicker')
: item.value?.isEventItem
? t('pages.eventItems.detailKicker')
: t('pages.items.detailKicker')
);
const listTarget = computed(() => (isAncientArtifactRoute.value ? '/ancient-artifacts' : item.value?.isEventItem ? '/event-items' : '/items'));
const editTarget = computed(() =>
item.value ? (isAncientArtifactRoute.value ? `/ancient-artifacts/${item.value.id}/edit` : `/items/${item.value.id}/edit`) : ''
);
const detailCanonicalPath = computed(() =>
item.value ? (isAncientArtifactRoute.value ? `/ancient-artifacts/${item.value.id}` : `/items/${item.value.id}`) : ''
);
const detailTitleKey = computed(() =>
isAncientArtifactRoute.value ? 'pages.ancientArtifacts.title' : item.value?.isEventItem ? 'pages.eventItems.title' : 'pages.items.title'
);
const detailDescriptionKey = computed(() =>
isAncientArtifactRoute.value ? 'seo.ancientArtifactDetailDescription' : 'seo.itemDetailDescription'
);
const basePriceDisplay = computed(() => {
const price = item.value?.basePrice;
return price === null || price === undefined ? t('common.none') : new Intl.NumberFormat(locale.value).format(price);
});
const customization = computed(() => {
if (!item.value) {
return [];
}
return [
item.value.customization.dyeable ? t('pages.items.dyeable') : '',
item.value.customization.dualDyeable ? t('pages.items.dualDyeable') : '',
item.value.customization.patternEditable ? t('pages.items.patternEditable') : ''
].filter(Boolean);
});
async function loadItemDetail() {
const nextItem = await api.itemDetail(String(route.params.id));
if (isAncientArtifactRoute.value && !nextItem.ancientArtifactCategory) {
await router.replace(route.name === 'ancient-artifact-edit' ? `/items/${nextItem.id}/edit` : `/items/${nextItem.id}`);
return;
}
item.value = nextItem;
if (route.meta.editorModal !== true) {
applySeo({
title: `${nextItem.name} - ${t(detailTitleKey.value)}`,
description: t(detailDescriptionKey.value, { name: nextItem.name }),
canonicalPath: detailCanonicalPath.value,
image: nextItem.image?.url
});
}
}
onMounted(async () => {
if (getAuthToken()) {
try {
currentUser.value = (await api.me()).user;
} catch {
currentUser.value = null;
}
}
await loadItemDetail();
});
watch(
() => route.name,
(name, oldName) => {
if (
(oldName === 'item-edit' && name === 'item-detail') ||
(oldName === 'ancient-artifact-edit' && name === 'ancient-artifact-detail')
) {
void loadItemDetail();
}
}
);
watch(
() => route.params.id,
() => {
item.value = null;
detailTab.value = 'details';
void loadItemDetail();
}
);
</script>
<template>
<section v-if="!item" class="page-stack" aria-busy="true" :aria-label="t('pages.items.loadingDetail')">
<div class="page-header page-header--skeleton" aria-hidden="true">
<div class="page-header__copy">
<Skeleton width="96px" />
<Skeleton width="260px" height="46px" />
<Skeleton width="220px" />
<Skeleton width="300px" />
</div>
<div class="page-header__actions">
<Skeleton variant="box" width="88px" height="36px" />
</div>
</div>
<div class="detail-grid" aria-hidden="true">
<section v-for="index in 3" :key="`chips-${index}`" class="detail-section skeleton-detail-section">
<div class="detail-section__header">
<Skeleton :width="index === 2 ? '68px' : '92px'" height="24px" />
</div>
<div class="detail-section__body">
<div class="skeleton-chip-row">
<Skeleton v-for="chipIndex in 3" :key="chipIndex" width="82px" class="skeleton-chip" />
</div>
</div>
</section>
<section class="detail-section skeleton-detail-section">
<div class="detail-section__header">
<Skeleton width="112px" height="24px" />
</div>
<div class="detail-section__body">
<Skeleton width="45%" />
<div class="skeleton-chip-row">
<Skeleton v-for="index in 3" :key="index" width="76px" class="skeleton-chip" />
</div>
</div>
</section>
<section class="detail-section skeleton-detail-section">
<div class="detail-section__header">
<Skeleton width="108px" height="24px" />
</div>
<div class="detail-section__body">
<ul class="row-list skeleton-row-list">
<li v-for="index in 3" :key="index">
<Skeleton width="120px" />
<Skeleton width="44px" />
</li>
</ul>
</div>
</section>
</div>
</section>
<section v-else class="page-stack">
<PageHeader :title="item.name" :subtitle="itemSubtitle">
<template #kicker>{{ detailKicker }}</template>
<template #actions>
<RouterLink v-if="canUpdateItem" class="ui-button ui-button--primary ui-button--small" :to="editTarget">
<Icon :icon="iconEdit" class="ui-icon" aria-hidden="true" />
{{ t('common.edit') }}
</RouterLink>
<RouterLink class="ui-button ui-button--blue ui-button--small" :to="listTarget">
<Icon :icon="iconBack" class="ui-icon" aria-hidden="true" />
{{ t('common.backToList') }}
</RouterLink>
</template>
</PageHeader>
<div class="detail-tabs">
<Tabs id="item-detail-tabs" v-model="detailTab" :tabs="detailTabs" :label="t('common.details')" />
<div v-if="detailTab === 'details'" class="detail-grid detail-grid--stack">
<div class="entity-profile-grid">
<section class="detail-section entity-profile-media-section" :aria-label="t('media.image')">
<div class="entity-detail-image">
<div class="entity-detail-image__frame" :class="{ 'entity-detail-image__frame--placeholder': !item.image }">
<img v-if="item.image" :src="item.image.url" :alt="t('media.imageAlt', { name: item.name })" />
<span v-else class="entity-card__mark entity-detail-image__mark" role="img" :aria-label="t('media.imageEmpty')">
<Icon :icon="iconItem" class="entity-card__icon" aria-hidden="true" />
</span>
</div>
</div>
</section>
<div class="entity-profile-main">
<section class="detail-section entity-profile-overview" :aria-label="t('common.details')">
<dl class="entity-profile-facts">
<div>
<dt>{{ t('pages.items.category') }}</dt>
<dd>{{ item.category.name }}</dd>
</div>
<div>
<dt>{{ t('pages.items.usage') }}</dt>
<dd>{{ item.usage?.name ?? t('common.none') }}</dd>
</div>
<div>
<dt>{{ t('pages.items.basePrice') }}</dt>
<dd>{{ basePriceDisplay }}</dd>
</div>
<div v-if="item.ancientArtifactCategory">
<dt>{{ t('pages.items.ancientArtifact') }}</dt>
<dd>{{ item.ancientArtifactCategory.name }}</dd>
</div>
<div>
<dt>{{ t('pages.items.recipeInfo') }}</dt>
<dd>{{ item.noRecipe ? t('pages.items.noRecipe') : item.recipe ? item.recipe.name : t('common.none') }}</dd>
</div>
</dl>
<div class="entity-profile-groups">
<div class="entity-profile-group">
<h3 class="section-subtitle">{{ t('pages.items.description') }}</h3>
<p v-if="item.details" class="preserve-lines">{{ item.details }}</p>
<p v-else class="meta-line">{{ t('common.none') }}</p>
</div>
<div class="entity-profile-group">
<h3 class="section-subtitle">{{ t('pages.items.acquisitionMethods') }}</h3>
<EntityChips v-if="item.acquisitionMethods.length" :items="item.acquisitionMethods" />
<p v-else class="meta-line">{{ t('common.none') }}</p>
</div>
<div class="entity-profile-group">
<h3 class="section-subtitle">{{ t('pages.items.customization') }}</h3>
<div v-if="customization.length" class="chips">
<span v-for="entry in customization" :key="entry" class="chip">{{ entry }}</span>
</div>
<p v-else class="meta-line">{{ t('common.none') }}</p>
</div>
<div class="entity-profile-group">
<h3 class="section-subtitle">{{ t('pages.items.tags') }}</h3>
<EntityChips v-if="item.tags.length" :items="item.tags" />
<p v-else class="meta-line">{{ t('common.none') }}</p>
</div>
</div>
</section>
</div>
</div>
<div class="detail-grid">
<DetailSection :title="t('pages.items.recipeInfo')">
<template v-if="item.recipe">
<RouterLink class="related-entity-link related-entity-link--compact" :to="`/recipes/${item.recipe.id}`">
<span class="related-entity-media related-entity-media--inline" aria-hidden="true">
<img v-if="item.recipe.item.image" :src="item.recipe.item.image.url" alt="" loading="lazy" />
<Icon v-else :icon="iconItem" class="related-entity-media__icon" aria-hidden="true" />
</span>
<span>{{ item.recipe.name }}</span>
</RouterLink>
<EntityChips :items="item.recipe.materials" />
</template>
<p v-else-if="item.noRecipe" class="meta-line">{{ t('pages.items.noRecipe') }}</p>
<template v-else>
<p class="meta-line">{{ t('common.none') }}</p>
<RouterLink v-if="canCreateRecipe" class="ui-button ui-button--primary ui-button--small" :to="`/recipes/new?itemId=${item.id}`">
<Icon :icon="iconAdd" class="ui-icon" aria-hidden="true" />
{{ t('pages.items.createRecipe') }}
</RouterLink>
</template>
</DetailSection>
<DetailSection :title="t('pages.items.relatedRecipes')">
<ul v-if="item.relatedRecipes.length" class="row-list">
<li v-for="recipe in item.relatedRecipes" :key="recipe.id">
<RouterLink class="related-entity-link related-entity-link--compact" :to="`/recipes/${recipe.id}`">
<span class="related-entity-media related-entity-media--inline" aria-hidden="true">
<img v-if="recipe.image" :src="recipe.image.url" alt="" loading="lazy" />
<Icon v-else :icon="iconItem" class="related-entity-media__icon" aria-hidden="true" />
</span>
<span>{{ recipe.name }}</span>
</RouterLink>
<EntityChips :items="recipe.materials" />
</li>
</ul>
<p v-else class="meta-line">{{ t('common.none') }}</p>
</DetailSection>
<DetailSection :title="t('pages.items.relatedHabitats')">
<ul v-if="item.relatedHabitats.length" class="row-list">
<li v-for="habitat in item.relatedHabitats" :key="habitat.id">
<RouterLink class="related-entity-link related-entity-link--compact" :to="`/habitats/${habitat.id}`">
<span class="related-entity-media related-entity-media--inline" aria-hidden="true">
<img v-if="habitat.image" :src="habitat.image.url" alt="" loading="lazy" />
<Icon v-else :icon="iconHabitat" class="related-entity-media__icon" aria-hidden="true" />
</span>
<span>{{ habitat.name }}</span>
</RouterLink>
<EntityChips :items="habitat.recipe" />
</li>
</ul>
<p v-else class="meta-line">{{ t('common.none') }}</p>
</DetailSection>
<DetailSection :title="t('pages.items.pokemonDrops')">
<ul v-if="item.droppedByPokemon.length" class="row-list">
<li v-for="entry in item.droppedByPokemon" :key="`${entry.pokemon.id}-${entry.skill.id}`">
<RouterLink class="related-entity-link related-entity-link--compact" :to="`/pokemon/${entry.pokemon.id}`">
<span class="related-entity-media related-entity-media--inline related-entity-media--pokemon" aria-hidden="true">
<img v-if="entry.pokemon.image" :src="entry.pokemon.image.url" alt="" loading="lazy" />
<PokeBallMark v-else size="22px" />
</span>
<span>#{{ entry.pokemon.displayId }} {{ entry.pokemon.name }}</span>
</RouterLink>
<span>{{ t('pages.pokemon.skillDrop', { name: entry.skill.name }) }}</span>
</li>
</ul>
<p v-else class="meta-line">{{ t('common.none') }}</p>
</DetailSection>
</div>
</div>
<div v-else-if="detailTab === 'discussion'" class="detail-tab-panel">
<EntityDiscussionPanel entity-type="items" :entity-id="item.id" />
</div>
<div v-else class="detail-tab-panel">
<EditHistoryPanel :entity="item" :history="item.editHistory" />
</div>
</div>
</section>
<ItemEdit v-if="showEditor" />
</template>