feat(pokemon): remove manual sorting and enforce ID-based order

Remove pokemon.order permission and related API endpoints
Update queries to sort Pokemon by internal ID ascending
Replace reorderable list with standard list in Admin view
This commit is contained in:
2026-05-06 22:35:46 +08:00
parent df78685dc3
commit 82f08c1684
6 changed files with 24 additions and 69 deletions

View File

@@ -27,7 +27,7 @@
- 全局搜索 API 只返回公开浏览所需的最小结果字段结果类型、ID、展示标题、目标 URL、可选摘要和可选图片用户搜索结果只使用公开 Profile 所需的 `id``displayName` 和目标 URL不返回邮箱、角色、权限、Referral、编辑审计、审核原因、token/hash、内部字段或调试信息。
- 用户界面只展示业务数据和设计内的文案,不展示提示词、计划、调试信息、字段内部名或修改说明。
- 可编辑 Wiki 内容必须记录创建者、最后编辑者、创建时间、最后编辑时间和编辑历史。
- 列表顺序由 `sort_order` 控制,默认按创建时间旧到新初始化,排序值按 10 递增以便后续插入和拖拽排序。
- 除 Pokemon 外,列表顺序由 `sort_order` 控制,默认按创建时间旧到新初始化,排序值按 10 递增以便后续插入和拖拽排序Pokemon 列表按内部 `id` 升序展示,不提供手动排序
## 国际化
@@ -358,7 +358,7 @@
- `created_at`
- 详情页展示最后编辑者、最后编辑时间和编辑历史面板。
- 编辑历史中的用户信息只展示必要署名不暴露邮箱、token、hash 或内部元数据。
- 排序操作仍更新列表顺序、最后编辑者和最后编辑时间,但 `sort_order` / Sort order 字段变更不写入或展示在详情页编辑历史面板中。
- 非 Pokemon 列表排序操作仍更新列表顺序、最后编辑者和最后编辑时间,但 `sort_order` / Sort order 字段变更不写入或展示在详情页编辑历史面板中。
- 编辑署名、编辑历史署名、Life 作者和讨论作者可链接到对应公开 Profile。
## Wiki 图片上传
@@ -529,7 +529,6 @@ Pokemon 可配置:
- Speed
- 出现的栖息地:由栖息地出现配置反向展示
- 翻译
- 排序
普通 Pokemon 与 Event Pokemon 分开展示:
@@ -586,7 +585,7 @@ Pokemon 列表功能:
- 按喜欢的东西筛选:
- 满足任意条件
- 满足全部条件
-自定义排序展示
- Pokemon 内部 `id`序展示
- 列表首屏只读取一页数据;滚动到列表底部时继续读取下一页,不一次性加载全部 Pokemon。
- Pokemon 列表卡片只展示 Pokemon 图片和下方的 `#ID 名称`;不展示喜欢的环境、属性、特长、喜欢的东西或编辑元信息。
- Pokemon 卡片在已配置图片时展示所选图片缩略图;未配置图片时保留默认 Poké Ball 标记。
@@ -1005,7 +1004,7 @@ API 暴露边界:
- 全局主导航使用 `AppShell` 侧边栏;移动端通过导航按钮打开侧边栏抽屉。
- 管理入口在全局侧边栏中保持单一 Admin 入口,`/admin` 内部使用页面内二级菜单分组组织管理模块:
- 配置System config。
- 内容Daily CheckList、Pokemon、物品、材料单、栖息地的维护、排序或删除入口以及 Data Tools。
- 内容Daily CheckList、Pokemon、物品、材料单、栖息地的维护、排序或删除入口以及 Data ToolsPokemon 在 Admin 中可删除但不提供手动排序
- 内容管理包含 Items、Event Items 与 Ancient ArtifactsItems / Event Items 使用同一物品数据模型,通过 `is_event_item` 拆分入口。
- 本地化Languages、System wordings。
- 访问权限Users、Roles、Permissions、Rate limits。
@@ -1190,7 +1189,7 @@ API 暴露边界:
- `GET /api/admin/ai-moderation`
- `PUT /api/admin/ai-moderation`
- `PUT /api/admin/system-wordings/:key`
- Pokemon、物品、材料单、栖息地的列表排序需要对应实体的 `order` 权限。
- 物品、材料单、栖息地的列表排序需要对应实体的 `order` 权限Pokemon 按内部 `id` 排序,不提供列表排序 API 或 Admin 手动排序入口
## 开发与验证

View File

@@ -231,7 +231,6 @@ VALUES
('pokemon.create', 'Create Pokemon', 'Create Pokemon records.', 'Pokemon', true),
('pokemon.update', 'Update Pokemon', 'Edit Pokemon records.', 'Pokemon', true),
('pokemon.delete', 'Delete Pokemon', 'Delete Pokemon records.', 'Pokemon', true),
('pokemon.order', 'Order Pokemon', 'Reorder Pokemon records.', 'Pokemon', true),
('pokemon.fetch', 'Fetch Pokemon data', 'Fetch Pokemon data and sprite candidates.', 'Pokemon', true),
('pokemon.upload', 'Upload Pokemon images', 'Upload Pokemon images.', 'Pokemon', true),
('habitats.create', 'Create habitats', 'Create habitat records.', 'Habitats', true),
@@ -275,6 +274,9 @@ VALUES
('discussions.comments.like', 'Like discussion comments', 'Like and unlike entity discussion comments.', 'Discussions', true)
ON CONFLICT (key) DO NOTHING;
DELETE FROM permissions
WHERE key = 'pokemon.order';
INSERT INTO roles (key, name, description, level, enabled, system_role)
VALUES
('owner', 'Owner', 'Highest-level system owner with all permissions.', 1000, true, true),
@@ -329,7 +331,6 @@ JOIN permissions p ON p.key = ANY (ARRAY[
'pokemon.create',
'pokemon.update',
'pokemon.delete',
'pokemon.order',
'pokemon.fetch',
'pokemon.upload',
'habitats.create',
@@ -411,7 +412,6 @@ JOIN permissions p ON p.key = ANY (ARRAY[
'checklist.order',
'pokemon.create',
'pokemon.update',
'pokemon.order',
'pokemon.fetch',
'pokemon.upload',
'habitats.create',

View File

@@ -108,7 +108,7 @@ type ConfigDefinition = {
hasRateable?: boolean;
hasChangeLog?: boolean;
};
type SortableContentType = 'pokemon' | 'items' | 'ancient-artifacts' | 'recipes' | 'habitats';
type SortableContentType = 'items' | 'ancient-artifacts' | 'recipes' | 'habitats';
type SortableContentDefinition = {
table: string;
entityType: SortableContentType;
@@ -691,7 +691,6 @@ const configDefinitions: Record<ConfigType, ConfigDefinition> = {
};
const sortableContentDefinitions: Record<SortableContentType, SortableContentDefinition> = {
pokemon: { table: 'pokemon', entityType: 'pokemon' },
items: { table: 'items', entityType: 'items' },
'ancient-artifacts': { table: 'items', entityType: 'ancient-artifacts' },
recipes: { table: 'recipes', entityType: 'recipes' },
@@ -2809,7 +2808,7 @@ export async function globalSearch(paramsQuery: QueryParams = {}, locale = defau
${pokemonImageJson('p')} AS image
FROM pokemon p
WHERE ${pokemonName} ILIKE $1
ORDER BY ${orderByEntity('p')}
ORDER BY p.id
LIMIT $2
`,
[pattern, limit]
@@ -5746,11 +5745,6 @@ async function reorderContent(type: SortableContentType, payload: Record<string,
});
}
export async function reorderPokemon(payload: Record<string, unknown>, userId: number, locale = defaultLocale) {
await reorderContent('pokemon', payload, userId);
return listPokemon({}, locale);
}
export async function reorderItems(payload: Record<string, unknown>, userId: number, locale = defaultLocale) {
await reorderContent('items', payload, userId);
return listItems({}, locale);
@@ -5822,7 +5816,7 @@ export async function listPokemon(paramsQuery: QueryParams, locale = defaultLoca
}
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
return queryMaybePaged(`${pokemonProjection(locale)} ${whereClause} ORDER BY ${orderByEntity('p')}`, params, paramsQuery);
return queryMaybePaged(`${pokemonProjection(locale)} ${whereClause} ORDER BY p.id`, params, paramsQuery);
}
export async function getPokemon(id: number, locale = defaultLocale) {
@@ -5927,7 +5921,6 @@ export async function getPokemon(id: number, locale = defaultLocale) {
scored_pokemon AS (
SELECT
related_pokemon.id,
related_pokemon.sort_order,
(related_pokemon.environment_id = current_pokemon.environment_id) AS "environmentMatches",
COUNT(current_favourites.favorite_thing_id)::integer AS "favoriteThingMatchCount"
FROM current_pokemon
@@ -5936,7 +5929,7 @@ export async function getPokemon(id: number, locale = defaultLocale) {
ON related_pokemon_favourite.pokemon_id = related_pokemon.id
LEFT JOIN current_favourites
ON current_favourites.favorite_thing_id = related_pokemon_favourite.favorite_thing_id
GROUP BY related_pokemon.id, related_pokemon.sort_order, related_pokemon.environment_id, current_pokemon.environment_id
GROUP BY related_pokemon.id, related_pokemon.environment_id, current_pokemon.environment_id
HAVING related_pokemon.environment_id = current_pokemon.environment_id
OR COUNT(current_favourites.favorite_thing_id) > 0
)
@@ -5981,7 +5974,7 @@ export async function getPokemon(id: number, locale = defaultLocale) {
FROM scored_pokemon
JOIN pokemon related_pokemon ON related_pokemon.id = scored_pokemon.id
JOIN environments related_environment ON related_environment.id = related_pokemon.environment_id
ORDER BY scored_pokemon."environmentMatches" DESC, scored_pokemon."favoriteThingMatchCount" DESC, scored_pokemon.sort_order, related_pokemon.id
ORDER BY scored_pokemon."environmentMatches" DESC, scored_pokemon."favoriteThingMatchCount" DESC, related_pokemon.id
`,
[id]
),
@@ -6369,10 +6362,10 @@ export async function listHabitats(paramsQuery: QueryParams = {}, locale = defau
'name', pokemon_rows.name,
'isEventItem', pokemon_rows.is_event_item
)
ORDER BY pokemon_rows.sort_order, pokemon_rows.id
ORDER BY pokemon_rows.id
)
FROM (
SELECT DISTINCT p.id, p.display_id, ${pokemonName} AS name, p.is_event_item, p.sort_order
SELECT DISTINCT p.id, p.display_id, ${pokemonName} AS name, p.is_event_item
FROM habitat_pokemon hp
JOIN pokemon p ON p.id = hp.pokemon_id
WHERE hp.habitat_id = h.id
@@ -6443,7 +6436,7 @@ export async function getHabitat(id: number, locale = defaultLocale) {
JOIN pokemon p ON p.id = hp.pokemon_id
JOIN maps m ON m.id = hp.map_id
WHERE hp.habitat_id = $1
ORDER BY hp.rarity, ${orderByEntity('p')}, ${orderByEntity('m')}
ORDER BY hp.rarity, p.id, ${orderByEntity('m')}
`,
[id]
),
@@ -6855,7 +6848,7 @@ export async function getItem(id: number, locale = defaultLocale) {
JOIN skills s ON s.id = psid.skill_id
WHERE psid.item_id = $1
AND s.has_item_drop = true
ORDER BY ${orderByEntity('p')}, ${orderByEntity('s')}
ORDER BY p.id, ${orderByEntity('s')}
`,
[id]
),
@@ -6893,7 +6886,7 @@ export async function getItem(id: number, locale = defaultLocale) {
WHERE ps.pokemon_id = p.id
AND trading_skill.has_trading = true
)
ORDER BY pti.preference DESC, ${orderByEntity('p')}
ORDER BY pti.preference DESC, p.id
`,
[id]
),
@@ -8493,7 +8486,7 @@ async function exportGenericScopeData(client: DbClient, entityType: string, incl
async function exportScopeData(client: DbClient, scope: DataToolScope): Promise<DataToolScopeData> {
if (scope === 'pokemon') {
return {
pokemon: await tableRows(client, 'SELECT * FROM pokemon ORDER BY sort_order, id'),
pokemon: await tableRows(client, 'SELECT * FROM pokemon ORDER BY id'),
pokemonTypeLinks: await tableRows(client, 'SELECT * FROM pokemon_pokemon_types ORDER BY pokemon_id, slot_order'),
pokemonSkills: await tableRows(client, 'SELECT * FROM pokemon_skills ORDER BY pokemon_id, skill_id'),
pokemonFavoriteThings: await tableRows(client, 'SELECT * FROM pokemon_favorite_things ORDER BY pokemon_id, favorite_thing_id'),

View File

@@ -111,7 +111,6 @@ import {
reorderHabitats,
reorderItems,
reorderLanguages,
reorderPokemon,
reorderRecipes,
retryEntityDiscussionCommentModeration,
retryLifeCommentModeration,
@@ -2092,11 +2091,6 @@ app.delete('/api/admin/daily-checklist/:id', async (request, reply) => {
return deleted ? reply.code(204).send() : notFound(reply, request);
});
app.put('/api/admin/pokemon/order', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'pokemon.order', 'wikiWrite');
return user ? reorderPokemon(request.body as Record<string, unknown>, user.id, requestLocale(request)) : undefined;
});
app.put('/api/admin/items/order', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'items.order', 'wikiWrite');
return user ? reorderItems(request.body as Record<string, unknown>, user.id, requestLocale(request)) : undefined;

View File

@@ -1460,7 +1460,6 @@ 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: (params: Record<string, string | number | boolean | undefined> = {}) =>
getJson<Habitat[]>(`/api/habitats${buildQuery(params)}`),
habitatsPage: (params: PublicListQueryParams = {}) =>

View File

@@ -156,7 +156,7 @@ const adminNavigationGroups = computed<AdminNavGroup[]>(() => {
label: t('pages.admin.contentGroup'),
items: [
{ key: 'checklist', label: t('pages.admin.checklist'), permission: ['checklist.create', 'checklist.update', 'checklist.delete', 'checklist.order'] },
{ key: 'pokemon', label: t('pages.admin.pokemonList'), permission: ['pokemon.order', 'pokemon.delete'] },
{ key: 'pokemon', label: t('pages.admin.pokemonList'), permission: 'pokemon.delete' },
{ key: 'items', label: t('pages.admin.itemList'), permission: ['items.order', 'items.delete'] },
{
key: 'ancientArtifacts',
@@ -502,8 +502,6 @@ 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.displayId} ${item.name}`;
const itemKey = (item: Item) => item.id;
const itemLabel = (item: Item) => item.name;
const ancientArtifactKey = (item: AncientArtifact) => item.id;
@@ -932,10 +930,6 @@ function previewConfigOrder(rows: EditableConfig[]) {
configRows.value = rows;
}
function previewPokemonOrder(rows: Pokemon[]) {
pokemonRows.value = rows;
}
function previewItemOrder(rows: Item[]) {
itemRows.value = rows;
}
@@ -1004,18 +998,6 @@ async function persistConfigOrder(nextRows: EditableConfig[], fallbackRows: Edit
});
}
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 () => {
@@ -2319,20 +2301,8 @@ onMounted(() => {
<section v-else-if="canEdit && activeTab === 'pokemon'" class="detail-section">
<h2>{{ t('pages.admin.pokemonList') }}</h2>
<ReorderableList
v-if="pokemonRows.length"
:items="pokemonRows"
:item-key="pokemonKey"
:item-label="pokemonLabel"
list-key-prefix="pokemon"
:disabled="busy || !can('pokemon.order')"
:handle-label="dragSortLabel"
:handle-title="t('pages.admin.dragSortTitle')"
@preview="previewPokemonOrder"
@cancel="previewPokemonOrder"
@reorder="persistPokemonOrder"
>
<template #default="{ item }">
<ul v-if="pokemonRows.length" class="row-list">
<li v-for="item in pokemonRows" :key="item.id">
<RouterLink :to="`/pokemon/${item.id}`">#{{ item.displayId }} {{ item.name }}</RouterLink>
<span class="row-actions">
<button v-if="can('pokemon.delete')" type="button" :disabled="busy" @click="removePokemon(item.id)">
@@ -2340,8 +2310,8 @@ onMounted(() => {
{{ t('common.delete') }}
</button>
</span>
</template>
</ReorderableList>
</li>
</ul>
<p v-else class="meta-line">{{ t('common.noRecords') }}</p>
</section>