feat: add pokemon trading preferences and item tag inference

Introduce trading preference (Likes/Neutral) for Pokemon with trading skills
Infer possible hidden tags for items based on trading observations
Update import/export, wipe, and admin config to support trading data
This commit is contained in:
2026-05-05 22:54:32 +08:00
parent 5b22d788d7
commit 22016365d8
12 changed files with 1097 additions and 33 deletions

View File

@@ -12,10 +12,11 @@ import PageHeader from '../components/PageHeader.vue';
import PokeBallMark from '../components/PokeBallMark.vue';
import PokemonStatsPanel from '../components/PokemonStatsPanel.vue';
import Skeleton from '../components/Skeleton.vue';
import StatusMessage from '../components/StatusMessage.vue';
import Tabs, { type TabOption } from '../components/Tabs.vue';
import { iconBack, iconEdit, iconHabitat, iconItem } from '../icons';
import { iconAdd, iconBack, iconCancel, iconCheck, iconEdit, iconHabitat, iconItem } from '../icons';
import { applySeo } from '../seo';
import { api, getAuthToken, type AuthUser, type PokemonDetail } from '../services/api';
import { api, getAuthToken, type AuthUser, type Item, type PokemonDetail, type PokemonPayload, type TradingPreference } from '../services/api';
import PokemonEdit from './PokemonEdit.vue';
const route = useRoute();
@@ -26,6 +27,15 @@ const itemCategoryTab = ref('');
const relatedHabitatTab = ref('');
const detailTab = ref('details');
const imageModalOpen = ref(false);
const tradingModalOpen = ref(false);
const tradingBusy = ref(false);
const tradingItemsLoading = ref(false);
const tradingMessage = ref('');
const tradingSearch = ref('');
const tradingCategoryId = ref('');
const tradingDefaultPreference = ref<TradingPreference>('like');
const tradingItemChoices = ref<Item[]>([]);
const tradingDraftItems = ref<Array<{ itemId: number; preference: TradingPreference }>>([]);
const timeOfDays = ['早晨', '中午', '傍晚', '晚上'];
const weathers = ['晴天', '阴天', '雨天'];
const relatedPokemonLimit = 6;
@@ -118,7 +128,60 @@ const habitatRows = computed<HabitatRow[]>(() => {
maps: [...row.maps]
}));
});
const skillDropRows = computed(() => pokemon.value?.skills.filter((skill) => skill.itemDrop) ?? []);
const skillDropRows = computed(() => pokemon.value?.skills.filter((skill) => skill.hasItemDrop) ?? []);
const hasItemDropSkill = computed(() => skillDropRows.value.length > 0);
const hasTradingSkill = computed(() => pokemon.value?.skills.some((skill) => skill.hasTrading) === true);
const tradingGroups = computed(() => ({
likes: pokemon.value?.tradingItems.filter((item) => item.preference === 'like') ?? [],
neutral: pokemon.value?.tradingItems.filter((item) => item.preference === 'neutral') ?? []
}));
const tradingDetailSections = computed(() => [
{ key: 'like', title: t('pages.pokemon.tradingLikes'), items: tradingGroups.value.likes },
{ key: 'neutral', title: t('pages.pokemon.tradingNeutral'), items: tradingGroups.value.neutral }
]);
const tradingCategoryOptions = computed(() => {
const categories = new Map<string, string>();
tradingItemChoices.value.forEach((item) => {
categories.set(String(item.category.id), item.category.name);
});
return [{ value: '', label: t('common.all') }, ...[...categories.entries()].map(([value, label]) => ({ value, label }))];
});
const tradingDraftPreferenceByItemId = computed(() => new Map(tradingDraftItems.value.map((item) => [String(item.itemId), item.preference])));
const filteredTradingItems = computed(() => {
const search = tradingSearch.value.trim().toLocaleLowerCase();
return tradingItemChoices.value.filter((item) => {
if (tradingCategoryId.value && String(item.category.id) !== tradingCategoryId.value) {
return false;
}
if (!search) {
return true;
}
return [item.name, item.category.name, item.usage?.name ?? ''].some((value) => value.toLocaleLowerCase().includes(search));
});
});
const tradingDraftGroups = computed(() => {
const itemsById = new Map(tradingItemChoices.value.map((item) => [item.id, item]));
const rows = tradingDraftItems.value
.map((item) => {
const row = itemsById.get(item.itemId);
return row ? { ...row, preference: item.preference } : null;
})
.filter((item): item is Item & { preference: TradingPreference } => item !== null);
return {
likes: rows.filter((item) => item.preference === 'like'),
neutral: rows.filter((item) => item.preference === 'neutral')
};
});
const tradingDraftSections = computed(() => [
{ key: 'like' as TradingPreference, title: t('pages.pokemon.tradingLikes'), items: tradingDraftGroups.value.likes },
{ key: 'neutral' as TradingPreference, title: t('pages.pokemon.tradingNeutral'), items: tradingDraftGroups.value.neutral }
]);
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'));
@@ -220,6 +283,133 @@ function closeImageModal() {
imageModalOpen.value = false;
}
function buildPokemonPayload(tradingItems: Array<{ itemId: number; preference: TradingPreference }>): PokemonPayload | null {
if (!pokemon.value) {
return null;
}
return {
dataId: pokemon.value.dataId ?? null,
dataIdentifier: pokemon.value.dataIdentifier ?? '',
displayId: pokemon.value.displayId,
isEventItem: pokemon.value.isEventItem,
name: pokemon.value.baseName ?? pokemon.value.name,
genus: pokemon.value.baseGenus ?? pokemon.value.genus,
details: pokemon.value.baseDetails ?? pokemon.value.details,
heightInches: pokemon.value.heightInches,
weightPounds: pokemon.value.weightPounds,
translations: pokemon.value.translations ?? {},
typeIds: pokemon.value.types.map((type) => type.id),
stats: pokemon.value.stats,
environmentId: pokemon.value.environment.id,
skillIds: pokemon.value.skills.map((skill) => skill.id),
favoriteThingIds: pokemon.value.favorite_things.map((thing) => thing.id),
skillItemDrops: pokemon.value.skills
.filter((skill) => skill.hasItemDrop && skill.itemDrop)
.map((skill) => ({ skillId: skill.id, itemId: skill.itemDrop!.id })),
tradingItems: hasTradingSkill.value
? tradingItems.map((item) => ({
itemId: item.itemId,
preference: item.preference
}))
: [],
imagePath: pokemon.value.image?.path ?? ''
};
}
async function loadTradingItems() {
if (tradingItemsLoading.value) {
return;
}
tradingItemsLoading.value = true;
tradingMessage.value = '';
try {
if (!tradingItemChoices.value.length) {
tradingItemChoices.value = await api.items({});
}
} catch (error) {
tradingMessage.value = error instanceof Error && error.message ? error.message : t('errors.loadFailed');
} finally {
tradingItemsLoading.value = false;
}
}
function resetTradingDraft() {
tradingDraftItems.value = pokemon.value?.tradingItems.map((item) => ({
itemId: item.itemId,
preference: item.preference
})) ?? [];
tradingDefaultPreference.value = 'like';
tradingSearch.value = '';
tradingCategoryId.value = '';
tradingMessage.value = '';
}
function isTradingItemSelected(itemId: string | number) {
return tradingDraftPreferenceByItemId.value.has(String(itemId));
}
function addTradingItem(item: Item) {
const itemId = String(item.id);
if (isTradingItemSelected(itemId)) {
return;
}
tradingDraftItems.value.push({ itemId: item.id, preference: tradingDefaultPreference.value });
}
function removeTradingItem(itemId: string | number) {
const value = Number(itemId);
tradingDraftItems.value = tradingDraftItems.value.filter((item) => item.itemId !== value);
}
function setTradingPreference(itemId: string | number, preference: TradingPreference) {
const value = Number(itemId);
const row = tradingDraftItems.value.find((item) => item.itemId === value);
if (row) {
row.preference = preference;
}
}
async function openTradingModal() {
if (!pokemon.value) {
return;
}
resetTradingDraft();
tradingModalOpen.value = true;
await loadTradingItems();
}
function closeTradingModal() {
tradingModalOpen.value = false;
tradingMessage.value = '';
}
async function saveTradingItems() {
if (!pokemon.value || tradingBusy.value) {
return;
}
tradingBusy.value = true;
tradingMessage.value = '';
try {
const payload = buildPokemonPayload(tradingDraftItems.value);
if (!payload) {
return;
}
pokemon.value = await api.updatePokemon(pokemon.value.id, payload);
tradingModalOpen.value = false;
} catch (error) {
tradingMessage.value = error instanceof Error && error.message ? error.message : t('errors.saveFailed');
} finally {
tradingBusy.value = false;
}
}
async function loadPokemonDetail() {
const nextPokemon = await api.pokemonDetail(String(route.params.id));
pokemon.value = nextPokemon;
@@ -262,6 +452,8 @@ watch(
relatedHabitatTab.value = '';
detailTab.value = 'details';
imageModalOpen.value = false;
tradingModalOpen.value = false;
tradingMessage.value = '';
void loadPokemonDetail();
}
);
@@ -404,7 +596,7 @@ watch(
<EntityChips :items="pokemon.skills" />
</DetailSection>
<DetailSection v-if="skillDropRows.length" :title="t('pages.pokemon.skillDrops')">
<DetailSection v-if="hasItemDropSkill" :title="t('pages.pokemon.skillDrops')">
<ul class="row-list skill-drop-summary">
<li v-for="skill in skillDropRows" :key="skill.id">
<span>{{ t('pages.pokemon.skillDrop', { name: skill.name }) }}</span>
@@ -415,10 +607,40 @@ watch(
</span>
<span>{{ skill.itemDrop.name }}</span>
</RouterLink>
<span v-else class="meta-line">{{ t('common.none') }}</span>
</li>
</ul>
</DetailSection>
<DetailSection v-if="hasTradingSkill" :title="t('pages.pokemon.trading')">
<template #actions>
<button v-if="canUpdatePokemon" type="button" class="ui-button ui-button--blue ui-button--small" @click="openTradingModal">
<Icon :icon="iconEdit" class="ui-icon" aria-hidden="true" />
{{ t('pages.pokemon.manageTrading') }}
</button>
</template>
<div class="trading-detail-grid">
<div v-for="section in tradingDetailSections" :key="section.key" class="trading-detail-group">
<h3 class="section-subtitle">
{{ section.title }}
<span v-if="section.key === 'like'" class="chip">{{ t('pages.pokemon.tradingPriceBonus') }}</span>
</h3>
<ul v-if="section.items.length" class="row-list trading-detail-list">
<li v-for="item in section.items" :key="`${section.key}-${item.id}`">
<RouterLink class="related-entity-link related-entity-link--compact" :to="`/items/${item.id}`">
<span class="related-entity-media related-entity-media--inline" aria-hidden="true">
<img v-if="item.image" :src="item.image.url" alt="" loading="lazy" />
<Icon v-else :icon="iconItem" class="related-entity-media__icon" aria-hidden="true" />
</span>
<span>{{ item.name }}</span>
</RouterLink>
</li>
</ul>
<p v-else class="meta-line">{{ t('common.none') }}</p>
</div>
</div>
</DetailSection>
<DetailSection :title="t('pages.pokemon.favoriteThings')">
<EntityChips :items="pokemon.favorite_things" />
</DetailSection>
@@ -567,5 +789,137 @@ watch(
</div>
</Modal>
<Modal
v-if="tradingModalOpen"
:title="t('pages.pokemon.trading')"
:subtitle="t('pages.pokemon.tradingModalSubtitle')"
:close-label="t('common.close')"
size="wide"
@close="closeTradingModal"
>
<StatusMessage v-if="tradingMessage" variant="danger">{{ tradingMessage }}</StatusMessage>
<div class="trading-manager">
<section class="trading-manager__panel" :aria-label="t('pages.pokemon.tradingAvailableItems')">
<div class="trading-manager__toolbar">
<div class="field">
<label for="pokemon-trading-search">{{ t('common.search') }}</label>
<input
id="pokemon-trading-search"
v-model="tradingSearch"
type="search"
:placeholder="t('pages.pokemon.searchItems')"
/>
</div>
<div class="field">
<label for="pokemon-trading-category">{{ t('pages.items.category') }}</label>
<select id="pokemon-trading-category" v-model="tradingCategoryId">
<option v-for="option in tradingCategoryOptions" :key="option.value || 'all'" :value="option.value">
{{ option.label }}
</option>
</select>
</div>
</div>
<div class="trading-manager__target">
<span class="field-label">{{ t('pages.pokemon.tradingDefaultGroup') }}</span>
<div class="segmented trading-default-toggle" :aria-label="t('pages.pokemon.tradingDefaultGroup')">
<button :class="{ active: tradingDefaultPreference === 'like' }" type="button" @click="tradingDefaultPreference = 'like'">
{{ t('pages.pokemon.tradingLikes') }}
</button>
<button :class="{ active: tradingDefaultPreference === 'neutral' }" type="button" @click="tradingDefaultPreference = 'neutral'">
{{ t('pages.pokemon.tradingNeutral') }}
</button>
</div>
</div>
<div class="trading-manager__list-frame">
<ul v-if="tradingItemsLoading" class="trading-item-list trading-item-list--loading" aria-busy="true">
<li v-for="index in 6" :key="index" class="trading-item-list__skeleton">
<Skeleton variant="box" height="58px" />
</li>
</ul>
<ul v-else-if="filteredTradingItems.length" class="trading-item-list">
<li v-for="item in filteredTradingItems" :key="item.id">
<button
type="button"
class="trading-pick-row"
:class="{ 'trading-pick-row--selected': isTradingItemSelected(item.id) }"
:disabled="isTradingItemSelected(item.id)"
@click="addTradingItem(item)"
>
<span class="related-entity-media related-entity-media--inline" aria-hidden="true">
<img v-if="item.image" :src="item.image.url" alt="" loading="lazy" />
<Icon v-else :icon="iconItem" class="related-entity-media__icon" aria-hidden="true" />
</span>
<span class="trading-pick-row__copy">
<strong>{{ item.name }}</strong>
<span>{{ item.category.name }}</span>
</span>
<span class="trading-pick-row__state">
<Icon :icon="isTradingItemSelected(item.id) ? iconCheck : iconAdd" class="ui-icon" aria-hidden="true" />
{{ isTradingItemSelected(item.id) ? t('common.selected') : tradingDefaultPreference === 'like' ? t('pages.pokemon.tradingLikes') : t('pages.pokemon.tradingNeutral') }}
</span>
</button>
</li>
</ul>
<p v-else class="meta-line">{{ t('common.noMatches') }}</p>
</div>
</section>
<section class="trading-manager__panel" :aria-label="t('pages.pokemon.tradingSelectedItems')">
<div class="trading-manager__list-frame trading-manager__list-frame--selected">
<div v-for="section in tradingDraftSections" :key="section.key" class="trading-selected-group">
<h3 class="section-subtitle">
{{ section.title }}
<span v-if="section.key === 'like'" class="chip">{{ t('pages.pokemon.tradingPriceBonus') }}</span>
</h3>
<ul v-if="section.items.length" class="trading-selected-list">
<li v-for="item in section.items" :key="`${section.key}-${item.id}`">
<span class="related-entity-media related-entity-media--inline" aria-hidden="true">
<img v-if="item.image" :src="item.image.url" alt="" loading="lazy" />
<Icon v-else :icon="iconItem" class="related-entity-media__icon" aria-hidden="true" />
</span>
<span class="trading-selected-list__copy">
<strong>{{ item.name }}</strong>
<span>{{ item.category.name }}</span>
</span>
<span class="segmented trading-preference-toggle" :aria-label="t('pages.pokemon.tradingPreferenceFor', { name: item.name })">
<button type="button" :class="{ active: item.preference === 'like' }" @click="setTradingPreference(item.id, 'like')">
{{ t('pages.pokemon.tradingLikes') }}
</button>
<button type="button" :class="{ active: item.preference === 'neutral' }" @click="setTradingPreference(item.id, 'neutral')">
{{ t('pages.pokemon.tradingNeutral') }}
</button>
</span>
<button
type="button"
class="plain-button plain-button--icon"
:aria-label="t('common.removeNamed', { name: item.name })"
@click="removeTradingItem(item.id)"
>
<Icon :icon="iconCancel" class="ui-icon" aria-hidden="true" />
</button>
</li>
</ul>
<p v-else class="meta-line">{{ t('common.none') }}</p>
</div>
</div>
</section>
</div>
<template #footer>
<button type="button" class="link-button" :disabled="tradingBusy || tradingItemsLoading" @click="saveTradingItems">
<Icon :icon="iconCheck" class="ui-icon" aria-hidden="true" />
{{ tradingBusy ? t('common.saving') : t('common.save') }}
</button>
<button type="button" class="plain-button" :disabled="tradingBusy || tradingItemsLoading" @click="closeTradingModal">
<Icon :icon="iconCancel" class="ui-icon" aria-hidden="true" />
{{ t('common.cancel') }}
</button>
</template>
</Modal>
<PokemonEdit v-if="showEditor" />
</template>