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:
@@ -45,6 +45,8 @@ const changeLabelKeys: Record<string, string> = {
|
||||
'Speciality drops': 'pages.pokemon.skillDrops',
|
||||
'Skill drops': 'pages.pokemon.skillDrops',
|
||||
特长掉落物: 'pages.pokemon.skillDrops',
|
||||
Trading: 'pages.pokemon.trading',
|
||||
'Trading items': 'pages.pokemon.tradingItems',
|
||||
Category: 'pages.items.category',
|
||||
分类: 'pages.items.category',
|
||||
Usage: 'pages.items.usage',
|
||||
@@ -76,6 +78,8 @@ const changeLabelKeys: Record<string, string> = {
|
||||
排序: 'pages.admin.sortOrder',
|
||||
'Has item drop': 'pages.admin.hasItemDrop',
|
||||
有掉落物: 'pages.admin.hasItemDrop',
|
||||
'Has trading': 'pages.admin.hasTrading',
|
||||
'有 Trading': 'pages.admin.hasTrading',
|
||||
'Default category': 'pages.admin.defaultCategory',
|
||||
默认分类: 'pages.admin.defaultCategory',
|
||||
Rateable: 'pages.admin.rateableCategory',
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
<script lang="ts">
|
||||
let openModalCount = 0;
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue';
|
||||
import { nextTick, onBeforeUnmount, onMounted, onUpdated, ref, watch } from 'vue';
|
||||
@@ -54,11 +58,15 @@ const bodyFallbackSelector = [
|
||||
].join(',');
|
||||
|
||||
function lockPage() {
|
||||
openModalCount += 1;
|
||||
document.body.classList.add('lock-scroll');
|
||||
}
|
||||
|
||||
function unlockPage() {
|
||||
document.body.classList.remove('lock-scroll');
|
||||
openModalCount = Math.max(0, openModalCount - 1);
|
||||
if (openModalCount === 0) {
|
||||
document.body.classList.remove('lock-scroll');
|
||||
}
|
||||
}
|
||||
|
||||
function restoreFocus() {
|
||||
|
||||
@@ -48,8 +48,11 @@ export interface GameVersion extends NamedEntity {
|
||||
|
||||
export interface Skill extends NamedEntity {
|
||||
hasItemDrop: boolean;
|
||||
hasTrading: boolean;
|
||||
}
|
||||
|
||||
export type TradingPreference = 'like' | 'neutral';
|
||||
|
||||
export interface PokemonStats {
|
||||
hp: number;
|
||||
attack: number;
|
||||
@@ -174,6 +177,12 @@ export interface Pokemon extends EditInfo {
|
||||
favorite_things: NamedEntity[];
|
||||
}
|
||||
|
||||
export interface PokemonTradingItem extends NamedEntity {
|
||||
itemId: number;
|
||||
preference: TradingPreference;
|
||||
image?: EntityImage | null;
|
||||
}
|
||||
|
||||
export interface RelatedPokemon {
|
||||
id: number;
|
||||
displayId: number;
|
||||
@@ -188,6 +197,7 @@ export interface RelatedPokemon {
|
||||
export interface PokemonDetail extends Pokemon {
|
||||
skills: Array<Skill & { itemDrop: (NamedEntity & { image?: EntityImage | null }) | null }>;
|
||||
favoriteThingItems: Array<NamedEntity & { image?: EntityImage | null; category: NamedEntity; tags: NamedEntity[] }>;
|
||||
tradingItems: PokemonTradingItem[];
|
||||
relatedPokemon: RelatedPokemon[];
|
||||
editHistory: EditHistoryEntry[];
|
||||
imageHistory: EntityImageUpload[];
|
||||
@@ -296,6 +306,7 @@ export interface ItemDetail extends Item {
|
||||
recipe: RecipeDetail | null;
|
||||
relatedRecipes: RecipeUsage[];
|
||||
relatedHabitats: HabitatUsage[];
|
||||
possibleTags: ItemPossibleTags;
|
||||
editHistory: EditHistoryEntry[];
|
||||
imageHistory: EntityImageUpload[];
|
||||
droppedByPokemon: Array<{
|
||||
@@ -304,6 +315,22 @@ export interface ItemDetail extends Item {
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface ItemPossibleTags {
|
||||
highlyLikely: NamedEntity[];
|
||||
possible: NamedEntity[];
|
||||
excluded: NamedEntity[];
|
||||
evidence: {
|
||||
likes: ItemPossibleTagEvidence[];
|
||||
neutral: ItemPossibleTagEvidence[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface ItemPossibleTagEvidence {
|
||||
pokemon: NamedEntity & { displayId: number; isEventItem: boolean; image?: PokemonImage | null };
|
||||
preference: TradingPreference;
|
||||
tags: NamedEntity[];
|
||||
}
|
||||
|
||||
export interface Recipe extends EditInfo {
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -761,6 +788,7 @@ export interface PokemonPayload {
|
||||
skillIds: number[];
|
||||
favoriteThingIds: number[];
|
||||
skillItemDrops: Array<{ skillId: number; itemId: number }>;
|
||||
tradingItems: Array<{ itemId: number; preference: TradingPreference }>;
|
||||
imagePath: string;
|
||||
}
|
||||
|
||||
@@ -1352,7 +1380,7 @@ export const api = {
|
||||
config: (type: ConfigType) => getJson<Array<Skill | LifeCategory | GameVersion | NamedEntity>>(`/api/admin/config/${type}`),
|
||||
createConfig: (
|
||||
type: ConfigType,
|
||||
payload: { name: string; translations?: TranslationMap; hasItemDrop?: boolean; isDefault?: boolean; isRateable?: boolean; changeLog?: string }
|
||||
payload: { name: string; translations?: TranslationMap; hasItemDrop?: boolean; hasTrading?: boolean; isDefault?: boolean; isRateable?: boolean; changeLog?: string }
|
||||
) =>
|
||||
sendJson<Skill | LifeCategory | GameVersion | NamedEntity>(`/api/admin/config/${type}`, 'POST', payload),
|
||||
reorderConfig: (type: ConfigType, ids: number[]) =>
|
||||
@@ -1360,7 +1388,7 @@ export const api = {
|
||||
updateConfig: (
|
||||
type: ConfigType,
|
||||
id: number,
|
||||
payload: { name: string; translations?: TranslationMap; hasItemDrop?: boolean; isDefault?: boolean; isRateable?: boolean; changeLog?: string }
|
||||
payload: { name: string; translations?: TranslationMap; hasItemDrop?: boolean; hasTrading?: boolean; isDefault?: boolean; isRateable?: boolean; changeLog?: string }
|
||||
) =>
|
||||
sendJson<Skill | LifeCategory | GameVersion | NamedEntity>(`/api/admin/config/${type}/${id}`, 'PUT', payload),
|
||||
deleteConfig: (type: ConfigType, id: number) => deleteJson(`/api/admin/config/${type}/${id}`),
|
||||
|
||||
@@ -1335,6 +1335,13 @@ svg {
|
||||
box-shadow: 0 2px 0 var(--line-strong);
|
||||
}
|
||||
|
||||
.plain-button--icon {
|
||||
width: 38px;
|
||||
min-width: 38px;
|
||||
height: 38px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
button:disabled,
|
||||
.ui-button:disabled,
|
||||
.primary-button:disabled,
|
||||
@@ -5082,6 +5089,227 @@ button:disabled,
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.trading-manager__panel,
|
||||
.trading-selected-group,
|
||||
.possible-tags-evidence {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.trading-detail-grid,
|
||||
.possible-tags-grid,
|
||||
.possible-tags-evidence__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.trading-detail-group,
|
||||
.possible-tags-group,
|
||||
.possible-tags-evidence__group {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 9px;
|
||||
align-content: start;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-card);
|
||||
background: var(--surface-soft);
|
||||
}
|
||||
|
||||
.trading-detail-group h3 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.trading-manager {
|
||||
min-height: 640px;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1.1fr);
|
||||
gap: 16px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.trading-manager__panel {
|
||||
padding: 12px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-card);
|
||||
background: var(--surface-soft);
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.trading-manager__toolbar {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 180px;
|
||||
gap: 12px;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.trading-manager__target {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.trading-manager__list-frame {
|
||||
min-height: 420px;
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.trading-manager__list-frame--selected {
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.trading-default-toggle {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.trading-item-list,
|
||||
.trading-selected-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: auto;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.trading-item-list {
|
||||
min-height: 360px;
|
||||
max-height: 420px;
|
||||
}
|
||||
|
||||
.trading-selected-list {
|
||||
max-height: 220px;
|
||||
}
|
||||
|
||||
.trading-item-list--loading {
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.trading-pick-row,
|
||||
.trading-selected-list li {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 9px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-card);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.trading-pick-row {
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
color: var(--ink);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.trading-pick-row--selected {
|
||||
background: var(--surface-soft);
|
||||
}
|
||||
|
||||
.trading-pick-row__copy,
|
||||
.trading-selected-list__copy {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.trading-pick-row__copy strong,
|
||||
.trading-selected-list__copy strong,
|
||||
.possible-tags-evidence__group h4 {
|
||||
margin: 0;
|
||||
color: var(--ink);
|
||||
font-size: 14px;
|
||||
font-weight: 900;
|
||||
line-height: 1.2;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.trading-pick-row__copy span,
|
||||
.trading-selected-list__copy span {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.trading-pick-row__state {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
color: var(--pokemon-blue-deep);
|
||||
font-size: 12px;
|
||||
font-weight: 950;
|
||||
}
|
||||
|
||||
.trading-selected-list li {
|
||||
grid-template-columns: auto minmax(0, 1fr) auto auto;
|
||||
}
|
||||
|
||||
.trading-preference-toggle {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.trading-preference-toggle button {
|
||||
min-height: 34px;
|
||||
padding: 7px 9px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.trading-item-list__skeleton {
|
||||
padding: 9px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-card);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.possible-tags-evidence__list li {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.possible-tags-evidence__list .chips {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.trading-manager {
|
||||
grid-template-columns: 1fr;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.trading-manager__toolbar {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.trading-selected-list li {
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.trading-manager__list-frame,
|
||||
.trading-item-list {
|
||||
min-height: 280px;
|
||||
max-height: 360px;
|
||||
}
|
||||
|
||||
.trading-selected-list {
|
||||
max-height: 240px;
|
||||
}
|
||||
|
||||
.trading-preference-toggle,
|
||||
.trading-selected-list .plain-button--icon {
|
||||
grid-column: 2;
|
||||
justify-self: start;
|
||||
}
|
||||
}
|
||||
|
||||
.pokemon-related-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
|
||||
@@ -90,6 +90,7 @@ type AdminNavItem = { key: AdminTab; label: string; permission: string | string[
|
||||
type AdminNavGroup = { key: AdminGroup; label: string; items: AdminNavItem[] };
|
||||
type EditableConfig = (NamedEntity | Skill | LifeCategory | GameVersion) & {
|
||||
hasItemDrop?: boolean;
|
||||
hasTrading?: boolean;
|
||||
isDefault?: boolean;
|
||||
isRateable?: boolean;
|
||||
changeLog?: string;
|
||||
@@ -194,10 +195,18 @@ const adminNavigationGroups = computed<AdminNavGroup[]>(() => {
|
||||
const tabs = computed<AdminNavItem[]>(() => adminNavigationGroups.value.flatMap((group) => group.items));
|
||||
|
||||
const configTypes = computed<
|
||||
Array<{ key: ConfigType; label: string; supportsItemDrop?: boolean; supportsDefault?: boolean; supportsRateable?: boolean; supportsChangeLog?: boolean }>
|
||||
Array<{
|
||||
key: ConfigType;
|
||||
label: string;
|
||||
supportsItemDrop?: boolean;
|
||||
supportsTrading?: boolean;
|
||||
supportsDefault?: boolean;
|
||||
supportsRateable?: boolean;
|
||||
supportsChangeLog?: boolean;
|
||||
}>
|
||||
>(() => [
|
||||
{ key: 'pokemon-types', label: t('config.pokemonTypes') },
|
||||
{ key: 'skills', label: t('config.skills'), supportsItemDrop: true },
|
||||
{ key: 'skills', label: t('config.skills'), supportsItemDrop: true, supportsTrading: true },
|
||||
{ key: 'environments', label: t('config.environments') },
|
||||
{ key: 'favorite-things', label: t('config.favoriteThings') },
|
||||
{ key: 'acquisition-methods', label: t('config.acquisitionMethods') },
|
||||
@@ -237,6 +246,7 @@ const configForm = ref({
|
||||
name: '',
|
||||
translations: {} as TranslationMap,
|
||||
hasItemDrop: false,
|
||||
hasTrading: false,
|
||||
isDefault: false,
|
||||
isRateable: false,
|
||||
changeLog: ''
|
||||
@@ -561,7 +571,7 @@ async function loadLanguages() {
|
||||
}
|
||||
|
||||
function resetConfigForm() {
|
||||
configForm.value = { id: 0, name: '', translations: {}, hasItemDrop: false, isDefault: false, isRateable: false, changeLog: '' };
|
||||
configForm.value = { id: 0, name: '', translations: {}, hasItemDrop: false, hasTrading: false, isDefault: false, isRateable: false, changeLog: '' };
|
||||
}
|
||||
|
||||
function resetChecklistForm() {
|
||||
@@ -667,6 +677,7 @@ function editConfig(item: EditableConfig) {
|
||||
name: item.baseName ?? item.name,
|
||||
translations: item.translations ?? {},
|
||||
hasItemDrop: item.hasItemDrop === true,
|
||||
hasTrading: item.hasTrading === true,
|
||||
isDefault: item.isDefault === true,
|
||||
isRateable: item.isRateable === true,
|
||||
changeLog: item.changeLog ?? ''
|
||||
@@ -1047,6 +1058,7 @@ async function saveConfig() {
|
||||
name: configBaseNameForSave(),
|
||||
translations: configForm.value.translations,
|
||||
hasItemDrop: selectedConfig.value.supportsItemDrop ? configForm.value.hasItemDrop : undefined,
|
||||
hasTrading: selectedConfig.value.supportsTrading ? configForm.value.hasTrading : undefined,
|
||||
isDefault: selectedConfig.value.supportsDefault ? configForm.value.isDefault : undefined,
|
||||
isRateable: selectedConfig.value.supportsRateable ? configForm.value.isRateable : undefined,
|
||||
changeLog: selectedConfig.value.supportsChangeLog ? configForm.value.changeLog : undefined
|
||||
@@ -2002,6 +2014,7 @@ onMounted(() => {
|
||||
<span class="reorderable-row-title">
|
||||
{{ item.name }}
|
||||
<span v-if="item.hasItemDrop" class="config-flag">{{ t('pages.admin.hasItemDrop') }}</span>
|
||||
<span v-if="item.hasTrading" class="config-flag">{{ t('pages.admin.hasTrading') }}</span>
|
||||
<span v-if="item.isDefault" class="config-flag">{{ t('pages.admin.defaultCategory') }}</span>
|
||||
<span v-if="item.isRateable" class="config-flag">{{ t('pages.admin.rateableCategory') }}</span>
|
||||
</span>
|
||||
@@ -2801,6 +2814,12 @@ onMounted(() => {
|
||||
{{ t('pages.admin.hasItemDrop') }}
|
||||
</label>
|
||||
</div>
|
||||
<div v-if="selectedConfig.supportsTrading" class="check-row">
|
||||
<label>
|
||||
<input v-model="configForm.hasTrading" type="checkbox" />
|
||||
{{ t('pages.admin.hasTrading') }}
|
||||
</label>
|
||||
</div>
|
||||
<div v-if="selectedConfig.supportsDefault" class="check-row">
|
||||
<label>
|
||||
<input v-model="configForm.isDefault" type="checkbox" />
|
||||
|
||||
@@ -63,6 +63,15 @@ const basePriceDisplay = computed(() => {
|
||||
const price = item.value?.basePrice;
|
||||
return price === null || price === undefined ? t('common.none') : new Intl.NumberFormat(locale.value).format(price);
|
||||
});
|
||||
const possibleTagSections = computed(() => [
|
||||
{ key: 'highlyLikely', title: t('pages.items.highlyLikelyTags'), tags: item.value?.possibleTags?.highlyLikely ?? [] },
|
||||
{ key: 'possible', title: t('pages.items.possibleTagsPossible'), tags: item.value?.possibleTags?.possible ?? [] },
|
||||
{ key: 'excluded', title: t('pages.items.excludedTags'), tags: item.value?.possibleTags?.excluded ?? [] }
|
||||
]);
|
||||
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 customization = computed(() => {
|
||||
if (!item.value) {
|
||||
@@ -269,6 +278,39 @@ watch(
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DetailSection :title="t('pages.items.possibleTags')">
|
||||
<div class="possible-tags-grid">
|
||||
<div v-for="section in possibleTagSections" :key="section.key" class="possible-tags-group">
|
||||
<h3 class="section-subtitle">{{ section.title }}</h3>
|
||||
<EntityChips v-if="section.tags.length" :items="section.tags" />
|
||||
<p v-else class="meta-line">{{ t('common.none') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="possible-tags-evidence">
|
||||
<h3 class="section-subtitle">{{ t('pages.items.possibleTagsEvidence') }}</h3>
|
||||
<div class="possible-tags-evidence__grid">
|
||||
<div v-for="section in possibleTagEvidenceSections" :key="section.key" class="possible-tags-evidence__group">
|
||||
<h4>{{ section.title }}</h4>
|
||||
<ul v-if="section.rows.length" class="row-list possible-tags-evidence__list">
|
||||
<li v-for="entry in section.rows" :key="`${section.key}-${entry.pokemon.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>
|
||||
<EntityChips v-if="entry.tags.length" :items="entry.tags" />
|
||||
<p v-else class="meta-line">{{ t('common.none') }}</p>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-else class="meta-line">{{ t('common.none') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DetailSection>
|
||||
|
||||
<div class="detail-grid">
|
||||
<DetailSection :title="t('pages.items.recipeInfo')">
|
||||
<template v-if="item.recipe">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
type PokemonImage,
|
||||
type PokemonPayload,
|
||||
type PokemonStats,
|
||||
type TradingPreference,
|
||||
type TranslationMap
|
||||
} from '../services/api';
|
||||
|
||||
@@ -95,6 +96,7 @@ const pokemonForm = ref({
|
||||
skillIds: [] as string[],
|
||||
favoriteThingIds: [] as string[],
|
||||
skillItemDrops: [] as SkillItemDropForm[],
|
||||
tradingItems: [] as Array<{ itemId: string; preference: TradingPreference }>,
|
||||
imagePath: ''
|
||||
});
|
||||
|
||||
@@ -145,6 +147,7 @@ const selectedUploadImage = computed(() => (selectedPokemonImage.value?.source =
|
||||
const canCreateConfig = computed(() => currentUser.value?.permissions.includes('admin.config.create') === true);
|
||||
const canFetchPokemon = computed(() => currentUser.value?.permissions.includes('pokemon.fetch') === true);
|
||||
const canUploadImage = computed(() => currentUser.value?.permissions.includes('pokemon.upload') === true);
|
||||
const hasTradingSkill = computed(() => pokemonForm.value.skillIds.some((skillId) => skillSupportsTrading(skillId)));
|
||||
|
||||
function toIds(values: string[]): number[] {
|
||||
return values.map(Number).filter((item) => Number.isInteger(item) && item > 0);
|
||||
@@ -225,6 +228,17 @@ function skillSupportsItemDrop(skillId: string) {
|
||||
return options.value?.skills.some((skill) => String(skill.id) === skillId && skill.hasItemDrop) === true;
|
||||
}
|
||||
|
||||
function skillSupportsTrading(skillId: string) {
|
||||
return options.value?.skills.some((skill) => String(skill.id) === skillId && skill.hasTrading) === true;
|
||||
}
|
||||
|
||||
function syncSkillFeatures() {
|
||||
syncSkillItemDrops();
|
||||
if (!hasTradingSkill.value) {
|
||||
pokemonForm.value.tradingItems = [];
|
||||
}
|
||||
}
|
||||
|
||||
function skillDropLabel(skillId: string) {
|
||||
const name = skillName(skillId);
|
||||
return name ? t('pages.pokemon.skillDrop', { name }) : t('pages.pokemon.dropItem');
|
||||
@@ -334,12 +348,16 @@ async function loadEditor() {
|
||||
skillId: String(skill.id),
|
||||
itemId: skill.itemDrop ? String(skill.itemDrop.id) : ''
|
||||
})),
|
||||
tradingItems: pokemon.tradingItems.map((item) => ({
|
||||
itemId: String(item.itemId),
|
||||
preference: item.preference
|
||||
})),
|
||||
imagePath: pokemon.image?.path ?? ''
|
||||
};
|
||||
currentPokemonImage.value = pokemon.image;
|
||||
imageOptions.value = pokemon.image ? [pokemon.image] : [];
|
||||
imageHistory.value = pokemon.imageHistory;
|
||||
syncSkillItemDrops();
|
||||
syncSkillFeatures();
|
||||
} else {
|
||||
pokemonForm.value.isEventItem = isEventCreate.value;
|
||||
}
|
||||
@@ -720,6 +738,11 @@ async function savePokemon() {
|
||||
skillItemDrops: selectedSkillDropRows.value
|
||||
.map((row) => ({ skillId: Number(row.skillId), itemId: Number(row.itemId) }))
|
||||
.filter((row) => Number.isInteger(row.skillId) && row.skillId > 0 && Number.isInteger(row.itemId) && row.itemId > 0),
|
||||
tradingItems: hasTradingSkill.value
|
||||
? pokemonForm.value.tradingItems
|
||||
.map((item) => ({ itemId: Number(item.itemId), preference: item.preference }))
|
||||
.filter((item) => Number.isInteger(item.itemId) && item.itemId > 0)
|
||||
: [],
|
||||
imagePath: pokemonForm.value.imagePath
|
||||
};
|
||||
const saved = isEditing.value ? await api.updatePokemon(routeId.value, payload) : await api.createPokemon(payload);
|
||||
@@ -740,7 +763,7 @@ onBeforeUnmount(() => {
|
||||
removeFetchPositionListeners();
|
||||
});
|
||||
|
||||
watch(() => pokemonForm.value.skillIds.slice(), syncSkillItemDrops);
|
||||
watch(() => pokemonForm.value.skillIds.slice(), syncSkillFeatures);
|
||||
watch(fetchIdentifier, refreshFetchOptions);
|
||||
watch(locale, () => {
|
||||
resetFetchOptionsCache();
|
||||
@@ -749,7 +772,13 @@ watch(locale, () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal :title="pageTitle" :subtitle="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">
|
||||
@@ -1068,4 +1097,5 @@ watch(locale, () => {
|
||||
</button>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user