feat: separate regular and event entities for Pokemon and Habitats

Add dedicated routes and navigation for Event Pokemon and Event Habitats
Update API endpoints to filter by isEventItem and adapt frontend views
This commit is contained in:
2026-05-04 06:50:37 +08:00
parent f2a8b67ebf
commit 5ccc25b248
17 changed files with 278 additions and 77 deletions

View File

@@ -25,6 +25,8 @@ const timeOfDays = ['早晨', '中午', '傍晚', '晚上'];
const weathers = ['晴天', '阴天', '雨天'];
const showEditor = computed(() => route.name === 'habitat-edit');
const canUpdateHabitat = computed(() => currentUser.value?.permissions.includes('habitats.update') === true);
const listPath = computed(() => (habitat.value?.isEventItem ? '/event-habitats' : '/habitats'));
const detailKicker = computed(() => t(habitat.value?.isEventItem ? 'pages.eventHabitats.detailKicker' : 'pages.habitats.detailKicker'));
const detailTabs = computed<TabOption[]>(() => [
{ value: 'details', label: t('common.details') },
{ value: 'discussion', label: t('discussion.title') },
@@ -122,7 +124,7 @@ async function loadHabitatDetail() {
if (route.meta.editorModal !== true) {
applySeo({
title: `${nextHabitat.name} - ${t('pages.habitats.title')}`,
title: `${nextHabitat.name} - ${t(nextHabitat.isEventItem ? 'pages.eventHabitats.title' : 'pages.habitats.title')}`,
description: t('seo.habitatDetailDescription', { name: nextHabitat.name }),
canonicalPath: `/habitats/${nextHabitat.id}`,
image: nextHabitat.image?.url
@@ -208,13 +210,13 @@ watch(
</section>
<section v-else class="page-stack">
<PageHeader :title="habitat.name" :subtitle="t('pages.habitats.detailSubtitle')">
<template #kicker>{{ t('pages.habitats.detailKicker') }}</template>
<template #kicker>{{ detailKicker }}</template>
<template #actions>
<RouterLink v-if="canUpdateHabitat" class="ui-button ui-button--primary ui-button--small" :to="`/habitats/${habitat.id}/edit`">
<Icon :icon="iconEdit" class="ui-icon" aria-hidden="true" />
{{ t('common.edit') }}
</RouterLink>
<RouterLink class="ui-button ui-button--blue ui-button--small" to="/habitats">
<RouterLink class="ui-button ui-button--blue ui-button--small" :to="listPath">
<Icon :icon="iconBack" class="ui-icon" aria-hidden="true" />
{{ t('common.backToList') }}
</RouterLink>

View File

@@ -73,16 +73,20 @@ const weatherOptions = computed(() => [
]);
const routeId = computed(() => (typeof route.params.id === 'string' ? route.params.id : ''));
const isEditing = computed(() => routeId.value !== '');
const isEventCreate = computed(() => route.name === 'event-habitat-new');
const itemSelectOptions = computed(() => itemRows.value.map((item) => ({ id: item.id, name: item.name })));
const pokemonSelectOptions = computed(() =>
pokemonRows.value.map((pokemon) => ({ id: pokemon.id, name: pokemon.name, label: `#${pokemon.displayId} ${pokemon.name}` }))
);
const pageTitle = computed(() =>
isEditing.value
? t('pages.habitats.editTitle', { name: habitatForm.value.name || t('pages.habitats.fallbackName') })
: t('pages.habitats.newTitle')
? t(habitatForm.value.isEventItem ? 'pages.eventHabitats.editTitle' : 'pages.habitats.editTitle', {
name: habitatForm.value.name || t('pages.habitats.fallbackName')
})
: t(isEventCreate.value ? 'pages.eventHabitats.newTitle' : 'pages.habitats.newTitle')
);
const cancelTo = computed(() => (isEditing.value ? `/habitats/${routeId.value}` : '/habitats'));
const editSubtitle = computed(() => t(habitatForm.value.isEventItem || isEventCreate.value ? 'pages.eventHabitats.editSubtitle' : 'pages.habitats.editSubtitle'));
const cancelTo = computed(() => (isEditing.value ? `/habitats/${routeId.value}` : isEventCreate.value ? '/event-habitats' : '/habitats'));
const imageEntityName = computed(() => habitatNameForSave().trim());
const canCreateConfig = computed(() => currentUser.value?.permissions.includes('admin.config.create') === true);
const canUploadImage = computed(() => currentUser.value?.permissions.includes('habitats.upload') === true);
@@ -193,6 +197,8 @@ async function loadEditor() {
};
currentImage.value = habitat.image;
imageHistory.value = habitat.imageHistory;
} else {
habitatForm.value.isEventItem = isEventCreate.value;
}
} catch (error) {
message.value = errorText(error, t('errors.loadFailed'));
@@ -270,7 +276,7 @@ onMounted(() => {
</script>
<template>
<Modal :title="pageTitle" :subtitle="t('pages.habitats.editSubtitle')" :close-label="t('common.close')" size="wide" @close="closeEditor">
<Modal :title="pageTitle" :subtitle="editSubtitle" :close-label="t('common.close')" size="wide" @close="closeEditor">
<StatusMessage v-if="message" variant="danger">{{ message }}</StatusMessage>
<form v-if="!loading && options" id="habitat-edit-form" class="modal-edit-form" @submit.prevent="saveHabitat">
@@ -300,7 +306,7 @@ onMounted(() => {
/>
<div class="check-row">
<label><input v-model="habitatForm.isEventItem" type="checkbox" /> {{ t('pages.habitats.eventItem') }}</label>
<label><input v-model="habitatForm.isEventItem" type="checkbox" :disabled="isEventCreate" /> {{ t('pages.habitats.eventItem') }}</label>
</div>
<div class="field">

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import { Icon } from '@iconify/vue';
import { computed, onMounted, ref } from 'vue';
import { computed, onMounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import EntityCard from '../components/EntityCard.vue';
@@ -10,19 +10,37 @@ import { iconAdd, iconHabitat } from '../icons';
import { api, getAuthToken, type AuthUser, type Habitat } from '../services/api';
import HabitatEdit from './HabitatEdit.vue';
const props = defineProps<{
eventOnly?: boolean;
}>();
const habitats = ref<Habitat[]>([]);
const currentUser = ref<AuthUser | null>(null);
const route = useRoute();
const { t } = useI18n();
const loading = ref(true);
const skeletonCardCount = 6;
const showEditor = computed(() => route.name === 'habitat-new');
const query = computed(() => ({
isEventItem: props.eventOnly ? 'true' : 'false'
}));
const showEditor = computed(() => route.name === 'habitat-new' || route.name === 'event-habitat-new');
const canCreateHabitat = computed(() => currentUser.value?.permissions.includes('habitats.create') === true);
const pageTitle = computed(() => t(props.eventOnly ? 'pages.eventHabitats.title' : 'pages.habitats.title'));
const pageSubtitle = computed(() => t(props.eventOnly ? 'pages.eventHabitats.subtitle' : 'pages.habitats.subtitle'));
const pageKicker = computed(() => t(props.eventOnly ? 'pages.eventHabitats.kicker' : 'pages.habitats.listKicker'));
const newHabitatPath = computed(() => (props.eventOnly ? '/event-habitats/new' : '/habitats/new'));
const loadingListLabel = computed(() => t(props.eventOnly ? 'pages.eventHabitats.loadingList' : 'pages.habitats.loadingList'));
function habitatCardImage(item: Habitat) {
return item.image ? { src: item.image.url, alt: t('media.imageAlt', { name: item.name }) } : undefined;
}
async function loadHabitats() {
loading.value = true;
habitats.value = await api.habitats(query.value);
loading.value = false;
}
onMounted(async () => {
if (getAuthToken()) {
try {
@@ -31,24 +49,25 @@ onMounted(async () => {
currentUser.value = null;
}
}
habitats.value = await api.habitats();
loading.value = false;
await loadHabitats();
});
watch(query, loadHabitats);
</script>
<template>
<section class="page-stack">
<PageHeader :title="t('pages.habitats.title')" :subtitle="t('pages.habitats.subtitle')">
<template #kicker>Habitats</template>
<PageHeader :title="pageTitle" :subtitle="pageSubtitle">
<template #kicker>{{ pageKicker }}</template>
<template #actions>
<RouterLink v-if="canCreateHabitat" class="ui-button ui-button--primary ui-button--small" to="/habitats/new">
<RouterLink v-if="canCreateHabitat" class="ui-button ui-button--primary ui-button--small" :to="newHabitatPath">
<Icon :icon="iconAdd" class="ui-icon" aria-hidden="true" />
{{ t('common.add') }}
</RouterLink>
</template>
</PageHeader>
<div v-if="loading" class="entity-grid pokemon-list-grid" aria-busy="true" :aria-label="t('pages.habitats.loadingList')">
<div v-if="loading" class="entity-grid pokemon-list-grid" aria-busy="true" :aria-label="loadingListLabel">
<article v-for="index in skeletonCardCount" :key="index" class="entity-card entity-card--skeleton">
<Skeleton variant="box" width="92px" height="92px" class="skeleton-entity-mark" />
<div class="entity-card__content">

View File

@@ -32,7 +32,9 @@ const projectCommits = ref<ProjectUpdateCommit[]>([]);
const primarySections = computed(() => [
{ key: 'pokemon', to: '/pokemon', icon: iconPokemon },
{ key: 'eventPokemon', to: '/event-pokemon', icon: iconEvent },
{ key: 'habitats', to: '/habitats', icon: iconHabitat },
{ key: 'eventHabitats', to: '/event-habitats', icon: iconEvent },
{ key: 'items', to: '/items', icon: iconItem },
{ key: 'recipes', to: '/recipes', icon: iconRecipe }
]);

View File

@@ -121,6 +121,8 @@ const habitatRows = computed<HabitatRow[]>(() => {
const skillDropRows = computed(() => pokemon.value?.skills.filter((skill) => skill.itemDrop) ?? []);
const showEditor = computed(() => route.name === 'pokemon-edit');
const canUpdatePokemon = computed(() => currentUser.value?.permissions.includes('pokemon.update') === true);
const listPath = computed(() => (pokemon.value?.isEventItem ? '/event-pokemon' : '/pokemon'));
const detailKicker = computed(() => t(pokemon.value?.isEventItem ? 'pages.eventPokemon.detailKicker' : 'pages.pokemon.detailKicker'));
const detailTabs = computed<TabOption[]>(() => [
{ value: 'details', label: t('common.details') },
{ value: 'discussion', label: t('discussion.title') },
@@ -225,7 +227,7 @@ async function loadPokemonDetail() {
if (route.meta.editorModal !== true) {
applySeo({
title: `${nextPokemon.name} - ${t('pages.pokemon.title')}`,
title: `${nextPokemon.name} - ${t(nextPokemon.isEventItem ? 'pages.eventPokemon.title' : 'pages.pokemon.title')}`,
description: t('seo.pokemonDetailDescription', { name: nextPokemon.name }),
canonicalPath: `/pokemon/${nextPokemon.id}`,
image: nextPokemon.image?.url
@@ -324,13 +326,13 @@ watch(
</section>
<section v-else class="page-stack">
<PageHeader :title="`#${pokemon.displayId} ${pokemon.name}`" :subtitle="t('pages.pokemon.environmentPrefix', { name: pokemon.environment.name })">
<template #kicker>Pokédex Detail</template>
<template #kicker>{{ detailKicker }}</template>
<template #actions>
<RouterLink v-if="canUpdatePokemon" class="ui-button ui-button--primary ui-button--small" :to="`/pokemon/${pokemon.id}/edit`">
<Icon :icon="iconEdit" class="ui-icon" aria-hidden="true" />
{{ t('common.edit') }}
</RouterLink>
<RouterLink class="ui-button ui-button--blue ui-button--small" to="/pokemon">
<RouterLink class="ui-button ui-button--blue ui-button--small" :to="listPath">
<Icon :icon="iconBack" class="ui-icon" aria-hidden="true" />
{{ t('common.backToList') }}
</RouterLink>

View File

@@ -100,12 +100,17 @@ const pokemonForm = ref({
const routeId = computed(() => (typeof route.params.id === 'string' ? route.params.id : ''));
const isEditing = computed(() => routeId.value !== '');
const isEventCreate = computed(() => route.name === 'event-pokemon-new');
const pageTitle = computed(() =>
isEditing.value
? t('pages.pokemon.editTitle', { id: pokemonForm.value.id || routeId.value, name: pokemonForm.value.name })
: t('pages.pokemon.newTitle')
? t(pokemonForm.value.isEventItem ? 'pages.eventPokemon.editTitle' : 'pages.pokemon.editTitle', {
id: pokemonForm.value.id || routeId.value,
name: pokemonForm.value.name
})
: t(isEventCreate.value ? 'pages.eventPokemon.newTitle' : 'pages.pokemon.newTitle')
);
const cancelTo = computed(() => (isEditing.value ? `/pokemon/${routeId.value}` : '/pokemon'));
const editSubtitle = computed(() => t(pokemonForm.value.isEventItem || isEventCreate.value ? 'pages.eventPokemon.editSubtitle' : 'pages.pokemon.editSubtitle'));
const cancelTo = computed(() => (isEditing.value ? `/pokemon/${routeId.value}` : isEventCreate.value ? '/event-pokemon' : '/pokemon'));
const selectedSkillDropRows = computed(() =>
pokemonForm.value.skillItemDrops.filter((row) => pokemonForm.value.skillIds.includes(row.skillId) && skillSupportsItemDrop(row.skillId))
);
@@ -262,7 +267,6 @@ function applyFetchedPokemon(fetchedPokemon: PokemonFetchResult): boolean {
const routePokemonId = Number(routeId.value);
if (
isEditing.value &&
!pokemonForm.value.isEventItem &&
Number.isInteger(routePokemonId) &&
routePokemonId > 0 &&
fetchedPokemon.id !== routePokemonId
@@ -336,6 +340,8 @@ async function loadEditor() {
imageOptions.value = pokemon.image ? [pokemon.image] : [];
imageHistory.value = pokemon.imageHistory;
syncSkillItemDrops();
} else {
pokemonForm.value.isEventItem = isEventCreate.value;
}
} catch (error) {
message.value = errorText(error, t('errors.loadFailed'));
@@ -743,7 +749,7 @@ watch(locale, () => {
</script>
<template>
<Modal :title="pageTitle" :subtitle="t('pages.pokemon.editSubtitle')" :close-label="t('common.close')" size="wide" @close="closeEditor">
<Modal :title="pageTitle" :subtitle="editSubtitle" :close-label="t('common.close')" size="wide" @close="closeEditor">
<StatusMessage v-if="message" variant="danger">{{ message }}</StatusMessage>
<form v-if="!loading && options" id="pokemon-edit-form" class="modal-edit-form modal-edit-form--tabbed pokemon-edit-form" @submit.prevent="savePokemon">
@@ -825,7 +831,7 @@ watch(locale, () => {
</div>
<div class="check-row">
<label><input v-model="pokemonForm.isEventItem" type="checkbox" /> {{ t('pages.pokemon.eventItem') }}</label>
<label><input v-model="pokemonForm.isEventItem" type="checkbox" :disabled="isEventCreate" /> {{ t('pages.pokemon.eventItem') }}</label>
</div>
<div class="pokemon-edit-grid">

View File

@@ -12,6 +12,10 @@ import { iconAdd } from '../icons';
import { api, getAuthToken, type AuthUser, type Options, type Pokemon } from '../services/api';
import PokemonEdit from './PokemonEdit.vue';
const props = defineProps<{
eventOnly?: boolean;
}>();
const options = ref<Options | null>(null);
const route = useRoute();
const { t } = useI18n();
@@ -29,14 +33,20 @@ const skeletonCardCount = 6;
const query = computed(() => ({
search: search.value,
isEventItem: props.eventOnly ? 'true' : 'false',
environmentId: environmentId.value,
skillIds: skillIds.value.join(','),
skillMode: skillMode.value,
favoriteThingIds: favoriteThingIds.value.join(','),
favoriteThingMode: favoriteThingMode.value
}));
const showEditor = computed(() => route.name === 'pokemon-new');
const showEditor = computed(() => route.name === 'pokemon-new' || route.name === 'event-pokemon-new');
const canCreatePokemon = computed(() => currentUser.value?.permissions.includes('pokemon.create') === true);
const pageTitle = computed(() => t(props.eventOnly ? 'pages.eventPokemon.title' : 'pages.pokemon.title'));
const pageSubtitle = computed(() => t(props.eventOnly ? 'pages.eventPokemon.subtitle' : 'pages.pokemon.subtitle'));
const pageKicker = computed(() => t(props.eventOnly ? 'pages.eventPokemon.kicker' : 'pages.pokemon.listKicker'));
const newPokemonPath = computed(() => (props.eventOnly ? '/event-pokemon/new' : '/pokemon/new'));
const loadingListLabel = computed(() => t(props.eventOnly ? 'pages.eventPokemon.loadingList' : 'pages.pokemon.loadingList'));
async function loadPokemon() {
loading.value = true;
@@ -65,10 +75,10 @@ watch(query, loadPokemon);
<template>
<section class="page-stack">
<PageHeader :title="t('pages.pokemon.title')" :subtitle="t('pages.pokemon.subtitle')">
<template #kicker>Pokédex</template>
<PageHeader :title="pageTitle" :subtitle="pageSubtitle">
<template #kicker>{{ pageKicker }}</template>
<template #actions>
<RouterLink v-if="canCreatePokemon" class="ui-button ui-button--primary ui-button--small" to="/pokemon/new">
<RouterLink v-if="canCreatePokemon" class="ui-button ui-button--primary ui-button--small" :to="newPokemonPath">
<Icon :icon="iconAdd" class="ui-icon" aria-hidden="true" />
{{ t('common.add') }}
</RouterLink>
@@ -131,7 +141,7 @@ watch(query, loadPokemon);
</div>
</FilterPanel>
<div v-if="loading" class="entity-grid pokemon-list-grid" aria-busy="true" :aria-label="t('pages.pokemon.loadingList')">
<div v-if="loading" class="entity-grid pokemon-list-grid" aria-busy="true" :aria-label="loadingListLabel">
<article v-for="index in skeletonCardCount" :key="index" class="entity-card entity-card--skeleton">
<Skeleton variant="box" width="92px" height="92px" class="skeleton-entity-mark" />
<div class="entity-card__content">