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:
36
DESIGN.md
36
DESIGN.md
@@ -5,7 +5,7 @@
|
||||
- Pokopia Wiki 是一个面向 Pokopia 游戏资料的社区 Wiki。
|
||||
- 所有人都可以浏览 Wiki 内容。
|
||||
- 已注册并完成邮箱验证且拥有对应权限的用户可以创建、编辑、删除 Wiki 内容。
|
||||
- 前台以 Home 首页、Pokemon、栖息地、物品、材料单、每日 CheckList、Life、Automation、Dish、Events、Actions、Dream Island、Clothes 为主要浏览入口。
|
||||
- 前台以 Home 首页、Pokemon、Event Pokemon、栖息地、Event Habitats、物品、材料单、每日 CheckList、Life、Automation、Dish、Events、Actions、Dream Island、Clothes 为主要浏览入口。
|
||||
- Home 首页路径为 `/`,用于聚合公开 Wiki 入口;Logo 导航回到 Home,用户可从 Home 进入核心资料、每日 CheckList、Life 和正在准备中的分区。
|
||||
- 管理入口用于维护全局配置、语言、系统文案、列表排序和每日 CheckList。
|
||||
|
||||
@@ -206,7 +206,7 @@
|
||||
- Wipe Items 会先删除 Recipes,再删除物品、物品入手方式 / 喜欢的东西关联、栖息地配方项和 Pokemon 掉落关联。
|
||||
- Wipe Recipes 会删除材料单、材料项和入手方式关联,但不删除 Items。
|
||||
- Wipe Daily CheckList 会删除清单任务和任务翻译 / 编辑历史。
|
||||
- 对被清空的 identity 主表重置自增 ID;Pokemon 内部 ID 不是 identity,自定义 / 活动 Pokemon 的系统分配区间仍按当前数据库最大值继续。
|
||||
- 对被清空的 identity 主表重置自增 ID;Pokemon 内部 ID 不是 identity,未关联官方 data 的自定义 Pokemon 系统分配区间仍按当前数据库最大值继续。
|
||||
- Export 行为:
|
||||
- 导出为版本化 JSON bundle,包含 `version`、`exportedAt`、`scopes` 和对应范围数据。
|
||||
- JSON bundle 用于系统导入,不作为前台展示内容。
|
||||
@@ -443,10 +443,10 @@
|
||||
|
||||
Pokemon 可配置:
|
||||
|
||||
- 内部 ID:`id`,系统唯一,用于路由、外键和实体关联;从 CSV Fetch 创建的普通 Pokemon 使用官方 data Pokemon ID 作为内部 ID,活动 Pokemon 和未关联官方 data 的自定义 Pokemon 由系统分配唯一内部 ID
|
||||
- 内部 ID:`id`,系统唯一,用于路由、外键和实体关联;所有关联官方 data 的 Pokemon(包含普通 Pokemon 和 Event Pokemon)使用官方 data Pokemon ID 作为内部 ID;未关联官方 data 的自定义 Pokemon 由系统分配唯一内部 ID
|
||||
- 官方 data 身份:`data_id` 和 `data_identifier`,可为空;用于记录该 Pokemon 对应的 CSV 官方 Pokemon ID 与 identifier,不作为用户可编辑展示 ID
|
||||
- 展示 ID:`display_id`,详情页、列表卡片和选择器中显示为 `#ID`
|
||||
- 是否为活动物品:`is_event_item`
|
||||
- Pokopia 展示 ID:`display_id`,详情页、列表卡片和选择器中显示为 `#ID`,由 Pokopia 业务单独维护,不作为路由、外键或官方 data 身份
|
||||
- 是否为 Event Pokemon:`is_event_item`
|
||||
- 名称
|
||||
- Genus:可为空,支持翻译
|
||||
- 介绍 / Details:可为空,支持翻译
|
||||
@@ -469,7 +469,13 @@ Pokemon 可配置:
|
||||
- 翻译
|
||||
- 排序
|
||||
|
||||
Pokemon 的展示 ID 在普通 Pokemon 和活动 Pokemon 之间可以重复,例如允许同时存在普通 `#1 妙蛙种子` 和活动 `#1 毽子草`。数据库只要求同一个 `display_id + is_event_item` 组合唯一;前端路由和实体关联必须继续使用内部 `id`,不能使用展示 ID 作为路由或外键。Fetch 得到的官方 data ID 必须与展示 ID 分开保存;例如 Zorua 的官方 data ID 为 `570` 时,用户把展示 ID 改成 `123` 后仍应通过 `/pokemon/570` 访问该 Pokemon,`/pokemon/123` 只代表内部 ID 为 `123` 的其他 Pokemon。
|
||||
普通 Pokemon 与 Event Pokemon 分开展示:
|
||||
|
||||
- `/pokemon` 展示普通 Pokemon 列表。
|
||||
- `/event-pokemon` 展示 Event Pokemon 列表。
|
||||
- 两个列表复用 Pokemon 筛选、卡片和详情行为,但列表请求必须按 `is_event_item` 分开读取。
|
||||
|
||||
Pokemon 的 Pokopia 展示 ID 在普通 Pokemon 和 Event Pokemon 之间可以重复,例如允许同时存在普通 `#1 妙蛙种子` 和 Event `#1 毽子草`。数据库只要求同一个 `display_id + is_event_item` 组合唯一;前端路由和实体关联必须继续使用内部 `id`,不能使用展示 ID 作为路由或外键。Fetch 得到的官方 data ID 必须与展示 ID 分开保存;例如 Zorua 的官方 data ID 为 `570` 时,用户把 Pokopia 展示 ID 改成 `123` 后仍应通过 `/pokemon/570` 访问该 Pokemon,`/pokemon/123` 只代表内部 ID 为 `123` 的其他 Pokemon。普通 Pokemon 和 Event Pokemon 不会同时存在同一个内部系统 ID;当 Event Pokemon 关联官方 data 时,内部 ID 同样使用官方 data Pokemon ID。
|
||||
|
||||
Pokemon 编辑表单使用标签页组织字段:
|
||||
|
||||
@@ -481,7 +487,7 @@ Pokemon 编辑表单使用标签页组织字段:
|
||||
- Fetch 搜索不使用防抖或节流;前端在每次新搜索时取消上一条搜索请求,并且只渲染最新请求结果。
|
||||
- Fetch 只填入 CSV 可提供的字段:官方 data ID、官方 data identifier、名称、Genus、Height、Weight、Types、六维和名称/Genus 翻译;不填入 Details、喜欢的环境、特长、特长掉落物品或喜欢的东西。
|
||||
- Fetch data 不要求官方 data ID 与 Pokopia 展示 ID 相同;若表单 ID 已有用户输入则保留该展示 ID,只有新建且 ID 为空时才用官方 data ID 作为初始展示 ID。
|
||||
- Fetch 后保存普通 Pokemon 时,官方 data ID 作为内部路由 ID;展示 ID 只保存到 `display_id`。
|
||||
- Fetch 后保存关联官方 data 的 Pokemon 时,官方 data ID 作为内部路由 ID;Pokopia 展示 ID 只保存到 `display_id`。
|
||||
- Fetch 不直接创建或更新 Pokemon;用户仍需通过 Save 保存,保存时沿用现有编辑审计。
|
||||
- Fetch 根据 `languages.code` 自动匹配 CSV 语言列:`en`、`ja`、`ko`、`fr`、`de`、`es`、`it` 使用同名列;`zh-CN` / `zh-SG` 等简体语言使用 `zh_hans`;`zh-TW` / `zh-HK` / `zh-MO` 使用 `zh_hant`。
|
||||
- Fetch 会自动确保 canonical Pokemon Types 存在于 `pokemon_types`,Type ID 与 `data/localized_type_name.csv` 和 `frontend/public/types` 图标文件保持一致;用户不需要为 Fetch 手工创建 Type 配置。
|
||||
@@ -495,7 +501,7 @@ Pokemon 编辑表单使用标签页组织字段:
|
||||
- Pokemon 保存显示图片的相对路径、风格、版本、状态和描述;API 对外返回可直接展示的图片 URL,但不暴露内部校验状态。
|
||||
- Pokemon 也支持社区上传图片;上传图片使用通用 Wiki 图片上传历史,当前显示图片可在静态候选和上传图片之间切换。
|
||||
- 基础标签页:
|
||||
- 第一行:ID、名称
|
||||
- 第一行:Pokopia 展示 ID、名称
|
||||
- 第二行:喜欢的环境、特长
|
||||
- 第三行:喜欢的东西
|
||||
- 特长掉落物品随已选择且支持掉落物的特长显示
|
||||
@@ -520,6 +526,7 @@ Pokemon 列表功能:
|
||||
- 按自定义排序展示
|
||||
- Pokemon 列表卡片只展示 Pokemon 图片和下方的 `#ID 名称`;不展示喜欢的环境、属性、特长、喜欢的东西或编辑元信息。
|
||||
- Pokemon 卡片在已配置图片时展示所选图片缩略图;未配置图片时保留默认 Poké Ball 标记。
|
||||
- Event Pokemon 列表功能与 Pokemon 列表相同,但只展示 `is_event_item = true` 的 Pokemon;Pokemon 列表只展示 `is_event_item = false` 的 Pokemon。
|
||||
|
||||
Pokemon 详情页展示:
|
||||
|
||||
@@ -546,7 +553,7 @@ Pokemon 详情页展示:
|
||||
物品可配置:
|
||||
|
||||
- 名称
|
||||
- 是否为活动物品:`is_event_item`
|
||||
- 是否为 Event Habitat:`is_event_item`
|
||||
- 分类:必填
|
||||
- 用途:可为空
|
||||
- 入手方式:可多选
|
||||
@@ -656,6 +663,9 @@ Pokemon 出现配置:
|
||||
- 按自定义排序展示
|
||||
- 栖息地列表卡片使用与 Pokemon 列表一致的居中图鉴式布局,只展示栖息地图片和名称;不展示配方摘要、可能出现的 Pokemon 摘要或编辑元信息。
|
||||
- 已配置图片时,栖息地卡片展示图片缩略图;未配置图片时保留默认栖息地标记。
|
||||
- `/habitats` 只展示 `is_event_item = false` 的普通栖息地。
|
||||
- `/event-habitats` 只展示 `is_event_item = true` 的 Event Habitats。
|
||||
- Event Habitats 列表复用栖息地列表的排序、卡片和详情行为;详情、编辑、关联和讨论继续使用内部 `id`。
|
||||
|
||||
栖息地详情页展示:
|
||||
|
||||
@@ -821,8 +831,10 @@ API 暴露边界:
|
||||
- 多选和单选复用 `TagsSelect`,支持搜索、键盘操作和必要时的内联创建。
|
||||
- 主要实体的新建和编辑使用路由驱动的 Modal:
|
||||
- `/pokemon/new`
|
||||
- `/event-pokemon/new`
|
||||
- `/pokemon/:id/edit`
|
||||
- `/habitats/new`
|
||||
- `/event-habitats/new`
|
||||
- `/habitats/:id/edit`
|
||||
- `/items/new`
|
||||
- `/items/:id/edit`
|
||||
@@ -843,7 +855,9 @@ API 暴露边界:
|
||||
- 前端入口 `index.html` 提供默认 title、description、robots、canonical、Open Graph、Twitter card 和 favicon;客户端路由切换后根据当前路由更新页面 metadata。
|
||||
- 主要公开浏览入口可索引:
|
||||
- `/pokemon`
|
||||
- `/event-pokemon`
|
||||
- `/habitats`
|
||||
- `/event-habitats`
|
||||
- `/items`
|
||||
- `/recipes`
|
||||
- `/checklist`
|
||||
@@ -865,9 +879,9 @@ API 暴露边界:
|
||||
- `GET /api/options`
|
||||
- `GET /api/project-updates`:读取站点项目公开更新信息;支持 `cursor` / `limit` 分页读取最近提交;仅返回净化后的仓库、最近提交和发布版本展示字段。
|
||||
- `GET /api/daily-checklist`
|
||||
- `GET /api/pokemon`
|
||||
- `GET /api/pokemon`:支持 `isEventItem=true|false` 按普通 Pokemon / Event Pokemon 拆分列表;未传时返回全部 Pokemon 以兼容管理端和实体选择器
|
||||
- `GET /api/pokemon/:id`
|
||||
- `GET /api/habitats`
|
||||
- `GET /api/habitats`:支持 `isEventItem=true|false` 按普通栖息地 / Event Habitats 拆分列表;未传时返回全部栖息地以兼容管理端和实体选择器
|
||||
- `GET /api/habitats/:id`
|
||||
- `GET /api/items`
|
||||
- `GET /api/items/:id`
|
||||
|
||||
@@ -914,10 +914,9 @@ async function nextSortOrder(client: DbClient, tableName: string): Promise<numbe
|
||||
|
||||
async function nextPokemonInternalId(
|
||||
client: DbClient,
|
||||
dataId: number | null,
|
||||
isEventItem: boolean
|
||||
dataId: number | null
|
||||
): Promise<number> {
|
||||
if (!isEventItem && dataId !== null) {
|
||||
if (dataId !== null) {
|
||||
return dataId;
|
||||
}
|
||||
|
||||
@@ -2007,8 +2006,8 @@ async function pokemonEditChanges(
|
||||
.join(' / ');
|
||||
|
||||
pushChange(changes, 'Name', before.name, after.name);
|
||||
pushChange(changes, 'Pokemon ID', String(before.displayId), String(after.displayId));
|
||||
pushChange(changes, 'Event item', boolValue(before.isEventItem), boolValue(after.isEventItem));
|
||||
pushChange(changes, 'Pokopia ID', String(before.displayId), String(after.displayId));
|
||||
pushChange(changes, 'Event Pokemon', boolValue(before.isEventItem), boolValue(after.isEventItem));
|
||||
pushChange(changes, 'Genus', before.genus, after.genus);
|
||||
pushChange(changes, 'Details', before.details, after.details);
|
||||
pushTranslationChanges(changes, before.translations, after.translations, ['name', 'genus', 'details']);
|
||||
@@ -2072,7 +2071,7 @@ async function habitatEditChanges(
|
||||
|
||||
pushChange(changes, 'Name', before.name, after.name);
|
||||
pushTranslationChanges(changes, before.translations, after.translations, ['name']);
|
||||
pushChange(changes, 'Event item', boolValue(before.isEventItem), boolValue(after.isEventItem));
|
||||
pushChange(changes, 'Event Habitat', boolValue(before.isEventItem), boolValue(after.isEventItem));
|
||||
pushChange(changes, 'Image', imagePathLabel(before.image?.path), imagePathLabel(after.imagePath));
|
||||
pushChange(changes, 'Recipe', quantityListValue(before.recipe), await quantityPayloadValue(client, after.recipeItems));
|
||||
pushChange(changes, 'Possible Pokemon', appearanceListValue(before.pokemon), afterAppearances);
|
||||
@@ -4442,17 +4441,23 @@ export async function reorderRecipes(payload: Record<string, unknown>, userId: n
|
||||
|
||||
export async function reorderHabitats(payload: Record<string, unknown>, userId: number, locale = defaultLocale) {
|
||||
await reorderContent('habitats', payload, userId);
|
||||
return listHabitats(locale);
|
||||
return listHabitats({}, locale);
|
||||
}
|
||||
|
||||
export async function listPokemon(paramsQuery: QueryParams, locale = defaultLocale) {
|
||||
const params: unknown[] = [];
|
||||
const conditions: string[] = [];
|
||||
const search = asString(paramsQuery.search)?.trim();
|
||||
const isEventItem = asString(paramsQuery.isEventItem);
|
||||
const environmentId = Number(asString(paramsQuery.environmentId));
|
||||
const skillIds = parseIdList(asString(paramsQuery.skillIds));
|
||||
const favoriteThingIds = parseIdList(asString(paramsQuery.favoriteThingIds));
|
||||
|
||||
if (isEventItem === 'true' || isEventItem === 'false') {
|
||||
params.push(isEventItem === 'true');
|
||||
conditions.push(`p.is_event_item = $${params.length}`);
|
||||
}
|
||||
|
||||
if (search) {
|
||||
params.push(`%${search}%`);
|
||||
conditions.push(`${localizedName('pokemon', 'p', locale)} ILIKE $${params.length}`);
|
||||
@@ -4777,7 +4782,7 @@ export async function createPokemon(payload: Record<string, unknown>, userId: nu
|
||||
await normalizePokemonDataIdentity(cleanPayload);
|
||||
|
||||
const id = await withTransaction(async (client) => {
|
||||
const pokemonId = await nextPokemonInternalId(client, cleanPayload.dataId, cleanPayload.isEventItem);
|
||||
const pokemonId = await nextPokemonInternalId(client, cleanPayload.dataId);
|
||||
const sortOrder = await nextSortOrder(client, 'pokemon');
|
||||
await client.query(
|
||||
`
|
||||
@@ -4849,7 +4854,7 @@ export async function createPokemon(payload: Record<string, unknown>, userId: nu
|
||||
export async function updatePokemon(id: number, payload: Record<string, unknown>, userId: number, locale = defaultLocale) {
|
||||
const cleanPayload = cleanPokemonPayload(payload);
|
||||
await normalizePokemonDataIdentity(cleanPayload);
|
||||
if (!cleanPayload.isEventItem && cleanPayload.dataId !== null && cleanPayload.dataId !== id) {
|
||||
if (cleanPayload.dataId !== null && cleanPayload.dataId !== id) {
|
||||
throw validationError('server.validation.pokemonDataIdMismatch');
|
||||
}
|
||||
const before = await getPokemon(id, defaultLocale);
|
||||
@@ -4937,10 +4942,20 @@ export async function deletePokemon(id: number, userId: number) {
|
||||
});
|
||||
}
|
||||
|
||||
export async function listHabitats(locale = defaultLocale) {
|
||||
export async function listHabitats(paramsQuery: QueryParams = {}, locale = defaultLocale) {
|
||||
const habitatName = localizedName('habitats', 'h', locale);
|
||||
const itemName = localizedName('items', 'i', locale);
|
||||
const pokemonName = localizedName('pokemon', 'p', locale);
|
||||
const params: unknown[] = [];
|
||||
const conditions: string[] = [];
|
||||
const isEventItem = asString(paramsQuery.isEventItem);
|
||||
|
||||
if (isEventItem === 'true' || isEventItem === 'false') {
|
||||
params.push(isEventItem === 'true');
|
||||
conditions.push(`h.is_event_item = $${params.length}`);
|
||||
}
|
||||
|
||||
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||
|
||||
return query(`
|
||||
SELECT
|
||||
@@ -4976,8 +4991,9 @@ export async function listHabitats(locale = defaultLocale) {
|
||||
), '[]'::json) AS pokemon
|
||||
FROM habitats h
|
||||
${auditJoins('h', 'habitat_created_user', 'habitat_updated_user')}
|
||||
${whereClause}
|
||||
ORDER BY ${orderByEntity('h')}
|
||||
`);
|
||||
`, params);
|
||||
}
|
||||
|
||||
export async function getHabitat(id: number, locale = defaultLocale) {
|
||||
|
||||
@@ -1549,7 +1549,9 @@ app.delete('/api/pokemon/:id', async (request, reply) => {
|
||||
return deleted ? reply.code(204).send() : notFound(reply, request);
|
||||
});
|
||||
|
||||
app.get('/api/habitats', async (request) => listHabitats(requestLocale(request)));
|
||||
app.get('/api/habitats', async (request) =>
|
||||
listHabitats(request.query as Record<string, string | string[] | undefined>, requestLocale(request))
|
||||
);
|
||||
|
||||
app.get('/api/habitats/:id', async (request, reply) => {
|
||||
const { id } = request.params as { id: string };
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta
|
||||
name="description"
|
||||
content="Browse Pokopia Wiki for Pokemon, habitats, items, recipes, daily tasks, and Life community posts for Pokemon Pokopia."
|
||||
content="Browse Pokopia Wiki for Pokemon, Event Pokemon, habitats, Event Habitats, items, recipes, daily tasks, and Life community posts for Pokemon Pokopia."
|
||||
/>
|
||||
<meta name="robots" content="index, follow" />
|
||||
<meta name="theme-color" content="#6ccf32" />
|
||||
@@ -16,7 +16,7 @@
|
||||
<meta property="og:title" content="Pokopia Wiki - Pokemon Pokopia Guide" />
|
||||
<meta
|
||||
property="og:description"
|
||||
content="Browse Pokopia Wiki for Pokemon, habitats, items, recipes, daily tasks, and Life community posts for Pokemon Pokopia."
|
||||
content="Browse Pokopia Wiki for Pokemon, Event Pokemon, habitats, Event Habitats, items, recipes, daily tasks, and Life community posts for Pokemon Pokopia."
|
||||
/>
|
||||
<meta property="og:url" content="%POKOPIA_SITE_URL%/pokemon" />
|
||||
<meta property="og:image" content="%POKOPIA_SITE_URL%/seo/pokopia-hero.jpg" />
|
||||
@@ -25,7 +25,7 @@
|
||||
<meta name="twitter:title" content="Pokopia Wiki - Pokemon Pokopia Guide" />
|
||||
<meta
|
||||
name="twitter:description"
|
||||
content="Browse Pokopia Wiki for Pokemon, habitats, items, recipes, daily tasks, and Life community posts for Pokemon Pokopia."
|
||||
content="Browse Pokopia Wiki for Pokemon, Event Pokemon, habitats, Event Habitats, items, recipes, daily tasks, and Life community posts for Pokemon Pokopia."
|
||||
/>
|
||||
<meta name="twitter:image" content="%POKOPIA_SITE_URL%/seo/pokopia-hero.jpg" />
|
||||
<script>
|
||||
|
||||
@@ -45,7 +45,9 @@ const navItems = computed(() => {
|
||||
const items = [
|
||||
{ label: t('nav.home'), to: '/', icon: iconHome },
|
||||
{ label: t('nav.pokemon'), to: '/pokemon', icon: iconPokemon },
|
||||
{ label: t('nav.eventPokemon'), to: '/event-pokemon', icon: iconEvent },
|
||||
{ label: t('nav.habitats'), to: '/habitats', icon: iconHabitat },
|
||||
{ label: t('nav.eventHabitats'), to: '/event-habitats', icon: iconEvent },
|
||||
{ label: t('nav.items'), to: '/items', icon: iconItem },
|
||||
{ label: t('nav.recipes'), to: '/recipes', icon: iconRecipe },
|
||||
{ label: t('nav.automation'), to: '/automation', icon: iconAutomation, badge: inDevBadge() },
|
||||
|
||||
@@ -15,7 +15,10 @@ const changeLabelKeys: Record<string, string> = {
|
||||
Title: 'pages.checklist.task',
|
||||
标题: 'pages.checklist.task',
|
||||
'Pokemon ID': 'pages.pokemon.id',
|
||||
'Pokopia ID': 'pages.pokemon.id',
|
||||
'Event item': 'common.eventItem',
|
||||
'Event Pokemon': 'pages.pokemon.eventItem',
|
||||
'Event Habitat': 'pages.habitats.eventItem',
|
||||
Genus: 'pages.pokemon.genus',
|
||||
Details: 'pages.pokemon.details',
|
||||
介绍: 'pages.pokemon.details',
|
||||
|
||||
@@ -29,17 +29,42 @@ export const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{ path: '/', name: 'home', component: HomeView, meta: { seo: seo({ titleKey: 'pages.home.title', descriptionKey: 'pages.home.subtitle', canonicalPath: '/' }) } },
|
||||
{ path: '/pokemon', name: 'pokemon-list', component: PokemonList, meta: { seo: seo({ titleKey: 'pages.pokemon.title', descriptionKey: 'pages.pokemon.subtitle' }) } },
|
||||
{
|
||||
path: '/pokemon',
|
||||
name: 'pokemon-list',
|
||||
component: PokemonList,
|
||||
props: { eventOnly: false },
|
||||
meta: { seo: seo({ titleKey: 'pages.pokemon.title', descriptionKey: 'pages.pokemon.subtitle' }) }
|
||||
},
|
||||
{
|
||||
path: '/pokemon/new',
|
||||
name: 'pokemon-new',
|
||||
component: PokemonList,
|
||||
props: { eventOnly: false },
|
||||
meta: {
|
||||
requiredPermission: 'pokemon.create',
|
||||
editorModal: true,
|
||||
seo: seo({ titleKey: 'pages.pokemon.newTitle', descriptionKey: 'pages.pokemon.editSubtitle', canonicalPath: '/pokemon', noindex: true })
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/event-pokemon',
|
||||
name: 'event-pokemon-list',
|
||||
component: PokemonList,
|
||||
props: { eventOnly: true },
|
||||
meta: { seo: seo({ titleKey: 'pages.eventPokemon.title', descriptionKey: 'pages.eventPokemon.subtitle', canonicalPath: '/event-pokemon' }) }
|
||||
},
|
||||
{
|
||||
path: '/event-pokemon/new',
|
||||
name: 'event-pokemon-new',
|
||||
component: PokemonList,
|
||||
props: { eventOnly: true },
|
||||
meta: {
|
||||
requiredPermission: 'pokemon.create',
|
||||
editorModal: true,
|
||||
seo: seo({ titleKey: 'pages.eventPokemon.newTitle', descriptionKey: 'pages.eventPokemon.editSubtitle', canonicalPath: '/event-pokemon', noindex: true })
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/pokemon/:id/edit',
|
||||
name: 'pokemon-edit',
|
||||
@@ -56,17 +81,42 @@ export const router = createRouter({
|
||||
}
|
||||
},
|
||||
{ path: '/pokemon/:id', name: 'pokemon-detail', component: PokemonDetail, meta: { seo: seo({ titleKey: 'pages.pokemon.detailKicker', descriptionKey: 'pages.pokemon.subtitle' }) } },
|
||||
{ path: '/habitats', name: 'habitat-list', component: HabitatList, meta: { seo: seo({ titleKey: 'pages.habitats.title', descriptionKey: 'pages.habitats.subtitle' }) } },
|
||||
{
|
||||
path: '/habitats',
|
||||
name: 'habitat-list',
|
||||
component: HabitatList,
|
||||
props: { eventOnly: false },
|
||||
meta: { seo: seo({ titleKey: 'pages.habitats.title', descriptionKey: 'pages.habitats.subtitle' }) }
|
||||
},
|
||||
{
|
||||
path: '/habitats/new',
|
||||
name: 'habitat-new',
|
||||
component: HabitatList,
|
||||
props: { eventOnly: false },
|
||||
meta: {
|
||||
requiredPermission: 'habitats.create',
|
||||
editorModal: true,
|
||||
seo: seo({ titleKey: 'pages.habitats.newTitle', descriptionKey: 'pages.habitats.editSubtitle', canonicalPath: '/habitats', noindex: true })
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/event-habitats',
|
||||
name: 'event-habitat-list',
|
||||
component: HabitatList,
|
||||
props: { eventOnly: true },
|
||||
meta: { seo: seo({ titleKey: 'pages.eventHabitats.title', descriptionKey: 'pages.eventHabitats.subtitle', canonicalPath: '/event-habitats' }) }
|
||||
},
|
||||
{
|
||||
path: '/event-habitats/new',
|
||||
name: 'event-habitat-new',
|
||||
component: HabitatList,
|
||||
props: { eventOnly: true },
|
||||
meta: {
|
||||
requiredPermission: 'habitats.create',
|
||||
editorModal: true,
|
||||
seo: seo({ titleKey: 'pages.eventHabitats.newTitle', descriptionKey: 'pages.eventHabitats.editSubtitle', canonicalPath: '/event-habitats', noindex: true })
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/habitats/:id/edit',
|
||||
name: 'habitat-edit',
|
||||
|
||||
@@ -1080,7 +1080,7 @@ export const api = {
|
||||
) =>
|
||||
sendJson<Skill | LifeCategory | GameVersion | NamedEntity>(`/api/admin/config/${type}/${id}`, 'PUT', payload),
|
||||
deleteConfig: (type: ConfigType, id: number) => deleteJson(`/api/admin/config/${type}/${id}`),
|
||||
pokemon: (params: Record<string, string | number | undefined>) =>
|
||||
pokemon: (params: Record<string, string | number | boolean | undefined>) =>
|
||||
getJson<Pokemon[]>(`/api/pokemon${buildQuery(params)}`),
|
||||
pokemonDetail: (id: string | number) => getJson<PokemonDetail>(`/api/pokemon/${id}`),
|
||||
pokemonFetchOptions: (search: string, signal?: AbortSignal, all = false) =>
|
||||
@@ -1096,7 +1096,8 @@ export const api = {
|
||||
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'),
|
||||
habitats: (params: Record<string, string | number | boolean | undefined> = {}) =>
|
||||
getJson<Habitat[]>(`/api/habitats${buildQuery(params)}`),
|
||||
habitatDetail: (id: string | number) => getJson<HabitatDetail>(`/api/habitats/${id}`),
|
||||
createHabitat: (payload: HabitatPayload) => sendJson<HabitatDetail>('/api/habitats', 'POST', payload),
|
||||
updateHabitat: (id: string | number, payload: HabitatPayload) =>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 }
|
||||
]);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -3,7 +3,7 @@ import vue from '@vitejs/plugin-vue';
|
||||
|
||||
const fallbackSiteUrl = 'https://pokopiawiki.tootaio.com';
|
||||
const frontendPort = 20015;
|
||||
const sitemapPaths = ['/pokemon', '/habitats', '/items', '/recipes', '/checklist', '/life'];
|
||||
const sitemapPaths = ['/pokemon', '/event-pokemon', '/habitats', '/event-habitats', '/items', '/recipes', '/checklist', '/life'];
|
||||
const robotsDisallowPaths = [
|
||||
'/admin',
|
||||
'/login',
|
||||
@@ -12,8 +12,10 @@ const robotsDisallowPaths = [
|
||||
'/reset-password',
|
||||
'/verify-email',
|
||||
'/pokemon/new',
|
||||
'/event-pokemon/new',
|
||||
'/pokemon/*/edit',
|
||||
'/habitats/new',
|
||||
'/event-habitats/new',
|
||||
'/habitats/*/edit',
|
||||
'/items/new',
|
||||
'/items/*/edit',
|
||||
|
||||
@@ -44,7 +44,9 @@ export const systemWordingMessages = {
|
||||
nav: {
|
||||
home: 'Home',
|
||||
pokemon: 'Pokemon',
|
||||
eventPokemon: 'Event Pokemon',
|
||||
habitats: 'Habitats',
|
||||
eventHabitats: 'Event Habitats',
|
||||
items: 'Items',
|
||||
recipes: 'Recipes',
|
||||
automation: 'Automation',
|
||||
@@ -78,7 +80,7 @@ export const systemWordingMessages = {
|
||||
},
|
||||
seo: {
|
||||
siteDescription:
|
||||
'Browse Pokopia Wiki for Pokemon, habitats, items, recipes, daily tasks, and Life community posts for Pokemon Pokopia.',
|
||||
'Browse Pokopia Wiki for Pokemon, Event Pokemon, habitats, Event Habitats, items, recipes, daily tasks, and Life community posts for Pokemon Pokopia.',
|
||||
pokemonDetailDescription:
|
||||
'Read {name} details in Pokopia Wiki, including habitat, types, specialities, favourites, stats, related items, discussions, and edit history.',
|
||||
itemDetailDescription:
|
||||
@@ -143,7 +145,7 @@ export const systemWordingMessages = {
|
||||
home: {
|
||||
kicker: 'Community Wiki',
|
||||
title: 'Pokopia Wiki',
|
||||
subtitle: 'Browse Pokemon, habitats, items, recipes, daily tasks, and Life posts for Pokemon Pokopia.',
|
||||
subtitle: 'Browse Pokemon, Event Pokemon, habitats, Event Habitats, items, recipes, daily tasks, and Life posts for Pokemon Pokopia.',
|
||||
primaryActions: 'Primary home actions',
|
||||
browsePokemon: 'Browse Pokemon',
|
||||
openChecklist: 'Daily CheckList',
|
||||
@@ -173,10 +175,18 @@ export const systemWordingMessages = {
|
||||
title: 'Pokemon',
|
||||
description: 'Search Pokemon and filter by specialities, ideal habitat, and favourites.'
|
||||
},
|
||||
eventPokemon: {
|
||||
title: 'Event Pokemon',
|
||||
description: 'Browse limited Pokemon entries with their own Pokopia IDs and list order.'
|
||||
},
|
||||
habitats: {
|
||||
title: 'Habitats',
|
||||
description: 'View recipes, maps, weather, time, and Pokemon that may appear.'
|
||||
},
|
||||
eventHabitats: {
|
||||
title: 'Event Habitats',
|
||||
description: 'Browse limited habitats with event recipes and possible Pokemon appearances.'
|
||||
},
|
||||
items: {
|
||||
title: 'Items',
|
||||
description: 'Browse categories, usage, acquisition methods, customization, and tags.'
|
||||
@@ -466,6 +476,7 @@ export const systemWordingMessages = {
|
||||
pokemon: {
|
||||
title: 'Pokemon',
|
||||
subtitle: 'Search Pokemon and filter by specialities, ideal habitat, and favourites.',
|
||||
listKicker: 'Pokédex',
|
||||
detailKicker: 'Pokédex Detail',
|
||||
editKicker: 'Pokédex Edit',
|
||||
editSubtitle: 'Maintain Pokemon profile, details, types, stats, specialities, and favourites.',
|
||||
@@ -474,14 +485,14 @@ export const systemWordingMessages = {
|
||||
editTabAdvance: 'Advance',
|
||||
newTitle: 'New Pokemon',
|
||||
editTitle: 'Edit #{id} {name}',
|
||||
id: 'Pokemon ID',
|
||||
id: 'Pokopia ID',
|
||||
fetchData: 'Fetch data',
|
||||
fetchingData: 'Fetching',
|
||||
fetchIdentifier: 'Data identifier',
|
||||
fetchIdentifierPlaceholder: 'bulbasaur or 1',
|
||||
fetchIdentifierRequired: 'Enter a Pokemon identifier',
|
||||
fetchFailed: 'Pokemon data fetch failed',
|
||||
fetchIdMismatch: 'Fetched Pokemon ID #{id} does not match this editor.',
|
||||
fetchIdMismatch: 'Fetched official ID #{id} does not match this editor.',
|
||||
fetchResults: 'Pokemon data results',
|
||||
fetchSearching: 'Searching data',
|
||||
fetchNoMatches: 'No matching Pokemon data',
|
||||
@@ -497,7 +508,7 @@ export const systemWordingMessages = {
|
||||
clearImage: 'Clear image',
|
||||
imageEmpty: 'No Pokemon image selected',
|
||||
imageAlt: '{name} {variant} image',
|
||||
eventItem: 'Event item',
|
||||
eventItem: 'Event Pokemon',
|
||||
loadingList: 'Loading Pokemon list',
|
||||
loadingDetail: 'Loading Pokemon detail',
|
||||
loadingEdit: 'Loading Pokemon editor',
|
||||
@@ -552,9 +563,20 @@ export const systemWordingMessages = {
|
||||
searchFavoriteThings: 'Search favourites',
|
||||
searchItems: 'Search items'
|
||||
},
|
||||
eventPokemon: {
|
||||
title: 'Event Pokemon',
|
||||
subtitle: 'Search Event Pokemon and filter by specialities, ideal habitat, and favourites.',
|
||||
kicker: 'Event Pokédex',
|
||||
detailKicker: 'Event Pokemon Detail',
|
||||
editSubtitle: 'Maintain Event Pokemon profile, Pokopia ID, official data identity, images, stats, specialities, and favourites.',
|
||||
newTitle: 'New Event Pokemon',
|
||||
editTitle: 'Edit Event #{id} {name}',
|
||||
loadingList: 'Loading Event Pokemon list'
|
||||
},
|
||||
habitats: {
|
||||
title: 'Habitats',
|
||||
subtitle: 'View recipes and Pokemon that may appear.',
|
||||
listKicker: 'Habitats',
|
||||
detailKicker: 'Habitat Detail',
|
||||
detailSubtitle: 'Habitat detail',
|
||||
editSubtitle: 'Maintain habitat recipes and possible Pokemon appearances.',
|
||||
@@ -564,7 +586,7 @@ export const systemWordingMessages = {
|
||||
loadingList: 'Loading habitat list',
|
||||
loadingDetail: 'Loading habitat detail',
|
||||
loadingEdit: 'Loading habitat editor',
|
||||
eventItem: 'Event item',
|
||||
eventItem: 'Event Habitat',
|
||||
recipe: 'Recipe',
|
||||
recipeList: 'Recipe list',
|
||||
possiblePokemon: 'Possible Pokemon',
|
||||
@@ -573,6 +595,16 @@ export const systemWordingMessages = {
|
||||
maps: 'Maps',
|
||||
searchMaps: 'Search maps'
|
||||
},
|
||||
eventHabitats: {
|
||||
title: 'Event Habitats',
|
||||
subtitle: 'View limited habitats, event recipes, and Pokemon that may appear.',
|
||||
kicker: 'Event Habitats',
|
||||
detailKicker: 'Event Habitat Detail',
|
||||
editSubtitle: 'Maintain Event Habitat recipes, possible Pokemon appearances, and image.',
|
||||
newTitle: 'New Event Habitat',
|
||||
editTitle: 'Edit Event Habitat {name}',
|
||||
loadingList: 'Loading Event Habitat list'
|
||||
},
|
||||
items: {
|
||||
title: 'Items',
|
||||
subtitle: 'Browse items by category, usage, and tags.',
|
||||
@@ -1078,7 +1110,7 @@ export const systemWordingMessages = {
|
||||
pokemonIdentifierRequired: 'Pokemon identifier is required',
|
||||
pokemonTypeDataUnavailable: 'Pokemon type data is unavailable',
|
||||
pokemonDataNotFound: 'Pokemon data was not found',
|
||||
pokemonDataIdMismatch: 'Pokemon data ID does not match this Pokemon',
|
||||
pokemonDataIdMismatch: 'Official Pokemon data ID does not match this Pokemon',
|
||||
dataToolScopeRequired: 'Select at least one data scope',
|
||||
dataToolScopeInvalid: 'Data scope is invalid',
|
||||
dataToolBundleInvalid: 'Data bundle is invalid',
|
||||
@@ -1112,7 +1144,7 @@ export const systemWordingMessages = {
|
||||
skillMax: 'Choose at most 2 specialities',
|
||||
favoriteMax: 'Choose at most 6 favourites',
|
||||
dropItemSelectedSkill: 'Drop items must be linked to selected specialities',
|
||||
pokemonIdRequired: 'Pokemon ID is required',
|
||||
pokemonIdRequired: 'Pokopia ID is required',
|
||||
pokemonNameRequired: 'Pokemon name is required',
|
||||
heightNonNegative: 'Height must be a non-negative number',
|
||||
weightNonNegative: 'Weight must be a non-negative number',
|
||||
@@ -1210,7 +1242,9 @@ export const systemWordingMessages = {
|
||||
nav: {
|
||||
home: '首页',
|
||||
pokemon: 'Pokemon',
|
||||
eventPokemon: 'Event Pokemon',
|
||||
habitats: '栖息地',
|
||||
eventHabitats: 'Event Habitats',
|
||||
items: '物品',
|
||||
recipes: '材料单',
|
||||
automation: '自动化',
|
||||
@@ -1243,7 +1277,7 @@ export const systemWordingMessages = {
|
||||
}
|
||||
},
|
||||
seo: {
|
||||
siteDescription: '浏览 Pokopia Wiki 的 Pokemon、栖息地、物品、材料单、每日清单和 Life 社区动态。',
|
||||
siteDescription: '浏览 Pokopia Wiki 的 Pokemon、Event Pokemon、栖息地、Event Habitats、物品、材料单、每日清单和 Life 社区动态。',
|
||||
pokemonDetailDescription: '查看 {name} 在 Pokopia Wiki 中的栖息地、属性、特长、喜欢的东西、六维、相关物品、讨论和编辑历史。',
|
||||
itemDetailDescription: '查看 {name} 在 Pokopia Wiki 中的分类、用途、入手方式、自定义、相关材料单、栖息地和 Pokemon 掉落。',
|
||||
habitatDetailDescription: '查看 {name} 在 Pokopia Wiki 中的配方、可能出现的 Pokemon、地图、时间、天气、讨论和编辑历史。',
|
||||
@@ -1304,7 +1338,7 @@ export const systemWordingMessages = {
|
||||
home: {
|
||||
kicker: '社区 Wiki',
|
||||
title: 'Pokopia Wiki',
|
||||
subtitle: '浏览 Pokemon、栖息地、物品、材料单、每日任务和 Pokemon Pokopia 的 Life 动态。',
|
||||
subtitle: '浏览 Pokemon、Event Pokemon、栖息地、Event Habitats、物品、材料单、每日任务和 Pokemon Pokopia 的 Life 动态。',
|
||||
primaryActions: '首页主要操作',
|
||||
browsePokemon: '浏览 Pokemon',
|
||||
openChecklist: '每日 CheckList',
|
||||
@@ -1334,10 +1368,18 @@ export const systemWordingMessages = {
|
||||
title: 'Pokemon',
|
||||
description: '搜索 Pokemon,并按特长、喜欢的环境和喜欢的东西筛选。'
|
||||
},
|
||||
eventPokemon: {
|
||||
title: 'Event Pokemon',
|
||||
description: '浏览限时 Pokemon 条目,并维护独立的 Pokopia ID 与排序。'
|
||||
},
|
||||
habitats: {
|
||||
title: '栖息地',
|
||||
description: '查看配方、地图、天气、时间和可能出现的 Pokemon。'
|
||||
},
|
||||
eventHabitats: {
|
||||
title: 'Event Habitats',
|
||||
description: '浏览限时栖息地、活动配方和可能出现的 Pokemon。'
|
||||
},
|
||||
items: {
|
||||
title: '物品',
|
||||
description: '按分类、用途、入手方式、自定义和标签浏览物品。'
|
||||
@@ -1607,6 +1649,7 @@ export const systemWordingMessages = {
|
||||
pokemon: {
|
||||
title: 'Pokemon',
|
||||
subtitle: '搜索宝可梦,并按特长、环境、喜欢的东西筛选。',
|
||||
listKicker: 'Pokédex',
|
||||
detailKicker: 'Pokédex Detail',
|
||||
editKicker: 'Pokédex Edit',
|
||||
editSubtitle: '维护 Pokemon 介绍、属性、六维、特长和喜欢的东西。',
|
||||
@@ -1615,14 +1658,14 @@ export const systemWordingMessages = {
|
||||
editTabAdvance: '进阶',
|
||||
newTitle: '新增 Pokemon',
|
||||
editTitle: '编辑 #{id} {name}',
|
||||
id: 'Pokemon ID',
|
||||
id: 'Pokopia ID',
|
||||
fetchData: '获取数据',
|
||||
fetchingData: '正在获取',
|
||||
fetchIdentifier: '数据标识',
|
||||
fetchIdentifierPlaceholder: 'bulbasaur 或 1',
|
||||
fetchIdentifierRequired: '请输入 Pokemon 数据标识',
|
||||
fetchFailed: 'Pokemon 数据获取失败',
|
||||
fetchIdMismatch: '获取到的 Pokemon ID #{id} 与当前编辑内容不一致。',
|
||||
fetchIdMismatch: '获取到的官方 ID #{id} 与当前编辑内容不一致。',
|
||||
fetchResults: 'Pokemon 数据结果',
|
||||
fetchSearching: '正在搜索数据',
|
||||
fetchNoMatches: '没有匹配的 Pokemon 数据',
|
||||
@@ -1638,7 +1681,7 @@ export const systemWordingMessages = {
|
||||
clearImage: '清除图片',
|
||||
imageEmpty: '尚未选择 Pokemon 图片',
|
||||
imageAlt: '{name} {variant} 图片',
|
||||
eventItem: '活动物品',
|
||||
eventItem: 'Event Pokemon',
|
||||
loadingList: '正在加载 Pokemon 列表',
|
||||
loadingDetail: '正在加载 Pokemon 详情',
|
||||
loadingEdit: '正在加载 Pokemon 编辑内容',
|
||||
@@ -1693,9 +1736,20 @@ export const systemWordingMessages = {
|
||||
searchFavoriteThings: '搜索喜欢的东西',
|
||||
searchItems: '搜索物品'
|
||||
},
|
||||
eventPokemon: {
|
||||
title: 'Event Pokemon',
|
||||
subtitle: '搜索 Event Pokemon,并按特长、环境、喜欢的东西筛选。',
|
||||
kicker: 'Event Pokédex',
|
||||
detailKicker: 'Event Pokemon Detail',
|
||||
editSubtitle: '维护 Event Pokemon 介绍、Pokopia ID、官方数据身份、图片、六维、特长和喜欢的东西。',
|
||||
newTitle: '新增 Event Pokemon',
|
||||
editTitle: '编辑 Event #{id} {name}',
|
||||
loadingList: '正在加载 Event Pokemon 列表'
|
||||
},
|
||||
habitats: {
|
||||
title: '栖息地',
|
||||
subtitle: '查看配方和可能出现的宝可梦。',
|
||||
listKicker: 'Habitats',
|
||||
detailKicker: 'Habitat Detail',
|
||||
detailSubtitle: '栖息地详情',
|
||||
editSubtitle: '维护栖息地配方和可能出现的 Pokemon。',
|
||||
@@ -1705,7 +1759,7 @@ export const systemWordingMessages = {
|
||||
loadingList: '正在加载栖息地列表',
|
||||
loadingDetail: '正在加载栖息地详情',
|
||||
loadingEdit: '正在加载栖息地编辑内容',
|
||||
eventItem: '活动物品',
|
||||
eventItem: 'Event Habitat',
|
||||
recipe: '配方',
|
||||
recipeList: '配方列表',
|
||||
possiblePokemon: '可能出现的宝可梦',
|
||||
@@ -1714,6 +1768,16 @@ export const systemWordingMessages = {
|
||||
maps: '地图',
|
||||
searchMaps: '搜索地图'
|
||||
},
|
||||
eventHabitats: {
|
||||
title: 'Event Habitats',
|
||||
subtitle: '查看限时栖息地、活动配方和可能出现的 Pokemon。',
|
||||
kicker: 'Event Habitats',
|
||||
detailKicker: 'Event Habitat Detail',
|
||||
editSubtitle: '维护 Event Habitat 配方、可能出现的 Pokemon 和图片。',
|
||||
newTitle: '新增 Event Habitat',
|
||||
editTitle: '编辑 Event Habitat {name}',
|
||||
loadingList: '正在加载 Event Habitat 列表'
|
||||
},
|
||||
items: {
|
||||
title: '物品',
|
||||
subtitle: '按分类、用途、标签查看物品。',
|
||||
@@ -2219,7 +2283,7 @@ export const systemWordingMessages = {
|
||||
pokemonIdentifierRequired: '请输入 Pokemon 标识',
|
||||
pokemonTypeDataUnavailable: 'Pokemon 属性数据不可用',
|
||||
pokemonDataNotFound: '未找到 Pokemon 数据',
|
||||
pokemonDataIdMismatch: 'Pokemon 数据 ID 与当前 Pokemon 不一致',
|
||||
pokemonDataIdMismatch: '官方 Pokemon 数据 ID 与当前 Pokemon 不一致',
|
||||
dataToolScopeRequired: '请至少选择一个数据范围',
|
||||
dataToolScopeInvalid: '数据范围不合法',
|
||||
dataToolBundleInvalid: '数据包不合法',
|
||||
@@ -2253,7 +2317,7 @@ export const systemWordingMessages = {
|
||||
skillMax: '最多选择 2 个特长',
|
||||
favoriteMax: '最多选择 6 个喜欢的东西',
|
||||
dropItemSelectedSkill: '掉落物必须关联到已选择的特长',
|
||||
pokemonIdRequired: '请输入 Pokemon ID',
|
||||
pokemonIdRequired: '请输入 Pokopia ID',
|
||||
pokemonNameRequired: '请输入 Pokemon 名称',
|
||||
heightNonNegative: '身高必须是不小于 0 的数字',
|
||||
weightNonNegative: '体重必须是不小于 0 的数字',
|
||||
|
||||
Reference in New Issue
Block a user