feat: add ancient artifacts and refactor item categories

Introduce Ancient Artifacts with full CRUD and image support
Migrate item categories and usages to system-defined lists
Add display_id to items and artifacts for custom sorting
This commit is contained in:
2026-05-04 08:28:56 +08:00
parent 5ccc25b248
commit 4238be7761
25 changed files with 1857 additions and 181 deletions

109
DESIGN.md
View File

@@ -5,7 +5,7 @@
- Pokopia Wiki 是一个面向 Pokopia 游戏资料的社区 Wiki。
- 所有人都可以浏览 Wiki 内容。
- 已注册并完成邮箱验证且拥有对应权限的用户可以创建、编辑、删除 Wiki 内容。
- 前台以 Home 首页、Pokemon、Event Pokemon、栖息地、Event Habitats、物品、材料单、每日 CheckList、Life、Automation、Dish、Events、Actions、Dream Island、Clothes 为主要浏览入口。
- 前台以 Home 首页、Pokemon、Event Pokemon、栖息地、Event Habitats、物品、Event Items、Ancient Artifacts、材料单、每日 CheckList、Life、Automation、Dish、Events、Actions、Dream Island、Clothes 为主要浏览入口。
- Home 首页路径为 `/`,用于聚合公开 Wiki 入口Logo 导航回到 Home用户可从 Home 进入核心资料、每日 CheckList、Life 和正在准备中的分区。
- 管理入口用于维护全局配置、语言、系统文案、列表排序和每日 CheckList。
@@ -53,10 +53,9 @@
- Pokemon Types
- 喜欢的环境
- 喜欢的东西 / 标签
- 物品分类
- 物品用途
- 入手方式
- 物品
- Ancient Artifacts
- 地图
- 栖息地
- 每日 CheckList Task
@@ -65,7 +64,7 @@
- 支持翻译的字段:
- `name`
- `title`
- `details`Pokemon 介绍使用
- `details`Pokemon、物品和 Ancient Artifacts 的介绍 / 说明
- `genus`:仅 Pokemon Genus 使用
- 实体仍保留基础 `name``title``details``genus` 字段,默认语言内容以基础字段为准。
- API 返回展示名称时按当前语言解析,回退顺序为:请求语言翻译 -> 默认语言翻译 -> 基础字段。
@@ -196,6 +195,7 @@
- Pokemon
- Habitats
- Items
- Ancient Artifacts
- Recipes
- Daily CheckList
- Items 与 Recipes 存在依赖关系;选择 Items 进行导出、导入或 Wipe 时,系统必须自动同时纳入 Recipes前端确认内容也必须显示 Recipes。
@@ -204,6 +204,7 @@
- Wipe Pokemon 会删除 Pokemon 及其属性 / 特长 / 喜欢的东西 / 掉落关联,并移除栖息地中的 Pokemon 出现配置,但不删除栖息地本身。
- Wipe Habitats 会删除栖息地、栖息地配方项和 Pokemon 出现配置,但不删除 Pokemon、Items 或 Maps。
- Wipe Items 会先删除 Recipes再删除物品、物品入手方式 / 喜欢的东西关联、栖息地配方项和 Pokemon 掉落关联。
- Wipe Ancient Artifacts 会删除 Ancient Artifacts、标签关联、实体翻译、编辑历史和实体讨论评论。
- Wipe Recipes 会删除材料单、材料项和入手方式关联,但不删除 Items。
- Wipe Daily CheckList 会删除清单任务和任务翻译 / 编辑历史。
- 对被清空的 identity 主表重置自增 IDPokemon 内部 ID 不是 identity未关联官方 data 的自定义 Pokemon 系统分配区间仍按当前数据库最大值继续。
@@ -378,7 +379,7 @@
## 全局配置数据
以下配置项都支持创建、编辑、删除、翻译和拖拽排序。
以下配置项都支持创建、编辑、删除、翻译和拖拽排序。物品分类、物品用途和 Ancient Artifacts 分类是代码维护的系统固定列表,不属于可配置数据。
### 特长
@@ -404,16 +405,6 @@
- Pokemon 喜欢的东西
- 物品标签
### 物品分类
- 名称
- 用于物品和材料单按结果物品分类展示。
### 物品用途
- 名称
- 物品用途可为空。
### 入手方式
- 名称
@@ -552,10 +543,28 @@ Pokemon 详情页展示:
物品可配置:
- Display ID用于物品和 Event Items 各自列表内展示与排序;`display_id``is_event_item` 组合唯一
- 名称
- 是否为 Event Habitat`is_event_item`
- 分类:必填
- 用途:可为空
- 介绍
- 是否为 Event Item`is_event_item`
- 分类:必填,使用系统固定列表,不在管理端配置:
- Furniture
- Misc
- Outdoor
- Utilities
- Buildings
- Blocks
- Kits
- Nature
- Food
- Materials
- Key Items
- Other
- 用途:可为空,使用系统固定列表,不在管理端配置:
- Decoration
- Relaxation
- Toy
- Road
- 入手方式:可多选
- 客制化:
- 可染色
@@ -567,14 +576,20 @@ Pokemon 详情页展示:
- 翻译
- 排序
Items 与 Event Items 使用相同数据模型:
- Items 列表只展示 `is_event_item = false` 的物品。
- Event Items 列表只展示 `is_event_item = true` 的物品。
- Event Items 与 Items 共用物品分类、用途、入手方式、标签、图片、翻译和材料单逻辑。
物品列表功能:
- 搜索
- 按分类展示为标签页
- 按用途筛选
- 按标签筛选
- 按自定义排序展示
- 物品列表卡片使用与 Pokemon 列表一致的居中图鉴式布局,只展示物品图标、名称和分类;不展示标签、入手方式或编辑元信息。
- Display ID 和自定义排序展示
- 物品列表卡片使用与 Pokemon 列表一致的居中图鉴式布局,只展示物品图标、`#Display ID 名称` 和分类;不展示标签、入手方式或编辑元信息。
- 有用途的物品在卡片左上角以斜 Ribbon 展示用途名称。
- 已配置图标时,物品卡片展示图标缩略图;未配置图标时保留默认物品标记。
@@ -583,6 +598,8 @@ Pokemon 详情页展示:
- 基本信息
- 当前图标图片;未配置图标时展示默认物品标记占位符
- 顶部按图标 / 占位符与核心信息概览并排展示,移动端改为单列;顶部概览卡片不显示 `Image` / `Details` 通用区块标题,也不展示图片历史缩略图
- Display ID
- 介绍
- 分类
- 用途
- 入手方式
@@ -596,6 +613,42 @@ Pokemon 详情页展示:
- 讨论
- 编辑历史
## Ancient Artifacts
Ancient Artifacts 是独立 Wiki 内容类型,可配置:
- Display ID用于展示与排序
- 名称
- 介绍
- 图片:使用 Ancient Artifacts 上传目录,支持图片历史
- 分类:必填,使用系统固定列表,不在管理端配置:
- Lost Relics (L)
- Lost Relics (S)
- Fossils
- 标签:复用全局“喜欢的东西 / 标签”配置,可多选
- 翻译
- 排序
Ancient Artifacts 列表功能:
- 搜索
- 按分类展示为标签页
- 按标签筛选
- 按 Display ID 和自定义排序展示
- 列表卡片使用与 Pokemon 列表一致的居中图鉴式布局,展示图片 / 默认 Ancient Artifact 标记、`#Display ID 名称` 和分类;不展示编辑元信息。
Ancient Artifacts 详情页展示:
- Display ID
- 名称
- 图片;未配置图片时展示默认 Ancient Artifact 标记
- 介绍
- 分类
- 标签
- 最后编辑信息
- 讨论
- 编辑历史
## 材料单
材料单与物品是一对一关系:
@@ -616,8 +669,8 @@ Pokemon 详情页展示:
- 独立于物品列表展示
- 按结果物品分类展示
- 按自定义排序展示
- 材料单列表卡片使用与 Pokemon 列表一致的居中图鉴式布局,按结果物品展示图标、名称和分类;不展示编辑元信息。
-结果物品 Display ID 和自定义排序展示
- 材料单列表卡片使用与 Pokemon 列表一致的居中图鉴式布局,按结果物品展示图标、`#Display ID 名称` 和分类;不展示编辑元信息。
- 有用途的结果物品在卡片左上角以斜 Ribbon 展示用途名称。
- Create Recipe 按钮展示在结果物品名称下方;已有材料单的卡片保留同等按钮空间但不显示按钮;标记为无材料单的物品展示禁用按钮;可创建材料单的物品展示可点击按钮并进入创建流程。
@@ -820,6 +873,7 @@ API 暴露边界:
- 管理入口在全局侧边栏中保持单一 Admin 入口,`/admin` 内部使用页面内二级菜单分组组织管理模块:
- 配置System config。
- 内容Daily CheckList、Pokemon、物品、材料单、栖息地的维护、排序或删除入口以及 Data Tools。
- 内容管理包含 Items、Event Items 与 Ancient ArtifactsItems / Event Items 使用同一物品数据模型,通过 `is_event_item` 拆分入口。
- 本地化Languages、System wordings。
- 访问权限Users、Roles、Permissions、Rate limits。
- 登录用户的侧边栏账号入口进入 `/profile`User Profile 属于账号入口,不作为 Wiki 主内容导航项。
@@ -837,7 +891,10 @@ API 暴露边界:
- `/event-habitats/new`
- `/habitats/:id/edit`
- `/items/new`
- `/event-items/new`
- `/items/:id/edit`
- `/ancient-artifacts/new`
- `/ancient-artifacts/:id/edit`
- `/recipes/new`
- `/recipes/:id/edit`
- Life 使用信息流顶部 New Post / 编辑按钮打开普通 Modal 发布与编辑,不使用路由驱动 Modal。
@@ -859,6 +916,8 @@ API 暴露边界:
- `/habitats`
- `/event-habitats`
- `/items`
- `/event-items`
- `/ancient-artifacts`
- `/recipes`
- `/checklist`
- `/life`
@@ -883,8 +942,10 @@ API 暴露边界:
- `GET /api/pokemon/:id`
- `GET /api/habitats`:支持 `isEventItem=true|false` 按普通栖息地 / Event Habitats 拆分列表;未传时返回全部栖息地以兼容管理端和实体选择器
- `GET /api/habitats/:id`
- `GET /api/items`
- `GET /api/items`:支持 `isEventItem=true|false` 按普通 Items / Event Items 拆分列表;未传时返回全部 Items 以兼容管理端和实体选择器
- `GET /api/items/:id`
- `GET /api/ancient-artifacts`:支持 `search``categoryId``tagIds` 筛选
- `GET /api/ancient-artifacts/:id`
- `GET /api/recipes`
- `GET /api/recipes/:id`
- `GET /api/life-posts`:支持 `cursor` / `limit` 分页读取;支持 `search` 按 Life Post 正文搜索;支持 `categoryId` 按 Life Category 筛选;支持 `language` 按审核语言区筛选,`all` 表示全部语言区;支持 `gameVersionId` 按 Game Version 筛选;支持 `rateable` 按可评分 Category 筛选;支持 `sort``latest``oldest``top-rated`
@@ -893,7 +954,7 @@ API 暴露边界:
- `GET /api/users/:id/life-posts`:分页读取该用户发布过且未删除的 Life Post。
- `GET /api/users/:id/reactions`:分页读取该用户设置过 Reaction 且目标未删除的 Life Post。
- `GET /api/users/:id/comments`:分页读取该用户未删除的 Life 评论和实体讨论评论。
- `GET /api/discussions/:entityType/:entityId/comments`:支持 `cursor` / `limit` 分页读取实体讨论;支持 `language` 按审核语言区筛选;`entityType` 支持 `pokemon``items``recipes``habitats`
- `GET /api/discussions/:entityType/:entityId/comments`:支持 `cursor` / `limit` 分页读取实体讨论;支持 `language` 按审核语言区筛选;`entityType` 支持 `pokemon``items``recipes``habitats``ancient-artifacts`
认证 API

View File

@@ -30,10 +30,12 @@ CREATE TABLE IF NOT EXISTS entity_translations (
'item-usages',
'acquisition-methods',
'items',
'ancient-artifacts',
'maps',
'habitats',
'daily-checklist-items',
'life-tags'
'life-tags',
'game-versions'
)
),
entity_id integer NOT NULL,
@@ -46,6 +48,30 @@ CREATE TABLE IF NOT EXISTS entity_translations (
CREATE INDEX IF NOT EXISTS entity_translations_lookup_idx
ON entity_translations (entity_type, entity_id, field_name, locale);
ALTER TABLE entity_translations
DROP CONSTRAINT IF EXISTS entity_translations_entity_type_check;
ALTER TABLE entity_translations
ADD CONSTRAINT entity_translations_entity_type_check CHECK (
entity_type IN (
'pokemon',
'pokemon-types',
'skills',
'environments',
'favorite-things',
'item-categories',
'item-usages',
'acquisition-methods',
'items',
'ancient-artifacts',
'maps',
'habitats',
'daily-checklist-items',
'life-tags',
'game-versions'
)
);
CREATE TABLE IF NOT EXISTS users (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
email text NOT NULL UNIQUE,
@@ -241,6 +267,11 @@ VALUES
('items.delete', 'Delete items', 'Delete item records.', 'Items', true),
('items.order', 'Order items', 'Reorder item records.', 'Items', true),
('items.upload', 'Upload item images', 'Upload item images.', 'Items', true),
('ancient-artifacts.create', 'Create Ancient Artifacts', 'Create Ancient Artifact records.', 'Ancient Artifacts', true),
('ancient-artifacts.update', 'Update Ancient Artifacts', 'Edit Ancient Artifact records.', 'Ancient Artifacts', true),
('ancient-artifacts.delete', 'Delete Ancient Artifacts', 'Delete Ancient Artifact records.', 'Ancient Artifacts', true),
('ancient-artifacts.order', 'Order Ancient Artifacts', 'Reorder Ancient Artifact records.', 'Ancient Artifacts', true),
('ancient-artifacts.upload', 'Upload Ancient Artifact images', 'Upload Ancient Artifact images.', 'Ancient Artifacts', true),
('recipes.create', 'Create recipes', 'Create recipe records.', 'Recipes', true),
('recipes.update', 'Update recipes', 'Edit recipe records.', 'Recipes', true),
('recipes.delete', 'Delete recipes', 'Delete recipe records.', 'Recipes', true),
@@ -327,6 +358,11 @@ JOIN permissions p ON p.key = ANY (ARRAY[
'items.delete',
'items.order',
'items.upload',
'ancient-artifacts.create',
'ancient-artifacts.update',
'ancient-artifacts.delete',
'ancient-artifacts.order',
'ancient-artifacts.upload',
'recipes.create',
'recipes.update',
'recipes.delete',
@@ -395,6 +431,10 @@ JOIN permissions p ON p.key = ANY (ARRAY[
'items.update',
'items.order',
'items.upload',
'ancient-artifacts.create',
'ancient-artifacts.update',
'ancient-artifacts.order',
'ancient-artifacts.upload',
'recipes.create',
'recipes.update',
'recipes.order',
@@ -416,6 +456,31 @@ WHERE r.key = 'editor'
)
ON CONFLICT DO NOTHING;
INSERT INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id
FROM roles r
JOIN permissions p ON p.key = ANY (ARRAY[
'ancient-artifacts.create',
'ancient-artifacts.update',
'ancient-artifacts.delete',
'ancient-artifacts.order',
'ancient-artifacts.upload'
])
WHERE r.key = 'admin'
ON CONFLICT DO NOTHING;
INSERT INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id
FROM roles r
JOIN permissions p ON p.key = ANY (ARRAY[
'ancient-artifacts.create',
'ancient-artifacts.update',
'ancient-artifacts.order',
'ancient-artifacts.upload'
])
WHERE r.key = 'editor'
ON CONFLICT DO NOTHING;
INSERT INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id
FROM roles r
@@ -798,8 +863,12 @@ CREATE TABLE IF NOT EXISTS acquisition_methods (
CREATE TABLE IF NOT EXISTS items (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
display_id integer NOT NULL CHECK (display_id > 0),
name text NOT NULL UNIQUE,
category_id integer NOT NULL REFERENCES item_categories(id),
details text NOT NULL DEFAULT '',
category_key text NOT NULL DEFAULT 'other',
usage_key text,
category_id integer REFERENCES item_categories(id),
usage_id integer REFERENCES item_usages(id),
dyeable boolean NOT NULL DEFAULT false,
dual_dyeable boolean NOT NULL DEFAULT false,
@@ -811,6 +880,35 @@ CREATE TABLE IF NOT EXISTS items (
created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CHECK (category_key IN (
'furniture',
'misc',
'outdoor',
'utilities',
'buildings',
'blocks',
'kits',
'nature',
'food',
'materials',
'key-items',
'other'
)),
CHECK (usage_key IS NULL OR usage_key IN ('decoration', 'relaxation', 'toy', 'road'))
);
CREATE TABLE IF NOT EXISTS ancient_artifacts (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
display_id integer NOT NULL UNIQUE CHECK (display_id > 0),
name text NOT NULL UNIQUE,
details text NOT NULL DEFAULT '',
category_key text NOT NULL CHECK (category_key IN ('lost-relics-l', 'lost-relics-s', 'fossils')),
image_path text NOT NULL DEFAULT '',
sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0),
created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
@@ -842,6 +940,12 @@ CREATE TABLE IF NOT EXISTS item_favorite_things (
PRIMARY KEY (item_id, favorite_thing_id)
);
CREATE TABLE IF NOT EXISTS ancient_artifact_favorite_things (
ancient_artifact_id integer NOT NULL REFERENCES ancient_artifacts(id) ON DELETE CASCADE,
favorite_thing_id integer NOT NULL REFERENCES favorite_things(id),
PRIMARY KEY (ancient_artifact_id, favorite_thing_id)
);
CREATE TABLE IF NOT EXISTS pokemon_skill_item_drops (
pokemon_id integer NOT NULL,
skill_id integer NOT NULL,
@@ -899,6 +1003,116 @@ CREATE TABLE IF NOT EXISTS habitat_pokemon (
ALTER TABLE life_tags
ADD COLUMN IF NOT EXISTS is_default boolean NOT NULL DEFAULT false;
ALTER TABLE items
ADD COLUMN IF NOT EXISTS display_id integer,
ADD COLUMN IF NOT EXISTS details text NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS category_key text,
ADD COLUMN IF NOT EXISTS usage_key text;
ALTER TABLE ancient_artifacts
ADD COLUMN IF NOT EXISTS image_path text NOT NULL DEFAULT '';
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_name = 'items'
AND column_name = 'category_id'
AND table_schema = current_schema()
) THEN
ALTER TABLE items ALTER COLUMN category_id DROP NOT NULL;
END IF;
END $$;
UPDATE items
SET display_id = id
WHERE display_id IS NULL;
UPDATE items i
SET category_key = CASE lower(trim(c.name))
WHEN 'furniture' THEN 'furniture'
WHEN 'misc' THEN 'misc'
WHEN 'outdoor' THEN 'outdoor'
WHEN 'utilities' THEN 'utilities'
WHEN 'buildings' THEN 'buildings'
WHEN 'blocks' THEN 'blocks'
WHEN 'kits' THEN 'kits'
WHEN 'nature' THEN 'nature'
WHEN 'food' THEN 'food'
WHEN 'materials' THEN 'materials'
WHEN 'key items' THEN 'key-items'
WHEN 'key-items' THEN 'key-items'
WHEN 'other' THEN 'other'
ELSE 'other'
END
FROM item_categories c
WHERE i.category_id = c.id
AND (i.category_key IS NULL OR i.category_key = '');
UPDATE items i
SET usage_key = CASE lower(trim(u.name))
WHEN 'decoration' THEN 'decoration'
WHEN 'relaxation' THEN 'relaxation'
WHEN 'toy' THEN 'toy'
WHEN 'road' THEN 'road'
ELSE NULL
END
FROM item_usages u
WHERE i.usage_id = u.id
AND i.usage_key IS NULL;
UPDATE items
SET category_key = 'other'
WHERE category_key IS NULL
OR category_key NOT IN (
'furniture',
'misc',
'outdoor',
'utilities',
'buildings',
'blocks',
'kits',
'nature',
'food',
'materials',
'key-items',
'other'
);
UPDATE items
SET usage_key = NULL
WHERE usage_key IS NOT NULL
AND usage_key NOT IN ('decoration', 'relaxation', 'toy', 'road');
ALTER TABLE items
ALTER COLUMN display_id SET NOT NULL,
ALTER COLUMN category_key SET NOT NULL,
ALTER COLUMN category_key SET DEFAULT 'other';
ALTER TABLE items
DROP CONSTRAINT IF EXISTS items_display_id_positive,
DROP CONSTRAINT IF EXISTS items_category_key_check,
DROP CONSTRAINT IF EXISTS items_usage_key_check;
ALTER TABLE items
ADD CONSTRAINT items_display_id_positive CHECK (display_id > 0),
ADD CONSTRAINT items_category_key_check CHECK (category_key IN (
'furniture',
'misc',
'outdoor',
'utilities',
'buildings',
'blocks',
'kits',
'nature',
'food',
'materials',
'key-items',
'other'
)),
ADD CONSTRAINT items_usage_key_check CHECK (usage_key IS NULL OR usage_key IN ('decoration', 'relaxation', 'toy', 'road'));
CREATE INDEX IF NOT EXISTS environments_sort_order_idx ON environments(sort_order, id);
CREATE INDEX IF NOT EXISTS skills_sort_order_idx ON skills(sort_order, id);
CREATE INDEX IF NOT EXISTS favorite_things_sort_order_idx ON favorite_things(sort_order, id);
@@ -911,6 +1125,10 @@ CREATE INDEX IF NOT EXISTS item_categories_sort_order_idx ON item_categories(sor
CREATE INDEX IF NOT EXISTS item_usages_sort_order_idx ON item_usages(sort_order, id);
CREATE INDEX IF NOT EXISTS acquisition_methods_sort_order_idx ON acquisition_methods(sort_order, id);
CREATE INDEX IF NOT EXISTS items_sort_order_idx ON items(sort_order, id);
CREATE UNIQUE INDEX IF NOT EXISTS items_display_event_item_key ON items(display_id, is_event_item);
CREATE INDEX IF NOT EXISTS items_display_order_idx ON items(is_event_item, display_id, sort_order, id);
CREATE INDEX IF NOT EXISTS ancient_artifacts_sort_order_idx ON ancient_artifacts(sort_order, id);
CREATE INDEX IF NOT EXISTS ancient_artifacts_display_order_idx ON ancient_artifacts(display_id, sort_order, id);
CREATE INDEX IF NOT EXISTS recipes_sort_order_idx ON recipes(sort_order, id);
CREATE INDEX IF NOT EXISTS maps_sort_order_idx ON maps(sort_order, id);
CREATE INDEX IF NOT EXISTS habitats_sort_order_idx ON habitats(sort_order, id);
@@ -933,7 +1151,7 @@ CREATE INDEX IF NOT EXISTS wiki_edit_logs_user_id_idx
CREATE TABLE IF NOT EXISTS entity_image_uploads (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
entity_type text NOT NULL CHECK (entity_type IN ('pokemon', 'items', 'habitats')),
entity_type text NOT NULL CHECK (entity_type IN ('pokemon', 'items', 'habitats', 'ancient-artifacts')),
entity_id integer,
entity_name text NOT NULL,
path text NOT NULL UNIQUE,
@@ -946,6 +1164,14 @@ CREATE TABLE IF NOT EXISTS entity_image_uploads (
CHECK (path !~ '(^/|\\.\\.)')
);
ALTER TABLE entity_image_uploads
DROP CONSTRAINT IF EXISTS entity_image_uploads_entity_type_check;
ALTER TABLE entity_image_uploads
ADD CONSTRAINT entity_image_uploads_entity_type_check CHECK (
entity_type IN ('pokemon', 'items', 'habitats', 'ancient-artifacts')
);
CREATE INDEX IF NOT EXISTS entity_image_uploads_entity_idx
ON entity_image_uploads(entity_type, entity_id, created_at DESC, id DESC);
@@ -954,7 +1180,7 @@ CREATE INDEX IF NOT EXISTS entity_image_uploads_user_idx
CREATE TABLE IF NOT EXISTS entity_discussion_comments (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
entity_type text NOT NULL CHECK (entity_type IN ('pokemon', 'items', 'recipes', 'habitats')),
entity_type text NOT NULL CHECK (entity_type IN ('pokemon', 'items', 'recipes', 'habitats', 'ancient-artifacts')),
entity_id integer NOT NULL,
parent_comment_id integer REFERENCES entity_discussion_comments(id) ON DELETE CASCADE,
body text NOT NULL CHECK (length(body) BETWEEN 1 AND 1000),
@@ -980,6 +1206,14 @@ CREATE INDEX IF NOT EXISTS entity_discussion_comments_parent_idx
CREATE INDEX IF NOT EXISTS entity_discussion_comments_user_idx
ON entity_discussion_comments(created_by_user_id);
ALTER TABLE entity_discussion_comments
DROP CONSTRAINT IF EXISTS entity_discussion_comments_entity_type_check;
ALTER TABLE entity_discussion_comments
ADD CONSTRAINT entity_discussion_comments_entity_type_check CHECK (
entity_type IN ('pokemon', 'items', 'recipes', 'habitats', 'ancient-artifacts')
);
ALTER TABLE life_tags
ADD COLUMN IF NOT EXISTS is_rateable boolean NOT NULL DEFAULT false;

View File

@@ -22,7 +22,7 @@ type QueryValue = string | string[] | undefined;
type QueryParams = Record<string, QueryValue>;
type DbClient = PoolClient;
type DataToolScope = 'pokemon' | 'habitats' | 'items' | 'recipes' | 'checklist';
type DataToolScope = 'pokemon' | 'habitats' | 'items' | 'artifacts' | 'recipes' | 'checklist';
type DataToolScopeSummary = {
scope: DataToolScope;
count: number;
@@ -48,6 +48,7 @@ type EntityType =
| 'item-usages'
| 'acquisition-methods'
| 'items'
| 'ancient-artifacts'
| 'maps'
| 'habitats'
| 'daily-checklist-items'
@@ -59,8 +60,6 @@ type ConfigType =
| 'skills'
| 'environments'
| 'favorite-things'
| 'item-categories'
| 'item-usages'
| 'acquisition-methods'
| 'maps'
| 'life-tags'
@@ -74,7 +73,7 @@ type ConfigDefinition = {
hasRateable?: boolean;
hasChangeLog?: boolean;
};
type SortableContentType = 'pokemon' | 'items' | 'recipes' | 'habitats';
type SortableContentType = 'pokemon' | 'items' | 'ancient-artifacts' | 'recipes' | 'habitats';
type SortableContentDefinition = {
table: string;
entityType: SortableContentType;
@@ -171,10 +170,14 @@ type PokemonCsvData = {
};
type ItemPayload = {
displayId: number;
name: string;
details: string;
translations: TranslationInput;
categoryId: number;
categoryKey: string;
usageId: number | null;
usageKey: string | null;
dyeable: boolean;
dualDyeable: boolean;
patternEditable: boolean;
@@ -185,6 +188,17 @@ type ItemPayload = {
imagePath: string;
};
type AncientArtifactPayload = {
displayId: number;
name: string;
details: string;
translations: TranslationInput;
categoryId: number;
categoryKey: string;
tagIds: number[];
imagePath: string;
};
type RecipePayload = {
itemId: number;
acquisitionMethodIds: number[];
@@ -208,7 +222,7 @@ type LifeCommentPayload = {
languageCode: string | null;
};
type DiscussionEntityType = 'pokemon' | 'items' | 'recipes' | 'habitats';
type DiscussionEntityType = 'pokemon' | 'items' | 'recipes' | 'habitats' | 'ancient-artifacts';
type DiscussionEntityDefinition = {
table: string;
};
@@ -436,7 +450,9 @@ type PokemonChangeSource = {
favorite_things: Array<{ name: string }>;
} & TranslationChangeSource;
type ItemChangeSource = {
displayId: number;
name: string;
details: string;
isEventItem: boolean;
image: EntityImageValue | null;
category: { name: string };
@@ -446,6 +462,14 @@ type ItemChangeSource = {
acquisitionMethods: Array<{ name: string }>;
tags: Array<{ name: string }>;
} & TranslationChangeSource;
type AncientArtifactChangeSource = {
displayId: number;
name: string;
details: string;
image: EntityImageValue | null;
category: { name: string };
tags: Array<{ name: string }>;
} & TranslationChangeSource;
type HabitatChangeSource = {
name: string;
isEventItem: boolean;
@@ -491,13 +515,45 @@ const pokemonStatLabels: Array<{ key: keyof PokemonStats; label: string }> = [
{ key: 'speed', label: 'Speed' }
];
type SystemListOption = {
id: number;
key: string;
labels: Record<typeof defaultLocale | 'zh-CN', string>;
};
const itemCategoryOptions = [
{ id: 1, key: 'furniture', labels: { en: 'Furniture', 'zh-CN': '家具' } },
{ id: 2, key: 'misc', labels: { en: 'Misc', 'zh-CN': '杂项' } },
{ id: 3, key: 'outdoor', labels: { en: 'Outdoor', 'zh-CN': '户外' } },
{ id: 4, key: 'utilities', labels: { en: 'Utilities', 'zh-CN': '实用工具' } },
{ id: 5, key: 'buildings', labels: { en: 'Buildings', 'zh-CN': '建筑' } },
{ id: 6, key: 'blocks', labels: { en: 'Blocks', 'zh-CN': '方块' } },
{ id: 7, key: 'kits', labels: { en: 'Kits', 'zh-CN': '套件' } },
{ id: 8, key: 'nature', labels: { en: 'Nature', 'zh-CN': '自然' } },
{ id: 9, key: 'food', labels: { en: 'Food', 'zh-CN': '食物' } },
{ id: 10, key: 'materials', labels: { en: 'Materials', 'zh-CN': '材料' } },
{ id: 11, key: 'key-items', labels: { en: 'Key Items', 'zh-CN': '关键物品' } },
{ id: 12, key: 'other', labels: { en: 'Other', 'zh-CN': '其他' } }
] as const satisfies readonly SystemListOption[];
const itemUsageOptions = [
{ id: 1, key: 'decoration', labels: { en: 'Decoration', 'zh-CN': '装饰' } },
{ id: 2, key: 'relaxation', labels: { en: 'Relaxation', 'zh-CN': '休闲' } },
{ id: 3, key: 'toy', labels: { en: 'Toy', 'zh-CN': '玩具' } },
{ id: 4, key: 'road', labels: { en: 'Road', 'zh-CN': '道路' } }
] as const satisfies readonly SystemListOption[];
const ancientArtifactCategoryOptions = [
{ id: 1, key: 'lost-relics-l', labels: { en: 'Lost Relics (L)', 'zh-CN': 'Lost Relics (L)' } },
{ id: 2, key: 'lost-relics-s', labels: { en: 'Lost Relics (S)', 'zh-CN': 'Lost Relics (S)' } },
{ id: 3, key: 'fossils', labels: { en: 'Fossils', 'zh-CN': '化石' } }
] as const satisfies readonly SystemListOption[];
const configDefinitions: Record<ConfigType, ConfigDefinition> = {
'pokemon-types': { table: 'pokemon_types', entityType: 'pokemon-types' },
skills: { table: 'skills', entityType: 'skills', hasItemDrop: true },
environments: { table: 'environments', entityType: 'environments' },
'favorite-things': { table: 'favorite_things', entityType: 'favorite-things' },
'item-categories': { table: 'item_categories', entityType: 'item-categories' },
'item-usages': { table: 'item_usages', entityType: 'item-usages' },
'acquisition-methods': { table: 'acquisition_methods', entityType: 'acquisition-methods' },
maps: { table: 'maps', entityType: 'maps' },
'life-tags': { table: 'life_tags', entityType: 'life-tags', hasDefault: true, hasRateable: true },
@@ -507,6 +563,7 @@ const configDefinitions: Record<ConfigType, ConfigDefinition> = {
const sortableContentDefinitions: Record<SortableContentType, SortableContentDefinition> = {
pokemon: { table: 'pokemon', entityType: 'pokemon' },
items: { table: 'items', entityType: 'items' },
'ancient-artifacts': { table: 'ancient_artifacts', entityType: 'ancient-artifacts' },
recipes: { table: 'recipes', entityType: 'recipes' },
habitats: { table: 'habitats', entityType: 'habitats' }
};
@@ -515,7 +572,8 @@ const discussionEntityDefinitions: Record<DiscussionEntityType, DiscussionEntity
pokemon: { table: 'pokemon' },
items: { table: 'items' },
recipes: { table: 'recipes' },
habitats: { table: 'habitats' }
habitats: { table: 'habitats' },
'ancient-artifacts': { table: 'ancient_artifacts' }
};
let pokemonCsvDataCache: Promise<PokemonCsvData> | null = null;
@@ -726,6 +784,52 @@ function optionSelect(
return query(`SELECT o.id, ${name} AS name FROM ${tableName} o ORDER BY ${orderByEntity('o')}`);
}
function systemListLabel(option: SystemListOption, locale: string): string {
const clean = cleanLocale(locale) as keyof SystemListOption['labels'];
return option.labels[clean] ?? option.labels[defaultLocale];
}
function systemListOptions(options: readonly SystemListOption[], locale: string): Array<{ id: number; key: string; name: string }> {
return options.map((option) => ({ id: option.id, key: option.key, name: systemListLabel(option, locale) }));
}
function systemListOptionById(
options: readonly SystemListOption[],
id: number,
message: string
): SystemListOption {
const option = options.find((item) => item.id === id);
if (!option) {
throw validationError(message);
}
return option;
}
function systemListOptionByKey(options: readonly SystemListOption[], key: string | null | undefined): SystemListOption | null {
return options.find((item) => item.key === key) ?? null;
}
function systemListNameByKey(options: readonly SystemListOption[], key: string | null | undefined, locale = defaultLocale): string | null {
const option = systemListOptionByKey(options, key);
return option ? systemListLabel(option, locale) : null;
}
function systemListIdSql(expression: string, options: readonly SystemListOption[]): string {
const cases = options.map((option) => `WHEN ${sqlLiteral(option.key)} THEN ${option.id}`).join(' ');
return `CASE ${expression} ${cases} ELSE NULL END`;
}
function systemListNameSql(expression: string, options: readonly SystemListOption[], locale: string): string {
const cases = options
.map((option) => `WHEN ${sqlLiteral(option.key)} THEN ${sqlLiteral(systemListLabel(option, locale))}`)
.join(' ');
return `CASE ${expression} ${cases} ELSE '' END`;
}
function systemListJsonSql(expression: string, options: readonly SystemListOption[], locale: string): string {
return `json_build_object('id', ${systemListIdSql(expression, options)}, 'key', ${expression}, 'name', ${systemListNameSql(expression, options, locale)})`;
}
function lifeCategoryOptions(locale: string): Promise<Array<{ id: number; name: string; isDefault: boolean; isRateable: boolean }>> {
const name = localizedName('life-tags', 'lc', locale);
return query(
@@ -821,7 +925,7 @@ function cleanOptionalText(value: unknown): string {
return typeof value === 'string' ? value.trim() : '';
}
function cleanUploadImagePath(value: unknown, entityType: 'items' | 'habitats'): string {
function cleanUploadImagePath(value: unknown, entityType: 'items' | 'habitats' | 'ancient-artifacts'): string {
const imagePath = cleanOptionalText(value);
if (imagePath === '') {
return '';
@@ -2030,17 +2134,17 @@ async function itemEditChanges(
after: ItemPayload
): Promise<EditChange[]> {
const changes: EditChange[] = [];
const categoryNames = await entityNameMap(client, 'item_categories', [after.categoryId]);
const usageNames = await entityNameMap(client, 'item_usages', after.usageId ? [after.usageId] : []);
const methodNames = await entityNameMap(client, 'acquisition_methods', after.acquisitionMethodIds);
const tagNames = await entityNameMap(client, 'favorite_things', after.tagIds);
pushChange(changes, 'Name', before.name, after.name);
pushTranslationChanges(changes, before.translations, after.translations, ['name']);
pushChange(changes, 'Display ID', String(before.displayId), String(after.displayId));
pushChange(changes, 'Description', before.details, after.details);
pushTranslationChanges(changes, before.translations, after.translations, ['name', 'details']);
pushChange(changes, 'Event item', boolValue(before.isEventItem), boolValue(after.isEventItem));
pushChange(changes, 'Image', imagePathLabel(before.image?.path), imagePathLabel(after.imagePath));
pushChange(changes, 'Category', before.category.name, categoryNames.get(after.categoryId));
pushChange(changes, 'Usage', before.usage?.name, after.usageId ? usageNames.get(after.usageId) : null);
pushChange(changes, 'Category', before.category.name, systemListNameByKey(itemCategoryOptions, after.categoryKey));
pushChange(changes, 'Usage', before.usage?.name, systemListNameByKey(itemUsageOptions, after.usageKey));
pushChange(changes, 'Dyeable', boolValue(before.customization.dyeable), boolValue(after.dyeable));
pushChange(changes, 'Dual dyeable', boolValue(before.customization.dualDyeable), boolValue(after.dualDyeable));
pushChange(changes, 'Pattern editable', boolValue(before.customization.patternEditable), boolValue(after.patternEditable));
@@ -2051,6 +2155,24 @@ async function itemEditChanges(
return changes;
}
async function ancientArtifactEditChanges(
client: DbClient,
before: AncientArtifactChangeSource,
after: AncientArtifactPayload
): Promise<EditChange[]> {
const changes: EditChange[] = [];
const tagNames = await entityNameMap(client, 'favorite_things', after.tagIds);
pushChange(changes, 'Name', before.name, after.name);
pushChange(changes, 'Display ID', String(before.displayId), String(after.displayId));
pushChange(changes, 'Description', before.details, after.details);
pushTranslationChanges(changes, before.translations, after.translations, ['name', 'details']);
pushChange(changes, 'Image', imagePathLabel(before.image?.path), imagePathLabel(after.imagePath));
pushChange(changes, 'Category', before.category.name, systemListNameByKey(ancientArtifactCategoryOptions, after.categoryKey));
pushChange(changes, 'Tags', namedListValue(before.tags), namesFromIds(after.tagIds, tagNames));
return changes;
}
async function habitatEditChanges(
client: DbClient,
before: HabitatChangeSource,
@@ -2221,8 +2343,6 @@ export async function getOptions(locale = defaultLocale) {
skills,
environments,
favoriteThings,
itemCategories,
itemUsages,
acquisitionMethods,
maps,
lifeCategories,
@@ -2232,8 +2352,6 @@ export async function getOptions(locale = defaultLocale) {
skillOptions(locale),
optionSelect('environments', 'environments', locale),
optionSelect('favorite_things', 'favorite-things', locale),
optionSelect('item_categories', 'item-categories', locale),
optionSelect('item_usages', 'item-usages', locale),
optionSelect('acquisition_methods', 'acquisition-methods', locale),
optionSelect('maps', 'maps', locale),
lifeCategoryOptions(locale),
@@ -2245,8 +2363,9 @@ export async function getOptions(locale = defaultLocale) {
skills,
environments,
favoriteThings,
itemCategories,
itemUsages,
itemCategories: systemListOptions(itemCategoryOptions, locale),
itemUsages: systemListOptions(itemUsageOptions, locale),
ancientArtifactCategories: systemListOptions(ancientArtifactCategoryOptions, locale),
acquisitionMethods,
itemTags: favoriteThings,
maps,
@@ -3379,6 +3498,7 @@ export async function listUserCommentActivities(
const itemName = localizedName('items', 'i', locale);
const recipeItemName = localizedName('items', 'recipe_item', locale);
const habitatName = localizedName('habitats', 'h', locale);
const artifactName = localizedName('ancient-artifacts', 'a', locale);
const params: unknown[] = [user.id];
const outerConditions: string[] = [];
@@ -3444,6 +3564,7 @@ export async function listUserCommentActivities(
WHEN 'items' THEN ${itemName}
WHEN 'recipes' THEN ${recipeItemName}
WHEN 'habitats' THEN ${habitatName}
WHEN 'ancient-artifacts' THEN ${artifactName}
ELSE ''
END,
''
@@ -3455,6 +3576,7 @@ export async function listUserCommentActivities(
LEFT JOIN recipes r ON edc.entity_type = 'recipes' AND r.id = edc.entity_id
LEFT JOIN items recipe_item ON recipe_item.id = r.item_id
LEFT JOIN habitats h ON edc.entity_type = 'habitats' AND h.id = edc.entity_id
LEFT JOIN ancient_artifacts a ON edc.entity_type = 'ancient-artifacts' AND a.id = edc.entity_id
WHERE edc.created_by_user_id = $1
AND edc.deleted_at IS NULL
AND edc.ai_moderation_status = 'approved'
@@ -4434,6 +4556,11 @@ export async function reorderItems(payload: Record<string, unknown>, userId: num
return listItems({}, locale);
}
export async function reorderAncientArtifacts(payload: Record<string, unknown>, userId: number, locale = defaultLocale) {
await reorderContent('ancient-artifacts', payload, userId);
return listAncientArtifacts({}, locale);
}
export async function reorderRecipes(payload: Record<string, unknown>, userId: number, locale = defaultLocale) {
await reorderContent('recipes', payload, userId);
return listRecipes({}, locale);
@@ -4507,7 +4634,6 @@ export async function getPokemon(id: number, locale = defaultLocale) {
const habitatName = localizedName('habitats', 'h', locale);
const mapName = localizedName('maps', 'm', locale);
const itemName = localizedName('items', 'i', locale);
const categoryName = localizedName('item-categories', 'c', locale);
const tagName = localizedName('favorite-things', 'ft', locale);
const relatedPokemonName = localizedName('pokemon', 'related_pokemon', locale);
const relatedEnvironmentName = localizedName('environments', 'related_environment', locale);
@@ -4551,16 +4677,15 @@ export async function getPokemon(id: number, locale = defaultLocale) {
i.id,
${itemName} AS name,
${uploadedImageJson('i.image_path')} AS image,
json_build_object('id', c.id, 'name', ${categoryName}) AS category,
${systemListJsonSql('i.category_key', itemCategoryOptions, locale)} AS category,
json_agg(json_build_object('id', ft.id, 'name', ${tagName}) ORDER BY ${orderByEntity('ft')}) AS tags
FROM pokemon_favorite_things pft
JOIN item_favorite_things ift ON ift.favorite_thing_id = pft.favorite_thing_id
JOIN favorite_things ft ON ft.id = pft.favorite_thing_id
JOIN items i ON i.id = ift.item_id
JOIN item_categories c ON c.id = i.category_id
WHERE pft.pokemon_id = $1
GROUP BY i.id, i.name, i.image_path, i.sort_order, c.id, c.name, c.sort_order
ORDER BY ${orderByEntity('c')}, ${orderByEntity('i')}
GROUP BY i.id, i.name, i.image_path, i.category_key, i.sort_order
ORDER BY i.category_key, ${orderByEntity('i')}
`,
[id]
),
@@ -5190,21 +5315,26 @@ export async function deleteHabitat(id: number, userId: number) {
function itemProjection(locale: string): string {
const itemName = localizedName('items', 'i', locale);
const categoryName = localizedName('item-categories', 'c', locale);
const usageName = localizedName('item-usages', 'u', locale);
const itemDetails = localizedField('items', 'i.id', 'i.details', 'details', locale);
const tagName = localizedName('favorite-things', 't', locale);
return `
SELECT
i.id,
i.display_id AS "displayId",
${itemName} AS name,
i.name AS "baseName",
${itemDetails} AS details,
i.details AS "baseDetails",
i.is_event_item AS "isEventItem",
${translationsSelect('items', 'i.id')} AS translations,
${auditSelect('i', 'item_created_user', 'item_updated_user')},
${uploadedImageJson('i.image_path')} AS image,
json_build_object('id', c.id, 'name', ${categoryName}) AS category,
CASE WHEN u.id IS NULL THEN NULL ELSE json_build_object('id', u.id, 'name', ${usageName}) END AS usage,
${systemListJsonSql('i.category_key', itemCategoryOptions, locale)} AS category,
CASE
WHEN i.usage_key IS NULL THEN NULL
ELSE ${systemListJsonSql('i.usage_key', itemUsageOptions, locale)}
END AS usage,
json_build_object(
'dyeable', i.dyeable,
'dualDyeable', i.dual_dyeable,
@@ -5234,8 +5364,6 @@ function itemProjection(locale: string): string {
)
END AS recipe
FROM items i
JOIN item_categories c ON c.id = i.category_id
LEFT JOIN item_usages u ON u.id = i.usage_id
LEFT JOIN recipes item_recipe ON item_recipe.item_id = i.id
LEFT JOIN users recipe_created_user ON recipe_created_user.id = item_recipe.created_by_user_id
LEFT JOIN users recipe_updated_user ON recipe_updated_user.id = item_recipe.updated_by_user_id
@@ -5248,23 +5376,35 @@ export async function listItems(paramsQuery: QueryParams, locale = defaultLocale
const conditions: string[] = [];
const categoryId = Number(asString(paramsQuery.categoryId));
const usageId = Number(asString(paramsQuery.usageId));
const isEventItem = asString(paramsQuery.isEventItem);
const tagIds = parseIdList(asString(paramsQuery.tagIds));
const search = asString(paramsQuery.search)?.trim();
const recipeOrder = asString(paramsQuery.recipeOrder) === '1';
const categoryOption = Number.isInteger(categoryId) && categoryId > 0
? systemListOptionById(itemCategoryOptions, categoryId, 'server.validation.categoryRequired')
: null;
const usageOption = Number.isInteger(usageId) && usageId > 0
? systemListOptionById(itemUsageOptions, usageId, 'server.validation.usageRequired')
: null;
if (search) {
params.push(`%${search}%`);
conditions.push(`${localizedName('items', 'i', locale)} ILIKE $${params.length}`);
}
if (Number.isInteger(categoryId) && categoryId > 0) {
params.push(categoryId);
conditions.push(`i.category_id = $${params.length}`);
if (isEventItem === 'true' || isEventItem === 'false') {
params.push(isEventItem === 'true');
conditions.push(`i.is_event_item = $${params.length}`);
}
if (Number.isInteger(usageId) && usageId > 0) {
params.push(usageId);
conditions.push(`i.usage_id = $${params.length}`);
if (categoryOption) {
params.push(categoryOption.key);
conditions.push(`i.category_key = $${params.length}`);
}
if (usageOption) {
params.push(usageOption.key);
conditions.push(`i.usage_key = $${params.length}`);
}
const tagFilter = sqlForRelationFilter(
@@ -5282,8 +5422,8 @@ export async function listItems(paramsQuery: QueryParams, locale = defaultLocale
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
const orderClause = recipeOrder
? `ORDER BY CASE WHEN item_recipe.id IS NULL THEN 1 ELSE 0 END, item_recipe.sort_order, item_recipe.id, ${orderByEntity('i')}`
: `ORDER BY ${orderByEntity('i')}`;
? `ORDER BY CASE WHEN item_recipe.id IS NULL THEN 1 ELSE 0 END, item_recipe.sort_order, item_recipe.id, i.display_id, ${orderByEntity('i')}`
: `ORDER BY i.display_id, ${orderByEntity('i')}`;
return query(`${itemProjection(locale)} ${whereClause} ${orderClause}`, params);
}
@@ -5295,8 +5435,6 @@ export async function getItem(id: number, locale = defaultLocale) {
const acquisitionMethodName = localizedName('acquisition-methods', 'am', locale);
const resultItemName = localizedName('items', 'result_item', locale);
const resultItemCategoryName = localizedName('item-categories', 'result_category', locale);
const resultItemUsageName = localizedName('item-usages', 'result_usage', locale);
const materialItemName = localizedName('items', 'mi', locale);
const habitatName = localizedName('habitats', 'h', locale);
const recipeItemName = localizedName('items', 'recipe_item', locale);
@@ -5342,18 +5480,17 @@ export async function getItem(id: number, locale = defaultLocale) {
), '[]'::json) AS materials,
json_build_object(
'id', result_item.id,
'displayId', result_item.display_id,
'name', ${resultItemName},
'image', ${uploadedImageJson('result_item.image_path')},
'category', json_build_object('id', result_category.id, 'name', ${resultItemCategoryName}),
'category', ${systemListJsonSql('result_item.category_key', itemCategoryOptions, locale)},
'usage', CASE
WHEN result_usage.id IS NULL THEN NULL
ELSE json_build_object('id', result_usage.id, 'name', ${resultItemUsageName})
WHEN result_item.usage_key IS NULL THEN NULL
ELSE ${systemListJsonSql('result_item.usage_key', itemUsageOptions, locale)}
END
) AS item
FROM recipes r
JOIN items result_item ON result_item.id = r.item_id
JOIN item_categories result_category ON result_category.id = result_item.category_id
LEFT JOIN item_usages result_usage ON result_usage.id = result_item.usage_id
${auditJoins('r', 'recipe_created_user', 'recipe_updated_user')}
WHERE r.item_id = $1
`,
@@ -5442,15 +5579,22 @@ export async function getItem(id: number, locale = defaultLocale) {
}
function cleanItemPayload(payload: Record<string, unknown>): ItemPayload {
const categoryId = requirePositiveInteger(payload.categoryId, 'server.validation.categoryRequired');
const usageId = payload.usageId === null || payload.usageId === '' || payload.usageId === undefined
? null
: requirePositiveInteger(payload.usageId, 'server.validation.usageRequired');
const category = systemListOptionById(itemCategoryOptions, categoryId, 'server.validation.categoryRequired');
const usage = usageId === null ? null : systemListOptionById(itemUsageOptions, usageId, 'server.validation.usageRequired');
return {
displayId: requirePositiveInteger(payload.displayId, 'server.validation.itemDisplayIdRequired'),
name: cleanName(payload.name, 'server.validation.itemNameRequired'),
translations: cleanTranslations(payload.translations, ['name']),
categoryId: requirePositiveInteger(payload.categoryId, 'server.validation.categoryRequired'),
details: cleanOptionalText(payload.details),
translations: cleanTranslations(payload.translations, ['name', 'details']),
categoryId,
categoryKey: category.key,
usageId,
usageKey: usage?.key ?? null,
dyeable: Boolean(payload.dyeable),
dualDyeable: Boolean(payload.dualDyeable),
patternEditable: Boolean(payload.patternEditable),
@@ -5500,9 +5644,11 @@ export async function createItem(payload: Record<string, unknown>, userId: numbe
const result = await client.query<{ id: number }>(
`
INSERT INTO items (
display_id,
name,
category_id,
usage_id,
details,
category_key,
usage_key,
dyeable,
dual_dyeable,
pattern_editable,
@@ -5513,13 +5659,15 @@ export async function createItem(payload: Record<string, unknown>, userId: numbe
created_by_user_id,
updated_by_user_id
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $11)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $13)
RETURNING id
`,
[
cleanPayload.displayId,
cleanPayload.name,
cleanPayload.categoryId,
cleanPayload.usageId,
cleanPayload.details,
cleanPayload.categoryKey,
cleanPayload.usageKey,
cleanPayload.dyeable,
cleanPayload.dualDyeable,
cleanPayload.patternEditable,
@@ -5533,7 +5681,7 @@ export async function createItem(payload: Record<string, unknown>, userId: numbe
const itemId = result.rows[0].id;
await linkEntityImageUpload(client, 'items', itemId, cleanPayload.imagePath, cleanPayload.name);
await replaceItemRelations(client, itemId, cleanPayload);
await replaceEntityTranslations(client, 'items', itemId, cleanPayload.translations, ['name']);
await replaceEntityTranslations(client, 'items', itemId, cleanPayload.translations, ['name', 'details']);
await recordEditLog(client, 'items', itemId, 'create', userId);
return itemId;
});
@@ -5549,23 +5697,27 @@ export async function updateItem(id: number, payload: Record<string, unknown>, u
const result = await client.query(
`
UPDATE items
SET name = $1,
category_id = $2,
usage_id = $3,
dyeable = $4,
dual_dyeable = $5,
pattern_editable = $6,
no_recipe = $7,
is_event_item = $8,
image_path = $9,
updated_by_user_id = $10,
SET display_id = $1,
name = $2,
details = $3,
category_key = $4,
usage_key = $5,
dyeable = $6,
dual_dyeable = $7,
pattern_editable = $8,
no_recipe = $9,
is_event_item = $10,
image_path = $11,
updated_by_user_id = $12,
updated_at = now()
WHERE id = $11
WHERE id = $13
`,
[
cleanPayload.displayId,
cleanPayload.name,
cleanPayload.categoryId,
cleanPayload.usageId,
cleanPayload.details,
cleanPayload.categoryKey,
cleanPayload.usageKey,
cleanPayload.dyeable,
cleanPayload.dualDyeable,
cleanPayload.patternEditable,
@@ -5581,7 +5733,7 @@ export async function updateItem(id: number, payload: Record<string, unknown>, u
}
await linkEntityImageUpload(client, 'items', id, cleanPayload.imagePath, cleanPayload.name);
await replaceItemRelations(client, id, cleanPayload);
await replaceEntityTranslations(client, 'items', id, cleanPayload.translations, ['name']);
await replaceEntityTranslations(client, 'items', id, cleanPayload.translations, ['name', 'details']);
const changes = before ? await itemEditChanges(client, before as unknown as ItemChangeSource, cleanPayload) : [];
await recordEditLog(client, 'items', id, 'update', userId, changes);
return true;
@@ -5603,16 +5755,211 @@ export async function deleteItem(id: number, userId: number) {
});
}
function ancientArtifactProjection(locale: string): string {
const artifactName = localizedName('ancient-artifacts', 'a', locale);
const artifactDetails = localizedField('ancient-artifacts', 'a.id', 'a.details', 'details', locale);
const tagName = localizedName('favorite-things', 't', locale);
return `
SELECT
a.id,
a.display_id AS "displayId",
${artifactName} AS name,
a.name AS "baseName",
${artifactDetails} AS details,
a.details AS "baseDetails",
${translationsSelect('ancient-artifacts', 'a.id')} AS translations,
${systemListJsonSql('a.category_key', ancientArtifactCategoryOptions, locale)} AS category,
${uploadedImageJson('a.image_path')} AS image,
COALESCE((
SELECT json_agg(json_build_object('id', t.id, 'name', ${tagName}) ORDER BY ${orderByEntity('t')})
FROM ancient_artifact_favorite_things aaft
JOIN favorite_things t ON t.id = aaft.favorite_thing_id
WHERE aaft.ancient_artifact_id = a.id
), '[]'::json) AS tags,
${auditSelect('a', 'artifact_created_user', 'artifact_updated_user')}
FROM ancient_artifacts a
${auditJoins('a', 'artifact_created_user', 'artifact_updated_user')}
`;
}
export async function listAncientArtifacts(paramsQuery: QueryParams = {}, locale = defaultLocale) {
const params: unknown[] = [];
const conditions: string[] = [];
const search = asString(paramsQuery.search)?.trim();
const categoryId = Number(asString(paramsQuery.categoryId));
const tagIds = parseIdList(asString(paramsQuery.tagIds));
const categoryOption = Number.isInteger(categoryId) && categoryId > 0
? systemListOptionById(ancientArtifactCategoryOptions, categoryId, 'server.validation.categoryRequired')
: null;
if (search) {
params.push(`%${search}%`);
conditions.push(`${localizedName('ancient-artifacts', 'a', locale)} ILIKE $${params.length}`);
}
if (categoryOption) {
params.push(categoryOption.key);
conditions.push(`a.category_key = $${params.length}`);
}
const tagFilter = sqlForRelationFilter(
tagIds,
'any',
'ancient_artifact_favorite_things',
'ancient_artifact_id',
'favorite_thing_id',
'a.id',
params
);
if (tagFilter) {
conditions.push(tagFilter);
}
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
return query(`${ancientArtifactProjection(locale)} ${whereClause} ORDER BY a.display_id, ${orderByEntity('a')}`, params);
}
export async function getAncientArtifact(id: number, locale = defaultLocale) {
const artifact = await queryOne(`${ancientArtifactProjection(locale)} WHERE a.id = $1`, [id]);
if (!artifact) {
return null;
}
const editHistory = await getEditHistory('ancient-artifacts', id);
const imageHistory = await listEntityImageUploads('ancient-artifacts', id);
return { ...artifact, editHistory, imageHistory };
}
function cleanAncientArtifactPayload(payload: Record<string, unknown>): AncientArtifactPayload {
const categoryId = requirePositiveInteger(payload.categoryId, 'server.validation.categoryRequired');
const category = systemListOptionById(ancientArtifactCategoryOptions, categoryId, 'server.validation.categoryRequired');
return {
displayId: requirePositiveInteger(payload.displayId, 'server.validation.artifactDisplayIdRequired'),
name: cleanName(payload.name, 'server.validation.artifactNameRequired'),
details: cleanOptionalText(payload.details),
translations: cleanTranslations(payload.translations, ['name', 'details']),
categoryId,
categoryKey: category.key,
tagIds: cleanIds(payload.tagIds),
imagePath: cleanUploadImagePath(payload.imagePath, 'ancient-artifacts')
};
}
async function replaceAncientArtifactRelations(client: DbClient, artifactId: number, payload: AncientArtifactPayload): Promise<void> {
await client.query('DELETE FROM ancient_artifact_favorite_things WHERE ancient_artifact_id = $1', [artifactId]);
for (const tagId of payload.tagIds) {
await client.query(
'INSERT INTO ancient_artifact_favorite_things (ancient_artifact_id, favorite_thing_id) VALUES ($1, $2)',
[artifactId, tagId]
);
}
}
export async function createAncientArtifact(payload: Record<string, unknown>, userId: number, locale = defaultLocale) {
const cleanPayload = cleanAncientArtifactPayload(payload);
const id = await withTransaction(async (client) => {
const sortOrder = await nextSortOrder(client, 'ancient_artifacts');
const result = await client.query<{ id: number }>(
`
INSERT INTO ancient_artifacts (
display_id,
name,
details,
category_key,
image_path,
sort_order,
created_by_user_id,
updated_by_user_id
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $7)
RETURNING id
`,
[
cleanPayload.displayId,
cleanPayload.name,
cleanPayload.details,
cleanPayload.categoryKey,
cleanPayload.imagePath,
sortOrder,
userId
]
);
const artifactId = result.rows[0].id;
await linkEntityImageUpload(client, 'ancient-artifacts', artifactId, cleanPayload.imagePath, cleanPayload.name);
await replaceAncientArtifactRelations(client, artifactId, cleanPayload);
await replaceEntityTranslations(client, 'ancient-artifacts', artifactId, cleanPayload.translations, ['name', 'details']);
await recordEditLog(client, 'ancient-artifacts', artifactId, 'create', userId);
return artifactId;
});
return getAncientArtifact(id, locale);
}
export async function updateAncientArtifact(id: number, payload: Record<string, unknown>, userId: number, locale = defaultLocale) {
const cleanPayload = cleanAncientArtifactPayload(payload);
const before = await getAncientArtifact(id, defaultLocale);
const updated = await withTransaction(async (client) => {
const result = await client.query(
`
UPDATE ancient_artifacts
SET display_id = $1,
name = $2,
details = $3,
category_key = $4,
image_path = $5,
updated_by_user_id = $6,
updated_at = now()
WHERE id = $7
`,
[cleanPayload.displayId, cleanPayload.name, cleanPayload.details, cleanPayload.categoryKey, cleanPayload.imagePath, userId, id]
);
if (result.rowCount === 0) {
return false;
}
await linkEntityImageUpload(client, 'ancient-artifacts', id, cleanPayload.imagePath, cleanPayload.name);
await replaceAncientArtifactRelations(client, id, cleanPayload);
await replaceEntityTranslations(client, 'ancient-artifacts', id, cleanPayload.translations, ['name', 'details']);
const changes = before ? await ancientArtifactEditChanges(client, before as unknown as AncientArtifactChangeSource, cleanPayload) : [];
await recordEditLog(client, 'ancient-artifacts', id, 'update', userId, changes);
return true;
});
return updated ? getAncientArtifact(id, locale) : null;
}
export async function deleteAncientArtifact(id: number, userId: number) {
return withTransaction(async (client) => {
const result = await client.query<{ id: number }>('DELETE FROM ancient_artifacts WHERE id = $1 RETURNING id', [id]);
if (result.rowCount === 0) {
return false;
}
await deleteEntityDiscussionCommentsForEntity(client, 'ancient-artifacts', id);
await deleteEntityTranslations(client, 'ancient-artifacts', id);
await recordEditLog(client, 'ancient-artifacts', id, 'delete', userId);
return true;
});
}
export async function listRecipes(paramsQuery: QueryParams = {}, locale = defaultLocale) {
const params: unknown[] = [];
const conditions: string[] = [];
const categoryId = Number(asString(paramsQuery.categoryId));
const categoryOption = Number.isInteger(categoryId) && categoryId > 0
? systemListOptionById(itemCategoryOptions, categoryId, 'server.validation.categoryRequired')
: null;
const resultItemName = localizedName('items', 'result_item', locale);
const materialItemName = localizedName('items', 'i', locale);
if (Number.isInteger(categoryId) && categoryId > 0) {
params.push(categoryId);
conditions.push(`result_item.category_id = $${params.length}`);
if (categoryOption) {
params.push(categoryOption.key);
conditions.push(`result_item.category_key = $${params.length}`);
}
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
@@ -5637,8 +5984,6 @@ export async function listRecipes(paramsQuery: QueryParams = {}, locale = defaul
export async function getRecipe(id: number, locale = defaultLocale) {
const resultItemName = localizedName('items', 'result_item', locale);
const resultItemCategoryName = localizedName('item-categories', 'result_category', locale);
const resultItemUsageName = localizedName('item-usages', 'result_usage', locale);
const acquisitionMethodName = localizedName('acquisition-methods', 'am', locale);
const materialItemName = localizedName('items', 'i', locale);
@@ -5670,18 +6015,17 @@ export async function getRecipe(id: number, locale = defaultLocale) {
), '[]'::json) AS materials,
json_build_object(
'id', result_item.id,
'displayId', result_item.display_id,
'name', ${resultItemName},
'image', ${uploadedImageJson('result_item.image_path')},
'category', json_build_object('id', result_category.id, 'name', ${resultItemCategoryName}),
'category', ${systemListJsonSql('result_item.category_key', itemCategoryOptions, locale)},
'usage', CASE
WHEN result_usage.id IS NULL THEN NULL
ELSE json_build_object('id', result_usage.id, 'name', ${resultItemUsageName})
WHEN result_item.usage_key IS NULL THEN NULL
ELSE ${systemListJsonSql('result_item.usage_key', itemUsageOptions, locale)}
END
) AS item
FROM recipes r
JOIN items result_item ON result_item.id = r.item_id
JOIN item_categories result_category ON result_category.id = result_item.category_id
LEFT JOIN item_usages result_usage ON result_usage.id = result_item.usage_id
${auditJoins('r', 'recipe_created_user', 'recipe_updated_user')}
WHERE r.id = $1
`,
@@ -5791,11 +6135,12 @@ export async function deleteRecipe(id: number, userId: number) {
});
}
const dataToolScopes = ['pokemon', 'habitats', 'items', 'recipes', 'checklist'] as const satisfies readonly DataToolScope[];
const dataToolScopes = ['pokemon', 'habitats', 'items', 'artifacts', 'recipes', 'checklist'] as const satisfies readonly DataToolScope[];
const dataToolMainTables: Record<DataToolScope, string> = {
pokemon: 'pokemon',
habitats: 'habitats',
items: 'items',
artifacts: 'ancient_artifacts',
recipes: 'recipes',
checklist: 'daily_checklist_items'
};
@@ -5839,9 +6184,11 @@ const dataToolColumns = {
habitatPokemon: ['habitat_id', 'pokemon_id', 'map_id', 'time_of_day', 'weather', 'rarity'],
items: [
'id',
'display_id',
'name',
'category_id',
'usage_id',
'details',
'category_key',
'usage_key',
'dyeable',
'dual_dyeable',
'pattern_editable',
@@ -5856,6 +6203,20 @@ const dataToolColumns = {
],
itemAcquisitionMethods: ['item_id', 'acquisition_method_id'],
itemFavoriteThings: ['item_id', 'favorite_thing_id'],
artifactFavoriteThings: ['ancient_artifact_id', 'favorite_thing_id'],
artifacts: [
'id',
'display_id',
'name',
'details',
'category_key',
'image_path',
'sort_order',
'created_by_user_id',
'updated_by_user_id',
'created_at',
'updated_at'
],
recipes: ['id', 'item_id', 'sort_order', 'created_by_user_id', 'updated_by_user_id', 'created_at', 'updated_at'],
recipeAcquisitionMethods: ['recipe_id', 'acquisition_method_id'],
recipeMaterials: ['recipe_id', 'item_id', 'quantity'],
@@ -5965,8 +6326,20 @@ async function tableRows(client: DbClient, sql: string, params: unknown[] = []):
return result.rows;
}
function normalizeImportValue(column: string, value: unknown): unknown {
function normalizeImportValue(column: string, value: unknown, row: Record<string, unknown>): unknown {
if (value === undefined) {
if (column === 'display_id' && typeof row.id === 'number') {
return row.id;
}
if (column === 'details') {
return '';
}
if (column === 'image_path') {
return '';
}
if (column === 'category_key') {
return 'other';
}
return null;
}
if (column === 'changes' && typeof value !== 'string') {
@@ -5978,7 +6351,7 @@ function normalizeImportValue(column: string, value: unknown): unknown {
async function insertRows(client: DbClient, tableName: string, columns: readonly string[], rows: DataToolRows): Promise<void> {
for (const row of rows) {
const placeholders = columns.map((_, index) => `$${index + 1}`).join(', ');
const values = columns.map((column) => normalizeImportValue(column, row[column]));
const values = columns.map((column) => normalizeImportValue(column, row[column], row));
await client.query(`INSERT INTO ${tableName} (${columns.join(', ')}) VALUES (${placeholders})`, values);
}
}
@@ -5995,7 +6368,16 @@ async function resetIdentity(client: DbClient, tableName: string): Promise<void>
}
async function resetDataToolIdentities(client: DbClient): Promise<void> {
for (const tableName of ['daily_checklist_items', 'items', 'recipes', 'habitats', 'wiki_edit_logs', 'entity_image_uploads', 'entity_discussion_comments']) {
for (const tableName of [
'daily_checklist_items',
'items',
'ancient_artifacts',
'recipes',
'habitats',
'wiki_edit_logs',
'entity_image_uploads',
'entity_discussion_comments'
]) {
await resetIdentity(client, tableName);
}
}
@@ -6024,6 +6406,12 @@ async function wipeItemsData(client: DbClient): Promise<void> {
await client.query('DELETE FROM items');
}
async function wipeAncientArtifactsData(client: DbClient): Promise<void> {
await deleteGenericEntityRows(client, ['ancient-artifacts']);
await client.query('DELETE FROM ancient_artifact_favorite_things');
await client.query('DELETE FROM ancient_artifacts');
}
async function wipePokemonData(client: DbClient): Promise<void> {
await deleteGenericEntityRows(client, ['pokemon']);
await client.query('DELETE FROM habitat_pokemon');
@@ -6053,6 +6441,9 @@ async function wipeDataToolScopes(client: DbClient, scopes: DataToolScope[], res
} else if (scopeSet.has('recipes')) {
await wipeRecipesData(client);
}
if (scopeSet.has('artifacts')) {
await wipeAncientArtifactsData(client);
}
if (scopeSet.has('pokemon')) {
await wipePokemonData(client);
}
@@ -6114,7 +6505,7 @@ async function exportScopeData(client: DbClient, scope: DataToolScope): Promise<
if (scope === 'items') {
return {
items: await tableRows(client, 'SELECT * FROM items ORDER BY sort_order, id'),
items: await tableRows(client, 'SELECT * FROM items ORDER BY display_id, sort_order, id'),
itemAcquisitionMethods: await tableRows(client, 'SELECT * FROM item_acquisition_methods ORDER BY item_id, acquisition_method_id'),
itemFavoriteThings: await tableRows(client, 'SELECT * FROM item_favorite_things ORDER BY item_id, favorite_thing_id'),
pokemonSkillItemDrops: await tableRows(client, 'SELECT * FROM pokemon_skill_item_drops ORDER BY pokemon_id, skill_id'),
@@ -6123,6 +6514,17 @@ async function exportScopeData(client: DbClient, scope: DataToolScope): Promise<
};
}
if (scope === 'artifacts') {
return {
artifacts: await tableRows(client, 'SELECT * FROM ancient_artifacts ORDER BY display_id, sort_order, id'),
artifactFavoriteThings: await tableRows(
client,
'SELECT * FROM ancient_artifact_favorite_things ORDER BY ancient_artifact_id, favorite_thing_id'
),
...(await exportGenericScopeData(client, 'ancient-artifacts', true))
};
}
if (scope === 'recipes') {
return {
recipes: await tableRows(client, 'SELECT * FROM recipes ORDER BY sort_order, id'),
@@ -6145,12 +6547,14 @@ async function exportScopeData(client: DbClient, scope: DataToolScope): Promise<
async function importScopeMainRows(client: DbClient, bundle: DataToolsBundle): Promise<void> {
const itemData = bundle.data.items;
const artifactData = bundle.data.artifacts;
const pokemonData = bundle.data.pokemon;
const habitatData = bundle.data.habitats;
const checklistData = bundle.data.checklist;
const recipeData = bundle.data.recipes;
await insertRows(client, 'items', dataToolColumns.items, dataToolTableRows(itemData, 'items'));
await insertRows(client, 'ancient_artifacts', dataToolColumns.artifacts, dataToolTableRows(artifactData, 'artifacts'));
await insertRows(client, 'pokemon', dataToolColumns.pokemon, dataToolTableRows(pokemonData, 'pokemon'));
await insertRows(client, 'habitats', dataToolColumns.habitats, dataToolTableRows(habitatData, 'habitats'));
await insertRows(client, 'daily_checklist_items', dataToolColumns.checklist, dataToolTableRows(checklistData, 'checklist'));
@@ -6159,6 +6563,7 @@ async function importScopeMainRows(client: DbClient, bundle: DataToolsBundle): P
async function importScopeRelationRows(client: DbClient, bundle: DataToolsBundle): Promise<void> {
const itemData = bundle.data.items;
const artifactData = bundle.data.artifacts;
const pokemonData = bundle.data.pokemon;
const habitatData = bundle.data.habitats;
const recipeData = bundle.data.recipes;
@@ -6168,6 +6573,12 @@ async function importScopeRelationRows(client: DbClient, bundle: DataToolsBundle
await insertRows(client, 'item_acquisition_methods', dataToolColumns.itemAcquisitionMethods, dataToolTableRows(itemData, 'itemAcquisitionMethods'));
await insertRows(client, 'item_favorite_things', dataToolColumns.itemFavoriteThings, dataToolTableRows(itemData, 'itemFavoriteThings'));
await insertRows(
client,
'ancient_artifact_favorite_things',
dataToolColumns.artifactFavoriteThings,
dataToolTableRows(artifactData, 'artifactFavoriteThings')
);
await insertRows(client, 'pokemon_pokemon_types', dataToolColumns.pokemonTypeLinks, dataToolTableRows(pokemonData, 'pokemonTypeLinks'));
await insertRows(client, 'pokemon_skills', dataToolColumns.pokemonSkills, dataToolTableRows(pokemonData, 'pokemonSkills'));
await insertRows(client, 'pokemon_favorite_things', dataToolColumns.pokemonFavoriteThings, dataToolTableRows(pokemonData, 'pokemonFavoriteThings'));

View File

@@ -35,6 +35,7 @@ import {
import { initializeDatabase, pool } from './db.ts';
import {
cleanLocale,
createAncientArtifact,
createConfig,
createDailyChecklistItem,
createEntityDiscussionComment,
@@ -48,6 +49,7 @@ import {
createPokemon,
createRecipe,
deleteConfig,
deleteAncientArtifact,
deleteDailyChecklistItem,
deleteEntityDiscussionComment,
deleteHabitat,
@@ -63,6 +65,7 @@ import {
fetchPokemonData,
fetchPokemonImageOptions,
getAdminDataToolsSummary,
getAncientArtifact,
getHabitat,
getItem,
getOptions,
@@ -71,6 +74,7 @@ import {
getRecipe,
importAdminData,
isConfigType,
listAncientArtifacts,
listEntityDiscussionComments,
listConfig,
listDailyChecklistItems,
@@ -86,6 +90,7 @@ import {
listUserLifePosts,
listUserReactionActivities,
reorderConfig,
reorderAncientArtifacts,
reorderDailyChecklistItems,
reorderHabitats,
reorderItems,
@@ -98,6 +103,7 @@ import {
setLifePostRating,
setLifePostReaction,
updateConfig,
updateAncientArtifact,
updateDailyChecklistItem,
updateHabitat,
updateItem,
@@ -1504,7 +1510,13 @@ app.post('/api/uploads/:entityType', async (request, reply) => {
}
const permissionKey =
entityType === 'pokemon' ? 'pokemon.upload' : entityType === 'items' ? 'items.upload' : 'habitats.upload';
entityType === 'pokemon'
? 'pokemon.upload'
: entityType === 'items'
? 'items.upload'
: entityType === 'habitats'
? 'habitats.upload'
: 'ancient-artifacts.upload';
const user = await requirePermissionWithRateLimits(request, reply, permissionKey, 'upload');
if (!user) {
return;
@@ -1643,6 +1655,53 @@ app.delete('/api/items/:id', async (request, reply) => {
return deleted ? reply.code(204).send() : notFound(reply, request);
});
app.get('/api/ancient-artifacts', async (request) =>
listAncientArtifacts(request.query as Record<string, string | string[] | undefined>, requestLocale(request))
);
app.get('/api/ancient-artifacts/:id', async (request, reply) => {
const { id } = request.params as { id: string };
const artifact = await getAncientArtifact(Number(id), requestLocale(request));
if (!artifact) {
return notFound(reply, request);
}
return artifact;
});
app.post('/api/ancient-artifacts', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'ancient-artifacts.create', 'wikiWrite');
return user
? reply.code(201).send(await createAncientArtifact(request.body as Record<string, unknown>, user.id, requestLocale(request)))
: undefined;
});
app.put('/api/ancient-artifacts/:id', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'ancient-artifacts.update', 'wikiWrite');
if (!user) {
return;
}
const { id } = request.params as { id: string };
const artifact = await updateAncientArtifact(Number(id), request.body as Record<string, unknown>, user.id, requestLocale(request));
if (!artifact) {
return notFound(reply, request);
}
return artifact;
});
app.delete('/api/ancient-artifacts/:id', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'ancient-artifacts.delete', 'wikiWrite');
if (!user) {
return;
}
const { id } = request.params as { id: string };
const deleted = await deleteAncientArtifact(Number(id), user.id);
return deleted ? reply.code(204).send() : notFound(reply, request);
});
app.get('/api/recipes', async (request) =>
listRecipes(request.query as Record<string, string | string[] | undefined>, requestLocale(request))
);
@@ -1739,6 +1798,11 @@ app.put('/api/admin/items/order', async (request, reply) => {
return user ? reorderItems(request.body as Record<string, unknown>, user.id, requestLocale(request)) : undefined;
});
app.put('/api/admin/ancient-artifacts/order', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'ancient-artifacts.order', 'wikiWrite');
return user ? reorderAncientArtifacts(request.body as Record<string, unknown>, user.id, requestLocale(request)) : undefined;
});
app.put('/api/admin/recipes/order', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'recipes.order', 'wikiWrite');
return user ? reorderRecipes(request.body as Record<string, unknown>, user.id, requestLocale(request)) : undefined;

View File

@@ -5,7 +5,7 @@ import type { PoolClient } from 'pg';
import type { AuthUser } from './auth.ts';
import { query, queryOne } from './db.ts';
export type UploadEntityType = 'pokemon' | 'items' | 'habitats';
export type UploadEntityType = 'pokemon' | 'items' | 'habitats' | 'ancient-artifacts';
export type EntityImageUpload = {
id: number;
@@ -26,7 +26,7 @@ type MultipartField = {
value?: unknown;
};
const uploadEntityTypes = new Set<UploadEntityType>(['pokemon', 'items', 'habitats']);
const uploadEntityTypes = new Set<UploadEntityType>(['pokemon', 'items', 'habitats', 'ancient-artifacts']);
const imageMimeTypes = new Map([
['image/png', '.png'],
['image/jpeg', '.jpg'],

View File

@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta
name="description"
content="Browse Pokopia Wiki for Pokemon, Event Pokemon, habitats, Event Habitats, items, recipes, daily tasks, and Life community posts for Pokemon Pokopia."
content="Browse Pokopia Wiki for Pokemon, Event Pokemon, habitats, Event Habitats, items, Event Items, Ancient Artifacts, 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, Event Pokemon, habitats, Event Habitats, items, recipes, daily tasks, and Life community posts for Pokemon Pokopia."
content="Browse Pokopia Wiki for Pokemon, Event Pokemon, habitats, Event Habitats, items, Event Items, Ancient Artifacts, 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, Event Pokemon, habitats, Event Habitats, items, recipes, daily tasks, and Life community posts for Pokemon Pokopia."
content="Browse Pokopia Wiki for Pokemon, Event Pokemon, habitats, Event Habitats, items, Event Items, Ancient Artifacts, recipes, daily tasks, and Life community posts for Pokemon Pokopia."
/>
<meta name="twitter:image" content="%POKOPIA_SITE_URL%/seo/pokopia-hero.jpg" />
<script>

View File

@@ -6,6 +6,7 @@ import AppShell from './components/AppShell.vue';
import {
iconAction,
iconAdmin,
iconArtifact,
iconAutomation,
iconChecklist,
iconClothes,
@@ -49,6 +50,8 @@ const navItems = computed(() => {
{ 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.eventItems'), to: '/event-items', icon: iconEvent },
{ label: t('nav.ancientArtifacts'), to: '/ancient-artifacts', icon: iconArtifact },
{ label: t('nav.recipes'), to: '/recipes', icon: iconRecipe },
{ label: t('nav.automation'), to: '/automation', icon: iconAutomation, badge: inDevBadge() },
{ label: t('nav.dish'), to: '/dish', icon: iconDish, badge: inDevBadge() },

View File

@@ -16,11 +16,13 @@ const changeLabelKeys: Record<string, string> = {
标题: 'pages.checklist.task',
'Pokemon ID': 'pages.pokemon.id',
'Pokopia ID': 'pages.pokemon.id',
'Display ID': 'pages.items.displayId',
'Event item': 'common.eventItem',
'Event Pokemon': 'pages.pokemon.eventItem',
'Event Habitat': 'pages.habitats.eventItem',
Genus: 'pages.pokemon.genus',
Details: 'pages.pokemon.details',
Description: 'pages.items.description',
介绍: 'pages.pokemon.details',
Image: 'pages.pokemon.image',
图片: 'pages.pokemon.image',

View File

@@ -3,6 +3,7 @@ export type AppIcon = string;
export const iconAdd: AppIcon = 'mdi:plus';
export const iconAdmin: AppIcon = 'mdi:tune-variant';
export const iconAction: AppIcon = 'mdi:gesture-tap-button';
export const iconArtifact: AppIcon = 'mdi:diamond-stone';
export const iconAutomation: AppIcon = 'mdi:factory';
export const iconBack: AppIcon = 'mdi:arrow-left';
export const iconCancel: AppIcon = 'mdi:close';

View File

@@ -6,6 +6,8 @@ import HabitatList from '../views/HabitatList.vue';
import HabitatDetail from '../views/HabitatDetail.vue';
import ItemsList from '../views/ItemsList.vue';
import ItemDetail from '../views/ItemDetail.vue';
import AncientArtifactList from '../views/AncientArtifactList.vue';
import AncientArtifactDetail from '../views/AncientArtifactDetail.vue';
import RecipeList from '../views/RecipeList.vue';
import RecipeDetail from '../views/RecipeDetail.vue';
import DailyChecklistView from '../views/DailyChecklistView.vue';
@@ -133,17 +135,42 @@ export const router = createRouter({
}
},
{ path: '/habitats/:id', name: 'habitat-detail', component: HabitatDetail, meta: { seo: seo({ titleKey: 'pages.habitats.detailKicker', descriptionKey: 'pages.habitats.subtitle' }) } },
{ path: '/items', name: 'item-list', component: ItemsList, meta: { seo: seo({ titleKey: 'pages.items.title', descriptionKey: 'pages.items.subtitle' }) } },
{
path: '/items',
name: 'item-list',
component: ItemsList,
props: { eventOnly: false },
meta: { seo: seo({ titleKey: 'pages.items.title', descriptionKey: 'pages.items.subtitle' }) }
},
{
path: '/items/new',
name: 'item-new',
component: ItemsList,
props: { eventOnly: false },
meta: {
requiredPermission: 'items.create',
editorModal: true,
seo: seo({ titleKey: 'pages.items.newTitle', descriptionKey: 'pages.items.editSubtitle', canonicalPath: '/items', noindex: true })
}
},
{
path: '/event-items',
name: 'event-item-list',
component: ItemsList,
props: { eventOnly: true },
meta: { seo: seo({ titleKey: 'pages.eventItems.title', descriptionKey: 'pages.eventItems.subtitle', canonicalPath: '/event-items' }) }
},
{
path: '/event-items/new',
name: 'event-item-new',
component: ItemsList,
props: { eventOnly: true },
meta: {
requiredPermission: 'items.create',
editorModal: true,
seo: seo({ titleKey: 'pages.eventItems.newTitle', descriptionKey: 'pages.eventItems.editSubtitle', canonicalPath: '/event-items', noindex: true })
}
},
{
path: '/items/:id/edit',
name: 'item-edit',
@@ -160,6 +187,48 @@ export const router = createRouter({
}
},
{ path: '/items/:id', name: 'item-detail', component: ItemDetail, meta: { seo: seo({ titleKey: 'pages.items.detailKicker', descriptionKey: 'pages.items.subtitle' }) } },
{
path: '/ancient-artifacts',
name: 'ancient-artifact-list',
component: AncientArtifactList,
meta: { seo: seo({ titleKey: 'pages.ancientArtifacts.title', descriptionKey: 'pages.ancientArtifacts.subtitle' }) }
},
{
path: '/ancient-artifacts/new',
name: 'ancient-artifact-new',
component: AncientArtifactList,
meta: {
requiredPermission: 'ancient-artifacts.create',
editorModal: true,
seo: seo({
titleKey: 'pages.ancientArtifacts.newTitle',
descriptionKey: 'pages.ancientArtifacts.editSubtitle',
canonicalPath: '/ancient-artifacts',
noindex: true
})
}
},
{
path: '/ancient-artifacts/:id/edit',
name: 'ancient-artifact-edit',
component: AncientArtifactDetail,
meta: {
requiredPermission: 'ancient-artifacts.update',
editorModal: true,
seo: seo({
titleKey: 'pages.ancientArtifacts.editKicker',
descriptionKey: 'pages.ancientArtifacts.editSubtitle',
canonicalPath: (route) => `/ancient-artifacts/${String(route.params.id)}`,
noindex: true
})
}
},
{
path: '/ancient-artifacts/:id',
name: 'ancient-artifact-detail',
component: AncientArtifactDetail,
meta: { seo: seo({ titleKey: 'pages.ancientArtifacts.detailKicker', descriptionKey: 'pages.ancientArtifacts.subtitle' }) }
},
{ path: '/recipes', name: 'recipe-list', component: RecipeList, meta: { seo: seo({ titleKey: 'pages.recipes.title', descriptionKey: 'pages.recipes.subtitle' }) } },
{
path: '/recipes/new',

View File

@@ -117,7 +117,7 @@ export interface EntityImageUpload extends EntityImage {
uploadedBy: UserSummary | null;
}
export type ImageUploadEntityType = 'pokemon' | 'items' | 'habitats';
export type ImageUploadEntityType = 'pokemon' | 'items' | 'habitats' | 'ancient-artifacts';
export interface PokemonImage extends EntityImage {
style: string;
@@ -246,6 +246,7 @@ export interface HabitatUsage {
}
export interface RecipeResultItem extends NamedEntity {
displayId: number;
image?: EntityImage | null;
category?: NamedEntity;
usage?: NamedEntity | null;
@@ -253,8 +254,11 @@ export interface RecipeResultItem extends NamedEntity {
export interface Item extends EditInfo {
id: number;
displayId: number;
name: string;
baseName?: string;
details: string;
baseDetails?: string;
isEventItem: boolean;
translations?: TranslationMap;
image: EntityImage | null;
@@ -270,6 +274,24 @@ export interface Item extends EditInfo {
recipe: RecipeSummary | null;
}
export interface AncientArtifact extends EditInfo {
id: number;
displayId: number;
name: string;
baseName?: string;
details: string;
baseDetails?: string;
translations?: TranslationMap;
category: NamedEntity;
tags: NamedEntity[];
image: EntityImage | null;
}
export interface AncientArtifactDetail extends AncientArtifact {
editHistory: EditHistoryEntry[];
imageHistory: EntityImageUpload[];
}
export interface ItemDetail extends Item {
acquisitionMethods: NamedEntity[];
recipe: RecipeDetail | null;
@@ -296,7 +318,7 @@ export interface DailyChecklistItem {
translations?: TranslationMap;
}
export type DataToolScope = 'pokemon' | 'habitats' | 'items' | 'recipes' | 'checklist';
export type DataToolScope = 'pokemon' | 'habitats' | 'items' | 'artifacts' | 'recipes' | 'checklist';
export interface DataToolScopeSummary {
scope: DataToolScope;
@@ -395,6 +417,7 @@ export interface Options {
favoriteThings: NamedEntity[];
itemCategories: NamedEntity[];
itemUsages: NamedEntity[];
ancientArtifactCategories: NamedEntity[];
acquisitionMethods: NamedEntity[];
itemTags: NamedEntity[];
maps: NamedEntity[];
@@ -546,8 +569,6 @@ export type ConfigType =
| 'skills'
| 'environments'
| 'favorite-things'
| 'item-categories'
| 'item-usages'
| 'acquisition-methods'
| 'maps'
| 'life-tags'
@@ -598,7 +619,9 @@ export interface PokemonImageOptionsResult {
}
export interface ItemPayload {
displayId: number;
name: string;
details: string;
translations?: TranslationMap;
categoryId: number;
usageId: number | null;
@@ -612,6 +635,16 @@ export interface ItemPayload {
imagePath: string;
}
export interface AncientArtifactPayload {
displayId: number;
name: string;
details: string;
translations?: TranslationMap;
categoryId: number;
tagIds: number[];
imagePath: string;
}
export interface RecipePayload {
itemId: number;
acquisitionMethodIds: number[];
@@ -650,7 +683,7 @@ export interface LifeCommentPayload {
languageCode?: string | null;
}
export type DiscussionEntityType = 'pokemon' | 'items' | 'recipes' | 'habitats';
export type DiscussionEntityType = 'pokemon' | 'items' | 'recipes' | 'habitats' | 'ancient-artifacts';
export interface EntityDiscussionComment {
id: number;
@@ -1104,13 +1137,23 @@ export const api = {
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>) =>
items: (params: Record<string, string | number | boolean | 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 }),
ancientArtifacts: (params: Record<string, string | number | undefined> = {}) =>
getJson<AncientArtifact[]>(`/api/ancient-artifacts${buildQuery(params)}`),
ancientArtifactDetail: (id: string | number) => getJson<AncientArtifactDetail>(`/api/ancient-artifacts/${id}`),
createAncientArtifact: (payload: AncientArtifactPayload) =>
sendJson<AncientArtifactDetail>('/api/ancient-artifacts', 'POST', payload),
updateAncientArtifact: (id: string | number, payload: AncientArtifactPayload) =>
sendJson<AncientArtifactDetail>(`/api/ancient-artifacts/${id}`, 'PUT', payload),
deleteAncientArtifact: (id: string | number) => deleteJson(`/api/ancient-artifacts/${id}`),
reorderAncientArtifacts: (ids: number[]) =>
sendJson<AncientArtifact[]>('/api/admin/ancient-artifacts/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}`),

View File

@@ -4114,6 +4114,13 @@ button:disabled,
min-width: 0;
}
.preserve-lines {
margin: 0;
max-width: 72ch;
overflow-wrap: anywhere;
white-space: pre-wrap;
}
.entity-profile-facts {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(148px, 1fr));

View File

@@ -12,6 +12,7 @@ import TranslationFields from '../components/TranslationFields.vue';
import {
iconAdd,
iconAdmin,
iconArtifact,
iconCancel,
iconChecklist,
iconDelete,
@@ -30,6 +31,7 @@ import {
import { defaultLocale, getCurrentLocale, loadSystemWordings, setCurrentLocale } from '../i18n';
import {
api,
type AncientArtifact,
type AiModerationApiFormat,
type AiModerationAuthMode,
type AiModerationSettings,
@@ -76,6 +78,7 @@ type AdminTab =
| 'checklist'
| 'pokemon'
| 'items'
| 'ancientArtifacts'
| 'recipes'
| 'habitats';
type AdminGroup = 'content' | 'configuration' | 'localization' | 'access';
@@ -102,7 +105,7 @@ const rateLimitPolicyKeys: RateLimitPolicyKey[] = [
'upload',
'fetch'
];
const dataToolScopeKeys: DataToolScope[] = ['pokemon', 'habitats', 'items', 'recipes', 'checklist'];
const dataToolScopeKeys: DataToolScope[] = ['pokemon', 'habitats', 'items', 'artifacts', 'recipes', 'checklist'];
const defaultRateLimitPolicies: Record<RateLimitPolicyKey, RateLimitPolicySettings> = {
accountWrite: { maxRequests: 20, timeWindowSeconds: 60 * 60, cooldownSeconds: 5 },
adminWrite: { maxRequests: 120, timeWindowSeconds: 60 * 60, cooldownSeconds: 2 },
@@ -126,6 +129,7 @@ const adminTabIcons: Record<AdminTab, AppIcon> = {
checklist: iconChecklist,
pokemon: iconPokemon,
items: iconItem,
ancientArtifacts: iconArtifact,
recipes: iconRecipe,
habitats: iconHabitat
};
@@ -146,6 +150,11 @@ const adminNavigationGroups = computed<AdminNavGroup[]>(() => {
{ 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: 'items', label: t('pages.admin.itemList'), permission: ['items.order', 'items.delete'] },
{
key: 'ancientArtifacts',
label: t('pages.admin.ancientArtifactList'),
permission: ['ancient-artifacts.order', 'ancient-artifacts.delete']
},
{ key: 'recipes', label: t('pages.admin.recipeList'), permission: ['recipes.order', 'recipes.delete'] },
{ key: 'habitats', label: t('pages.admin.habitatList'), permission: ['habitats.order', 'habitats.delete'] },
{ key: 'dataTools', label: t('pages.admin.dataTools'), permission: ['admin.data.export', 'admin.data.import'] }
@@ -185,8 +194,6 @@ const configTypes = computed<
{ key: 'skills', label: t('config.skills'), supportsItemDrop: true },
{ key: 'environments', label: t('config.environments') },
{ key: 'favorite-things', label: t('config.favoriteThings') },
{ key: 'item-categories', label: t('config.itemCategories') },
{ key: 'item-usages', label: t('config.itemUsages') },
{ key: 'acquisition-methods', label: t('config.acquisitionMethods') },
{ key: 'maps', label: t('config.maps') },
{ key: 'life-tags', label: t('config.lifeCategories'), supportsDefault: true, supportsRateable: true },
@@ -203,6 +210,7 @@ const languageRows = ref<Language[]>([]);
const checklistRows = ref<DailyChecklistItem[]>([]);
const pokemonRows = ref<Pokemon[]>([]);
const itemRows = ref<Item[]>([]);
const ancientArtifactRows = ref<AncientArtifact[]>([]);
const recipeRows = ref<Recipe[]>([]);
const habitatRows = ref<Habitat[]>([]);
const wordingRows = ref<SystemWording[]>([]);
@@ -401,7 +409,9 @@ 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 itemLabel = (item: Item) => `#${item.displayId} ${item.name}`;
const ancientArtifactKey = (item: AncientArtifact) => item.id;
const ancientArtifactLabel = (item: AncientArtifact) => `#${item.displayId} ${item.name}`;
const recipeKey = (item: Recipe) => item.id;
const recipeLabel = (item: Recipe) => item.name;
const habitatKey = (item: Habitat) => item.id;
@@ -768,6 +778,10 @@ function previewItemOrder(rows: Item[]) {
itemRows.value = rows;
}
function previewAncientArtifactOrder(rows: AncientArtifact[]) {
ancientArtifactRows.value = rows;
}
function previewRecipeOrder(rows: Recipe[]) {
recipeRows.value = rows;
}
@@ -837,6 +851,18 @@ async function persistItemOrder(nextRows: Item[], fallbackRows: Item[]) {
});
}
async function persistAncientArtifactOrder(nextRows: AncientArtifact[], fallbackRows: AncientArtifact[]) {
ancientArtifactRows.value = nextRows;
await run(async () => {
try {
ancientArtifactRows.value = await api.reorderAncientArtifacts(nextRows.map((item) => item.id));
} catch (error) {
ancientArtifactRows.value = fallbackRows;
throw error;
}
});
}
async function persistRecipeOrder(nextRows: Recipe[], fallbackRows: Recipe[]) {
recipeRows.value = nextRows;
await run(async () => {
@@ -944,6 +970,10 @@ async function loadItems() {
itemRows.value = await api.items({});
}
async function loadAncientArtifacts() {
ancientArtifactRows.value = await api.ancientArtifacts();
}
async function loadRecipes() {
recipeRows.value = await api.recipes();
}
@@ -1121,6 +1151,7 @@ async function loadCurrentTab(showSkeleton = false) {
if (activeTab.value === 'checklist') await loadChecklist();
if (activeTab.value === 'pokemon') await loadPokemon();
if (activeTab.value === 'items') await loadItems();
if (activeTab.value === 'ancientArtifacts') await loadAncientArtifacts();
if (activeTab.value === 'recipes') await loadRecipes();
if (activeTab.value === 'habitats') await loadHabitats();
} finally {
@@ -1208,6 +1239,13 @@ async function removeItem(id: number) {
});
}
async function removeAncientArtifact(id: number) {
await run(async () => {
await api.deleteAncientArtifact(id);
await loadAncientArtifacts();
});
}
async function removeRecipe(id: number) {
await run(async () => {
await api.deleteRecipe(id);
@@ -1982,7 +2020,7 @@ onMounted(() => {
@reorder="persistItemOrder"
>
<template #default="{ item }">
<RouterLink :to="`/items/${item.id}`">{{ item.name }}</RouterLink>
<RouterLink :to="`/items/${item.id}`">#{{ item.displayId }} {{ item.name }}</RouterLink>
<span class="row-actions">
<button v-if="can('items.delete')" type="button" :disabled="busy" @click="removeItem(item.id)">
<Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" />
@@ -1994,6 +2032,34 @@ onMounted(() => {
<p v-else class="meta-line">{{ t('common.noRecords') }}</p>
</section>
<section v-else-if="canEdit && activeTab === 'ancientArtifacts'" class="detail-section">
<h2>{{ t('pages.admin.ancientArtifactList') }}</h2>
<ReorderableList
v-if="ancientArtifactRows.length"
:items="ancientArtifactRows"
:item-key="ancientArtifactKey"
:item-label="ancientArtifactLabel"
list-key-prefix="ancient-artifacts"
:disabled="busy || !can('ancient-artifacts.order')"
:handle-label="dragSortLabel"
:handle-title="t('pages.admin.dragSortTitle')"
@preview="previewAncientArtifactOrder"
@cancel="previewAncientArtifactOrder"
@reorder="persistAncientArtifactOrder"
>
<template #default="{ item }">
<RouterLink :to="`/ancient-artifacts/${item.id}`">#{{ item.displayId }} {{ item.name }}</RouterLink>
<span class="row-actions">
<button v-if="can('ancient-artifacts.delete')" type="button" :disabled="busy" @click="removeAncientArtifact(item.id)">
<Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" />
{{ t('common.delete') }}
</button>
</span>
</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>
<ReorderableList

View File

@@ -0,0 +1,170 @@
<script setup lang="ts">
import { Icon } from '@iconify/vue';
import { computed, onMounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import DetailSection from '../components/DetailSection.vue';
import EditHistoryPanel from '../components/EditHistoryPanel.vue';
import EntityChips from '../components/EntityChips.vue';
import EntityDiscussionPanel from '../components/EntityDiscussionPanel.vue';
import PageHeader from '../components/PageHeader.vue';
import Skeleton from '../components/Skeleton.vue';
import Tabs, { type TabOption } from '../components/Tabs.vue';
import { iconArtifact, iconBack, iconEdit } from '../icons';
import { applySeo } from '../seo';
import { api, getAuthToken, type AncientArtifactDetail, type AuthUser } from '../services/api';
import AncientArtifactEdit from './AncientArtifactEdit.vue';
const route = useRoute();
const { t } = useI18n();
const artifact = ref<AncientArtifactDetail | null>(null);
const currentUser = ref<AuthUser | null>(null);
const detailTab = ref('details');
const showEditor = computed(() => route.name === 'ancient-artifact-edit');
const canUpdateArtifact = computed(() => currentUser.value?.permissions.includes('ancient-artifacts.update') === true);
const detailTabs = computed<TabOption[]>(() => [
{ value: 'details', label: t('common.details') },
{ value: 'discussion', label: t('discussion.title') },
{ value: 'history', label: t('history.editHistory') }
]);
async function loadArtifactDetail() {
const nextArtifact = await api.ancientArtifactDetail(String(route.params.id));
artifact.value = nextArtifact;
if (route.meta.editorModal !== true) {
applySeo({
title: `${nextArtifact.name} - ${t('pages.ancientArtifacts.title')}`,
description: t('seo.ancientArtifactDetailDescription', { name: nextArtifact.name }),
canonicalPath: `/ancient-artifacts/${nextArtifact.id}`,
image: nextArtifact.image?.url
});
}
}
onMounted(async () => {
if (getAuthToken()) {
try {
currentUser.value = (await api.me()).user;
} catch {
currentUser.value = null;
}
}
await loadArtifactDetail();
});
watch(
() => route.name,
(name, oldName) => {
if (oldName === 'ancient-artifact-edit' && name === 'ancient-artifact-detail') {
void loadArtifactDetail();
}
}
);
watch(
() => route.params.id,
() => {
artifact.value = null;
detailTab.value = 'details';
void loadArtifactDetail();
}
);
</script>
<template>
<section v-if="!artifact" class="page-stack" aria-busy="true" :aria-label="t('pages.ancientArtifacts.loadingDetail')">
<div class="page-header page-header--skeleton" aria-hidden="true">
<div class="page-header__copy">
<Skeleton width="132px" />
<Skeleton width="260px" height="46px" />
<Skeleton width="220px" />
</div>
<div class="page-header__actions">
<Skeleton variant="box" width="88px" height="36px" />
</div>
</div>
<section class="detail-section skeleton-detail-section" aria-hidden="true">
<div class="detail-section__header">
<Skeleton width="112px" height="24px" />
</div>
<div class="detail-section__body">
<Skeleton width="45%" />
<Skeleton variant="box" height="120px" />
</div>
</section>
</section>
<section v-else class="page-stack">
<PageHeader :title="`#${artifact.displayId} ${artifact.name}`" :subtitle="artifact.category.name">
<template #kicker>{{ t('pages.ancientArtifacts.detailKicker') }}</template>
<template #actions>
<RouterLink v-if="canUpdateArtifact" class="ui-button ui-button--primary ui-button--small" :to="`/ancient-artifacts/${artifact.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="/ancient-artifacts">
<Icon :icon="iconBack" class="ui-icon" aria-hidden="true" />
{{ t('common.backToList') }}
</RouterLink>
</template>
</PageHeader>
<div class="detail-tabs">
<Tabs id="artifact-detail-tabs" v-model="detailTab" :tabs="detailTabs" :label="t('common.details')" />
<div v-if="detailTab === 'details'" class="detail-grid">
<DetailSection :title="t('common.details')">
<dl class="entity-profile-facts">
<div>
<dt>{{ t('pages.ancientArtifacts.displayId') }}</dt>
<dd>#{{ artifact.displayId }}</dd>
</div>
<div>
<dt>{{ t('pages.ancientArtifacts.category') }}</dt>
<dd>{{ artifact.category.name }}</dd>
</div>
</dl>
</DetailSection>
<DetailSection :title="t('media.image')">
<div class="entity-detail-image">
<div class="entity-detail-image__frame" :class="{ 'entity-detail-image__frame--placeholder': !artifact.image }">
<img v-if="artifact.image" :src="artifact.image.url" :alt="t('media.imageAlt', { name: artifact.name })" />
<span v-else class="entity-card__mark entity-detail-image__mark" role="img" :aria-label="t('media.imageEmpty')">
<Icon :icon="iconArtifact" class="entity-card__icon" aria-hidden="true" />
</span>
</div>
</div>
</DetailSection>
<DetailSection :title="t('pages.ancientArtifacts.description')">
<p v-if="artifact.details" class="preserve-lines">{{ artifact.details }}</p>
<p v-else class="meta-line">{{ t('common.none') }}</p>
</DetailSection>
<DetailSection :title="t('pages.ancientArtifacts.category')">
<span class="chip">
<Icon :icon="iconArtifact" class="ui-icon" aria-hidden="true" />
{{ artifact.category.name }}
</span>
</DetailSection>
<DetailSection :title="t('pages.ancientArtifacts.tags')">
<EntityChips v-if="artifact.tags.length" :items="artifact.tags" />
<p v-else class="meta-line">{{ t('common.none') }}</p>
</DetailSection>
</div>
<div v-else-if="detailTab === 'discussion'" class="detail-tab-panel">
<EntityDiscussionPanel entity-type="ancient-artifacts" :entity-id="artifact.id" />
</div>
<div v-else class="detail-tab-panel">
<EditHistoryPanel :entity="artifact" :history="artifact.editHistory" />
</div>
</div>
</section>
<AncientArtifactEdit v-if="showEditor" />
</template>

View File

@@ -0,0 +1,269 @@
<script setup lang="ts">
import { Icon } from '@iconify/vue';
import { computed, onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute, useRouter } from 'vue-router';
import ImageUploadField from '../components/ImageUploadField.vue';
import Modal from '../components/Modal.vue';
import Skeleton from '../components/Skeleton.vue';
import StatusMessage from '../components/StatusMessage.vue';
import TagsSelect from '../components/TagsSelect.vue';
import TranslationFields from '../components/TranslationFields.vue';
import { iconCancel, iconSave } from '../icons';
import {
api,
getAuthToken,
type AncientArtifactPayload,
type AuthUser,
type ConfigType,
type EntityImage,
type EntityImageUpload,
type Language,
type Options,
type TranslationMap
} from '../services/api';
const route = useRoute();
const router = useRouter();
const { locale, t } = useI18n();
const options = ref<Options | null>(null);
const languages = ref<Language[]>([]);
const currentUser = ref<AuthUser | null>(null);
const currentImage = ref<EntityImage | null>(null);
const imageHistory = ref<EntityImageUpload[]>([]);
const loading = ref(true);
const busy = ref(false);
const message = ref('');
const creatingSelect = ref('');
const artifactForm = ref({
displayId: 1,
name: '',
details: '',
translations: {} as TranslationMap,
categoryId: '',
tagIds: [] as string[],
imagePath: ''
});
const routeId = computed(() => (typeof route.params.id === 'string' ? route.params.id : ''));
const isEditing = computed(() => routeId.value !== '');
const pageTitle = computed(() =>
isEditing.value
? t('pages.ancientArtifacts.editTitle', { name: artifactForm.value.name || t('pages.ancientArtifacts.fallbackName') })
: t('pages.ancientArtifacts.newTitle')
);
const cancelTo = computed(() => (isEditing.value ? `/ancient-artifacts/${routeId.value}` : '/ancient-artifacts'));
const canCreateConfig = computed(() => currentUser.value?.permissions.includes('admin.config.create') === true);
const canUploadImage = computed(() => currentUser.value?.permissions.includes('ancient-artifacts.upload') === true);
const imageEntityName = computed(() => artifactNameForSave().trim());
function toIds(values: string[]): number[] {
return values.map(Number).filter((item) => Number.isInteger(item) && item > 0);
}
function errorText(error: unknown, fallback: string) {
return error instanceof Error && error.message ? error.message : fallback;
}
function closeEditor() {
void router.push(cancelTo.value);
}
function artifactNameForSave() {
const baseName = artifactForm.value.name.trim();
if (baseName !== '') {
return artifactForm.value.name;
}
return artifactForm.value.translations[String(locale.value || '')]?.name ?? '';
}
async function loadEditor() {
loading.value = true;
message.value = '';
try {
const [loadedOptions, loadedLanguages] = await Promise.all([api.options(), api.languages()]);
options.value = loadedOptions;
languages.value = loadedLanguages;
if (getAuthToken()) {
try {
currentUser.value = (await api.me()).user;
} catch {
currentUser.value = null;
}
}
if (isEditing.value) {
const artifact = await api.ancientArtifactDetail(routeId.value);
artifactForm.value = {
displayId: artifact.displayId,
name: artifact.baseName ?? artifact.name,
details: artifact.baseDetails ?? artifact.details,
translations: artifact.translations ?? {},
categoryId: String(artifact.category.id),
tagIds: artifact.tags.map((tag) => String(tag.id)),
imagePath: artifact.image?.path ?? ''
};
currentImage.value = artifact.image;
imageHistory.value = artifact.imageHistory;
}
} catch (error) {
message.value = errorText(error, t('errors.loadFailed'));
} finally {
loading.value = false;
}
}
async function createMultiOption(selectKey: string, type: ConfigType, name: string, values: string[]) {
const cleanName = name.trim();
if (!cleanName || !canCreateConfig.value) return;
creatingSelect.value = selectKey;
message.value = '';
try {
const created = await api.createConfig(type, { name: cleanName });
options.value = await api.options();
const value = String(created.id);
if (!values.includes(value)) {
values.push(value);
}
} catch (error) {
message.value = errorText(error, t('errors.addFailed'));
} finally {
creatingSelect.value = '';
}
}
async function saveArtifact() {
busy.value = true;
message.value = '';
try {
const payload: AncientArtifactPayload = {
displayId: artifactForm.value.displayId,
name: artifactNameForSave(),
details: artifactForm.value.details,
translations: artifactForm.value.translations,
categoryId: Number(artifactForm.value.categoryId),
tagIds: toIds(artifactForm.value.tagIds),
imagePath: artifactForm.value.imagePath
};
const saved = isEditing.value
? await api.updateAncientArtifact(routeId.value, payload)
: await api.createAncientArtifact(payload);
await router.push(`/ancient-artifacts/${saved.id}`);
} catch (error) {
message.value = errorText(error, t('errors.saveFailed'));
} finally {
busy.value = false;
}
}
function handleImageSelected(image: EntityImage) {
currentImage.value = image;
}
function handleImageUploaded(image: EntityImageUpload) {
currentImage.value = image;
imageHistory.value = [image, ...imageHistory.value.filter((item) => item.path !== image.path)];
}
onMounted(() => {
void loadEditor();
});
</script>
<template>
<Modal :title="pageTitle" :subtitle="t('pages.ancientArtifacts.editSubtitle')" :close-label="t('common.close')" size="wide" @close="closeEditor">
<StatusMessage v-if="message" variant="danger">{{ message }}</StatusMessage>
<form v-if="!loading && options" id="artifact-edit-form" class="modal-edit-form" @submit.prevent="saveArtifact">
<TranslationFields
id-prefix="artifact-name"
v-model:base-value="artifactForm.name"
v-model:translations="artifactForm.translations"
field="name"
:label="t('common.name')"
:languages="languages"
required
/>
<div class="field">
<label for="artifact-display-id">{{ t('pages.ancientArtifacts.displayId') }}</label>
<input id="artifact-display-id" v-model.number="artifactForm.displayId" type="number" min="1" required />
</div>
<TranslationFields
id-prefix="artifact-details"
v-model:base-value="artifactForm.details"
v-model:translations="artifactForm.translations"
field="details"
:label="t('pages.ancientArtifacts.description')"
:languages="languages"
multiline
:rows="4"
/>
<div class="field">
<label for="artifact-category">{{ t('pages.ancientArtifacts.category') }}</label>
<TagsSelect
id="artifact-category"
v-model="artifactForm.categoryId"
:options="options.ancientArtifactCategories"
:multiple="false"
:placeholder="t('common.select')"
:search-placeholder="t('pages.ancientArtifacts.searchCategory')"
/>
</div>
<ImageUploadField
v-model="artifactForm.imagePath"
entity-type="ancient-artifacts"
:entity-id="isEditing ? routeId : null"
:entity-name="imageEntityName"
:label="t('media.image')"
:current-image="currentImage"
:history="imageHistory"
:disabled="busy"
:allow-upload="canUploadImage"
@selected="handleImageSelected"
@uploaded="handleImageUploaded"
@error="message = $event"
/>
<div class="field">
<label for="artifact-tags">{{ t('pages.ancientArtifacts.tags') }}</label>
<TagsSelect
id="artifact-tags"
v-model="artifactForm.tagIds"
:options="options.itemTags"
:allow-create="canCreateConfig"
:creating="creatingSelect === 'artifact-tags'"
:placeholder="t('pages.ancientArtifacts.searchTags')"
@create="createMultiOption('artifact-tags', 'favorite-things', $event, artifactForm.tagIds)"
/>
</div>
</form>
<section v-else class="modal-edit-form skeleton-detail-section" aria-busy="true" :aria-label="t('pages.ancientArtifacts.loadingEdit')">
<Skeleton width="160px" />
<Skeleton variant="box" height="44px" />
<Skeleton width="140px" />
<Skeleton variant="box" height="120px" />
<Skeleton width="120px" />
<Skeleton variant="box" height="44px" />
</section>
<template #footer>
<button type="button" class="ui-button ui-button--ghost" @click="closeEditor">
<Icon :icon="iconCancel" class="ui-icon" aria-hidden="true" />
{{ t('common.cancel') }}
</button>
<button type="submit" form="artifact-edit-form" class="ui-button ui-button--primary" :disabled="busy || loading">
<Icon :icon="iconSave" class="ui-icon" aria-hidden="true" />
{{ busy ? t('common.saving') : t('common.save') }}
</button>
</template>
</Modal>
</template>

View File

@@ -0,0 +1,139 @@
<script setup lang="ts">
import { Icon } from '@iconify/vue';
import { computed, onMounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import EntityCard from '../components/EntityCard.vue';
import FilterPanel from '../components/FilterPanel.vue';
import PageHeader from '../components/PageHeader.vue';
import Skeleton from '../components/Skeleton.vue';
import Tabs, { type TabOption } from '../components/Tabs.vue';
import TagsSelect from '../components/TagsSelect.vue';
import { iconAdd, iconArtifact } from '../icons';
import { api, getAuthToken, type AncientArtifact, type AuthUser, type Options } from '../services/api';
import AncientArtifactEdit from './AncientArtifactEdit.vue';
const route = useRoute();
const { t } = useI18n();
const options = ref<Options | null>(null);
const artifacts = ref<AncientArtifact[]>([]);
const currentUser = ref<AuthUser | null>(null);
const loading = ref(true);
const search = ref('');
const categoryId = ref('');
const tagIds = ref<string[]>([]);
const categorySkeletonWidths = ['64px', '132px', '132px', '86px'];
const filterSkeletonWidths = ['52px', '36px'];
const skeletonCardCount = 6;
const categoryTabs = computed<TabOption[]>(() => [
{ value: '', label: t('common.all') },
...(options.value?.ancientArtifactCategories.map((item) => ({ value: String(item.id), label: item.name })) ?? [])
]);
const artifactQuery = computed(() => ({
search: search.value,
categoryId: categoryId.value,
tagIds: tagIds.value.join(',')
}));
const showEditor = computed(() => route.name === 'ancient-artifact-new');
const canCreateArtifact = computed(() => currentUser.value?.permissions.includes('ancient-artifacts.create') === true);
function artifactCardImage(artifact: AncientArtifact) {
return artifact.image ? { src: artifact.image.url, alt: t('media.imageAlt', { name: artifact.name }) } : undefined;
}
async function loadArtifacts() {
loading.value = true;
artifacts.value = await api.ancientArtifacts(artifactQuery.value);
loading.value = false;
}
onMounted(async () => {
if (getAuthToken()) {
try {
currentUser.value = (await api.me()).user;
} catch {
currentUser.value = null;
}
}
options.value = await api.options();
await loadArtifacts();
});
watch(artifactQuery, loadArtifacts);
</script>
<template>
<section class="page-stack">
<PageHeader :title="t('pages.ancientArtifacts.title')" :subtitle="t('pages.ancientArtifacts.subtitle')">
<template #kicker>{{ t('pages.ancientArtifacts.kicker') }}</template>
<template #actions>
<RouterLink v-if="canCreateArtifact" class="ui-button ui-button--primary ui-button--small" to="/ancient-artifacts/new">
<Icon :icon="iconAdd" class="ui-icon" aria-hidden="true" />
{{ t('common.add') }}
</RouterLink>
</template>
</PageHeader>
<Tabs v-if="options" id="artifact-category" v-model="categoryId" :tabs="categoryTabs" :label="t('pages.ancientArtifacts.category')" />
<div v-else class="tabs tabs--component" aria-hidden="true">
<div class="tab-list tab-list--skeleton">
<Skeleton
v-for="width in categorySkeletonWidths"
:key="width"
variant="box"
:width="width"
height="42px"
class="skeleton-tab"
/>
</div>
</div>
<FilterPanel v-if="options">
<div class="field">
<label for="artifact-search">{{ t('common.search') }}</label>
<input id="artifact-search" v-model="search" type="search" :placeholder="t('common.name')" />
</div>
<div class="field">
<label for="artifact-tags">{{ t('pages.ancientArtifacts.tags') }}</label>
<TagsSelect
id="artifact-tags"
v-model="tagIds"
:options="options.itemTags"
:placeholder="t('pages.ancientArtifacts.searchTags')"
/>
</div>
</FilterPanel>
<FilterPanel v-else class="filter-panel--skeleton" aria-hidden="true">
<div v-for="(width, index) in filterSkeletonWidths" :key="index" class="field">
<Skeleton :width="width" />
<Skeleton variant="box" height="44px" />
</div>
</FilterPanel>
<div v-if="loading" class="entity-grid catalog-card-grid" aria-busy="true" :aria-label="t('pages.ancientArtifacts.loadingList')">
<article v-for="index in skeletonCardCount" :key="`artifact-skeleton-${index}`" class="entity-card entity-card--skeleton">
<Skeleton variant="box" width="92px" height="92px" class="skeleton-entity-mark" />
<div class="entity-card__content">
<Skeleton width="128px" height="24px" />
<Skeleton width="92px" />
</div>
</article>
</div>
<div v-else class="entity-grid catalog-card-grid">
<EntityCard
v-for="artifact in artifacts"
:key="artifact.id"
:title="`#${artifact.displayId} ${artifact.name}`"
:subtitle="artifact.category.name"
:to="`/ancient-artifacts/${artifact.id}`"
:icon="iconArtifact"
:image="artifactCardImage(artifact)"
/>
</div>
<AncientArtifactEdit v-if="showEditor" />
</section>
</template>

View File

@@ -7,6 +7,7 @@ import Skeleton from '../components/Skeleton.vue';
import StatusBadge from '../components/StatusBadge.vue';
import {
iconAction,
iconArtifact,
iconAutomation,
iconChevronRight,
iconChecklist,
@@ -36,6 +37,8 @@ const primarySections = computed(() => [
{ key: 'habitats', to: '/habitats', icon: iconHabitat },
{ key: 'eventHabitats', to: '/event-habitats', icon: iconEvent },
{ key: 'items', to: '/items', icon: iconItem },
{ key: 'eventItems', to: '/event-items', icon: iconEvent },
{ key: 'ancientArtifacts', to: '/ancient-artifacts', icon: iconArtifact },
{ key: 'recipes', to: '/recipes', icon: iconRecipe }
]);

View File

@@ -36,6 +36,8 @@ const itemSubtitle = computed(() => {
return item.value.usage ? `${item.value.category.name} · ${item.value.usage.name}` : item.value.category.name;
});
const detailKicker = computed(() => (item.value?.isEventItem ? t('pages.eventItems.detailKicker') : t('pages.items.detailKicker')));
const listTarget = computed(() => (item.value?.isEventItem ? '/event-items' : '/items'));
const customization = computed(() => {
if (!item.value) {
@@ -55,7 +57,7 @@ async function loadItemDetail() {
if (route.meta.editorModal !== true) {
applySeo({
title: `${nextItem.name} - ${t('pages.items.title')}`,
title: `${nextItem.name} - ${t(nextItem.isEventItem ? 'pages.eventItems.title' : 'pages.items.title')}`,
description: t('seo.itemDetailDescription', { name: nextItem.name }),
canonicalPath: `/items/${nextItem.id}`,
image: nextItem.image?.url
@@ -147,14 +149,14 @@ watch(
</div>
</section>
<section v-else class="page-stack">
<PageHeader :title="item.name" :subtitle="itemSubtitle">
<template #kicker>{{ t('pages.items.detailKicker') }}</template>
<PageHeader :title="`#${item.displayId} ${item.name}`" :subtitle="itemSubtitle">
<template #kicker>{{ detailKicker }}</template>
<template #actions>
<RouterLink v-if="canUpdateItem" class="ui-button ui-button--primary ui-button--small" :to="`/items/${item.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="/items">
<RouterLink class="ui-button ui-button--blue ui-button--small" :to="listTarget">
<Icon :icon="iconBack" class="ui-icon" aria-hidden="true" />
{{ t('common.backToList') }}
</RouterLink>
@@ -180,6 +182,10 @@ watch(
<div class="entity-profile-main">
<section class="detail-section entity-profile-overview" :aria-label="t('common.details')">
<dl class="entity-profile-facts">
<div>
<dt>{{ t('pages.items.displayId') }}</dt>
<dd>#{{ item.displayId }}</dd>
</div>
<div>
<dt>{{ t('pages.items.category') }}</dt>
<dd>{{ item.category.name }}</dd>
@@ -195,6 +201,11 @@ watch(
</dl>
<div class="entity-profile-groups">
<div class="entity-profile-group">
<h3 class="section-subtitle">{{ t('pages.items.description') }}</h3>
<p v-if="item.details" class="preserve-lines">{{ item.details }}</p>
<p v-else class="meta-line">{{ t('common.none') }}</p>
</div>
<div class="entity-profile-group">
<h3 class="section-subtitle">{{ t('pages.items.acquisitionMethods') }}</h3>
<EntityChips v-if="item.acquisitionMethods.length" :items="item.acquisitionMethods" />

View File

@@ -36,7 +36,9 @@ const busy = ref(false);
const message = ref('');
const creatingSelect = ref('');
const itemForm = ref({
displayId: 1,
name: '',
details: '',
translations: {} as TranslationMap,
categoryId: '',
usageId: '',
@@ -52,12 +54,15 @@ const itemForm = ref({
const routeId = computed(() => (typeof route.params.id === 'string' ? route.params.id : ''));
const isEditing = computed(() => routeId.value !== '');
const isEventCreate = computed(() => route.name === 'event-item-new');
const pageTitle = computed(() =>
isEditing.value
? t('pages.items.editTitle', { name: itemForm.value.name || t('pages.items.fallbackName') })
: t('pages.items.newTitle')
: isEventCreate.value
? t('pages.eventItems.newTitle')
: t('pages.items.newTitle')
);
const cancelTo = computed(() => (isEditing.value ? `/items/${routeId.value}` : '/items'));
const cancelTo = computed(() => (isEditing.value ? `/items/${routeId.value}` : isEventCreate.value ? '/event-items' : '/items'));
const hasRecipe = ref(false);
const imageEntityName = computed(() => itemNameForSave().trim());
const canCreateConfig = computed(() => currentUser.value?.permissions.includes('admin.config.create') === true);
@@ -112,7 +117,9 @@ async function loadEditor() {
if (isEditing.value) {
const item = await api.itemDetail(routeId.value);
itemForm.value = {
displayId: item.displayId,
name: item.baseName ?? item.name,
details: item.baseDetails ?? item.details,
translations: item.translations ?? {},
categoryId: String(item.category.id),
usageId: item.usage ? String(item.usage.id) : '',
@@ -128,6 +135,10 @@ async function loadEditor() {
currentImage.value = item.image;
imageHistory.value = item.imageHistory;
hasRecipe.value = item.recipe !== null;
} else if (isEventCreate.value) {
itemForm.value.isEventItem = true;
} else {
itemForm.value.isEventItem = false;
}
} catch (error) {
message.value = errorText(error, t('errors.loadFailed'));
@@ -136,23 +147,6 @@ async function loadEditor() {
}
}
async function createSingleOption(selectKey: string, type: ConfigType, name: string, assign: (value: string) => void) {
const cleanName = name.trim();
if (!cleanName || !canCreateConfig.value) return;
creatingSelect.value = selectKey;
message.value = '';
try {
const created = await api.createConfig(type, { name: cleanName });
await loadOptions();
assign(String(created.id));
} catch (error) {
message.value = errorText(error, t('errors.addFailed'));
} finally {
creatingSelect.value = '';
}
}
async function createMultiOption(selectKey: string, type: ConfigType, name: string, values: string[]) {
const cleanName = name.trim();
if (!cleanName || !canCreateConfig.value) return;
@@ -179,7 +173,9 @@ async function saveItem() {
try {
const payload: ItemPayload = {
displayId: itemForm.value.displayId,
name: itemNameForSave(),
details: itemForm.value.details,
translations: itemForm.value.translations,
categoryId: Number(itemForm.value.categoryId),
usageId: itemForm.value.usageId ? Number(itemForm.value.usageId) : null,
@@ -230,6 +226,22 @@ onMounted(() => {
required
/>
<div class="field">
<label for="item-display-id">{{ t('pages.items.displayId') }}</label>
<input id="item-display-id" v-model.number="itemForm.displayId" type="number" min="1" required />
</div>
<TranslationFields
id-prefix="item-details"
v-model:base-value="itemForm.details"
v-model:translations="itemForm.translations"
field="details"
:label="t('pages.items.description')"
:languages="languages"
multiline
:rows="4"
/>
<ImageUploadField
v-model="itemForm.imagePath"
entity-type="items"
@@ -252,11 +264,8 @@ onMounted(() => {
v-model="itemForm.categoryId"
:options="options.itemCategories"
:multiple="false"
:allow-create="canCreateConfig"
:creating="creatingSelect === 'item-category'"
:placeholder="t('common.select')"
:search-placeholder="t('pages.items.searchCategory')"
@create="createSingleOption('item-category', 'item-categories', $event, (value) => (itemForm.categoryId = value))"
/>
</div>
@@ -267,11 +276,8 @@ onMounted(() => {
v-model="itemForm.usageId"
:options="options.itemUsages"
:multiple="false"
:allow-create="canCreateConfig"
:creating="creatingSelect === 'item-usage'"
:placeholder="t('common.none')"
:search-placeholder="t('pages.items.searchUsage')"
@create="createSingleOption('item-usage', 'item-usages', $event, (value) => (itemForm.usageId = value))"
/>
</div>
@@ -280,7 +286,7 @@ onMounted(() => {
<label><input v-model="itemForm.dualDyeable" type="checkbox" /> {{ t('pages.items.dualDyeable') }}</label>
<label><input v-model="itemForm.patternEditable" type="checkbox" /> {{ t('pages.items.patternEditable') }}</label>
<label><input v-model="itemForm.noRecipe" type="checkbox" :disabled="hasRecipe" /> {{ t('pages.items.noRecipe') }}</label>
<label><input v-model="itemForm.isEventItem" type="checkbox" /> {{ t('pages.items.eventItem') }}</label>
<label><input v-model="itemForm.isEventItem" type="checkbox" :disabled="isEventCreate" /> {{ t('pages.items.eventItem') }}</label>
</div>
<div class="field">

View File

@@ -13,6 +13,10 @@ import { iconAdd, iconItem } from '../icons';
import { api, getAuthToken, type AuthUser, type Item, type Options } from '../services/api';
import ItemEdit from './ItemEdit.vue';
const props = defineProps<{
eventOnly?: boolean;
}>();
const options = ref<Options | null>(null);
const route = useRoute();
const { t } = useI18n();
@@ -27,6 +31,10 @@ const tagIds = ref<string[]>([]);
const categorySkeletonWidths = ['64px', '92px', '78px', '104px', '86px'];
const filterSkeletonWidths = ['52px', '48px', '48px'];
const skeletonCardCount = 6;
const pageTitle = computed(() => (props.eventOnly ? t('pages.eventItems.title') : t('pages.items.title')));
const pageSubtitle = computed(() => (props.eventOnly ? t('pages.eventItems.subtitle') : t('pages.items.subtitle')));
const pageKicker = computed(() => (props.eventOnly ? t('pages.eventItems.kicker') : t('pages.items.kicker')));
const createTarget = computed(() => (props.eventOnly ? '/event-items/new' : '/items/new'));
const categoryTabs = computed<TabOption[]>(() => [
{ value: '', label: t('common.all') },
@@ -37,9 +45,10 @@ const itemQuery = computed(() => ({
search: search.value,
categoryId: categoryId.value,
usageId: usageId.value,
tagIds: tagIds.value.join(',')
tagIds: tagIds.value.join(','),
isEventItem: props.eventOnly
}));
const showEditor = computed(() => route.name === 'item-new');
const showEditor = computed(() => route.name === 'item-new' || route.name === 'event-item-new');
const canCreateItem = computed(() => currentUser.value?.permissions.includes('items.create') === true);
function itemCardImage(item: Item) {
@@ -69,10 +78,10 @@ watch(itemQuery, loadItems);
<template>
<section class="page-stack">
<PageHeader :title="t('pages.items.title')" :subtitle="t('pages.items.subtitle')">
<template #kicker>Bag</template>
<PageHeader :title="pageTitle" :subtitle="pageSubtitle">
<template #kicker>{{ pageKicker }}</template>
<template #actions>
<RouterLink v-if="canCreateItem" class="ui-button ui-button--primary ui-button--small" to="/items/new">
<RouterLink v-if="canCreateItem" class="ui-button ui-button--primary ui-button--small" :to="createTarget">
<Icon :icon="iconAdd" class="ui-icon" aria-hidden="true" />
{{ t('common.add') }}
</RouterLink>
@@ -136,7 +145,7 @@ watch(itemQuery, loadItems);
<EntityCard
v-for="item in items"
:key="item.id"
:title="item.name"
:title="`#${item.displayId} ${item.name}`"
:subtitle="item.category.name"
:to="`/items/${item.id}`"
:icon="iconItem"

View File

@@ -114,7 +114,7 @@ watch(
</div>
</section>
<section v-else class="page-stack">
<PageHeader :title="recipe.name" :subtitle="recipeSubtitle">
<PageHeader :title="`#${recipe.item.displayId} ${recipe.name}`" :subtitle="recipeSubtitle">
<template #kicker>{{ t('pages.recipes.detailKicker') }}</template>
<template #actions>
<RouterLink v-if="canUpdateRecipe" class="ui-button ui-button--primary ui-button--small" :to="`/recipes/${recipe.id}/edit`">
@@ -145,7 +145,7 @@ watch(
<Icon :icon="iconRecipe" class="entity-card__icon" aria-hidden="true" />
</span>
</RouterLink>
<RouterLink class="entity-profile-title-link" :to="`/items/${recipe.item.id}`">{{ recipe.item.name }}</RouterLink>
<RouterLink class="entity-profile-title-link" :to="`/items/${recipe.item.id}`">#{{ recipe.item.displayId }} {{ recipe.item.name }}</RouterLink>
</div>
</section>

View File

@@ -155,7 +155,7 @@ watch(itemQuery, loadItems);
<EntityCard
v-for="item in items"
:key="item.id"
:title="item.name"
:title="`#${item.displayId} ${item.name}`"
:subtitle="item.category.name"
:to="recipeTarget(item)"
:icon="itemIcon(item)"

View File

@@ -38,7 +38,7 @@ import {
} from '../services/api';
type ProfileTab = 'feeds' | 'contributions' | 'reactions' | 'comments' | 'account';
type PrimaryContributionFilter = 'pokemon' | 'items' | 'recipes' | 'habitats' | 'daily-checklist';
type PrimaryContributionFilter = 'pokemon' | 'items' | 'ancient-artifacts' | 'recipes' | 'habitats' | 'daily-checklist';
type ContributionFilter = 'all' | PrimaryContributionFilter | 'config';
type ReactionFilter = 'all' | LifeReactionType;
type CommentFilter = 'all' | ProfileCommentSource;
@@ -46,6 +46,7 @@ type CommentFilter = 'all' | ProfileCommentSource;
const primaryContributionFilters: PrimaryContributionFilter[] = [
'pokemon',
'items',
'ancient-artifacts',
'recipes',
'habitats',
'daily-checklist'
@@ -582,6 +583,7 @@ function contentTypeLabel(contentType: string): string {
const labels: Record<string, string> = {
pokemon: t('nav.pokemon'),
items: t('nav.items'),
'ancient-artifacts': t('nav.ancientArtifacts'),
recipes: t('nav.recipes'),
habitats: t('nav.habitats'),
'daily-checklist': t('nav.checklist'),
@@ -589,8 +591,6 @@ function contentTypeLabel(contentType: string): string {
skills: t('config.skills'),
environments: t('config.environments'),
'favorite-things': t('config.favoriteThings'),
'item-categories': t('config.itemCategories'),
'item-usages': t('config.itemUsages'),
'acquisition-methods': t('config.acquisitionMethods'),
maps: t('config.maps'),
'life-tags': t('config.lifeCategories')
@@ -603,7 +603,8 @@ function discussionTargetRoute(type: DiscussionEntityType, id: number): string {
pokemon: `/pokemon/${id}`,
items: `/items/${id}`,
recipes: `/recipes/${id}`,
habitats: `/habitats/${id}`
habitats: `/habitats/${id}`,
'ancient-artifacts': `/ancient-artifacts/${id}`
}[type];
}

View File

@@ -3,7 +3,18 @@ import vue from '@vitejs/plugin-vue';
const fallbackSiteUrl = 'https://pokopiawiki.tootaio.com';
const frontendPort = 20015;
const sitemapPaths = ['/pokemon', '/event-pokemon', '/habitats', '/event-habitats', '/items', '/recipes', '/checklist', '/life'];
const sitemapPaths = [
'/pokemon',
'/event-pokemon',
'/habitats',
'/event-habitats',
'/items',
'/event-items',
'/ancient-artifacts',
'/recipes',
'/checklist',
'/life'
];
const robotsDisallowPaths = [
'/admin',
'/login',
@@ -18,7 +29,10 @@ const robotsDisallowPaths = [
'/event-habitats/new',
'/habitats/*/edit',
'/items/new',
'/event-items/new',
'/items/*/edit',
'/ancient-artifacts/new',
'/ancient-artifacts/*/edit',
'/recipes/new',
'/recipes/*/edit',
'/automation',

View File

@@ -48,6 +48,8 @@ export const systemWordingMessages = {
habitats: 'Habitats',
eventHabitats: 'Event Habitats',
items: 'Items',
eventItems: 'Event Items',
ancientArtifacts: 'Ancient Artifacts',
recipes: 'Recipes',
automation: 'Automation',
dish: 'Dish',
@@ -80,11 +82,13 @@ export const systemWordingMessages = {
},
seo: {
siteDescription:
'Browse Pokopia Wiki for Pokemon, Event Pokemon, habitats, Event Habitats, items, recipes, daily tasks, and Life community posts for Pokemon Pokopia.',
'Browse Pokopia Wiki for Pokemon, Event Pokemon, habitats, Event Habitats, items, Event Items, Ancient Artifacts, 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:
'Browse {name} item details in Pokopia Wiki, including category, usage, acquisition methods, customization, related recipes, habitats, and Pokemon drops.',
ancientArtifactDetailDescription:
'Browse {name} Ancient Artifact details in Pokopia Wiki, including category, tags, description, discussions, and edit history.',
habitatDetailDescription:
'View {name} habitat details in Pokopia Wiki, including recipes, possible Pokemon, maps, time, weather, discussions, and edit history.',
recipeDetailDescription:
@@ -191,6 +195,14 @@ export const systemWordingMessages = {
title: 'Items',
description: 'Browse categories, usage, acquisition methods, customization, and tags.'
},
eventItems: {
title: 'Event Items',
description: 'Browse limited event items with their own Display IDs and shared item categories.'
},
ancientArtifacts: {
title: 'Ancient Artifacts',
description: 'Browse Lost Relics and Fossils with tags, descriptions, and wiki history.'
},
recipes: {
title: 'Recipes',
description: 'Find result items, materials, and acquisition details.'
@@ -608,6 +620,7 @@ export const systemWordingMessages = {
items: {
title: 'Items',
subtitle: 'Browse items by category, usage, and tags.',
kicker: 'Items',
detailKicker: 'Item Detail',
detailSubtitle: 'Item detail',
editKicker: 'Item Edit',
@@ -618,6 +631,8 @@ export const systemWordingMessages = {
loadingList: 'Loading item list',
loadingDetail: 'Loading item detail',
loadingEdit: 'Loading item editor',
displayId: 'Display ID',
description: 'Description',
category: 'Category',
usage: 'Usage',
tags: 'Tags',
@@ -638,6 +653,35 @@ export const systemWordingMessages = {
searchMethods: 'Search acquisition methods',
searchTags: 'Search tags'
},
eventItems: {
title: 'Event Items',
subtitle: 'Browse event items by category, usage, and tags.',
kicker: 'Event Items',
detailKicker: 'Event Item Detail',
editSubtitle: 'Maintain event item category, usage, acquisition methods, customization, and tags.',
newTitle: 'New event item'
},
ancientArtifacts: {
title: 'Ancient Artifacts',
subtitle: 'Browse Ancient Artifacts by relic, fossil category, and tags.',
kicker: 'Ancient Artifacts',
detailKicker: 'Ancient Artifact Detail',
detailSubtitle: 'Ancient Artifact detail',
editKicker: 'Ancient Artifact Edit',
editSubtitle: 'Maintain Ancient Artifact Display ID, image, description, category, tags, and translations.',
newTitle: 'New Ancient Artifact',
editTitle: 'Edit {name}',
fallbackName: 'Ancient Artifact',
loadingList: 'Loading Ancient Artifact list',
loadingDetail: 'Loading Ancient Artifact detail',
loadingEdit: 'Loading Ancient Artifact editor',
displayId: 'Display ID',
description: 'Description',
category: 'Category',
tags: 'Tags',
searchCategory: 'Search categories',
searchTags: 'Search tags'
},
recipes: {
title: 'Recipes',
subtitle: 'Browse recipes by category, usage, and tags.',
@@ -862,6 +906,7 @@ export const systemWordingMessages = {
checklist: 'CheckList',
pokemonList: 'Pokemon list',
itemList: 'Item list',
ancientArtifactList: 'Ancient Artifact list',
recipeList: 'Recipe list',
habitatList: 'Habitat list',
dataTools: 'Data tools',
@@ -886,6 +931,7 @@ export const systemWordingMessages = {
dataToolScopePokemon: 'Pokemon',
dataToolScopeHabitats: 'Habitats',
dataToolScopeItems: 'Items',
dataToolScopeArtifacts: 'Ancient Artifacts',
dataToolScopeRecipes: 'Recipes',
dataToolScopeChecklist: 'Daily CheckList',
languages: 'Languages',
@@ -973,8 +1019,6 @@ export const systemWordingMessages = {
skills: 'Specialities',
environments: 'Ideal Habitats',
favoriteThings: 'Favourites / tags',
itemCategories: 'Item categories',
itemUsages: 'Item usages',
acquisitionMethods: 'Acquisition methods',
maps: 'Maps',
lifeCategories: 'Life categories',
@@ -1151,9 +1195,12 @@ export const systemWordingMessages = {
environmentRequired: 'Ideal Habitat is required',
skillNoDrop: 'This speciality cannot have a drop item',
habitatNameRequired: 'Habitat name is required',
itemDisplayIdRequired: 'Item Display ID is required',
usageRequired: 'Usage is required',
itemNameRequired: 'Item name is required',
categoryRequired: 'Category is required',
artifactDisplayIdRequired: 'Ancient Artifact Display ID is required',
artifactNameRequired: 'Ancient Artifact name is required',
recipeFreeWithRecipe: 'An item with a recipe cannot be marked as recipe-free',
itemRequired: 'Item is required',
recipeFreeItem: 'This item is marked as recipe-free',
@@ -1246,6 +1293,8 @@ export const systemWordingMessages = {
habitats: '栖息地',
eventHabitats: 'Event Habitats',
items: '物品',
eventItems: 'Event Items',
ancientArtifacts: 'Ancient Artifacts',
recipes: '材料单',
automation: '自动化',
dish: '料理',
@@ -1277,9 +1326,10 @@ export const systemWordingMessages = {
}
},
seo: {
siteDescription: '浏览 Pokopia Wiki 的 Pokemon、Event Pokemon、栖息地、Event Habitats、物品、材料单、每日清单和 Life 社区动态。',
siteDescription: '浏览 Pokopia Wiki 的 Pokemon、Event Pokemon、栖息地、Event Habitats、物品、Event Items、Ancient Artifacts、材料单、每日清单和 Life 社区动态。',
pokemonDetailDescription: '查看 {name} 在 Pokopia Wiki 中的栖息地、属性、特长、喜欢的东西、六维、相关物品、讨论和编辑历史。',
itemDetailDescription: '查看 {name} 在 Pokopia Wiki 中的分类、用途、入手方式、自定义、相关材料单、栖息地和 Pokemon 掉落。',
ancientArtifactDetailDescription: '查看 {name} 在 Pokopia Wiki 中的分类、标签、介绍、讨论和编辑历史。',
habitatDetailDescription: '查看 {name} 在 Pokopia Wiki 中的配方、可能出现的 Pokemon、地图、时间、天气、讨论和编辑历史。',
recipeDetailDescription: '查看 {name} 材料单在 Pokopia Wiki 中的结果物品、入手方式、需要材料、讨论和编辑历史。'
},
@@ -1384,6 +1434,14 @@ export const systemWordingMessages = {
title: '物品',
description: '按分类、用途、入手方式、自定义和标签浏览物品。'
},
eventItems: {
title: 'Event Items',
description: '浏览限时活动物品,并维护独立的 Display ID 与共享分类。'
},
ancientArtifacts: {
title: 'Ancient Artifacts',
description: '浏览 Lost Relics 和 Fossils 的标签、介绍与 Wiki 历史。'
},
recipes: {
title: '材料单',
description: '查找结果物品、需要材料和入手方式。'
@@ -1781,6 +1839,7 @@ export const systemWordingMessages = {
items: {
title: '物品',
subtitle: '按分类、用途、标签查看物品。',
kicker: '物品',
detailKicker: 'Item Detail',
detailSubtitle: '物品详情',
editKicker: 'Item Edit',
@@ -1791,6 +1850,8 @@ export const systemWordingMessages = {
loadingList: '正在加载列表',
loadingDetail: '正在加载物品详情',
loadingEdit: '正在加载物品编辑内容',
displayId: 'Display ID',
description: '介绍',
category: '分类',
usage: '用途',
tags: '标签',
@@ -1811,6 +1872,35 @@ export const systemWordingMessages = {
searchMethods: '搜索入手方式',
searchTags: '搜索标签'
},
eventItems: {
title: 'Event Items',
subtitle: '按分类、用途、标签查看活动物品。',
kicker: 'Event Items',
detailKicker: 'Event Item Detail',
editSubtitle: '维护 Event Item 分类、用途、入手方式、自定义和标签。',
newTitle: '新增 Event Item'
},
ancientArtifacts: {
title: 'Ancient Artifacts',
subtitle: '按遗物、化石分类和标签查看 Ancient Artifacts。',
kicker: 'Ancient Artifacts',
detailKicker: 'Ancient Artifact Detail',
detailSubtitle: 'Ancient Artifact 详情',
editKicker: 'Ancient Artifact Edit',
editSubtitle: '维护 Ancient Artifact Display ID、图片、介绍、分类、标签和翻译。',
newTitle: '新增 Ancient Artifact',
editTitle: '编辑 {name}',
fallbackName: 'Ancient Artifact',
loadingList: '正在加载 Ancient Artifact 列表',
loadingDetail: '正在加载 Ancient Artifact 详情',
loadingEdit: '正在加载 Ancient Artifact 编辑内容',
displayId: 'Display ID',
description: '介绍',
category: '分类',
tags: '标签',
searchCategory: '搜索分类',
searchTags: '搜索标签'
},
recipes: {
title: '材料单',
subtitle: '按分类、用途、标签查看材料单。',
@@ -2035,6 +2125,7 @@ export const systemWordingMessages = {
checklist: 'CheckList',
pokemonList: 'Pokemon 列表',
itemList: '物品列表',
ancientArtifactList: 'Ancient Artifact 列表',
recipeList: '材料单列表',
habitatList: '栖息地列表',
dataTools: '数据工具',
@@ -2059,6 +2150,7 @@ export const systemWordingMessages = {
dataToolScopePokemon: 'Pokemon',
dataToolScopeHabitats: '栖息地',
dataToolScopeItems: '物品',
dataToolScopeArtifacts: 'Ancient Artifacts',
dataToolScopeRecipes: '材料单',
dataToolScopeChecklist: '每日 CheckList',
languages: '语言',
@@ -2146,8 +2238,6 @@ export const systemWordingMessages = {
skills: '特长',
environments: '喜欢的环境',
favoriteThings: '喜欢的东西 / 标签',
itemCategories: '物品分类',
itemUsages: '物品用途',
acquisitionMethods: '入手方式',
maps: '地图',
lifeCategories: 'Life Categories',
@@ -2324,9 +2414,12 @@ export const systemWordingMessages = {
environmentRequired: '请选择喜欢的环境',
skillNoDrop: '这个特长不能设置掉落物',
habitatNameRequired: '请输入栖息地名称',
itemDisplayIdRequired: '请输入物品 Display ID',
usageRequired: '请选择用途',
itemNameRequired: '请输入物品名称',
categoryRequired: '请选择分类',
artifactDisplayIdRequired: '请输入 Ancient Artifact Display ID',
artifactNameRequired: '请输入 Ancient Artifact 名称',
recipeFreeWithRecipe: '已有材料单的物品不能标记为无材料单',
itemRequired: '请选择物品',
recipeFreeItem: '这个物品已标记为无材料单',