feat: add custom sorting for all major entities

Add sort_order column to pokemon, items, recipes, habitats, and configs
Implement drag-and-drop reordering in the admin interface
Update API endpoints and database queries to respect the new sort order
This commit is contained in:
2026-05-01 12:30:46 +08:00
parent 27100fbd22
commit 239a2ec3b5
9 changed files with 546 additions and 97 deletions

View File

@@ -383,6 +383,8 @@ export const api = {
config: (type: ConfigType) => getJson<Array<Skill | NamedEntity>>(`/api/admin/config/${type}`),
createConfig: (type: ConfigType, payload: { name: string; translations?: TranslationMap; hasItemDrop?: boolean }) =>
sendJson<Skill | NamedEntity>(`/api/admin/config/${type}`, 'POST', payload),
reorderConfig: (type: ConfigType, ids: number[]) =>
sendJson<Array<Skill | NamedEntity>>(`/api/admin/config/${type}/order`, 'PUT', { ids }),
updateConfig: (type: ConfigType, id: number, payload: { name: string; translations?: TranslationMap; hasItemDrop?: boolean }) =>
sendJson<Skill | NamedEntity>(`/api/admin/config/${type}/${id}`, 'PUT', payload),
deleteConfig: (type: ConfigType, id: number) => deleteJson(`/api/admin/config/${type}/${id}`),
@@ -393,23 +395,27 @@ export const api = {
updatePokemon: (id: string | number, payload: PokemonPayload) =>
sendJson<PokemonDetail>(`/api/pokemon/${id}`, 'PUT', payload),
deletePokemon: (id: string | number) => deleteJson(`/api/pokemon/${id}`),
reorderPokemon: (ids: number[]) => sendJson<Pokemon[]>('/api/admin/pokemon/order', 'PUT', { ids }),
habitats: () => getJson<Habitat[]>('/api/habitats'),
habitatDetail: (id: string | number) => getJson<HabitatDetail>(`/api/habitats/${id}`),
createHabitat: (payload: HabitatPayload) => sendJson<HabitatDetail>('/api/habitats', 'POST', payload),
updateHabitat: (id: string | number, payload: HabitatPayload) =>
sendJson<HabitatDetail>(`/api/habitats/${id}`, 'PUT', payload),
deleteHabitat: (id: string | number) => deleteJson(`/api/habitats/${id}`),
reorderHabitats: (ids: number[]) => sendJson<Habitat[]>('/api/admin/habitats/order', 'PUT', { ids }),
items: (params: Record<string, string | number | undefined>) =>
getJson<Item[]>(`/api/items${buildQuery(params)}`),
itemDetail: (id: string | number) => getJson<ItemDetail>(`/api/items/${id}`),
createItem: (payload: ItemPayload) => sendJson<ItemDetail>('/api/items', 'POST', payload),
updateItem: (id: string | number, payload: ItemPayload) => sendJson<ItemDetail>(`/api/items/${id}`, 'PUT', payload),
deleteItem: (id: string | number) => deleteJson(`/api/items/${id}`),
reorderItems: (ids: number[]) => sendJson<Item[]>('/api/admin/items/order', 'PUT', { ids }),
recipes: (params: Record<string, string | number | undefined> = {}) =>
getJson<Recipe[]>(`/api/recipes${buildQuery(params)}`),
recipeDetail: (id: string | number) => getJson<RecipeDetail>(`/api/recipes/${id}`),
createRecipe: (payload: RecipePayload) => sendJson<RecipeDetail>('/api/recipes', 'POST', payload),
updateRecipe: (id: string | number, payload: RecipePayload) =>
sendJson<RecipeDetail>(`/api/recipes/${id}`, 'PUT', payload),
deleteRecipe: (id: string | number) => deleteJson(`/api/recipes/${id}`)
deleteRecipe: (id: string | number) => deleteJson(`/api/recipes/${id}`),
reorderRecipes: (ids: number[]) => sendJson<Recipe[]>('/api/admin/recipes/order', 'PUT', { ids })
};

View File

@@ -106,6 +106,16 @@ const checklistKey = (item: DailyChecklistItem) => item.id;
const checklistLabel = (item: DailyChecklistItem) => item.title;
const languageKey = (item: Language) => item.code;
const languageLabel = (item: Language) => item.name;
const configKey = (item: EditableConfig) => item.id;
const configLabel = (item: EditableConfig) => item.name;
const pokemonKey = (item: Pokemon) => item.id;
const pokemonLabel = (item: Pokemon) => `#${item.id} ${item.name}`;
const itemKey = (item: Item) => item.id;
const itemLabel = (item: Item) => item.name;
const recipeKey = (item: Recipe) => item.id;
const recipeLabel = (item: Recipe) => item.name;
const habitatKey = (item: Habitat) => item.id;
const habitatLabel = (item: Habitat) => item.name;
function dragSortLabel(name: string) {
return t('pages.admin.dragSort', { name });
@@ -203,6 +213,26 @@ function previewLanguageOrder(rows: Language[]) {
languageRows.value = rows;
}
function previewConfigOrder(rows: EditableConfig[]) {
configRows.value = rows;
}
function previewPokemonOrder(rows: Pokemon[]) {
pokemonRows.value = rows;
}
function previewItemOrder(rows: Item[]) {
itemRows.value = rows;
}
function previewRecipeOrder(rows: Recipe[]) {
recipeRows.value = rows;
}
function previewHabitatOrder(rows: Habitat[]) {
habitatRows.value = rows;
}
async function persistChecklistOrder(nextRows: DailyChecklistItem[], fallbackRows: DailyChecklistItem[]) {
checklistRows.value = nextRows;
await run(async () => {
@@ -228,6 +258,66 @@ async function persistLanguageOrder(nextRows: Language[], fallbackRows: Language
});
}
async function persistConfigOrder(nextRows: EditableConfig[], fallbackRows: EditableConfig[]) {
configRows.value = nextRows;
await run(async () => {
try {
configRows.value = (await api.reorderConfig(activeConfigType.value, nextRows.map((item) => item.id))) as EditableConfig[];
} catch (error) {
configRows.value = fallbackRows;
throw error;
}
});
}
async function persistPokemonOrder(nextRows: Pokemon[], fallbackRows: Pokemon[]) {
pokemonRows.value = nextRows;
await run(async () => {
try {
pokemonRows.value = await api.reorderPokemon(nextRows.map((item) => item.id));
} catch (error) {
pokemonRows.value = fallbackRows;
throw error;
}
});
}
async function persistItemOrder(nextRows: Item[], fallbackRows: Item[]) {
itemRows.value = nextRows;
await run(async () => {
try {
itemRows.value = await api.reorderItems(nextRows.map((item) => item.id));
} catch (error) {
itemRows.value = fallbackRows;
throw error;
}
});
}
async function persistRecipeOrder(nextRows: Recipe[], fallbackRows: Recipe[]) {
recipeRows.value = nextRows;
await run(async () => {
try {
recipeRows.value = await api.reorderRecipes(nextRows.map((item) => item.id));
} catch (error) {
recipeRows.value = fallbackRows;
throw error;
}
});
}
async function persistHabitatOrder(nextRows: Habitat[], fallbackRows: Habitat[]) {
habitatRows.value = nextRows;
await run(async () => {
try {
habitatRows.value = await api.reorderHabitats(nextRows.map((item) => item.id));
} catch (error) {
habitatRows.value = fallbackRows;
throw error;
}
});
}
async function saveConfig() {
await run(async () => {
const payload = {
@@ -516,15 +606,28 @@ onMounted(() => {
</form>
<h3 class="section-subtitle">{{ selectedConfig.label }}</h3>
<ul v-if="configRows.length" class="row-list">
<li v-for="item in configRows" :key="item.id">
<span>{{ item.name }}<span v-if="item.hasItemDrop" class="config-flag">{{ t('pages.admin.hasItemDrop') }}</span></span>
<span class="row-actions">
<button type="button" @click="editConfig(item)">{{ t('common.edit') }}</button>
<button type="button" @click="removeConfig(item.id)">{{ t('common.delete') }}</button>
<ReorderableList
v-if="configRows.length"
:items="configRows"
:item-key="configKey"
:item-label="configLabel"
:disabled="busy"
:handle-label="dragSortLabel"
:handle-title="t('pages.admin.dragSortTitle')"
@preview="previewConfigOrder"
@cancel="previewConfigOrder"
@reorder="persistConfigOrder"
>
<template #default="{ item }">
<span class="reorderable-row-title">
{{ item.name }}<span v-if="item.hasItemDrop" class="config-flag">{{ t('pages.admin.hasItemDrop') }}</span>
</span>
</li>
</ul>
<span class="row-actions">
<button type="button" :disabled="busy" @click="editConfig(item)">{{ t('common.edit') }}</button>
<button type="button" :disabled="busy" @click="removeConfig(item.id)">{{ t('common.delete') }}</button>
</span>
</template>
</ReorderableList>
<p v-else class="meta-line">{{ t('common.noRecords') }}</p>
</section>
@@ -582,53 +685,97 @@ onMounted(() => {
<section v-else-if="canEdit && activeTab === 'pokemon'" class="detail-section">
<h2>{{ t('pages.admin.pokemonList') }}</h2>
<ul v-if="pokemonRows.length" class="row-list">
<li v-for="item in pokemonRows" :key="item.id">
<ReorderableList
v-if="pokemonRows.length"
:items="pokemonRows"
:item-key="pokemonKey"
:item-label="pokemonLabel"
:disabled="busy"
:handle-label="dragSortLabel"
:handle-title="t('pages.admin.dragSortTitle')"
@preview="previewPokemonOrder"
@cancel="previewPokemonOrder"
@reorder="persistPokemonOrder"
>
<template #default="{ item }">
<RouterLink :to="`/pokemon/${item.id}`">#{{ item.id }} {{ item.name }}</RouterLink>
<span class="row-actions">
<button type="button" @click="removePokemon(item.id)">{{ t('common.delete') }}</button>
<button type="button" :disabled="busy" @click="removePokemon(item.id)">{{ t('common.delete') }}</button>
</span>
</li>
</ul>
</template>
</ReorderableList>
<p v-else class="meta-line">{{ t('common.noRecords') }}</p>
</section>
<section v-else-if="canEdit && activeTab === 'items'" class="detail-section">
<h2>{{ t('pages.admin.itemList') }}</h2>
<ul v-if="itemRows.length" class="row-list">
<li v-for="item in itemRows" :key="item.id">
<ReorderableList
v-if="itemRows.length"
:items="itemRows"
:item-key="itemKey"
:item-label="itemLabel"
:disabled="busy"
:handle-label="dragSortLabel"
:handle-title="t('pages.admin.dragSortTitle')"
@preview="previewItemOrder"
@cancel="previewItemOrder"
@reorder="persistItemOrder"
>
<template #default="{ item }">
<RouterLink :to="`/items/${item.id}`">{{ item.name }}</RouterLink>
<span class="row-actions">
<button type="button" @click="removeItem(item.id)">{{ t('common.delete') }}</button>
<button type="button" :disabled="busy" @click="removeItem(item.id)">{{ t('common.delete') }}</button>
</span>
</li>
</ul>
</template>
</ReorderableList>
<p v-else class="meta-line">{{ t('common.noRecords') }}</p>
</section>
<section v-else-if="canEdit && activeTab === 'recipes'" class="detail-section">
<h2>{{ t('pages.admin.recipeList') }}</h2>
<ul v-if="recipeRows.length" class="row-list">
<li v-for="item in recipeRows" :key="item.id">
<ReorderableList
v-if="recipeRows.length"
:items="recipeRows"
:item-key="recipeKey"
:item-label="recipeLabel"
:disabled="busy"
:handle-label="dragSortLabel"
:handle-title="t('pages.admin.dragSortTitle')"
@preview="previewRecipeOrder"
@cancel="previewRecipeOrder"
@reorder="persistRecipeOrder"
>
<template #default="{ item }">
<RouterLink :to="`/recipes/${item.id}`">{{ item.name }}</RouterLink>
<span class="row-actions">
<button type="button" @click="removeRecipe(item.id)">{{ t('common.delete') }}</button>
<button type="button" :disabled="busy" @click="removeRecipe(item.id)">{{ t('common.delete') }}</button>
</span>
</li>
</ul>
</template>
</ReorderableList>
<p v-else class="meta-line">{{ t('common.noRecords') }}</p>
</section>
<section v-else-if="canEdit && activeTab === 'habitats'" class="detail-section">
<h2>{{ t('pages.admin.habitatList') }}</h2>
<ul v-if="habitatRows.length" class="row-list">
<li v-for="item in habitatRows" :key="item.id">
<ReorderableList
v-if="habitatRows.length"
:items="habitatRows"
:item-key="habitatKey"
:item-label="habitatLabel"
:disabled="busy"
:handle-label="dragSortLabel"
:handle-title="t('pages.admin.dragSortTitle')"
@preview="previewHabitatOrder"
@cancel="previewHabitatOrder"
@reorder="persistHabitatOrder"
>
<template #default="{ item }">
<RouterLink :to="`/habitats/${item.id}`">{{ item.name }}</RouterLink>
<span class="row-actions">
<button type="button" @click="removeHabitat(item.id)">{{ t('common.delete') }}</button>
<button type="button" :disabled="busy" @click="removeHabitat(item.id)">{{ t('common.delete') }}</button>
</span>
</li>
</ul>
</template>
</ReorderableList>
<p v-else class="meta-line">{{ t('common.noRecords') }}</p>
</section>
</section>

View File

@@ -92,7 +92,7 @@ const pokemonRows = computed<PokemonRow[]>(() => {
timeOfDays: sortByOrder(row.timeOfDays, timeOfDays),
weathers: sortByOrder(row.weathers, weathers),
rarity: row.rarity,
maps: [...row.maps].sort((a, b) => a.localeCompare(b))
maps: [...row.maps]
}));
});

View File

@@ -94,7 +94,7 @@ const habitatRows = computed<HabitatRow[]>(() => {
timeOfDays: sortByOrder(row.timeOfDays, timeOfDays),
weathers: sortByOrder(row.weathers, weathers),
rarity: row.rarity,
maps: [...row.maps].sort((a, b) => a.localeCompare(b))
maps: [...row.maps]
}));
});
const skillDropRows = computed(() => pokemon.value?.skills.filter((skill) => skill.itemDrop) ?? []);
@@ -105,9 +105,7 @@ const itemCategoryTabs = computed<TabOption[]>(() => {
categories.set(String(item.category.id), item.category.name);
});
const tabs = [...categories.entries()]
.sort(([, nameA], [, nameB]) => nameA.localeCompare(nameB))
.map(([value, label]) => ({ value, label }));
const tabs = [...categories.entries()].map(([value, label]) => ({ value, label }));
return tabs.length > 1 ? [{ value: '', label: t('common.all') }, ...tabs] : [];
});

View File

@@ -32,7 +32,8 @@ const itemQuery = computed(() => ({
search: search.value,
categoryId: categoryId.value,
usageId: usageId.value,
tagIds: tagIds.value.join(',')
tagIds: tagIds.value.join(','),
recipeOrder: 1
}));
function recipeTarget(item: Item) {