Compare commits
4 Commits
357dc061d6
...
2919708cee
| Author | SHA1 | Date | |
|---|---|---|---|
| 2919708cee | |||
| 839a24566b | |||
| 9312156a3c | |||
| 8ee29e9549 |
38
DESIGN.md
38
DESIGN.md
@@ -58,8 +58,7 @@
|
|||||||
- 喜欢的环境
|
- 喜欢的环境
|
||||||
- 喜欢的东西 / 标签
|
- 喜欢的东西 / 标签
|
||||||
- 入手方式
|
- 入手方式
|
||||||
- 物品
|
- 物品(包含 Ancient Artifacts 视图中的物品)
|
||||||
- Ancient Artifacts
|
|
||||||
- 地图
|
- 地图
|
||||||
- 栖息地
|
- 栖息地
|
||||||
- 每日 CheckList Task
|
- 每日 CheckList Task
|
||||||
@@ -71,7 +70,7 @@
|
|||||||
- 支持翻译的字段:
|
- 支持翻译的字段:
|
||||||
- `name`
|
- `name`
|
||||||
- `title`
|
- `title`
|
||||||
- `details`:Pokemon、物品和 Ancient Artifacts 的介绍 / 说明
|
- `details`:Pokemon 和物品的介绍 / 说明
|
||||||
- `genus`:仅 Pokemon Genus 使用
|
- `genus`:仅 Pokemon Genus 使用
|
||||||
- `effect`:Dish Category 的吃后效果
|
- `effect`:Dish Category 的吃后效果
|
||||||
- `mosslaxEffect`:Dish 给 Mosslax 吃之后的效果
|
- `mosslaxEffect`:Dish 给 Mosslax 吃之后的效果
|
||||||
@@ -218,7 +217,7 @@
|
|||||||
- Wipe Pokemon 会删除 Pokemon 及其属性 / 特长 / 喜欢的东西 / 掉落关联,并移除栖息地中的 Pokemon 出现配置,但不删除栖息地本身。
|
- Wipe Pokemon 会删除 Pokemon 及其属性 / 特长 / 喜欢的东西 / 掉落关联,并移除栖息地中的 Pokemon 出现配置,但不删除栖息地本身。
|
||||||
- Wipe Habitats 会删除栖息地、栖息地配方项和 Pokemon 出现配置,但不删除 Pokemon、Items 或 Maps。
|
- Wipe Habitats 会删除栖息地、栖息地配方项和 Pokemon 出现配置,但不删除 Pokemon、Items 或 Maps。
|
||||||
- Wipe Items 会先删除 Recipes,再删除物品、物品入手方式 / 喜欢的东西关联、栖息地配方项和 Pokemon 掉落关联。
|
- Wipe Items 会先删除 Recipes,再删除物品、物品入手方式 / 喜欢的东西关联、栖息地配方项和 Pokemon 掉落关联。
|
||||||
- Wipe Ancient Artifacts 会删除 Ancient Artifacts、标签关联、实体翻译、编辑历史和实体讨论评论。
|
- Wipe Ancient Artifacts 会清空物品上的 Ancient Artifact 分类并删除对应 Ancient Artifact 讨论;物品本身仍保留在 Items / Event Items 中。
|
||||||
- Wipe Recipes 会删除材料单、材料项和入手方式关联,但不删除 Items。
|
- Wipe Recipes 会删除材料单、材料项和入手方式关联,但不删除 Items。
|
||||||
- Wipe Daily CheckList 会删除清单任务和任务翻译 / 编辑历史。
|
- Wipe Daily CheckList 会删除清单任务和任务翻译 / 编辑历史。
|
||||||
- 对被清空的 identity 主表重置自增 ID;Pokemon 内部 ID 不是 identity,未关联官方 data 的自定义 Pokemon 系统分配区间仍按当前数据库最大值继续。
|
- 对被清空的 identity 主表重置自增 ID;Pokemon 内部 ID 不是 identity,未关联官方 data 的自定义 Pokemon 系统分配区间仍按当前数据库最大值继续。
|
||||||
@@ -344,6 +343,7 @@
|
|||||||
- `created_at`
|
- `created_at`
|
||||||
- 详情页展示最后编辑者、最后编辑时间和编辑历史面板。
|
- 详情页展示最后编辑者、最后编辑时间和编辑历史面板。
|
||||||
- 编辑历史中的用户信息只展示必要署名,不暴露邮箱、token、hash 或内部元数据。
|
- 编辑历史中的用户信息只展示必要署名,不暴露邮箱、token、hash 或内部元数据。
|
||||||
|
- 排序操作仍更新列表顺序、最后编辑者和最后编辑时间,但 `sort_order` / Sort order 字段变更不写入或展示在详情页编辑历史面板中。
|
||||||
- 编辑署名、编辑历史署名、Life 作者和讨论作者可链接到对应公开 Profile。
|
- 编辑署名、编辑历史署名、Life 作者和讨论作者可链接到对应公开 Profile。
|
||||||
|
|
||||||
## Wiki 图片上传
|
## Wiki 图片上传
|
||||||
@@ -595,6 +595,11 @@ Pokemon 详情页展示:
|
|||||||
|
|
||||||
- 名称
|
- 名称
|
||||||
- 介绍
|
- 介绍
|
||||||
|
- Base Price:可为空
|
||||||
|
- Ancient Artifact:可为空,Items Edit 使用单选框维护;`No` 表示普通物品,其他值使用系统固定列表:
|
||||||
|
- Lost Relics (L)
|
||||||
|
- Lost Relics (S)
|
||||||
|
- Fossils
|
||||||
- 是否为 Event Item:`is_event_item`
|
- 是否为 Event Item:`is_event_item`
|
||||||
- 分类:必填,使用系统固定列表,不在管理端配置:
|
- 分类:必填,使用系统固定列表,不在管理端配置:
|
||||||
- Furniture
|
- Furniture
|
||||||
@@ -630,6 +635,7 @@ Items 与 Event Items 使用相同数据模型:
|
|||||||
- Items 列表只展示 `is_event_item = false` 的物品。
|
- Items 列表只展示 `is_event_item = false` 的物品。
|
||||||
- Event Items 列表只展示 `is_event_item = true` 的物品。
|
- Event Items 列表只展示 `is_event_item = true` 的物品。
|
||||||
- Event Items 与 Items 共用物品分类、用途、入手方式、标签、图片、翻译和材料单逻辑。
|
- Event Items 与 Items 共用物品分类、用途、入手方式、标签、图片、翻译和材料单逻辑。
|
||||||
|
- 已选择 Ancient Artifact 分类的物品仍显示在 Items / Event Items 列表中,并额外进入 Ancient Artifacts 对应分类列表。
|
||||||
|
|
||||||
物品列表功能:
|
物品列表功能:
|
||||||
|
|
||||||
@@ -639,7 +645,7 @@ Items 与 Event Items 使用相同数据模型:
|
|||||||
- 按标签筛选
|
- 按标签筛选
|
||||||
- 按自定义排序展示
|
- 按自定义排序展示
|
||||||
- All 视图在满足写入权限时支持对 Grid Item 右键插入新物品到前/后,并支持直接拖曳 Item 调整排序;插入与拖曳只作用于当前展示的 Items 列表,不影响 Event Items 入口。
|
- All 视图在满足写入权限时支持对 Grid Item 右键插入新物品到前/后,并支持直接拖曳 Item 调整排序;插入与拖曳只作用于当前展示的 Items 列表,不影响 Event Items 入口。
|
||||||
- 新增物品入口支持当前浏览器 Session 的默认值菜单;用户可为新建物品预设分类、客制化勾选项和入手方式。默认值只影响 `/items/new` 与 `/event-items/new` 的新建表单初始值,不影响编辑已有物品,不改变 API、数据库模型、权限或审计行为;Event Items 仍由 `/event-items/new` 入口决定 `is_event_item`。
|
- 新增物品入口支持当前浏览器 Session 的默认值菜单;用户可为新建物品预设分类、用途、客制化勾选项和入手方式。默认值只影响 `/items/new` 与 `/event-items/new` 的新建表单初始值,不影响编辑已有物品,不改变 API、数据库模型、权限或审计行为;Event Items 仍由 `/event-items/new` 入口决定 `is_event_item`。
|
||||||
- 物品列表桌面端使用 12 列紧凑 Grid,每个格子只展示物品图标;有用途的物品在卡片左上角以斜 Ribbon 展示用途名称;物品名称通过 hover / focus Tooltip 展示。
|
- 物品列表桌面端使用 12 列紧凑 Grid,每个格子只展示物品图标;有用途的物品在卡片左上角以斜 Ribbon 展示用途名称;物品名称通过 hover / focus Tooltip 展示。
|
||||||
- 物品列表移动端保持常规卡片布局,展示物品图标、名称和分类。
|
- 物品列表移动端保持常规卡片布局,展示物品图标、名称和分类。
|
||||||
- 物品列表不展示标签、入手方式或编辑元信息。
|
- 物品列表不展示标签、入手方式或编辑元信息。
|
||||||
@@ -651,6 +657,8 @@ Items 与 Event Items 使用相同数据模型:
|
|||||||
- 当前图标图片;未配置图标时展示默认物品标记占位符
|
- 当前图标图片;未配置图标时展示默认物品标记占位符
|
||||||
- 顶部按图标 / 占位符与核心信息概览并排展示,移动端改为单列;顶部概览卡片不显示 `Image` / `Details` 通用区块标题,也不展示图片历史缩略图
|
- 顶部按图标 / 占位符与核心信息概览并排展示,移动端改为单列;顶部概览卡片不显示 `Image` / `Details` 通用区块标题,也不展示图片历史缩略图
|
||||||
- 介绍
|
- 介绍
|
||||||
|
- Base Price
|
||||||
|
- Ancient Artifact 分类:仅在物品已配置 Ancient Artifact 分类时展示
|
||||||
- 分类
|
- 分类
|
||||||
- 用途
|
- 用途
|
||||||
- 入手方式
|
- 入手方式
|
||||||
@@ -666,12 +674,12 @@ Items 与 Event Items 使用相同数据模型:
|
|||||||
|
|
||||||
## Ancient Artifacts
|
## Ancient Artifacts
|
||||||
|
|
||||||
Ancient Artifacts 是独立 Wiki 内容类型,可配置:
|
Ancient Artifacts 是 Items 的可选分类视图,不再维护独立主数据结构或独立表;列表、详情和排序从 `items.ancient_artifact_category_key IS NOT NULL` 的物品获取。已配置 Ancient Artifact 分类的物品仍保留在 Items / Event Items 列表中,并额外出现在 Ancient Artifacts 对应分类列表。Ancient Artifact 路由继续保留,用于浏览、编辑和导航对应的物品记录。
|
||||||
|
|
||||||
- 名称
|
- 名称
|
||||||
- 介绍
|
- 介绍
|
||||||
- 图片:使用 Ancient Artifacts 上传目录,支持图片历史
|
- 图片:使用 Items 编辑器和上传目录,支持图片历史
|
||||||
- 分类:必填,使用系统固定列表,不在管理端配置:
|
- 分类:在 Items Edit 的 Ancient Artifact 单选框中维护;`No` 表示不进入 Ancient Artifacts 列表,其他选项使用系统固定列表,不在管理端配置:
|
||||||
- Lost Relics (L)
|
- Lost Relics (L)
|
||||||
- Lost Relics (S)
|
- Lost Relics (S)
|
||||||
- Fossils
|
- Fossils
|
||||||
@@ -689,16 +697,7 @@ Ancient Artifacts 列表功能:
|
|||||||
- 列表移动端保持常规卡片布局,展示图片 / 默认 Ancient Artifact 标记、名称和分类。
|
- 列表移动端保持常规卡片布局,展示图片 / 默认 Ancient Artifact 标记、名称和分类。
|
||||||
- 列表不展示编辑元信息。
|
- 列表不展示编辑元信息。
|
||||||
|
|
||||||
Ancient Artifacts 详情页展示:
|
Ancient Artifacts 详情页使用同一套 Item Details 视图展示同一条 `items` 记录;顶部、图片、基础信息、Base Price、物品分类、用途、入手方式、客制化、标签、材料单关联、讨论和编辑历史均按物品详情页规则展示,并额外展示 Ancient Artifact 分类。通过 `/ancient-artifacts/:id` 打开的普通非 Ancient Artifact 物品会回到对应 `/items/:id`。
|
||||||
|
|
||||||
- 名称
|
|
||||||
- 图片;未配置图片时展示默认 Ancient Artifact 标记
|
|
||||||
- 介绍
|
|
||||||
- 分类
|
|
||||||
- 标签
|
|
||||||
- 最后编辑信息
|
|
||||||
- 讨论
|
|
||||||
- 编辑历史
|
|
||||||
|
|
||||||
## 材料单
|
## 材料单
|
||||||
|
|
||||||
@@ -997,6 +996,7 @@ API 暴露边界:
|
|||||||
- `/ancient-artifacts/:id/edit`
|
- `/ancient-artifacts/:id/edit`
|
||||||
- `/recipes/new`
|
- `/recipes/new`
|
||||||
- `/recipes/:id/edit`
|
- `/recipes/:id/edit`
|
||||||
|
- `/ancient-artifacts/new` 和 `/ancient-artifacts/:id/edit` 使用 Items 编辑器与 Items create/update 权限;保存的是同一条 `items` 记录。
|
||||||
- Life 使用信息流顶部 New Post / 编辑按钮打开普通 Modal 发布与编辑,不使用路由驱动 Modal。
|
- Life 使用信息流顶部 New Post / 编辑按钮打开普通 Modal 发布与编辑,不使用路由驱动 Modal。
|
||||||
- 进入或关闭编辑 Modal 时应保留底层页面上下文,不进行不必要的滚动跳转。
|
- 进入或关闭编辑 Modal 时应保留底层页面上下文,不进行不必要的滚动跳转。
|
||||||
- 用户界面不得展示内部字段名、调试数据、计划说明或“已修改某字段”一类实现说明。
|
- 用户界面不得展示内部字段名、调试数据、计划说明或“已修改某字段”一类实现说明。
|
||||||
@@ -1050,7 +1050,7 @@ API 暴露边界:
|
|||||||
- `GET /api/pokemon/:id`
|
- `GET /api/pokemon/:id`
|
||||||
- `GET /api/habitats`:支持 `isEventItem=true|false` 按普通栖息地 / Event Habitats 拆分列表;未传时返回全部栖息地以兼容管理端和实体选择器
|
- `GET /api/habitats`:支持 `isEventItem=true|false` 按普通栖息地 / Event Habitats 拆分列表;未传时返回全部栖息地以兼容管理端和实体选择器
|
||||||
- `GET /api/habitats/:id`
|
- `GET /api/habitats/:id`
|
||||||
- `GET /api/items`:支持 `isEventItem=true|false` 按普通 Items / Event Items 拆分列表;未传时返回全部 Items 以兼容管理端和实体选择器
|
- `GET /api/items`:支持 `isEventItem=true|false` 按普通 Items / Event Items 拆分列表;默认返回所有物品,包括已配置 Ancient Artifact 分类的物品;传入 `ancientArtifactCategoryId` 时可额外筛选对应 Ancient Artifact 分类下的物品
|
||||||
- `GET /api/items/:id`
|
- `GET /api/items/:id`
|
||||||
- `GET /api/ancient-artifacts`:支持 `search`、`categoryId` 和 `tagIds` 筛选
|
- `GET /api/ancient-artifacts`:支持 `search`、`categoryId` 和 `tagIds` 筛选
|
||||||
- `GET /api/ancient-artifacts/:id`
|
- `GET /api/ancient-artifacts/:id`
|
||||||
|
|||||||
@@ -976,6 +976,8 @@ CREATE TABLE IF NOT EXISTS items (
|
|||||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
name text NOT NULL UNIQUE,
|
name text NOT NULL UNIQUE,
|
||||||
details text NOT NULL DEFAULT '',
|
details text NOT NULL DEFAULT '',
|
||||||
|
base_price integer,
|
||||||
|
ancient_artifact_category_key text,
|
||||||
category_key text NOT NULL DEFAULT 'other',
|
category_key text NOT NULL DEFAULT 'other',
|
||||||
usage_key text,
|
usage_key text,
|
||||||
category_id integer REFERENCES item_categories(id),
|
category_id integer REFERENCES item_categories(id),
|
||||||
@@ -1005,22 +1007,13 @@ CREATE TABLE IF NOT EXISTS items (
|
|||||||
'key-items',
|
'key-items',
|
||||||
'other'
|
'other'
|
||||||
)),
|
)),
|
||||||
|
CHECK (
|
||||||
|
ancient_artifact_category_key IS NULL
|
||||||
|
OR ancient_artifact_category_key IN ('lost-relics-l', 'lost-relics-s', 'fossils')
|
||||||
|
),
|
||||||
CHECK (usage_key IS NULL OR usage_key IN ('decoration', 'relaxation', 'toy', 'road'))
|
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,
|
|
||||||
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()
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS recipes (
|
CREATE TABLE IF NOT EXISTS recipes (
|
||||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
item_id integer NOT NULL UNIQUE REFERENCES items(id),
|
item_id integer NOT NULL UNIQUE REFERENCES items(id),
|
||||||
@@ -1049,12 +1042,6 @@ CREATE TABLE IF NOT EXISTS item_favorite_things (
|
|||||||
PRIMARY KEY (item_id, favorite_thing_id)
|
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 (
|
CREATE TABLE IF NOT EXISTS pokemon_skill_item_drops (
|
||||||
pokemon_id integer NOT NULL,
|
pokemon_id integer NOT NULL,
|
||||||
skill_id integer NOT NULL,
|
skill_id integer NOT NULL,
|
||||||
@@ -1220,11 +1207,14 @@ ALTER TABLE life_tags
|
|||||||
|
|
||||||
ALTER TABLE items
|
ALTER TABLE items
|
||||||
ADD COLUMN IF NOT EXISTS details text NOT NULL DEFAULT '',
|
ADD COLUMN IF NOT EXISTS details text NOT NULL DEFAULT '',
|
||||||
|
ADD COLUMN IF NOT EXISTS base_price integer,
|
||||||
|
ADD COLUMN IF NOT EXISTS ancient_artifact_category_key text,
|
||||||
ADD COLUMN IF NOT EXISTS category_key text,
|
ADD COLUMN IF NOT EXISTS category_key text,
|
||||||
ADD COLUMN IF NOT EXISTS usage_key text;
|
ADD COLUMN IF NOT EXISTS usage_key text;
|
||||||
|
|
||||||
ALTER TABLE ancient_artifacts
|
UPDATE items
|
||||||
ADD COLUMN IF NOT EXISTS image_path text NOT NULL DEFAULT '';
|
SET base_price = NULL
|
||||||
|
WHERE base_price < 0;
|
||||||
|
|
||||||
DO $$
|
DO $$
|
||||||
BEGIN
|
BEGIN
|
||||||
@@ -1301,10 +1291,17 @@ ALTER TABLE items
|
|||||||
|
|
||||||
ALTER TABLE items
|
ALTER TABLE items
|
||||||
DROP CONSTRAINT IF EXISTS items_display_id_positive,
|
DROP CONSTRAINT IF EXISTS items_display_id_positive,
|
||||||
|
DROP CONSTRAINT IF EXISTS items_base_price_check,
|
||||||
|
DROP CONSTRAINT IF EXISTS items_ancient_artifact_category_key_check,
|
||||||
DROP CONSTRAINT IF EXISTS items_category_key_check,
|
DROP CONSTRAINT IF EXISTS items_category_key_check,
|
||||||
DROP CONSTRAINT IF EXISTS items_usage_key_check;
|
DROP CONSTRAINT IF EXISTS items_usage_key_check;
|
||||||
|
|
||||||
ALTER TABLE items
|
ALTER TABLE items
|
||||||
|
ADD CONSTRAINT items_base_price_check CHECK (base_price IS NULL OR base_price >= 0),
|
||||||
|
ADD CONSTRAINT items_ancient_artifact_category_key_check CHECK (
|
||||||
|
ancient_artifact_category_key IS NULL
|
||||||
|
OR ancient_artifact_category_key IN ('lost-relics-l', 'lost-relics-s', 'fossils')
|
||||||
|
),
|
||||||
ADD CONSTRAINT items_category_key_check CHECK (category_key IN (
|
ADD CONSTRAINT items_category_key_check CHECK (category_key IN (
|
||||||
'furniture',
|
'furniture',
|
||||||
'misc',
|
'misc',
|
||||||
@@ -1321,19 +1318,104 @@ ALTER TABLE items
|
|||||||
)),
|
)),
|
||||||
ADD CONSTRAINT items_usage_key_check CHECK (usage_key IS NULL OR usage_key IN ('decoration', 'relaxation', 'toy', 'road'));
|
ADD CONSTRAINT items_usage_key_check CHECK (usage_key IS NULL OR usage_key IN ('decoration', 'relaxation', 'toy', 'road'));
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF to_regclass('ancient_artifacts') IS NOT NULL THEN
|
||||||
|
ALTER TABLE ancient_artifacts
|
||||||
|
ADD COLUMN IF NOT EXISTS image_path text NOT NULL DEFAULT '';
|
||||||
|
|
||||||
|
CREATE TEMP TABLE migrated_ancient_artifact_items (
|
||||||
|
old_id integer PRIMARY KEY,
|
||||||
|
item_id integer NOT NULL
|
||||||
|
) ON COMMIT DROP;
|
||||||
|
|
||||||
|
INSERT INTO items (
|
||||||
|
name,
|
||||||
|
details,
|
||||||
|
ancient_artifact_category_key,
|
||||||
|
category_key,
|
||||||
|
image_path,
|
||||||
|
sort_order,
|
||||||
|
created_by_user_id,
|
||||||
|
updated_by_user_id,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
a.name,
|
||||||
|
a.details,
|
||||||
|
a.category_key,
|
||||||
|
'other',
|
||||||
|
a.image_path,
|
||||||
|
a.sort_order,
|
||||||
|
a.created_by_user_id,
|
||||||
|
a.updated_by_user_id,
|
||||||
|
a.created_at,
|
||||||
|
a.updated_at
|
||||||
|
FROM ancient_artifacts a
|
||||||
|
ON CONFLICT (name) DO UPDATE
|
||||||
|
SET ancient_artifact_category_key = EXCLUDED.ancient_artifact_category_key,
|
||||||
|
details = CASE WHEN items.details = '' THEN EXCLUDED.details ELSE items.details END,
|
||||||
|
image_path = CASE WHEN items.image_path = '' THEN EXCLUDED.image_path ELSE items.image_path END,
|
||||||
|
updated_by_user_id = COALESCE(items.updated_by_user_id, EXCLUDED.updated_by_user_id),
|
||||||
|
updated_at = GREATEST(items.updated_at, EXCLUDED.updated_at);
|
||||||
|
|
||||||
|
INSERT INTO migrated_ancient_artifact_items (old_id, item_id)
|
||||||
|
SELECT a.id, i.id
|
||||||
|
FROM ancient_artifacts a
|
||||||
|
JOIN items i ON i.name = a.name
|
||||||
|
ON CONFLICT (old_id) DO UPDATE SET item_id = EXCLUDED.item_id;
|
||||||
|
|
||||||
|
IF to_regclass('ancient_artifact_favorite_things') IS NOT NULL THEN
|
||||||
|
INSERT INTO item_favorite_things (item_id, favorite_thing_id)
|
||||||
|
SELECT m.item_id, aft.favorite_thing_id
|
||||||
|
FROM ancient_artifact_favorite_things aft
|
||||||
|
JOIN migrated_ancient_artifact_items m ON m.old_id = aft.ancient_artifact_id
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
INSERT INTO entity_translations (entity_type, entity_id, locale, field_name, value)
|
||||||
|
SELECT 'items', m.item_id, et.locale, et.field_name, et.value
|
||||||
|
FROM entity_translations et
|
||||||
|
JOIN migrated_ancient_artifact_items m ON m.old_id = et.entity_id
|
||||||
|
WHERE et.entity_type = 'ancient-artifacts'
|
||||||
|
ON CONFLICT (entity_type, entity_id, locale, field_name) DO UPDATE
|
||||||
|
SET value = EXCLUDED.value;
|
||||||
|
|
||||||
|
UPDATE wiki_edit_logs wel
|
||||||
|
SET entity_type = 'items',
|
||||||
|
entity_id = m.item_id
|
||||||
|
FROM migrated_ancient_artifact_items m
|
||||||
|
WHERE wel.entity_type = 'ancient-artifacts'
|
||||||
|
AND wel.entity_id = m.old_id;
|
||||||
|
|
||||||
|
UPDATE entity_image_uploads eiu
|
||||||
|
SET entity_type = 'items',
|
||||||
|
entity_id = m.item_id
|
||||||
|
FROM migrated_ancient_artifact_items m
|
||||||
|
WHERE eiu.entity_type = 'ancient-artifacts'
|
||||||
|
AND eiu.entity_id = m.old_id;
|
||||||
|
|
||||||
|
UPDATE entity_discussion_comments edc
|
||||||
|
SET entity_id = m.item_id
|
||||||
|
FROM migrated_ancient_artifact_items m
|
||||||
|
WHERE edc.entity_type = 'ancient-artifacts'
|
||||||
|
AND edc.entity_id = m.old_id;
|
||||||
|
|
||||||
|
DELETE FROM entity_translations
|
||||||
|
WHERE entity_type = 'ancient-artifacts';
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
DROP INDEX IF EXISTS items_display_event_item_key;
|
DROP INDEX IF EXISTS items_display_event_item_key;
|
||||||
DROP INDEX IF EXISTS items_display_order_idx;
|
DROP INDEX IF EXISTS items_display_order_idx;
|
||||||
DROP INDEX IF EXISTS ancient_artifacts_display_order_idx;
|
DROP INDEX IF EXISTS ancient_artifacts_display_order_idx;
|
||||||
|
|
||||||
ALTER TABLE ancient_artifacts
|
|
||||||
DROP CONSTRAINT IF EXISTS ancient_artifacts_display_id_key,
|
|
||||||
DROP CONSTRAINT IF EXISTS ancient_artifacts_display_id_check;
|
|
||||||
|
|
||||||
ALTER TABLE items
|
ALTER TABLE items
|
||||||
DROP COLUMN IF EXISTS display_id;
|
DROP COLUMN IF EXISTS display_id;
|
||||||
|
|
||||||
ALTER TABLE ancient_artifacts
|
DROP TABLE IF EXISTS ancient_artifact_favorite_things;
|
||||||
DROP COLUMN IF EXISTS display_id;
|
DROP TABLE IF EXISTS ancient_artifacts;
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS environments_sort_order_idx ON environments(sort_order, id);
|
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 skills_sort_order_idx ON skills(sort_order, id);
|
||||||
@@ -1347,7 +1429,6 @@ 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 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 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 INDEX IF NOT EXISTS items_sort_order_idx ON items(sort_order, id);
|
||||||
CREATE INDEX IF NOT EXISTS ancient_artifacts_sort_order_idx ON ancient_artifacts(sort_order, id);
|
|
||||||
CREATE INDEX IF NOT EXISTS recipes_sort_order_idx ON recipes(sort_order, id);
|
CREATE INDEX IF NOT EXISTS recipes_sort_order_idx ON recipes(sort_order, id);
|
||||||
CREATE INDEX IF NOT EXISTS dish_categories_sort_order_idx ON dish_categories(sort_order, id);
|
CREATE INDEX IF NOT EXISTS dish_categories_sort_order_idx ON dish_categories(sort_order, id);
|
||||||
CREATE INDEX IF NOT EXISTS dish_flavors_sort_order_idx ON dish_flavors(sort_order, id);
|
CREATE INDEX IF NOT EXISTS dish_flavors_sort_order_idx ON dish_flavors(sort_order, id);
|
||||||
|
|||||||
@@ -203,6 +203,9 @@ type PokemonCsvData = {
|
|||||||
type ItemPayload = {
|
type ItemPayload = {
|
||||||
name: string;
|
name: string;
|
||||||
details: string;
|
details: string;
|
||||||
|
basePrice: number | null;
|
||||||
|
ancientArtifactCategoryId: number | null;
|
||||||
|
ancientArtifactCategoryKey: string | null;
|
||||||
translations: TranslationInput;
|
translations: TranslationInput;
|
||||||
categoryId: number;
|
categoryId: number;
|
||||||
categoryKey: string;
|
categoryKey: string;
|
||||||
@@ -543,6 +546,8 @@ type PokemonChangeSource = {
|
|||||||
type ItemChangeSource = {
|
type ItemChangeSource = {
|
||||||
name: string;
|
name: string;
|
||||||
details: string;
|
details: string;
|
||||||
|
basePrice: number | null;
|
||||||
|
ancientArtifactCategory: { name: string } | null;
|
||||||
isEventItem: boolean;
|
isEventItem: boolean;
|
||||||
image: EntityImageValue | null;
|
image: EntityImageValue | null;
|
||||||
category: { name: string };
|
category: { name: string };
|
||||||
@@ -672,7 +677,7 @@ const configDefinitions: Record<ConfigType, ConfigDefinition> = {
|
|||||||
const sortableContentDefinitions: Record<SortableContentType, SortableContentDefinition> = {
|
const sortableContentDefinitions: Record<SortableContentType, SortableContentDefinition> = {
|
||||||
pokemon: { table: 'pokemon', entityType: 'pokemon' },
|
pokemon: { table: 'pokemon', entityType: 'pokemon' },
|
||||||
items: { table: 'items', entityType: 'items' },
|
items: { table: 'items', entityType: 'items' },
|
||||||
'ancient-artifacts': { table: 'ancient_artifacts', entityType: 'ancient-artifacts' },
|
'ancient-artifacts': { table: 'items', entityType: 'ancient-artifacts' },
|
||||||
recipes: { table: 'recipes', entityType: 'recipes' },
|
recipes: { table: 'recipes', entityType: 'recipes' },
|
||||||
habitats: { table: 'habitats', entityType: 'habitats' }
|
habitats: { table: 'habitats', entityType: 'habitats' }
|
||||||
};
|
};
|
||||||
@@ -682,7 +687,7 @@ const discussionEntityDefinitions: Record<DiscussionEntityType, DiscussionEntity
|
|||||||
items: { table: 'items' },
|
items: { table: 'items' },
|
||||||
recipes: { table: 'recipes' },
|
recipes: { table: 'recipes' },
|
||||||
habitats: { table: 'habitats' },
|
habitats: { table: 'habitats' },
|
||||||
'ancient-artifacts': { table: 'ancient_artifacts' }
|
'ancient-artifacts': { table: 'items' }
|
||||||
};
|
};
|
||||||
|
|
||||||
let pokemonCsvDataCache: Promise<PokemonCsvData> | null = null;
|
let pokemonCsvDataCache: Promise<PokemonCsvData> | null = null;
|
||||||
@@ -1045,6 +1050,17 @@ function cleanUploadImagePath(value: unknown, entityType: 'items' | 'habitats' |
|
|||||||
return imagePath;
|
return imagePath;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cleanItemOrArtifactImagePath(value: unknown): string {
|
||||||
|
const imagePath = cleanOptionalText(value);
|
||||||
|
if (imagePath === '') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
if (!isUploadImagePath(imagePath) || (!imagePath.startsWith('items/') && !imagePath.startsWith('ancient-artifacts/'))) {
|
||||||
|
throw validationError('server.validation.imagePathInvalid');
|
||||||
|
}
|
||||||
|
return imagePath;
|
||||||
|
}
|
||||||
|
|
||||||
function cleanIds(value: unknown): number[] {
|
function cleanIds(value: unknown): number[] {
|
||||||
if (!Array.isArray(value)) {
|
if (!Array.isArray(value)) {
|
||||||
return [];
|
return [];
|
||||||
@@ -1077,6 +1093,33 @@ function cleanNonNegativeNumber(value: unknown, message: string): number {
|
|||||||
return numberValue;
|
return numberValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cleanOptionalNonNegativeInteger(value: unknown, message: string): number | null {
|
||||||
|
const rawValue = typeof value === 'string' ? value.trim() : value;
|
||||||
|
if (rawValue === undefined || rawValue === null || rawValue === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const numberValue = Number(rawValue);
|
||||||
|
if (!Number.isInteger(numberValue) || numberValue < 0) {
|
||||||
|
throw validationError(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return numberValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanOptionalSystemListOption(
|
||||||
|
value: unknown,
|
||||||
|
options: readonly SystemListOption[],
|
||||||
|
message: string
|
||||||
|
): SystemListOption | null {
|
||||||
|
const optionId = cleanOptionalNonNegativeInteger(value, message);
|
||||||
|
if (optionId === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return systemListOptionById(options, optionId, message);
|
||||||
|
}
|
||||||
|
|
||||||
function cleanQuantities(value: unknown): IdQuantity[] {
|
function cleanQuantities(value: unknown): IdQuantity[] {
|
||||||
if (!Array.isArray(value)) {
|
if (!Array.isArray(value)) {
|
||||||
return [];
|
return [];
|
||||||
@@ -1142,7 +1185,6 @@ async function nextPokemonInternalId(
|
|||||||
async function reorderTableRows(
|
async function reorderTableRows(
|
||||||
client: DbClient,
|
client: DbClient,
|
||||||
tableName: string,
|
tableName: string,
|
||||||
entityType: string,
|
|
||||||
ids: number[],
|
ids: number[],
|
||||||
userId: number
|
userId: number
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
@@ -1171,9 +1213,6 @@ async function reorderTableRows(
|
|||||||
`,
|
`,
|
||||||
[nextSortOrder, userId, id]
|
[nextSortOrder, userId, id]
|
||||||
);
|
);
|
||||||
const changes: EditChange[] = [];
|
|
||||||
pushChange(changes, 'Sort order', String(previousSortOrder), String(nextSortOrder));
|
|
||||||
await recordEditLog(client, entityType, id, 'update', userId, changes);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2250,6 +2289,18 @@ async function itemEditChanges(
|
|||||||
|
|
||||||
pushChange(changes, 'Name', before.name, after.name);
|
pushChange(changes, 'Name', before.name, after.name);
|
||||||
pushChange(changes, 'Description', before.details, after.details);
|
pushChange(changes, 'Description', before.details, after.details);
|
||||||
|
pushChange(
|
||||||
|
changes,
|
||||||
|
'Base Price',
|
||||||
|
before.basePrice === null ? null : String(before.basePrice),
|
||||||
|
after.basePrice === null ? null : String(after.basePrice)
|
||||||
|
);
|
||||||
|
pushChange(
|
||||||
|
changes,
|
||||||
|
'Ancient Artifact',
|
||||||
|
before.ancientArtifactCategory?.name ?? 'None',
|
||||||
|
systemListNameByKey(ancientArtifactCategoryOptions, after.ancientArtifactCategoryKey) ?? 'None'
|
||||||
|
);
|
||||||
pushTranslationChanges(changes, before.translations, after.translations, ['name', 'details']);
|
pushTranslationChanges(changes, before.translations, after.translations, ['name', 'details']);
|
||||||
pushChange(changes, 'Event item', boolValue(before.isEventItem), boolValue(after.isEventItem));
|
pushChange(changes, 'Event item', boolValue(before.isEventItem), boolValue(after.isEventItem));
|
||||||
pushChange(changes, 'Image', imagePathLabel(before.image?.path), imagePathLabel(after.imagePath));
|
pushChange(changes, 'Image', imagePathLabel(before.image?.path), imagePathLabel(after.imagePath));
|
||||||
@@ -2516,8 +2567,8 @@ export async function globalSearch(paramsQuery: QueryParams = {}, locale = defau
|
|||||||
const habitatName = localizedName('habitats', 'h', locale);
|
const habitatName = localizedName('habitats', 'h', locale);
|
||||||
const itemName = localizedName('items', 'i', locale);
|
const itemName = localizedName('items', 'i', locale);
|
||||||
const itemCategoryName = systemListJsonSql('i.category_key', itemCategoryOptions, locale);
|
const itemCategoryName = systemListJsonSql('i.category_key', itemCategoryOptions, locale);
|
||||||
const artifactName = localizedName('ancient-artifacts', 'a', locale);
|
const artifactName = localizedName('items', 'artifact_item', locale);
|
||||||
const artifactCategoryName = systemListJsonSql('a.category_key', ancientArtifactCategoryOptions, locale);
|
const artifactCategoryName = systemListJsonSql('artifact_item.ancient_artifact_category_key', ancientArtifactCategoryOptions, locale);
|
||||||
const recipeItemName = localizedName('items', 'result_item', locale);
|
const recipeItemName = localizedName('items', 'result_item', locale);
|
||||||
const recipeMaterialName = localizedName('items', 'material_item', locale);
|
const recipeMaterialName = localizedName('items', 'material_item', locale);
|
||||||
const checklistTitle = localizedField('daily-checklist-items', 'c.id', 'c.title', 'title', locale);
|
const checklistTitle = localizedField('daily-checklist-items', 'c.id', 'c.title', 'title', locale);
|
||||||
@@ -2578,16 +2629,17 @@ export async function globalSearch(paramsQuery: QueryParams = {}, locale = defau
|
|||||||
query<GlobalSearchItem>(
|
query<GlobalSearchItem>(
|
||||||
`
|
`
|
||||||
SELECT
|
SELECT
|
||||||
a.id,
|
artifact_item.id,
|
||||||
'ancient-artifacts' AS type,
|
'ancient-artifacts' AS type,
|
||||||
${artifactName} AS title,
|
${artifactName} AS title,
|
||||||
'/ancient-artifacts/' || a.id AS url,
|
'/ancient-artifacts/' || artifact_item.id AS url,
|
||||||
NULLIF(a.details, '') AS summary,
|
NULLIF(artifact_item.details, '') AS summary,
|
||||||
(${artifactCategoryName}->>'name') AS meta,
|
(${artifactCategoryName}->>'name') AS meta,
|
||||||
${uploadedImageJson('a.image_path')} AS image
|
${uploadedImageJson('artifact_item.image_path')} AS image
|
||||||
FROM ancient_artifacts a
|
FROM items artifact_item
|
||||||
WHERE ${artifactName} ILIKE $1
|
WHERE ${artifactName} ILIKE $1
|
||||||
ORDER BY ${orderByEntity('a')}
|
AND artifact_item.ancient_artifact_category_key IS NOT NULL
|
||||||
|
ORDER BY ${orderByEntity('artifact_item')}
|
||||||
LIMIT $2
|
LIMIT $2
|
||||||
`,
|
`,
|
||||||
[pattern, limit]
|
[pattern, limit]
|
||||||
@@ -2798,9 +2850,6 @@ export async function reorderDailyChecklistItems(payload: Record<string, unknown
|
|||||||
`,
|
`,
|
||||||
[nextSortOrder, userId, id]
|
[nextSortOrder, userId, id]
|
||||||
);
|
);
|
||||||
const changes: EditChange[] = [];
|
|
||||||
pushChange(changes, 'Sort order', String(previousSortOrder), String(nextSortOrder));
|
|
||||||
await recordEditLog(client, 'daily-checklist-items', id, 'update', userId, changes);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -4208,7 +4257,7 @@ export async function listUserCommentActivities(
|
|||||||
const itemName = localizedName('items', 'i', locale);
|
const itemName = localizedName('items', 'i', locale);
|
||||||
const recipeItemName = localizedName('items', 'recipe_item', locale);
|
const recipeItemName = localizedName('items', 'recipe_item', locale);
|
||||||
const habitatName = localizedName('habitats', 'h', locale);
|
const habitatName = localizedName('habitats', 'h', locale);
|
||||||
const artifactName = localizedName('ancient-artifacts', 'a', locale);
|
const artifactName = localizedName('items', 'artifact_item', locale);
|
||||||
const params: unknown[] = [user.id];
|
const params: unknown[] = [user.id];
|
||||||
const outerConditions: string[] = [];
|
const outerConditions: string[] = [];
|
||||||
|
|
||||||
@@ -4286,7 +4335,7 @@ export async function listUserCommentActivities(
|
|||||||
LEFT JOIN recipes r ON edc.entity_type = 'recipes' AND r.id = edc.entity_id
|
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 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 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
|
LEFT JOIN items artifact_item ON edc.entity_type = 'ancient-artifacts' AND artifact_item.id = edc.entity_id
|
||||||
WHERE edc.created_by_user_id = $1
|
WHERE edc.created_by_user_id = $1
|
||||||
AND edc.deleted_at IS NULL
|
AND edc.deleted_at IS NULL
|
||||||
AND edc.ai_moderation_status = 'approved'
|
AND edc.ai_moderation_status = 'approved'
|
||||||
@@ -5339,7 +5388,7 @@ export async function reorderConfig(type: ConfigType, payload: Record<string, un
|
|||||||
}
|
}
|
||||||
|
|
||||||
await withTransaction(async (client) => {
|
await withTransaction(async (client) => {
|
||||||
await reorderTableRows(client, definition.table, type, ids, userId);
|
await reorderTableRows(client, definition.table, ids, userId);
|
||||||
});
|
});
|
||||||
|
|
||||||
return listConfig(type, locale);
|
return listConfig(type, locale);
|
||||||
@@ -5440,7 +5489,7 @@ async function reorderContent(type: SortableContentType, payload: Record<string,
|
|||||||
}
|
}
|
||||||
|
|
||||||
await withTransaction(async (client) => {
|
await withTransaction(async (client) => {
|
||||||
await reorderTableRows(client, definition.table, definition.entityType, ids, userId);
|
await reorderTableRows(client, definition.table, ids, userId);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -6223,6 +6272,11 @@ function itemProjection(locale: string): string {
|
|||||||
i.name AS "baseName",
|
i.name AS "baseName",
|
||||||
${itemDetails} AS details,
|
${itemDetails} AS details,
|
||||||
i.details AS "baseDetails",
|
i.details AS "baseDetails",
|
||||||
|
i.base_price AS "basePrice",
|
||||||
|
CASE
|
||||||
|
WHEN i.ancient_artifact_category_key IS NULL THEN NULL
|
||||||
|
ELSE ${systemListJsonSql('i.ancient_artifact_category_key', ancientArtifactCategoryOptions, locale)}
|
||||||
|
END AS "ancientArtifactCategory",
|
||||||
i.is_event_item AS "isEventItem",
|
i.is_event_item AS "isEventItem",
|
||||||
${translationsSelect('items', 'i.id')} AS translations,
|
${translationsSelect('items', 'i.id')} AS translations,
|
||||||
${auditSelect('i', 'item_created_user', 'item_updated_user')},
|
${auditSelect('i', 'item_created_user', 'item_updated_user')},
|
||||||
@@ -6273,6 +6327,7 @@ export async function listItems(paramsQuery: QueryParams, locale = defaultLocale
|
|||||||
const conditions: string[] = [];
|
const conditions: string[] = [];
|
||||||
const categoryId = Number(asString(paramsQuery.categoryId));
|
const categoryId = Number(asString(paramsQuery.categoryId));
|
||||||
const usageId = Number(asString(paramsQuery.usageId));
|
const usageId = Number(asString(paramsQuery.usageId));
|
||||||
|
const ancientArtifactCategoryId = Number(asString(paramsQuery.ancientArtifactCategoryId));
|
||||||
const isEventItem = asString(paramsQuery.isEventItem);
|
const isEventItem = asString(paramsQuery.isEventItem);
|
||||||
const tagIds = parseIdList(asString(paramsQuery.tagIds));
|
const tagIds = parseIdList(asString(paramsQuery.tagIds));
|
||||||
const search = asString(paramsQuery.search)?.trim();
|
const search = asString(paramsQuery.search)?.trim();
|
||||||
@@ -6283,6 +6338,9 @@ export async function listItems(paramsQuery: QueryParams, locale = defaultLocale
|
|||||||
const usageOption = Number.isInteger(usageId) && usageId > 0
|
const usageOption = Number.isInteger(usageId) && usageId > 0
|
||||||
? systemListOptionById(itemUsageOptions, usageId, 'server.validation.usageRequired')
|
? systemListOptionById(itemUsageOptions, usageId, 'server.validation.usageRequired')
|
||||||
: null;
|
: null;
|
||||||
|
const ancientArtifactCategoryOption = Number.isInteger(ancientArtifactCategoryId) && ancientArtifactCategoryId > 0
|
||||||
|
? systemListOptionById(ancientArtifactCategoryOptions, ancientArtifactCategoryId, 'server.validation.invalidField')
|
||||||
|
: null;
|
||||||
|
|
||||||
if (search) {
|
if (search) {
|
||||||
params.push(`%${search}%`);
|
params.push(`%${search}%`);
|
||||||
@@ -6304,6 +6362,11 @@ export async function listItems(paramsQuery: QueryParams, locale = defaultLocale
|
|||||||
conditions.push(`i.usage_key = $${params.length}`);
|
conditions.push(`i.usage_key = $${params.length}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (ancientArtifactCategoryOption) {
|
||||||
|
params.push(ancientArtifactCategoryOption.key);
|
||||||
|
conditions.push(`i.ancient_artifact_category_key = $${params.length}`);
|
||||||
|
}
|
||||||
|
|
||||||
const tagFilter = sqlForRelationFilter(
|
const tagFilter = sqlForRelationFilter(
|
||||||
tagIds,
|
tagIds,
|
||||||
'any',
|
'any',
|
||||||
@@ -6479,6 +6542,11 @@ function cleanItemPayload(payload: Record<string, unknown>): ItemPayload {
|
|||||||
const usageId = payload.usageId === null || payload.usageId === '' || payload.usageId === undefined
|
const usageId = payload.usageId === null || payload.usageId === '' || payload.usageId === undefined
|
||||||
? null
|
? null
|
||||||
: requirePositiveInteger(payload.usageId, 'server.validation.usageRequired');
|
: requirePositiveInteger(payload.usageId, 'server.validation.usageRequired');
|
||||||
|
const ancientArtifactCategory = cleanOptionalSystemListOption(
|
||||||
|
payload.ancientArtifactCategoryId,
|
||||||
|
ancientArtifactCategoryOptions,
|
||||||
|
'server.validation.invalidField'
|
||||||
|
);
|
||||||
const insertBeforeItemId = cleanOptionalPositiveInteger(payload.insertBeforeItemId);
|
const insertBeforeItemId = cleanOptionalPositiveInteger(payload.insertBeforeItemId);
|
||||||
const insertAfterItemId = cleanOptionalPositiveInteger(payload.insertAfterItemId);
|
const insertAfterItemId = cleanOptionalPositiveInteger(payload.insertAfterItemId);
|
||||||
const category = systemListOptionById(itemCategoryOptions, categoryId, 'server.validation.categoryRequired');
|
const category = systemListOptionById(itemCategoryOptions, categoryId, 'server.validation.categoryRequired');
|
||||||
@@ -6491,6 +6559,9 @@ function cleanItemPayload(payload: Record<string, unknown>): ItemPayload {
|
|||||||
return {
|
return {
|
||||||
name: cleanName(payload.name, 'server.validation.itemNameRequired'),
|
name: cleanName(payload.name, 'server.validation.itemNameRequired'),
|
||||||
details: cleanOptionalText(payload.details),
|
details: cleanOptionalText(payload.details),
|
||||||
|
basePrice: cleanOptionalNonNegativeInteger(payload.basePrice, 'server.validation.invalidField'),
|
||||||
|
ancientArtifactCategoryId: ancientArtifactCategory?.id ?? null,
|
||||||
|
ancientArtifactCategoryKey: ancientArtifactCategory?.key ?? null,
|
||||||
translations: cleanTranslations(payload.translations, ['name', 'details']),
|
translations: cleanTranslations(payload.translations, ['name', 'details']),
|
||||||
categoryId,
|
categoryId,
|
||||||
categoryKey: category.key,
|
categoryKey: category.key,
|
||||||
@@ -6503,7 +6574,7 @@ function cleanItemPayload(payload: Record<string, unknown>): ItemPayload {
|
|||||||
isEventItem: Boolean(payload.isEventItem),
|
isEventItem: Boolean(payload.isEventItem),
|
||||||
acquisitionMethodIds: cleanIds(payload.acquisitionMethodIds),
|
acquisitionMethodIds: cleanIds(payload.acquisitionMethodIds),
|
||||||
tagIds: cleanIds(payload.tagIds),
|
tagIds: cleanIds(payload.tagIds),
|
||||||
imagePath: cleanUploadImagePath(payload.imagePath, 'items'),
|
imagePath: cleanItemOrArtifactImagePath(payload.imagePath),
|
||||||
insertBeforeItemId,
|
insertBeforeItemId,
|
||||||
insertAfterItemId
|
insertAfterItemId
|
||||||
};
|
};
|
||||||
@@ -6565,6 +6636,8 @@ export async function createItem(payload: Record<string, unknown>, userId: numbe
|
|||||||
INSERT INTO items (
|
INSERT INTO items (
|
||||||
name,
|
name,
|
||||||
details,
|
details,
|
||||||
|
ancient_artifact_category_key,
|
||||||
|
base_price,
|
||||||
category_key,
|
category_key,
|
||||||
usage_key,
|
usage_key,
|
||||||
dyeable,
|
dyeable,
|
||||||
@@ -6577,12 +6650,14 @@ export async function createItem(payload: Record<string, unknown>, userId: numbe
|
|||||||
created_by_user_id,
|
created_by_user_id,
|
||||||
updated_by_user_id
|
updated_by_user_id
|
||||||
)
|
)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $12)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $14)
|
||||||
RETURNING id
|
RETURNING id
|
||||||
`,
|
`,
|
||||||
[
|
[
|
||||||
cleanPayload.name,
|
cleanPayload.name,
|
||||||
cleanPayload.details,
|
cleanPayload.details,
|
||||||
|
cleanPayload.ancientArtifactCategoryKey,
|
||||||
|
cleanPayload.basePrice,
|
||||||
cleanPayload.categoryKey,
|
cleanPayload.categoryKey,
|
||||||
cleanPayload.usageKey,
|
cleanPayload.usageKey,
|
||||||
cleanPayload.dyeable,
|
cleanPayload.dyeable,
|
||||||
@@ -6618,7 +6693,7 @@ export async function createItem(payload: Record<string, unknown>, userId: numbe
|
|||||||
}
|
}
|
||||||
|
|
||||||
orderedIds.splice(targetIndex + (cleanPayload.insertAfterItemId !== null ? 1 : 0), 0, itemId);
|
orderedIds.splice(targetIndex + (cleanPayload.insertAfterItemId !== null ? 1 : 0), 0, itemId);
|
||||||
await reorderTableRows(client, 'items', 'items', orderedIds, userId);
|
await reorderTableRows(client, 'items', orderedIds, userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
await recordEditLog(client, 'items', itemId, 'create', userId);
|
await recordEditLog(client, 'items', itemId, 'create', userId);
|
||||||
@@ -6638,21 +6713,25 @@ export async function updateItem(id: number, payload: Record<string, unknown>, u
|
|||||||
UPDATE items
|
UPDATE items
|
||||||
SET name = $1,
|
SET name = $1,
|
||||||
details = $2,
|
details = $2,
|
||||||
category_key = $3,
|
ancient_artifact_category_key = $3,
|
||||||
usage_key = $4,
|
base_price = $4,
|
||||||
dyeable = $5,
|
category_key = $5,
|
||||||
dual_dyeable = $6,
|
usage_key = $6,
|
||||||
pattern_editable = $7,
|
dyeable = $7,
|
||||||
no_recipe = $8,
|
dual_dyeable = $8,
|
||||||
is_event_item = $9,
|
pattern_editable = $9,
|
||||||
image_path = $10,
|
no_recipe = $10,
|
||||||
updated_by_user_id = $11,
|
is_event_item = $11,
|
||||||
|
image_path = $12,
|
||||||
|
updated_by_user_id = $13,
|
||||||
updated_at = now()
|
updated_at = now()
|
||||||
WHERE id = $12
|
WHERE id = $14
|
||||||
`,
|
`,
|
||||||
[
|
[
|
||||||
cleanPayload.name,
|
cleanPayload.name,
|
||||||
cleanPayload.details,
|
cleanPayload.details,
|
||||||
|
cleanPayload.ancientArtifactCategoryKey,
|
||||||
|
cleanPayload.basePrice,
|
||||||
cleanPayload.categoryKey,
|
cleanPayload.categoryKey,
|
||||||
cleanPayload.usageKey,
|
cleanPayload.usageKey,
|
||||||
cleanPayload.dyeable,
|
cleanPayload.dyeable,
|
||||||
@@ -6686,6 +6765,7 @@ export async function deleteItem(id: number, userId: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await deleteEntityDiscussionCommentsForEntity(client, 'items', id);
|
await deleteEntityDiscussionCommentsForEntity(client, 'items', id);
|
||||||
|
await deleteEntityDiscussionCommentsForEntity(client, 'ancient-artifacts', id);
|
||||||
await deleteEntityTranslations(client, 'items', id);
|
await deleteEntityTranslations(client, 'items', id);
|
||||||
await recordEditLog(client, 'items', id, 'delete', userId);
|
await recordEditLog(client, 'items', id, 'delete', userId);
|
||||||
return true;
|
return true;
|
||||||
@@ -6693,29 +6773,29 @@ export async function deleteItem(id: number, userId: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ancientArtifactProjection(locale: string): string {
|
function ancientArtifactProjection(locale: string): string {
|
||||||
const artifactName = localizedName('ancient-artifacts', 'a', locale);
|
const artifactName = localizedName('items', 'i', locale);
|
||||||
const artifactDetails = localizedField('ancient-artifacts', 'a.id', 'a.details', 'details', locale);
|
const artifactDetails = localizedField('items', 'i.id', 'i.details', 'details', locale);
|
||||||
const tagName = localizedName('favorite-things', 't', locale);
|
const tagName = localizedName('favorite-things', 't', locale);
|
||||||
|
|
||||||
return `
|
return `
|
||||||
SELECT
|
SELECT
|
||||||
a.id,
|
i.id,
|
||||||
${artifactName} AS name,
|
${artifactName} AS name,
|
||||||
a.name AS "baseName",
|
i.name AS "baseName",
|
||||||
${artifactDetails} AS details,
|
${artifactDetails} AS details,
|
||||||
a.details AS "baseDetails",
|
i.details AS "baseDetails",
|
||||||
${translationsSelect('ancient-artifacts', 'a.id')} AS translations,
|
${translationsSelect('items', 'i.id')} AS translations,
|
||||||
${systemListJsonSql('a.category_key', ancientArtifactCategoryOptions, locale)} AS category,
|
${systemListJsonSql('i.ancient_artifact_category_key', ancientArtifactCategoryOptions, locale)} AS category,
|
||||||
${uploadedImageJson('a.image_path')} AS image,
|
${uploadedImageJson('i.image_path')} AS image,
|
||||||
COALESCE((
|
COALESCE((
|
||||||
SELECT json_agg(json_build_object('id', t.id, 'name', ${tagName}) ORDER BY ${orderByEntity('t')})
|
SELECT json_agg(json_build_object('id', t.id, 'name', ${tagName}) ORDER BY ${orderByEntity('t')})
|
||||||
FROM ancient_artifact_favorite_things aaft
|
FROM item_favorite_things ift
|
||||||
JOIN favorite_things t ON t.id = aaft.favorite_thing_id
|
JOIN favorite_things t ON t.id = ift.favorite_thing_id
|
||||||
WHERE aaft.ancient_artifact_id = a.id
|
WHERE ift.item_id = i.id
|
||||||
), '[]'::json) AS tags,
|
), '[]'::json) AS tags,
|
||||||
${auditSelect('a', 'artifact_created_user', 'artifact_updated_user')}
|
${auditSelect('i', 'item_created_user', 'item_updated_user')}
|
||||||
FROM ancient_artifacts a
|
FROM items i
|
||||||
${auditJoins('a', 'artifact_created_user', 'artifact_updated_user')}
|
${auditJoins('i', 'item_created_user', 'item_updated_user')}
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -6729,23 +6809,25 @@ export async function listAncientArtifacts(paramsQuery: QueryParams = {}, locale
|
|||||||
? systemListOptionById(ancientArtifactCategoryOptions, categoryId, 'server.validation.categoryRequired')
|
? systemListOptionById(ancientArtifactCategoryOptions, categoryId, 'server.validation.categoryRequired')
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
conditions.push('i.ancient_artifact_category_key IS NOT NULL');
|
||||||
|
|
||||||
if (search) {
|
if (search) {
|
||||||
params.push(`%${search}%`);
|
params.push(`%${search}%`);
|
||||||
conditions.push(`${localizedName('ancient-artifacts', 'a', locale)} ILIKE $${params.length}`);
|
conditions.push(`${localizedName('items', 'i', locale)} ILIKE $${params.length}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (categoryOption) {
|
if (categoryOption) {
|
||||||
params.push(categoryOption.key);
|
params.push(categoryOption.key);
|
||||||
conditions.push(`a.category_key = $${params.length}`);
|
conditions.push(`i.ancient_artifact_category_key = $${params.length}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const tagFilter = sqlForRelationFilter(
|
const tagFilter = sqlForRelationFilter(
|
||||||
tagIds,
|
tagIds,
|
||||||
'any',
|
'any',
|
||||||
'ancient_artifact_favorite_things',
|
'item_favorite_things',
|
||||||
'ancient_artifact_id',
|
'item_id',
|
||||||
'favorite_thing_id',
|
'favorite_thing_id',
|
||||||
'a.id',
|
'i.id',
|
||||||
params
|
params
|
||||||
);
|
);
|
||||||
if (tagFilter) {
|
if (tagFilter) {
|
||||||
@@ -6753,17 +6835,17 @@ export async function listAncientArtifacts(paramsQuery: QueryParams = {}, locale
|
|||||||
}
|
}
|
||||||
|
|
||||||
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||||
return query(`${ancientArtifactProjection(locale)} ${whereClause} ORDER BY ${orderByEntity('a')}`, params);
|
return query(`${ancientArtifactProjection(locale)} ${whereClause} ORDER BY ${orderByEntity('i')}`, params);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getAncientArtifact(id: number, locale = defaultLocale) {
|
export async function getAncientArtifact(id: number, locale = defaultLocale) {
|
||||||
const artifact = await queryOne(`${ancientArtifactProjection(locale)} WHERE a.id = $1`, [id]);
|
const artifact = await queryOne(`${ancientArtifactProjection(locale)} WHERE i.id = $1 AND i.ancient_artifact_category_key IS NOT NULL`, [id]);
|
||||||
if (!artifact) {
|
if (!artifact) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const editHistory = await getEditHistory('ancient-artifacts', id);
|
const editHistory = await getEditHistory('items', id);
|
||||||
const imageHistory = await listEntityImageUploads('ancient-artifacts', id);
|
const imageHistory = await listEntityImageUploads('items', id);
|
||||||
return { ...artifact, editHistory, imageHistory };
|
return { ...artifact, editHistory, imageHistory };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -6778,16 +6860,16 @@ function cleanAncientArtifactPayload(payload: Record<string, unknown>): AncientA
|
|||||||
categoryId,
|
categoryId,
|
||||||
categoryKey: category.key,
|
categoryKey: category.key,
|
||||||
tagIds: cleanIds(payload.tagIds),
|
tagIds: cleanIds(payload.tagIds),
|
||||||
imagePath: cleanUploadImagePath(payload.imagePath, 'ancient-artifacts')
|
imagePath: cleanItemOrArtifactImagePath(payload.imagePath)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function replaceAncientArtifactRelations(client: DbClient, artifactId: number, payload: AncientArtifactPayload): Promise<void> {
|
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]);
|
await client.query('DELETE FROM item_favorite_things WHERE item_id = $1', [artifactId]);
|
||||||
|
|
||||||
for (const tagId of payload.tagIds) {
|
for (const tagId of payload.tagIds) {
|
||||||
await client.query(
|
await client.query(
|
||||||
'INSERT INTO ancient_artifact_favorite_things (ancient_artifact_id, favorite_thing_id) VALUES ($1, $2)',
|
'INSERT INTO item_favorite_things (item_id, favorite_thing_id) VALUES ($1, $2)',
|
||||||
[artifactId, tagId]
|
[artifactId, tagId]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -6797,13 +6879,13 @@ export async function createAncientArtifact(payload: Record<string, unknown>, us
|
|||||||
const cleanPayload = cleanAncientArtifactPayload(payload);
|
const cleanPayload = cleanAncientArtifactPayload(payload);
|
||||||
|
|
||||||
const id = await withTransaction(async (client) => {
|
const id = await withTransaction(async (client) => {
|
||||||
const sortOrder = await nextSortOrder(client, 'ancient_artifacts');
|
const sortOrder = await nextSortOrder(client, 'items');
|
||||||
const result = await client.query<{ id: number }>(
|
const result = await client.query<{ id: number }>(
|
||||||
`
|
`
|
||||||
INSERT INTO ancient_artifacts (
|
INSERT INTO items (
|
||||||
name,
|
name,
|
||||||
details,
|
details,
|
||||||
category_key,
|
ancient_artifact_category_key,
|
||||||
image_path,
|
image_path,
|
||||||
sort_order,
|
sort_order,
|
||||||
created_by_user_id,
|
created_by_user_id,
|
||||||
@@ -6822,10 +6904,10 @@ export async function createAncientArtifact(payload: Record<string, unknown>, us
|
|||||||
]
|
]
|
||||||
);
|
);
|
||||||
const artifactId = result.rows[0].id;
|
const artifactId = result.rows[0].id;
|
||||||
await linkEntityImageUpload(client, 'ancient-artifacts', artifactId, cleanPayload.imagePath, cleanPayload.name);
|
await linkEntityImageUpload(client, 'items', artifactId, cleanPayload.imagePath, cleanPayload.name);
|
||||||
await replaceAncientArtifactRelations(client, artifactId, cleanPayload);
|
await replaceAncientArtifactRelations(client, artifactId, cleanPayload);
|
||||||
await replaceEntityTranslations(client, 'ancient-artifacts', artifactId, cleanPayload.translations, ['name', 'details']);
|
await replaceEntityTranslations(client, 'items', artifactId, cleanPayload.translations, ['name', 'details']);
|
||||||
await recordEditLog(client, 'ancient-artifacts', artifactId, 'create', userId);
|
await recordEditLog(client, 'items', artifactId, 'create', userId);
|
||||||
return artifactId;
|
return artifactId;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -6839,14 +6921,15 @@ export async function updateAncientArtifact(id: number, payload: Record<string,
|
|||||||
const updated = await withTransaction(async (client) => {
|
const updated = await withTransaction(async (client) => {
|
||||||
const result = await client.query(
|
const result = await client.query(
|
||||||
`
|
`
|
||||||
UPDATE ancient_artifacts
|
UPDATE items
|
||||||
SET name = $1,
|
SET name = $1,
|
||||||
details = $2,
|
details = $2,
|
||||||
category_key = $3,
|
ancient_artifact_category_key = $3,
|
||||||
image_path = $4,
|
image_path = $4,
|
||||||
updated_by_user_id = $5,
|
updated_by_user_id = $5,
|
||||||
updated_at = now()
|
updated_at = now()
|
||||||
WHERE id = $6
|
WHERE id = $6
|
||||||
|
AND ancient_artifact_category_key IS NOT NULL
|
||||||
`,
|
`,
|
||||||
[cleanPayload.name, cleanPayload.details, cleanPayload.categoryKey, cleanPayload.imagePath, userId, id]
|
[cleanPayload.name, cleanPayload.details, cleanPayload.categoryKey, cleanPayload.imagePath, userId, id]
|
||||||
);
|
);
|
||||||
@@ -6854,11 +6937,11 @@ export async function updateAncientArtifact(id: number, payload: Record<string,
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
await linkEntityImageUpload(client, 'ancient-artifacts', id, cleanPayload.imagePath, cleanPayload.name);
|
await linkEntityImageUpload(client, 'items', id, cleanPayload.imagePath, cleanPayload.name);
|
||||||
await replaceAncientArtifactRelations(client, id, cleanPayload);
|
await replaceAncientArtifactRelations(client, id, cleanPayload);
|
||||||
await replaceEntityTranslations(client, 'ancient-artifacts', id, cleanPayload.translations, ['name', 'details']);
|
await replaceEntityTranslations(client, 'items', id, cleanPayload.translations, ['name', 'details']);
|
||||||
const changes = before ? await ancientArtifactEditChanges(client, before as unknown as AncientArtifactChangeSource, cleanPayload) : [];
|
const changes = before ? await ancientArtifactEditChanges(client, before as unknown as AncientArtifactChangeSource, cleanPayload) : [];
|
||||||
await recordEditLog(client, 'ancient-artifacts', id, 'update', userId, changes);
|
await recordEditLog(client, 'items', id, 'update', userId, changes);
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -6867,14 +6950,14 @@ export async function updateAncientArtifact(id: number, payload: Record<string,
|
|||||||
|
|
||||||
export async function deleteAncientArtifact(id: number, userId: number) {
|
export async function deleteAncientArtifact(id: number, userId: number) {
|
||||||
return withTransaction(async (client) => {
|
return withTransaction(async (client) => {
|
||||||
const result = await client.query<{ id: number }>('DELETE FROM ancient_artifacts WHERE id = $1 RETURNING id', [id]);
|
const result = await client.query<{ id: number }>('DELETE FROM items WHERE id = $1 AND ancient_artifact_category_key IS NOT NULL RETURNING id', [id]);
|
||||||
if (result.rowCount === 0) {
|
if (result.rowCount === 0) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
await deleteEntityDiscussionCommentsForEntity(client, 'ancient-artifacts', id);
|
await deleteEntityDiscussionCommentsForEntity(client, 'ancient-artifacts', id);
|
||||||
await deleteEntityTranslations(client, 'ancient-artifacts', id);
|
await deleteEntityTranslations(client, 'items', id);
|
||||||
await recordEditLog(client, 'ancient-artifacts', id, 'delete', userId);
|
await recordEditLog(client, 'items', id, 'delete', userId);
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -7526,7 +7609,7 @@ export async function reorderDishCategories(payload: Record<string, unknown>, us
|
|||||||
throw validationError('server.validation.selectRecord');
|
throw validationError('server.validation.selectRecord');
|
||||||
}
|
}
|
||||||
await withTransaction(async (client) => {
|
await withTransaction(async (client) => {
|
||||||
await reorderTableRows(client, 'dish_categories', 'dish-categories', ids, userId);
|
await reorderTableRows(client, 'dish_categories', ids, userId);
|
||||||
});
|
});
|
||||||
return listDish(locale);
|
return listDish(locale);
|
||||||
}
|
}
|
||||||
@@ -7537,17 +7620,16 @@ export async function reorderDishes(payload: Record<string, unknown>, userId: nu
|
|||||||
throw validationError('server.validation.selectRecord');
|
throw validationError('server.validation.selectRecord');
|
||||||
}
|
}
|
||||||
await withTransaction(async (client) => {
|
await withTransaction(async (client) => {
|
||||||
await reorderTableRows(client, 'dishes', 'dishes', ids, userId);
|
await reorderTableRows(client, 'dishes', ids, userId);
|
||||||
});
|
});
|
||||||
return listDish(locale);
|
return listDish(locale);
|
||||||
}
|
}
|
||||||
|
|
||||||
const dataToolScopes = ['pokemon', 'habitats', 'items', 'artifacts', '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> = {
|
const dataToolMainTables: Record<Exclude<DataToolScope, 'artifacts'>, string> = {
|
||||||
pokemon: 'pokemon',
|
pokemon: 'pokemon',
|
||||||
habitats: 'habitats',
|
habitats: 'habitats',
|
||||||
items: 'items',
|
items: 'items',
|
||||||
artifacts: 'ancient_artifacts',
|
|
||||||
recipes: 'recipes',
|
recipes: 'recipes',
|
||||||
checklist: 'daily_checklist_items'
|
checklist: 'daily_checklist_items'
|
||||||
};
|
};
|
||||||
@@ -7593,6 +7675,8 @@ const dataToolColumns = {
|
|||||||
'id',
|
'id',
|
||||||
'name',
|
'name',
|
||||||
'details',
|
'details',
|
||||||
|
'base_price',
|
||||||
|
'ancient_artifact_category_key',
|
||||||
'category_key',
|
'category_key',
|
||||||
'usage_key',
|
'usage_key',
|
||||||
'dyeable',
|
'dyeable',
|
||||||
@@ -7609,19 +7693,7 @@ const dataToolColumns = {
|
|||||||
],
|
],
|
||||||
itemAcquisitionMethods: ['item_id', 'acquisition_method_id'],
|
itemAcquisitionMethods: ['item_id', 'acquisition_method_id'],
|
||||||
itemFavoriteThings: ['item_id', 'favorite_thing_id'],
|
itemFavoriteThings: ['item_id', 'favorite_thing_id'],
|
||||||
artifactFavoriteThings: ['ancient_artifact_id', 'favorite_thing_id'],
|
artifacts: [] as string[],
|
||||||
artifacts: [
|
|
||||||
'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'],
|
recipes: ['id', 'item_id', 'sort_order', 'created_by_user_id', 'updated_by_user_id', 'created_at', 'updated_at'],
|
||||||
recipeAcquisitionMethods: ['recipe_id', 'acquisition_method_id'],
|
recipeAcquisitionMethods: ['recipe_id', 'acquisition_method_id'],
|
||||||
recipeMaterials: ['recipe_id', 'item_id', 'quantity'],
|
recipeMaterials: ['recipe_id', 'item_id', 'quantity'],
|
||||||
@@ -7669,6 +7741,7 @@ function normalizeDataToolScopes(scopes: DataToolScope[]): DataToolScope[] {
|
|||||||
const scopeSet = new Set(scopes);
|
const scopeSet = new Set(scopes);
|
||||||
if (scopeSet.has('items')) {
|
if (scopeSet.has('items')) {
|
||||||
scopeSet.add('recipes');
|
scopeSet.add('recipes');
|
||||||
|
scopeSet.delete('artifacts');
|
||||||
}
|
}
|
||||||
return dataToolScopes.filter((scope) => scopeSet.has(scope));
|
return dataToolScopes.filter((scope) => scopeSet.has(scope));
|
||||||
}
|
}
|
||||||
@@ -7727,6 +7800,36 @@ function dataToolDataWithRows(key: string, ...sources: Array<DataToolScopeData |
|
|||||||
return sources.find((source) => source?.[key] !== undefined);
|
return sources.find((source) => source?.[key] !== undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function dataToolArtifactRows(data: DataToolScopeData | undefined): DataToolRows {
|
||||||
|
return dataToolTableRows(data, 'artifacts').map((row) => {
|
||||||
|
if (row.ancient_artifact_category_key !== undefined) {
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...row,
|
||||||
|
base_price: null,
|
||||||
|
ancient_artifact_category_key: row.category_key,
|
||||||
|
category_key: 'other',
|
||||||
|
usage_key: null,
|
||||||
|
dyeable: false,
|
||||||
|
dual_dyeable: false,
|
||||||
|
pattern_editable: false,
|
||||||
|
no_recipe: false,
|
||||||
|
is_event_item: false
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function dataToolArtifactFavoriteThingRows(data: DataToolScopeData | undefined): DataToolRows {
|
||||||
|
const itemRows = dataToolTableRows(data, 'itemFavoriteThings');
|
||||||
|
const artifactRows = dataToolTableRows(data, 'artifactFavoriteThings').map((row) => ({
|
||||||
|
item_id: row.ancient_artifact_id,
|
||||||
|
favorite_thing_id: row.favorite_thing_id
|
||||||
|
}));
|
||||||
|
return [...itemRows, ...artifactRows];
|
||||||
|
}
|
||||||
|
|
||||||
async function tableRows(client: DbClient, sql: string, params: unknown[] = []): Promise<DataToolRows> {
|
async function tableRows(client: DbClient, sql: string, params: unknown[] = []): Promise<DataToolRows> {
|
||||||
const result = await client.query<Record<string, unknown>>(sql, params);
|
const result = await client.query<Record<string, unknown>>(sql, params);
|
||||||
return result.rows;
|
return result.rows;
|
||||||
@@ -7762,6 +7865,27 @@ async function insertRows(client: DbClient, tableName: string, columns: readonly
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function upsertRowsById(client: DbClient, tableName: string, columns: readonly string[], rows: DataToolRows): Promise<void> {
|
||||||
|
const updateColumns = columns.filter((column) => column !== 'id');
|
||||||
|
for (const row of rows) {
|
||||||
|
const placeholders = columns.map((_, index) => `$${index + 1}`).join(', ');
|
||||||
|
const assignments = updateColumns.map((column) => `${column} = EXCLUDED.${column}`).join(', ');
|
||||||
|
const values = columns.map((column) => normalizeImportValue(column, row[column], row));
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO ${tableName} (${columns.join(', ')}) VALUES (${placeholders}) ON CONFLICT (id) DO UPDATE SET ${assignments}`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function insertRowsIgnoreConflicts(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], row));
|
||||||
|
await client.query(`INSERT INTO ${tableName} (${columns.join(', ')}) VALUES (${placeholders}) ON CONFLICT DO NOTHING`, values);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function resetIdentity(client: DbClient, tableName: string): Promise<void> {
|
async function resetIdentity(client: DbClient, tableName: string): Promise<void> {
|
||||||
const result = await client.query<{ maxId: number | null }>(`SELECT MAX(id)::integer AS "maxId" FROM ${tableName}`);
|
const result = await client.query<{ maxId: number | null }>(`SELECT MAX(id)::integer AS "maxId" FROM ${tableName}`);
|
||||||
const maxId = result.rows[0]?.maxId ?? null;
|
const maxId = result.rows[0]?.maxId ?? null;
|
||||||
@@ -7777,7 +7901,6 @@ async function resetDataToolIdentities(client: DbClient): Promise<void> {
|
|||||||
for (const tableName of [
|
for (const tableName of [
|
||||||
'daily_checklist_items',
|
'daily_checklist_items',
|
||||||
'items',
|
'items',
|
||||||
'ancient_artifacts',
|
|
||||||
'recipes',
|
'recipes',
|
||||||
'habitats',
|
'habitats',
|
||||||
'wiki_edit_logs',
|
'wiki_edit_logs',
|
||||||
@@ -7813,9 +7936,17 @@ async function wipeItemsData(client: DbClient): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function wipeAncientArtifactsData(client: DbClient): Promise<void> {
|
async function wipeAncientArtifactsData(client: DbClient): Promise<void> {
|
||||||
await deleteGenericEntityRows(client, ['ancient-artifacts']);
|
await client.query(`
|
||||||
await client.query('DELETE FROM ancient_artifact_favorite_things');
|
DELETE FROM entity_discussion_comments
|
||||||
await client.query('DELETE FROM ancient_artifacts');
|
WHERE entity_type = 'ancient-artifacts'
|
||||||
|
AND entity_id IN (SELECT id FROM items WHERE ancient_artifact_category_key IS NOT NULL)
|
||||||
|
`);
|
||||||
|
await client.query(`
|
||||||
|
UPDATE items
|
||||||
|
SET ancient_artifact_category_key = NULL,
|
||||||
|
updated_at = now()
|
||||||
|
WHERE ancient_artifact_category_key IS NOT NULL
|
||||||
|
`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function wipePokemonData(client: DbClient): Promise<void> {
|
async function wipePokemonData(client: DbClient): Promise<void> {
|
||||||
@@ -7933,12 +8064,73 @@ async function exportScopeData(client: DbClient, scope: DataToolScope): Promise<
|
|||||||
|
|
||||||
if (scope === 'artifacts') {
|
if (scope === 'artifacts') {
|
||||||
return {
|
return {
|
||||||
artifacts: await tableRows(client, 'SELECT * FROM ancient_artifacts ORDER BY sort_order, id'),
|
artifacts: await tableRows(client, 'SELECT * FROM items WHERE ancient_artifact_category_key IS NOT NULL ORDER BY sort_order, id'),
|
||||||
artifactFavoriteThings: await tableRows(
|
itemFavoriteThings: await tableRows(
|
||||||
client,
|
client,
|
||||||
'SELECT * FROM ancient_artifact_favorite_things ORDER BY ancient_artifact_id, favorite_thing_id'
|
`
|
||||||
|
SELECT ift.*
|
||||||
|
FROM item_favorite_things ift
|
||||||
|
JOIN items i ON i.id = ift.item_id
|
||||||
|
WHERE i.ancient_artifact_category_key IS NOT NULL
|
||||||
|
ORDER BY ift.item_id, ift.favorite_thing_id
|
||||||
|
`
|
||||||
),
|
),
|
||||||
...(await exportGenericScopeData(client, 'ancient-artifacts', true))
|
translations: await tableRows(
|
||||||
|
client,
|
||||||
|
`
|
||||||
|
SELECT et.*
|
||||||
|
FROM entity_translations et
|
||||||
|
JOIN items i ON i.id = et.entity_id
|
||||||
|
WHERE et.entity_type = 'items'
|
||||||
|
AND i.ancient_artifact_category_key IS NOT NULL
|
||||||
|
ORDER BY et.entity_id, et.locale, et.field_name
|
||||||
|
`
|
||||||
|
),
|
||||||
|
editLogs: await tableRows(
|
||||||
|
client,
|
||||||
|
`
|
||||||
|
SELECT wel.*
|
||||||
|
FROM wiki_edit_logs wel
|
||||||
|
JOIN items i ON i.id = wel.entity_id
|
||||||
|
WHERE wel.entity_type = 'items'
|
||||||
|
AND i.ancient_artifact_category_key IS NOT NULL
|
||||||
|
ORDER BY wel.id
|
||||||
|
`
|
||||||
|
),
|
||||||
|
imageUploads: await tableRows(
|
||||||
|
client,
|
||||||
|
`
|
||||||
|
SELECT eiu.*
|
||||||
|
FROM entity_image_uploads eiu
|
||||||
|
JOIN items i ON i.id = eiu.entity_id
|
||||||
|
WHERE eiu.entity_type = 'items'
|
||||||
|
AND i.ancient_artifact_category_key IS NOT NULL
|
||||||
|
ORDER BY eiu.id
|
||||||
|
`
|
||||||
|
),
|
||||||
|
discussionComments: await tableRows(
|
||||||
|
client,
|
||||||
|
`
|
||||||
|
SELECT edc.*
|
||||||
|
FROM entity_discussion_comments edc
|
||||||
|
JOIN items i ON i.id = edc.entity_id
|
||||||
|
WHERE edc.entity_type = 'ancient-artifacts'
|
||||||
|
AND i.ancient_artifact_category_key IS NOT NULL
|
||||||
|
ORDER BY edc.parent_comment_id NULLS FIRST, edc.id
|
||||||
|
`
|
||||||
|
),
|
||||||
|
discussionCommentLikes: await tableRows(
|
||||||
|
client,
|
||||||
|
`
|
||||||
|
SELECT edcl.*
|
||||||
|
FROM entity_discussion_comment_likes edcl
|
||||||
|
JOIN entity_discussion_comments edc ON edc.id = edcl.comment_id
|
||||||
|
JOIN items i ON i.id = edc.entity_id
|
||||||
|
WHERE edc.entity_type = 'ancient-artifacts'
|
||||||
|
AND i.ancient_artifact_category_key IS NOT NULL
|
||||||
|
ORDER BY edcl.comment_id, edcl.user_id
|
||||||
|
`
|
||||||
|
)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -7971,7 +8163,7 @@ async function importScopeMainRows(client: DbClient, bundle: DataToolsBundle): P
|
|||||||
const recipeData = bundle.data.recipes;
|
const recipeData = bundle.data.recipes;
|
||||||
|
|
||||||
await insertRows(client, 'items', dataToolColumns.items, dataToolTableRows(itemData, 'items'));
|
await insertRows(client, 'items', dataToolColumns.items, dataToolTableRows(itemData, 'items'));
|
||||||
await insertRows(client, 'ancient_artifacts', dataToolColumns.artifacts, dataToolTableRows(artifactData, 'artifacts'));
|
await upsertRowsById(client, 'items', dataToolColumns.items, dataToolArtifactRows(artifactData));
|
||||||
await insertRows(client, 'pokemon', dataToolColumns.pokemon, dataToolTableRows(pokemonData, 'pokemon'));
|
await insertRows(client, 'pokemon', dataToolColumns.pokemon, dataToolTableRows(pokemonData, 'pokemon'));
|
||||||
await insertRows(client, 'habitats', dataToolColumns.habitats, dataToolTableRows(habitatData, 'habitats'));
|
await insertRows(client, 'habitats', dataToolColumns.habitats, dataToolTableRows(habitatData, 'habitats'));
|
||||||
await insertRows(client, 'daily_checklist_items', dataToolColumns.checklist, dataToolTableRows(checklistData, 'checklist'));
|
await insertRows(client, 'daily_checklist_items', dataToolColumns.checklist, dataToolTableRows(checklistData, 'checklist'));
|
||||||
@@ -7990,12 +8182,7 @@ async function importScopeRelationRows(client: DbClient, bundle: DataToolsBundle
|
|||||||
|
|
||||||
await insertRows(client, 'item_acquisition_methods', dataToolColumns.itemAcquisitionMethods, dataToolTableRows(itemData, 'itemAcquisitionMethods'));
|
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, 'item_favorite_things', dataToolColumns.itemFavoriteThings, dataToolTableRows(itemData, 'itemFavoriteThings'));
|
||||||
await insertRows(
|
await insertRowsIgnoreConflicts(client, 'item_favorite_things', dataToolColumns.itemFavoriteThings, dataToolArtifactFavoriteThingRows(artifactData));
|
||||||
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_pokemon_types', dataToolColumns.pokemonTypeLinks, dataToolTableRows(pokemonData, 'pokemonTypeLinks'));
|
||||||
await insertRows(client, 'pokemon_skills', dataToolColumns.pokemonSkills, dataToolTableRows(pokemonData, 'pokemonSkills'));
|
await insertRows(client, 'pokemon_skills', dataToolColumns.pokemonSkills, dataToolTableRows(pokemonData, 'pokemonSkills'));
|
||||||
await insertRows(client, 'pokemon_favorite_things', dataToolColumns.pokemonFavoriteThings, dataToolTableRows(pokemonData, 'pokemonFavoriteThings'));
|
await insertRows(client, 'pokemon_favorite_things', dataToolColumns.pokemonFavoriteThings, dataToolTableRows(pokemonData, 'pokemonFavoriteThings'));
|
||||||
@@ -8032,7 +8219,11 @@ async function importDataToolsBundle(client: DbClient, bundle: DataToolsBundle):
|
|||||||
export async function getAdminDataToolsSummary(): Promise<{ scopes: DataToolScopeSummary[] }> {
|
export async function getAdminDataToolsSummary(): Promise<{ scopes: DataToolScopeSummary[] }> {
|
||||||
const scopes: DataToolScopeSummary[] = [];
|
const scopes: DataToolScopeSummary[] = [];
|
||||||
for (const scope of dataToolScopes) {
|
for (const scope of dataToolScopes) {
|
||||||
const result = await queryOne<{ count: number }>(`SELECT COUNT(*)::integer AS count FROM ${dataToolMainTables[scope]}`);
|
const result = await queryOne<{ count: number }>(
|
||||||
|
scope === 'artifacts'
|
||||||
|
? 'SELECT COUNT(*)::integer AS count FROM items WHERE ancient_artifact_category_key IS NOT NULL'
|
||||||
|
: `SELECT COUNT(*)::integer AS count FROM ${dataToolMainTables[scope]}`
|
||||||
|
);
|
||||||
scopes.push({ scope, count: result?.count ?? 0 });
|
scopes.push({ scope, count: result?.count ?? 0 });
|
||||||
}
|
}
|
||||||
return { scopes };
|
return { scopes };
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import type { EditHistoryAction, EditHistoryEntry, EditInfo, UserSummary } from '../services/api';
|
import type { EditHistoryAction, EditHistoryEntry, EditInfo, UserSummary } from '../services/api';
|
||||||
|
|
||||||
defineProps<{
|
const props = defineProps<{
|
||||||
entity: EditInfo;
|
entity: EditInfo;
|
||||||
history: EditHistoryEntry[];
|
history: EditHistoryEntry[];
|
||||||
}>();
|
}>();
|
||||||
@@ -49,6 +49,9 @@ const changeLabelKeys: Record<string, string> = {
|
|||||||
分类: 'pages.items.category',
|
分类: 'pages.items.category',
|
||||||
Usage: 'pages.items.usage',
|
Usage: 'pages.items.usage',
|
||||||
用途: 'pages.items.usage',
|
用途: 'pages.items.usage',
|
||||||
|
'Base Price': 'pages.items.basePrice',
|
||||||
|
'Base price': 'pages.items.basePrice',
|
||||||
|
基础价格: 'pages.items.basePrice',
|
||||||
Dyeable: 'pages.items.dyeable',
|
Dyeable: 'pages.items.dyeable',
|
||||||
可染色: 'pages.items.dyeable',
|
可染色: 'pages.items.dyeable',
|
||||||
'Dual dyeable': 'pages.items.dualDyeable',
|
'Dual dyeable': 'pages.items.dualDyeable',
|
||||||
@@ -118,7 +121,11 @@ function changeValue(value: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function visibleChanges(entry: EditHistoryEntry) {
|
function visibleChanges(entry: EditHistoryEntry) {
|
||||||
return entry.changes.filter((change) => change.label !== 'Display ID');
|
return entry.changes.filter((change) => change.label !== 'Display ID' && change.label !== 'Sort order' && change.label !== '排序');
|
||||||
|
}
|
||||||
|
|
||||||
|
function visibleHistoryEntries() {
|
||||||
|
return props.history.filter((entry) => entry.action !== 'update' || visibleChanges(entry).length > 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
function historySummary(entry: EditHistoryEntry): string {
|
function historySummary(entry: EditHistoryEntry): string {
|
||||||
@@ -148,29 +155,29 @@ function formatDateTime(value: string): string {
|
|||||||
<div>
|
<div>
|
||||||
<dt>{{ t('history.createdBy') }}</dt>
|
<dt>{{ t('history.createdBy') }}</dt>
|
||||||
<dd>
|
<dd>
|
||||||
<RouterLink v-if="entity.createdBy" class="user-profile-link" :to="`/profile/${entity.createdBy.id}`">
|
<RouterLink v-if="props.entity.createdBy" class="user-profile-link" :to="`/profile/${props.entity.createdBy.id}`">
|
||||||
{{ entity.createdBy.displayName }}
|
{{ props.entity.createdBy.displayName }}
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
<strong v-else>{{ displayName(entity.createdBy) }}</strong>
|
<strong v-else>{{ displayName(props.entity.createdBy) }}</strong>
|
||||||
<time :datetime="entity.createdAt">{{ formatDateTime(entity.createdAt) }}</time>
|
<time :datetime="props.entity.createdAt">{{ formatDateTime(props.entity.createdAt) }}</time>
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt>{{ t('history.lastEdited') }}</dt>
|
<dt>{{ t('history.lastEdited') }}</dt>
|
||||||
<dd>
|
<dd>
|
||||||
<RouterLink v-if="entity.updatedBy" class="user-profile-link" :to="`/profile/${entity.updatedBy.id}`">
|
<RouterLink v-if="props.entity.updatedBy" class="user-profile-link" :to="`/profile/${props.entity.updatedBy.id}`">
|
||||||
{{ entity.updatedBy.displayName }}
|
{{ props.entity.updatedBy.displayName }}
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
<strong v-else>{{ displayName(entity.updatedBy) }}</strong>
|
<strong v-else>{{ displayName(props.entity.updatedBy) }}</strong>
|
||||||
<time :datetime="entity.updatedAt">{{ formatDateTime(entity.updatedAt) }}</time>
|
<time :datetime="props.entity.updatedAt">{{ formatDateTime(props.entity.updatedAt) }}</time>
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
|
|
||||||
<section class="edit-history-list" aria-labelledby="edit-history-list-title">
|
<section class="edit-history-list" aria-labelledby="edit-history-list-title">
|
||||||
<h3 id="edit-history-list-title">{{ t('history.editHistory') }}</h3>
|
<h3 id="edit-history-list-title">{{ t('history.editHistory') }}</h3>
|
||||||
<ol v-if="history.length" class="edit-timeline">
|
<ol v-if="visibleHistoryEntries().length" class="edit-timeline">
|
||||||
<li v-for="entry in history" :key="`${entry.action}-${entry.createdAt}-${entry.user?.id ?? 'system'}`">
|
<li v-for="entry in visibleHistoryEntries()" :key="`${entry.action}-${entry.createdAt}-${entry.user?.id ?? 'system'}`">
|
||||||
<span class="edit-timeline__avatar" aria-hidden="true">{{ actionMark(entry.action) }}</span>
|
<span class="edit-timeline__avatar" aria-hidden="true">{{ actionMark(entry.action) }}</span>
|
||||||
<div class="edit-timeline__body">
|
<div class="edit-timeline__body">
|
||||||
<details class="edit-history-entry">
|
<details class="edit-history-entry">
|
||||||
|
|||||||
@@ -33,12 +33,14 @@ const props = withDefaults(
|
|||||||
creating?: boolean;
|
creating?: boolean;
|
||||||
createLabel?: string;
|
createLabel?: string;
|
||||||
dropdownStrategy?: DropdownStrategy;
|
dropdownStrategy?: DropdownStrategy;
|
||||||
|
clearable?: boolean;
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
multiple: true,
|
multiple: true,
|
||||||
max: 0,
|
max: 0,
|
||||||
allowCreate: false,
|
allowCreate: false,
|
||||||
creating: false
|
creating: false,
|
||||||
|
clearable: false
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -167,6 +169,12 @@ function updateValue(values: string[]) {
|
|||||||
|
|
||||||
function selectOption(value: string) {
|
function selectOption(value: string) {
|
||||||
if (!props.multiple) {
|
if (!props.multiple) {
|
||||||
|
if (props.clearable && selectedValues.value.has(value)) {
|
||||||
|
updateValue([]);
|
||||||
|
closeDropdown();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
updateValue([value]);
|
updateValue([value]);
|
||||||
closeDropdown();
|
closeDropdown();
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import HabitatDetail from '../views/HabitatDetail.vue';
|
|||||||
import ItemsList from '../views/ItemsList.vue';
|
import ItemsList from '../views/ItemsList.vue';
|
||||||
import ItemDetail from '../views/ItemDetail.vue';
|
import ItemDetail from '../views/ItemDetail.vue';
|
||||||
import AncientArtifactList from '../views/AncientArtifactList.vue';
|
import AncientArtifactList from '../views/AncientArtifactList.vue';
|
||||||
import AncientArtifactDetail from '../views/AncientArtifactDetail.vue';
|
|
||||||
import RecipeList from '../views/RecipeList.vue';
|
import RecipeList from '../views/RecipeList.vue';
|
||||||
import RecipeDetail from '../views/RecipeDetail.vue';
|
import RecipeDetail from '../views/RecipeDetail.vue';
|
||||||
import DailyChecklistView from '../views/DailyChecklistView.vue';
|
import DailyChecklistView from '../views/DailyChecklistView.vue';
|
||||||
@@ -200,7 +199,7 @@ export const router = createRouter({
|
|||||||
name: 'ancient-artifact-new',
|
name: 'ancient-artifact-new',
|
||||||
component: AncientArtifactList,
|
component: AncientArtifactList,
|
||||||
meta: {
|
meta: {
|
||||||
requiredPermission: 'ancient-artifacts.create',
|
requiredPermission: 'items.create',
|
||||||
editorModal: true,
|
editorModal: true,
|
||||||
seo: seo({
|
seo: seo({
|
||||||
titleKey: 'pages.ancientArtifacts.newTitle',
|
titleKey: 'pages.ancientArtifacts.newTitle',
|
||||||
@@ -213,9 +212,9 @@ export const router = createRouter({
|
|||||||
{
|
{
|
||||||
path: '/ancient-artifacts/:id/edit',
|
path: '/ancient-artifacts/:id/edit',
|
||||||
name: 'ancient-artifact-edit',
|
name: 'ancient-artifact-edit',
|
||||||
component: AncientArtifactDetail,
|
component: ItemDetail,
|
||||||
meta: {
|
meta: {
|
||||||
requiredPermission: 'ancient-artifacts.update',
|
requiredPermission: 'items.update',
|
||||||
editorModal: true,
|
editorModal: true,
|
||||||
seo: seo({
|
seo: seo({
|
||||||
titleKey: 'pages.ancientArtifacts.editKicker',
|
titleKey: 'pages.ancientArtifacts.editKicker',
|
||||||
@@ -228,7 +227,7 @@ export const router = createRouter({
|
|||||||
{
|
{
|
||||||
path: '/ancient-artifacts/:id',
|
path: '/ancient-artifacts/:id',
|
||||||
name: 'ancient-artifact-detail',
|
name: 'ancient-artifact-detail',
|
||||||
component: AncientArtifactDetail,
|
component: ItemDetail,
|
||||||
meta: { seo: seo({ titleKey: 'pages.ancientArtifacts.detailKicker', descriptionKey: 'pages.ancientArtifacts.subtitle' }) }
|
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', name: 'recipe-list', component: RecipeList, meta: { seo: seo({ titleKey: 'pages.recipes.title', descriptionKey: 'pages.recipes.subtitle' }) } },
|
||||||
|
|||||||
@@ -257,6 +257,8 @@ export interface Item extends EditInfo {
|
|||||||
baseName?: string;
|
baseName?: string;
|
||||||
details: string;
|
details: string;
|
||||||
baseDetails?: string;
|
baseDetails?: string;
|
||||||
|
basePrice: number | null;
|
||||||
|
ancientArtifactCategory: NamedEntity | null;
|
||||||
isEventItem: boolean;
|
isEventItem: boolean;
|
||||||
translations?: TranslationMap;
|
translations?: TranslationMap;
|
||||||
image: EntityImage | null;
|
image: EntityImage | null;
|
||||||
@@ -789,6 +791,8 @@ export interface PokemonImageOptionsResult {
|
|||||||
export interface ItemPayload {
|
export interface ItemPayload {
|
||||||
name: string;
|
name: string;
|
||||||
details: string;
|
details: string;
|
||||||
|
basePrice: number | null;
|
||||||
|
ancientArtifactCategoryId: number | null;
|
||||||
translations?: TranslationMap;
|
translations?: TranslationMap;
|
||||||
categoryId: number;
|
categoryId: number;
|
||||||
usageId: number | null;
|
usageId: number | null;
|
||||||
|
|||||||
@@ -1,166 +0,0 @@
|
|||||||
<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.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.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>
|
|
||||||
@@ -1,261 +0,0 @@
|
|||||||
<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({
|
|
||||||
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 = {
|
|
||||||
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 = {
|
|
||||||
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
|
|
||||||
/>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
@@ -11,7 +11,7 @@ import Tabs, { type TabOption } from '../components/Tabs.vue';
|
|||||||
import TagsSelect from '../components/TagsSelect.vue';
|
import TagsSelect from '../components/TagsSelect.vue';
|
||||||
import { iconAdd, iconArtifact } from '../icons';
|
import { iconAdd, iconArtifact } from '../icons';
|
||||||
import { api, getAuthToken, type AncientArtifact, type AuthUser, type Options } from '../services/api';
|
import { api, getAuthToken, type AncientArtifact, type AuthUser, type Options } from '../services/api';
|
||||||
import AncientArtifactEdit from './AncientArtifactEdit.vue';
|
import ItemEdit from './ItemEdit.vue';
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
@@ -37,7 +37,7 @@ const artifactQuery = computed(() => ({
|
|||||||
tagIds: tagIds.value.join(',')
|
tagIds: tagIds.value.join(',')
|
||||||
}));
|
}));
|
||||||
const showEditor = computed(() => route.name === 'ancient-artifact-new');
|
const showEditor = computed(() => route.name === 'ancient-artifact-new');
|
||||||
const canCreateArtifact = computed(() => currentUser.value?.permissions.includes('ancient-artifacts.create') === true);
|
const canCreateArtifact = computed(() => currentUser.value?.permissions.includes('items.create') === true);
|
||||||
|
|
||||||
function artifactCardImage(artifact: AncientArtifact) {
|
function artifactCardImage(artifact: AncientArtifact) {
|
||||||
return artifact.image ? { src: artifact.image.url, alt: t('media.imageAlt', { name: artifact.name }) } : undefined;
|
return artifact.image ? { src: artifact.image.url, alt: t('media.imageAlt', { name: artifact.name }) } : undefined;
|
||||||
@@ -139,6 +139,6 @@ watch(artifactQuery, loadArtifacts);
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AncientArtifactEdit v-if="showEditor" />
|
<ItemEdit v-if="showEditor" />
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { Icon } from '@iconify/vue';
|
import { Icon } from '@iconify/vue';
|
||||||
import { computed, onMounted, ref, watch } from 'vue';
|
import { computed, onMounted, ref, watch } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { useRoute } from 'vue-router';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
import DetailSection from '../components/DetailSection.vue';
|
import DetailSection from '../components/DetailSection.vue';
|
||||||
import EditHistoryPanel from '../components/EditHistoryPanel.vue';
|
import EditHistoryPanel from '../components/EditHistoryPanel.vue';
|
||||||
import EntityDiscussionPanel from '../components/EntityDiscussionPanel.vue';
|
import EntityDiscussionPanel from '../components/EntityDiscussionPanel.vue';
|
||||||
@@ -17,11 +17,13 @@ import { api, getAuthToken, type AuthUser, type ItemDetail } from '../services/a
|
|||||||
import ItemEdit from './ItemEdit.vue';
|
import ItemEdit from './ItemEdit.vue';
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const { t } = useI18n();
|
const router = useRouter();
|
||||||
|
const { locale, t } = useI18n();
|
||||||
const item = ref<ItemDetail | null>(null);
|
const item = ref<ItemDetail | null>(null);
|
||||||
const currentUser = ref<AuthUser | null>(null);
|
const currentUser = ref<AuthUser | null>(null);
|
||||||
const detailTab = ref('details');
|
const detailTab = ref('details');
|
||||||
const showEditor = computed(() => route.name === 'item-edit');
|
const isAncientArtifactRoute = computed(() => route.name === 'ancient-artifact-detail' || route.name === 'ancient-artifact-edit');
|
||||||
|
const showEditor = computed(() => route.name === 'item-edit' || route.name === 'ancient-artifact-edit');
|
||||||
const canUpdateItem = computed(() => currentUser.value?.permissions.includes('items.update') === true);
|
const canUpdateItem = computed(() => currentUser.value?.permissions.includes('items.update') === true);
|
||||||
const canCreateRecipe = computed(() => currentUser.value?.permissions.includes('recipes.create') === true);
|
const canCreateRecipe = computed(() => currentUser.value?.permissions.includes('recipes.create') === true);
|
||||||
const detailTabs = computed<TabOption[]>(() => [
|
const detailTabs = computed<TabOption[]>(() => [
|
||||||
@@ -36,8 +38,30 @@ const itemSubtitle = computed(() => {
|
|||||||
|
|
||||||
return item.value.usage ? `${item.value.category.name} · ${item.value.usage.name}` : item.value.category.name;
|
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 detailKicker = computed(() =>
|
||||||
const listTarget = computed(() => (item.value?.isEventItem ? '/event-items' : '/items'));
|
isAncientArtifactRoute.value
|
||||||
|
? t('pages.ancientArtifacts.detailKicker')
|
||||||
|
: item.value?.isEventItem
|
||||||
|
? t('pages.eventItems.detailKicker')
|
||||||
|
: t('pages.items.detailKicker')
|
||||||
|
);
|
||||||
|
const listTarget = computed(() => (isAncientArtifactRoute.value ? '/ancient-artifacts' : item.value?.isEventItem ? '/event-items' : '/items'));
|
||||||
|
const editTarget = computed(() =>
|
||||||
|
item.value ? (isAncientArtifactRoute.value ? `/ancient-artifacts/${item.value.id}/edit` : `/items/${item.value.id}/edit`) : ''
|
||||||
|
);
|
||||||
|
const detailCanonicalPath = computed(() =>
|
||||||
|
item.value ? (isAncientArtifactRoute.value ? `/ancient-artifacts/${item.value.id}` : `/items/${item.value.id}`) : ''
|
||||||
|
);
|
||||||
|
const detailTitleKey = computed(() =>
|
||||||
|
isAncientArtifactRoute.value ? 'pages.ancientArtifacts.title' : item.value?.isEventItem ? 'pages.eventItems.title' : 'pages.items.title'
|
||||||
|
);
|
||||||
|
const detailDescriptionKey = computed(() =>
|
||||||
|
isAncientArtifactRoute.value ? 'seo.ancientArtifactDetailDescription' : 'seo.itemDetailDescription'
|
||||||
|
);
|
||||||
|
const basePriceDisplay = computed(() => {
|
||||||
|
const price = item.value?.basePrice;
|
||||||
|
return price === null || price === undefined ? t('common.none') : new Intl.NumberFormat(locale.value).format(price);
|
||||||
|
});
|
||||||
|
|
||||||
const customization = computed(() => {
|
const customization = computed(() => {
|
||||||
if (!item.value) {
|
if (!item.value) {
|
||||||
@@ -53,13 +77,19 @@ const customization = computed(() => {
|
|||||||
|
|
||||||
async function loadItemDetail() {
|
async function loadItemDetail() {
|
||||||
const nextItem = await api.itemDetail(String(route.params.id));
|
const nextItem = await api.itemDetail(String(route.params.id));
|
||||||
|
|
||||||
|
if (isAncientArtifactRoute.value && !nextItem.ancientArtifactCategory) {
|
||||||
|
await router.replace(route.name === 'ancient-artifact-edit' ? `/items/${nextItem.id}/edit` : `/items/${nextItem.id}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
item.value = nextItem;
|
item.value = nextItem;
|
||||||
|
|
||||||
if (route.meta.editorModal !== true) {
|
if (route.meta.editorModal !== true) {
|
||||||
applySeo({
|
applySeo({
|
||||||
title: `${nextItem.name} - ${t(nextItem.isEventItem ? 'pages.eventItems.title' : 'pages.items.title')}`,
|
title: `${nextItem.name} - ${t(detailTitleKey.value)}`,
|
||||||
description: t('seo.itemDetailDescription', { name: nextItem.name }),
|
description: t(detailDescriptionKey.value, { name: nextItem.name }),
|
||||||
canonicalPath: `/items/${nextItem.id}`,
|
canonicalPath: detailCanonicalPath.value,
|
||||||
image: nextItem.image?.url
|
image: nextItem.image?.url
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -79,7 +109,10 @@ onMounted(async () => {
|
|||||||
watch(
|
watch(
|
||||||
() => route.name,
|
() => route.name,
|
||||||
(name, oldName) => {
|
(name, oldName) => {
|
||||||
if (oldName === 'item-edit' && name === 'item-detail') {
|
if (
|
||||||
|
(oldName === 'item-edit' && name === 'item-detail') ||
|
||||||
|
(oldName === 'ancient-artifact-edit' && name === 'ancient-artifact-detail')
|
||||||
|
) {
|
||||||
void loadItemDetail();
|
void loadItemDetail();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -152,7 +185,7 @@ watch(
|
|||||||
<PageHeader :title="item.name" :subtitle="itemSubtitle">
|
<PageHeader :title="item.name" :subtitle="itemSubtitle">
|
||||||
<template #kicker>{{ detailKicker }}</template>
|
<template #kicker>{{ detailKicker }}</template>
|
||||||
<template #actions>
|
<template #actions>
|
||||||
<RouterLink v-if="canUpdateItem" class="ui-button ui-button--primary ui-button--small" :to="`/items/${item.id}/edit`">
|
<RouterLink v-if="canUpdateItem" class="ui-button ui-button--primary ui-button--small" :to="editTarget">
|
||||||
<Icon :icon="iconEdit" class="ui-icon" aria-hidden="true" />
|
<Icon :icon="iconEdit" class="ui-icon" aria-hidden="true" />
|
||||||
{{ t('common.edit') }}
|
{{ t('common.edit') }}
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
@@ -190,6 +223,14 @@ watch(
|
|||||||
<dt>{{ t('pages.items.usage') }}</dt>
|
<dt>{{ t('pages.items.usage') }}</dt>
|
||||||
<dd>{{ item.usage?.name ?? t('common.none') }}</dd>
|
<dd>{{ item.usage?.name ?? t('common.none') }}</dd>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>{{ t('pages.items.basePrice') }}</dt>
|
||||||
|
<dd>{{ basePriceDisplay }}</dd>
|
||||||
|
</div>
|
||||||
|
<div v-if="item.ancientArtifactCategory">
|
||||||
|
<dt>{{ t('pages.items.ancientArtifact') }}</dt>
|
||||||
|
<dd>{{ item.ancientArtifactCategory.name }}</dd>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt>{{ t('pages.items.recipeInfo') }}</dt>
|
<dt>{{ t('pages.items.recipeInfo') }}</dt>
|
||||||
<dd>{{ item.noRecipe ? t('pages.items.noRecipe') : item.recipe ? item.recipe.name : t('common.none') }}</dd>
|
<dd>{{ item.noRecipe ? t('pages.items.noRecipe') : item.recipe ? item.recipe.name : t('common.none') }}</dd>
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ const creatingSelect = ref('');
|
|||||||
const itemForm = ref({
|
const itemForm = ref({
|
||||||
name: '',
|
name: '',
|
||||||
details: '',
|
details: '',
|
||||||
|
basePrice: '',
|
||||||
|
ancientArtifactCategoryId: '',
|
||||||
translations: {} as TranslationMap,
|
translations: {} as TranslationMap,
|
||||||
categoryId: '',
|
categoryId: '',
|
||||||
usageId: '',
|
usageId: '',
|
||||||
@@ -53,6 +55,7 @@ const itemForm = ref({
|
|||||||
|
|
||||||
type ItemCreateDefaults = {
|
type ItemCreateDefaults = {
|
||||||
categoryId: string;
|
categoryId: string;
|
||||||
|
usageId: string;
|
||||||
dyeable: boolean;
|
dyeable: boolean;
|
||||||
dualDyeable: boolean;
|
dualDyeable: boolean;
|
||||||
patternEditable: boolean;
|
patternEditable: boolean;
|
||||||
@@ -65,18 +68,39 @@ const itemCreateDefaultsStorageKey = 'pokopia_item_create_defaults';
|
|||||||
const routeId = computed(() => (typeof route.params.id === 'string' ? route.params.id : ''));
|
const routeId = computed(() => (typeof route.params.id === 'string' ? route.params.id : ''));
|
||||||
const isEditing = computed(() => routeId.value !== '');
|
const isEditing = computed(() => routeId.value !== '');
|
||||||
const isEventCreate = computed(() => route.name === 'event-item-new');
|
const isEventCreate = computed(() => route.name === 'event-item-new');
|
||||||
|
const isAncientArtifactRoute = computed(() => route.name === 'ancient-artifact-new' || route.name === 'ancient-artifact-edit');
|
||||||
|
const isAncientArtifactCreate = computed(() => route.name === 'ancient-artifact-new');
|
||||||
const insertBeforeItemId = computed(() => queryItemId(route.query.insertBeforeItemId));
|
const insertBeforeItemId = computed(() => queryItemId(route.query.insertBeforeItemId));
|
||||||
const insertAfterItemId = computed(() => queryItemId(route.query.insertAfterItemId));
|
const insertAfterItemId = computed(() => queryItemId(route.query.insertAfterItemId));
|
||||||
const pageTitle = computed(() =>
|
const pageTitle = computed(() =>
|
||||||
isEditing.value
|
isEditing.value
|
||||||
? t('pages.items.editTitle', { name: itemForm.value.name || t('pages.items.fallbackName') })
|
? isAncientArtifactRoute.value
|
||||||
|
? t('pages.ancientArtifacts.editTitle', { name: itemForm.value.name || t('pages.ancientArtifacts.fallbackName') })
|
||||||
|
: t('pages.items.editTitle', { name: itemForm.value.name || t('pages.items.fallbackName') })
|
||||||
|
: isAncientArtifactCreate.value
|
||||||
|
? t('pages.ancientArtifacts.newTitle')
|
||||||
: isEventCreate.value
|
: isEventCreate.value
|
||||||
? t('pages.eventItems.newTitle')
|
? t('pages.eventItems.newTitle')
|
||||||
: t('pages.items.newTitle')
|
: t('pages.items.newTitle')
|
||||||
);
|
);
|
||||||
const cancelTo = computed(() => (isEditing.value ? `/items/${routeId.value}` : isEventCreate.value ? '/event-items' : '/items'));
|
const pageSubtitle = computed(() => (isAncientArtifactRoute.value ? t('pages.ancientArtifacts.editSubtitle') : t('pages.items.editSubtitle')));
|
||||||
|
const cancelTo = computed(() =>
|
||||||
|
isEditing.value
|
||||||
|
? isAncientArtifactRoute.value
|
||||||
|
? `/ancient-artifacts/${routeId.value}`
|
||||||
|
: `/items/${routeId.value}`
|
||||||
|
: isAncientArtifactCreate.value
|
||||||
|
? '/ancient-artifacts'
|
||||||
|
: isEventCreate.value
|
||||||
|
? '/event-items'
|
||||||
|
: '/items'
|
||||||
|
);
|
||||||
const hasRecipe = ref(false);
|
const hasRecipe = ref(false);
|
||||||
const imageEntityName = computed(() => itemNameForSave().trim());
|
const imageEntityName = computed(() => itemNameForSave().trim());
|
||||||
|
const ancientArtifactOptions = computed(() => [
|
||||||
|
{ value: '', label: t('common.no') },
|
||||||
|
...(options.value?.ancientArtifactCategories.map((item) => ({ value: String(item.id), label: item.name })) ?? [])
|
||||||
|
]);
|
||||||
const canCreateConfig = computed(() => currentUser.value?.permissions.includes('admin.config.create') === true);
|
const canCreateConfig = computed(() => currentUser.value?.permissions.includes('admin.config.create') === true);
|
||||||
const canUploadImage = computed(() => currentUser.value?.permissions.includes('items.upload') === true);
|
const canUploadImage = computed(() => currentUser.value?.permissions.includes('items.upload') === true);
|
||||||
|
|
||||||
@@ -98,6 +122,7 @@ function readItemCreateDefaults(): ItemCreateDefaults {
|
|||||||
if (typeof sessionStorage === 'undefined') {
|
if (typeof sessionStorage === 'undefined') {
|
||||||
return {
|
return {
|
||||||
categoryId: '',
|
categoryId: '',
|
||||||
|
usageId: '',
|
||||||
dyeable: false,
|
dyeable: false,
|
||||||
dualDyeable: false,
|
dualDyeable: false,
|
||||||
patternEditable: false,
|
patternEditable: false,
|
||||||
@@ -111,6 +136,7 @@ function readItemCreateDefaults(): ItemCreateDefaults {
|
|||||||
if (!rawValue) {
|
if (!rawValue) {
|
||||||
return {
|
return {
|
||||||
categoryId: '',
|
categoryId: '',
|
||||||
|
usageId: '',
|
||||||
dyeable: false,
|
dyeable: false,
|
||||||
dualDyeable: false,
|
dualDyeable: false,
|
||||||
patternEditable: false,
|
patternEditable: false,
|
||||||
@@ -122,6 +148,7 @@ function readItemCreateDefaults(): ItemCreateDefaults {
|
|||||||
const parsedValue = JSON.parse(rawValue) as Partial<ItemCreateDefaults>;
|
const parsedValue = JSON.parse(rawValue) as Partial<ItemCreateDefaults>;
|
||||||
return {
|
return {
|
||||||
categoryId: typeof parsedValue.categoryId === 'string' ? parsedValue.categoryId : '',
|
categoryId: typeof parsedValue.categoryId === 'string' ? parsedValue.categoryId : '',
|
||||||
|
usageId: typeof parsedValue.usageId === 'string' ? parsedValue.usageId : '',
|
||||||
dyeable: parsedValue.dyeable === true,
|
dyeable: parsedValue.dyeable === true,
|
||||||
dualDyeable: parsedValue.dualDyeable === true,
|
dualDyeable: parsedValue.dualDyeable === true,
|
||||||
patternEditable: parsedValue.patternEditable === true,
|
patternEditable: parsedValue.patternEditable === true,
|
||||||
@@ -133,6 +160,7 @@ function readItemCreateDefaults(): ItemCreateDefaults {
|
|||||||
} catch {
|
} catch {
|
||||||
return {
|
return {
|
||||||
categoryId: '',
|
categoryId: '',
|
||||||
|
usageId: '',
|
||||||
dyeable: false,
|
dyeable: false,
|
||||||
dualDyeable: false,
|
dualDyeable: false,
|
||||||
patternEditable: false,
|
patternEditable: false,
|
||||||
@@ -151,10 +179,13 @@ function applyItemCreateDefaults(isEventItem: boolean) {
|
|||||||
|
|
||||||
const defaults = readItemCreateDefaults();
|
const defaults = readItemCreateDefaults();
|
||||||
const categoryIds = new Set(loadedOptions.itemCategories.map((item) => String(item.id)));
|
const categoryIds = new Set(loadedOptions.itemCategories.map((item) => String(item.id)));
|
||||||
|
const usageIds = new Set(loadedOptions.itemUsages.map((item) => String(item.id)));
|
||||||
const methodIds = new Set(loadedOptions.acquisitionMethods.map((item) => String(item.id)));
|
const methodIds = new Set(loadedOptions.acquisitionMethods.map((item) => String(item.id)));
|
||||||
itemForm.value = {
|
itemForm.value = {
|
||||||
...itemForm.value,
|
...itemForm.value,
|
||||||
categoryId: categoryIds.has(defaults.categoryId) ? defaults.categoryId : '',
|
categoryId: categoryIds.has(defaults.categoryId) ? defaults.categoryId : '',
|
||||||
|
usageId: usageIds.has(defaults.usageId) ? defaults.usageId : '',
|
||||||
|
ancientArtifactCategoryId: isAncientArtifactCreate.value ? String(loadedOptions.ancientArtifactCategories[0]?.id ?? '') : '',
|
||||||
dyeable: defaults.dyeable,
|
dyeable: defaults.dyeable,
|
||||||
dualDyeable: defaults.dualDyeable,
|
dualDyeable: defaults.dualDyeable,
|
||||||
patternEditable: defaults.patternEditable,
|
patternEditable: defaults.patternEditable,
|
||||||
@@ -207,6 +238,8 @@ async function loadEditor() {
|
|||||||
itemForm.value = {
|
itemForm.value = {
|
||||||
name: item.baseName ?? item.name,
|
name: item.baseName ?? item.name,
|
||||||
details: item.baseDetails ?? item.details,
|
details: item.baseDetails ?? item.details,
|
||||||
|
basePrice: item.basePrice === null || item.basePrice === undefined ? '' : String(item.basePrice),
|
||||||
|
ancientArtifactCategoryId: item.ancientArtifactCategory ? String(item.ancientArtifactCategory.id) : '',
|
||||||
translations: item.translations ?? {},
|
translations: item.translations ?? {},
|
||||||
categoryId: String(item.category.id),
|
categoryId: String(item.category.id),
|
||||||
usageId: item.usage ? String(item.usage.id) : '',
|
usageId: item.usage ? String(item.usage.id) : '',
|
||||||
@@ -260,6 +293,9 @@ async function saveItem() {
|
|||||||
const payload: ItemPayload = {
|
const payload: ItemPayload = {
|
||||||
name: itemNameForSave(),
|
name: itemNameForSave(),
|
||||||
details: itemForm.value.details,
|
details: itemForm.value.details,
|
||||||
|
basePrice: itemForm.value.basePrice.trim() === '' ? null : Number(itemForm.value.basePrice),
|
||||||
|
ancientArtifactCategoryId:
|
||||||
|
itemForm.value.ancientArtifactCategoryId.trim() === '' ? null : Number(itemForm.value.ancientArtifactCategoryId),
|
||||||
translations: itemForm.value.translations,
|
translations: itemForm.value.translations,
|
||||||
categoryId: Number(itemForm.value.categoryId),
|
categoryId: Number(itemForm.value.categoryId),
|
||||||
usageId: itemForm.value.usageId ? Number(itemForm.value.usageId) : null,
|
usageId: itemForm.value.usageId ? Number(itemForm.value.usageId) : null,
|
||||||
@@ -279,7 +315,7 @@ async function saveItem() {
|
|||||||
payload.insertAfterItemId = insertAfterItemId.value;
|
payload.insertAfterItemId = insertAfterItemId.value;
|
||||||
}
|
}
|
||||||
const saved = isEditing.value ? await api.updateItem(routeId.value, payload) : await api.createItem(payload);
|
const saved = isEditing.value ? await api.updateItem(routeId.value, payload) : await api.createItem(payload);
|
||||||
await router.push(`/items/${saved.id}`);
|
await router.push(isAncientArtifactRoute.value ? `/ancient-artifacts/${saved.id}` : `/items/${saved.id}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
message.value = errorText(error, t('errors.saveFailed'));
|
message.value = errorText(error, t('errors.saveFailed'));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -302,19 +338,26 @@ onMounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Modal :title="pageTitle" :subtitle="t('pages.items.editSubtitle')" :close-label="t('common.close')" size="wide" @close="closeEditor">
|
<Modal :title="pageTitle" :subtitle="pageSubtitle" :close-label="t('common.close')" size="wide" @close="closeEditor">
|
||||||
<StatusMessage v-if="message" variant="danger">{{ message }}</StatusMessage>
|
<StatusMessage v-if="message" variant="danger">{{ message }}</StatusMessage>
|
||||||
|
|
||||||
<form v-if="!loading && options" id="item-edit-form" class="modal-edit-form" @submit.prevent="saveItem">
|
<form v-if="!loading && options" id="item-edit-form" class="modal-edit-form" @submit.prevent="saveItem">
|
||||||
<TranslationFields
|
<div class="item-edit-row item-edit-row--name-price">
|
||||||
id-prefix="item-name"
|
<TranslationFields
|
||||||
v-model:base-value="itemForm.name"
|
id-prefix="item-name"
|
||||||
v-model:translations="itemForm.translations"
|
v-model:base-value="itemForm.name"
|
||||||
field="name"
|
v-model:translations="itemForm.translations"
|
||||||
:label="t('common.name')"
|
field="name"
|
||||||
:languages="languages"
|
:label="t('common.name')"
|
||||||
required
|
:languages="languages"
|
||||||
/>
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="item-base-price">{{ t('pages.items.basePrice') }}</label>
|
||||||
|
<input id="item-base-price" v-model="itemForm.basePrice" type="number" min="0" step="1" inputmode="numeric" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<TranslationFields
|
<TranslationFields
|
||||||
id-prefix="item-details"
|
id-prefix="item-details"
|
||||||
@@ -342,28 +385,43 @@ onMounted(() => {
|
|||||||
@error="message = $event"
|
@error="message = $event"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="field">
|
<div class="item-edit-row item-edit-row--category-usage">
|
||||||
<label for="item-category">{{ t('pages.items.category') }}</label>
|
<div class="field">
|
||||||
<TagsSelect
|
<label for="item-category">{{ t('pages.items.category') }}</label>
|
||||||
id="item-category"
|
<TagsSelect
|
||||||
v-model="itemForm.categoryId"
|
id="item-category"
|
||||||
:options="options.itemCategories"
|
v-model="itemForm.categoryId"
|
||||||
:multiple="false"
|
:options="options.itemCategories"
|
||||||
:placeholder="t('common.select')"
|
:multiple="false"
|
||||||
:search-placeholder="t('pages.items.searchCategory')"
|
:placeholder="t('common.select')"
|
||||||
/>
|
:search-placeholder="t('pages.items.searchCategory')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="item-usage">{{ t('pages.items.usage') }}</label>
|
||||||
|
<TagsSelect
|
||||||
|
id="item-usage"
|
||||||
|
v-model="itemForm.usageId"
|
||||||
|
:options="options.itemUsages"
|
||||||
|
:multiple="false"
|
||||||
|
clearable
|
||||||
|
:placeholder="t('common.none')"
|
||||||
|
:search-placeholder="t('pages.items.searchUsage')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="item-usage">{{ t('pages.items.usage') }}</label>
|
<fieldset class="radio-group">
|
||||||
<TagsSelect
|
<legend>{{ t('pages.items.ancientArtifact') }}</legend>
|
||||||
id="item-usage"
|
<div class="radio-group__options">
|
||||||
v-model="itemForm.usageId"
|
<label v-for="option in ancientArtifactOptions" :key="option.value" class="radio-group__option">
|
||||||
:options="options.itemUsages"
|
<input v-model="itemForm.ancientArtifactCategoryId" type="radio" name="item-ancient-artifact" :value="option.value" />
|
||||||
:multiple="false"
|
<span>{{ option.label }}</span>
|
||||||
:placeholder="t('common.none')"
|
</label>
|
||||||
:search-placeholder="t('pages.items.searchUsage')"
|
</div>
|
||||||
/>
|
</fieldset>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="check-row">
|
<div class="check-row">
|
||||||
@@ -402,7 +460,7 @@ onMounted(() => {
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
<section v-else class="modal-edit-form skeleton-detail-section" aria-busy="true" :aria-label="t('pages.items.loadingEdit')">
|
<section v-else class="modal-edit-form skeleton-detail-section" aria-busy="true" :aria-label="t('pages.items.loadingEdit')">
|
||||||
<div v-for="index in 6" :key="index" class="field">
|
<div v-for="index in 7" :key="index" class="field">
|
||||||
<Skeleton :width="index === 1 ? '52px' : '88px'" />
|
<Skeleton :width="index === 1 ? '52px' : '88px'" />
|
||||||
<Skeleton variant="box" height="44px" />
|
<Skeleton variant="box" height="44px" />
|
||||||
</div>
|
</div>
|
||||||
@@ -420,3 +478,70 @@ onMounted(() => {
|
|||||||
</template>
|
</template>
|
||||||
</Modal>
|
</Modal>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.item-edit-row {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-edit-row--name-price {
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(180px, 240px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-edit-row--category-usage {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-edit-row > * {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radio-group {
|
||||||
|
display: grid;
|
||||||
|
gap: 7px;
|
||||||
|
min-width: 0;
|
||||||
|
min-inline-size: 0;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radio-group legend {
|
||||||
|
padding: 0;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 850;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radio-group__options {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px 12px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radio-group__option {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
min-height: 36px;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
font-weight: 850;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radio-group__option input {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
accent-color: var(--pokemon-blue);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.item-edit-row--name-price,
|
||||||
|
.item-edit-row--category-usage {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ const dropCommitted = ref(false);
|
|||||||
|
|
||||||
type ItemCreateDefaults = {
|
type ItemCreateDefaults = {
|
||||||
categoryId: string;
|
categoryId: string;
|
||||||
|
usageId: string;
|
||||||
dyeable: boolean;
|
dyeable: boolean;
|
||||||
dualDyeable: boolean;
|
dualDyeable: boolean;
|
||||||
patternEditable: boolean;
|
patternEditable: boolean;
|
||||||
@@ -57,6 +58,7 @@ const itemCreateDefaultsStorageKey = 'pokopia_item_create_defaults';
|
|||||||
|
|
||||||
const emptyItemCreateDefaults = (): ItemCreateDefaults => ({
|
const emptyItemCreateDefaults = (): ItemCreateDefaults => ({
|
||||||
categoryId: '',
|
categoryId: '',
|
||||||
|
usageId: '',
|
||||||
dyeable: false,
|
dyeable: false,
|
||||||
dualDyeable: false,
|
dualDyeable: false,
|
||||||
patternEditable: false,
|
patternEditable: false,
|
||||||
@@ -101,6 +103,7 @@ const canCreateItem = computed(() => currentUser.value?.permissions.includes('it
|
|||||||
const hasItemCreateDefaults = computed(
|
const hasItemCreateDefaults = computed(
|
||||||
() =>
|
() =>
|
||||||
itemCreateDefaults.value.categoryId !== '' ||
|
itemCreateDefaults.value.categoryId !== '' ||
|
||||||
|
itemCreateDefaults.value.usageId !== '' ||
|
||||||
itemCreateDefaults.value.dyeable ||
|
itemCreateDefaults.value.dyeable ||
|
||||||
itemCreateDefaults.value.dualDyeable ||
|
itemCreateDefaults.value.dualDyeable ||
|
||||||
itemCreateDefaults.value.patternEditable ||
|
itemCreateDefaults.value.patternEditable ||
|
||||||
@@ -177,6 +180,7 @@ function readItemCreateDefaults(): ItemCreateDefaults {
|
|||||||
const parsedValue = JSON.parse(rawValue) as Partial<ItemCreateDefaults>;
|
const parsedValue = JSON.parse(rawValue) as Partial<ItemCreateDefaults>;
|
||||||
return {
|
return {
|
||||||
categoryId: typeof parsedValue.categoryId === 'string' ? parsedValue.categoryId : '',
|
categoryId: typeof parsedValue.categoryId === 'string' ? parsedValue.categoryId : '',
|
||||||
|
usageId: typeof parsedValue.usageId === 'string' ? parsedValue.usageId : '',
|
||||||
dyeable: parsedValue.dyeable === true,
|
dyeable: parsedValue.dyeable === true,
|
||||||
dualDyeable: parsedValue.dualDyeable === true,
|
dualDyeable: parsedValue.dualDyeable === true,
|
||||||
patternEditable: parsedValue.patternEditable === true,
|
patternEditable: parsedValue.patternEditable === true,
|
||||||
@@ -209,10 +213,12 @@ function sanitizeItemCreateDefaults() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const categoryIds = new Set(options.value.itemCategories.map((item) => String(item.id)));
|
const categoryIds = new Set(options.value.itemCategories.map((item) => String(item.id)));
|
||||||
|
const usageIds = new Set(options.value.itemUsages.map((item) => String(item.id)));
|
||||||
const methodIds = new Set(options.value.acquisitionMethods.map((item) => String(item.id)));
|
const methodIds = new Set(options.value.acquisitionMethods.map((item) => String(item.id)));
|
||||||
const nextDefaults = {
|
const nextDefaults = {
|
||||||
...itemCreateDefaults.value,
|
...itemCreateDefaults.value,
|
||||||
categoryId: categoryIds.has(itemCreateDefaults.value.categoryId) ? itemCreateDefaults.value.categoryId : '',
|
categoryId: categoryIds.has(itemCreateDefaults.value.categoryId) ? itemCreateDefaults.value.categoryId : '',
|
||||||
|
usageId: usageIds.has(itemCreateDefaults.value.usageId) ? itemCreateDefaults.value.usageId : '',
|
||||||
acquisitionMethodIds: itemCreateDefaults.value.acquisitionMethodIds.filter((item) => methodIds.has(item))
|
acquisitionMethodIds: itemCreateDefaults.value.acquisitionMethodIds.filter((item) => methodIds.has(item))
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -509,6 +515,19 @@ watch(itemSortingAllowed, (allowed) => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="item-default-usage">{{ t('pages.items.usage') }}</label>
|
||||||
|
<TagsSelect
|
||||||
|
id="item-default-usage"
|
||||||
|
v-model="itemCreateDefaults.usageId"
|
||||||
|
:options="options.itemUsages"
|
||||||
|
:multiple="false"
|
||||||
|
clearable
|
||||||
|
:placeholder="t('common.none')"
|
||||||
|
:search-placeholder="t('pages.items.searchUsage')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="check-row item-create-defaults-menu__checks">
|
<div class="check-row item-create-defaults-menu__checks">
|
||||||
<label><input v-model="itemCreateDefaults.dyeable" type="checkbox" /> {{ t('pages.items.dyeable') }}</label>
|
<label><input v-model="itemCreateDefaults.dyeable" type="checkbox" /> {{ t('pages.items.dyeable') }}</label>
|
||||||
<label><input v-model="itemCreateDefaults.dualDyeable" type="checkbox" /> {{ t('pages.items.dualDyeable') }}</label>
|
<label><input v-model="itemCreateDefaults.dualDyeable" type="checkbox" /> {{ t('pages.items.dualDyeable') }}</label>
|
||||||
@@ -557,6 +576,7 @@ watch(itemSortingAllowed, (allowed) => {
|
|||||||
v-model="usageId"
|
v-model="usageId"
|
||||||
:options="options.itemUsages"
|
:options="options.itemUsages"
|
||||||
:multiple="false"
|
:multiple="false"
|
||||||
|
clearable
|
||||||
:placeholder="t('common.all')"
|
:placeholder="t('common.all')"
|
||||||
:search-placeholder="t('pages.items.searchUsage')"
|
:search-placeholder="t('pages.items.searchUsage')"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export const systemWordingMessages = {
|
|||||||
loading: 'Loading',
|
loading: 'Loading',
|
||||||
name: 'Name',
|
name: 'Name',
|
||||||
new: 'New',
|
new: 'New',
|
||||||
|
no: 'No',
|
||||||
none: 'None',
|
none: 'None',
|
||||||
save: 'Save',
|
save: 'Save',
|
||||||
saving: 'Saving',
|
saving: 'Saving',
|
||||||
@@ -134,7 +135,7 @@ export const systemWordingMessages = {
|
|||||||
pokemonDetailDescription:
|
pokemonDetailDescription:
|
||||||
'Read {name} details in Pokopia Wiki, including habitat, types, specialities, favourites, stats, related items, discussions, and edit history.',
|
'Read {name} details in Pokopia Wiki, including habitat, types, specialities, favourites, stats, related items, discussions, and edit history.',
|
||||||
itemDetailDescription:
|
itemDetailDescription:
|
||||||
'Browse {name} item details in Pokopia Wiki, including category, usage, acquisition methods, customization, related recipes, habitats, and Pokemon drops.',
|
'Browse {name} item details in Pokopia Wiki, including base price, category, usage, acquisition methods, customization, related recipes, habitats, and Pokemon drops.',
|
||||||
ancientArtifactDetailDescription:
|
ancientArtifactDetailDescription:
|
||||||
'Browse {name} Ancient Artifact details in Pokopia Wiki, including category, tags, description, discussions, and edit history.',
|
'Browse {name} Ancient Artifact details in Pokopia Wiki, including category, tags, description, discussions, and edit history.',
|
||||||
habitatDetailDescription:
|
habitatDetailDescription:
|
||||||
@@ -680,7 +681,7 @@ export const systemWordingMessages = {
|
|||||||
detailKicker: 'Item Detail',
|
detailKicker: 'Item Detail',
|
||||||
detailSubtitle: 'Item detail',
|
detailSubtitle: 'Item detail',
|
||||||
editKicker: 'Item Edit',
|
editKicker: 'Item Edit',
|
||||||
editSubtitle: 'Maintain item category, usage, acquisition methods, customization, and tags.',
|
editSubtitle: 'Maintain item base price, category, usage, acquisition methods, customization, and tags.',
|
||||||
newTitle: 'New item',
|
newTitle: 'New item',
|
||||||
editTitle: 'Edit {name}',
|
editTitle: 'Edit {name}',
|
||||||
fallbackName: 'Item',
|
fallbackName: 'Item',
|
||||||
@@ -688,6 +689,8 @@ export const systemWordingMessages = {
|
|||||||
loadingDetail: 'Loading item detail',
|
loadingDetail: 'Loading item detail',
|
||||||
loadingEdit: 'Loading item editor',
|
loadingEdit: 'Loading item editor',
|
||||||
description: 'Description',
|
description: 'Description',
|
||||||
|
basePrice: 'Base Price',
|
||||||
|
ancientArtifact: 'Ancient Artifact',
|
||||||
category: 'Category',
|
category: 'Category',
|
||||||
usage: 'Usage',
|
usage: 'Usage',
|
||||||
tags: 'Tags',
|
tags: 'Tags',
|
||||||
@@ -720,7 +723,7 @@ export const systemWordingMessages = {
|
|||||||
subtitle: 'Browse event items by category, usage, and tags.',
|
subtitle: 'Browse event items by category, usage, and tags.',
|
||||||
kicker: 'Event Items',
|
kicker: 'Event Items',
|
||||||
detailKicker: 'Event Item Detail',
|
detailKicker: 'Event Item Detail',
|
||||||
editSubtitle: 'Maintain event item category, usage, acquisition methods, customization, and tags.',
|
editSubtitle: 'Maintain event item base price, category, usage, acquisition methods, customization, and tags.',
|
||||||
newTitle: 'New event item'
|
newTitle: 'New event item'
|
||||||
},
|
},
|
||||||
ancientArtifacts: {
|
ancientArtifacts: {
|
||||||
@@ -730,7 +733,7 @@ export const systemWordingMessages = {
|
|||||||
detailKicker: 'Ancient Artifact Detail',
|
detailKicker: 'Ancient Artifact Detail',
|
||||||
detailSubtitle: 'Ancient Artifact detail',
|
detailSubtitle: 'Ancient Artifact detail',
|
||||||
editKicker: 'Ancient Artifact Edit',
|
editKicker: 'Ancient Artifact Edit',
|
||||||
editSubtitle: 'Maintain Ancient Artifact image, description, category, tags, and translations.',
|
editSubtitle: 'Maintain the item details, base price, category, usage, Ancient Artifact classification, and tags.',
|
||||||
newTitle: 'New Ancient Artifact',
|
newTitle: 'New Ancient Artifact',
|
||||||
editTitle: 'Edit {name}',
|
editTitle: 'Edit {name}',
|
||||||
fallbackName: 'Ancient Artifact',
|
fallbackName: 'Ancient Artifact',
|
||||||
@@ -1386,6 +1389,7 @@ export const systemWordingMessages = {
|
|||||||
loading: '加载中',
|
loading: '加载中',
|
||||||
name: '名称',
|
name: '名称',
|
||||||
new: '新建',
|
new: '新建',
|
||||||
|
no: '否',
|
||||||
none: '无',
|
none: '无',
|
||||||
save: '保存',
|
save: '保存',
|
||||||
saving: '保存中',
|
saving: '保存中',
|
||||||
@@ -1495,7 +1499,7 @@ export const systemWordingMessages = {
|
|||||||
seo: {
|
seo: {
|
||||||
siteDescription: '浏览 Pokopia Wiki 的 Pokemon、Event Pokemon、栖息地、Event Habitats、物品、Event Items、Ancient Artifacts、材料单、每日清单和 Life 社区动态。',
|
siteDescription: '浏览 Pokopia Wiki 的 Pokemon、Event Pokemon、栖息地、Event Habitats、物品、Event Items、Ancient Artifacts、材料单、每日清单和 Life 社区动态。',
|
||||||
pokemonDetailDescription: '查看 {name} 在 Pokopia Wiki 中的栖息地、属性、特长、喜欢的东西、六维、相关物品、讨论和编辑历史。',
|
pokemonDetailDescription: '查看 {name} 在 Pokopia Wiki 中的栖息地、属性、特长、喜欢的东西、六维、相关物品、讨论和编辑历史。',
|
||||||
itemDetailDescription: '查看 {name} 在 Pokopia Wiki 中的分类、用途、入手方式、自定义、相关材料单、栖息地和 Pokemon 掉落。',
|
itemDetailDescription: '查看 {name} 在 Pokopia Wiki 中的基础价格、分类、用途、入手方式、自定义、相关材料单、栖息地和 Pokemon 掉落。',
|
||||||
ancientArtifactDetailDescription: '查看 {name} 在 Pokopia Wiki 中的分类、标签、介绍、讨论和编辑历史。',
|
ancientArtifactDetailDescription: '查看 {name} 在 Pokopia Wiki 中的分类、标签、介绍、讨论和编辑历史。',
|
||||||
habitatDetailDescription: '查看 {name} 在 Pokopia Wiki 中的配方、可能出现的 Pokemon、地图、时间、天气、讨论和编辑历史。',
|
habitatDetailDescription: '查看 {name} 在 Pokopia Wiki 中的配方、可能出现的 Pokemon、地图、时间、天气、讨论和编辑历史。',
|
||||||
recipeDetailDescription: '查看 {name} 材料单在 Pokopia Wiki 中的结果物品、入手方式、需要材料、讨论和编辑历史。'
|
recipeDetailDescription: '查看 {name} 材料单在 Pokopia Wiki 中的结果物品、入手方式、需要材料、讨论和编辑历史。'
|
||||||
@@ -2018,7 +2022,7 @@ export const systemWordingMessages = {
|
|||||||
detailKicker: 'Item Detail',
|
detailKicker: 'Item Detail',
|
||||||
detailSubtitle: '物品详情',
|
detailSubtitle: '物品详情',
|
||||||
editKicker: 'Item Edit',
|
editKicker: 'Item Edit',
|
||||||
editSubtitle: '维护物品分类、用途、入手方式、自定义和标签。',
|
editSubtitle: '维护物品基础价格、分类、用途、入手方式、自定义和标签。',
|
||||||
newTitle: '新增物品',
|
newTitle: '新增物品',
|
||||||
editTitle: '编辑 {name}',
|
editTitle: '编辑 {name}',
|
||||||
fallbackName: '物品',
|
fallbackName: '物品',
|
||||||
@@ -2026,6 +2030,8 @@ export const systemWordingMessages = {
|
|||||||
loadingDetail: '正在加载物品详情',
|
loadingDetail: '正在加载物品详情',
|
||||||
loadingEdit: '正在加载物品编辑内容',
|
loadingEdit: '正在加载物品编辑内容',
|
||||||
description: '介绍',
|
description: '介绍',
|
||||||
|
basePrice: '基础价格',
|
||||||
|
ancientArtifact: 'Ancient Artifact',
|
||||||
category: '分类',
|
category: '分类',
|
||||||
usage: '用途',
|
usage: '用途',
|
||||||
tags: '标签',
|
tags: '标签',
|
||||||
@@ -2058,7 +2064,7 @@ export const systemWordingMessages = {
|
|||||||
subtitle: '按分类、用途、标签查看活动物品。',
|
subtitle: '按分类、用途、标签查看活动物品。',
|
||||||
kicker: 'Event Items',
|
kicker: 'Event Items',
|
||||||
detailKicker: 'Event Item Detail',
|
detailKicker: 'Event Item Detail',
|
||||||
editSubtitle: '维护 Event Item 分类、用途、入手方式、自定义和标签。',
|
editSubtitle: '维护 Event Item 基础价格、分类、用途、入手方式、自定义和标签。',
|
||||||
newTitle: '新增 Event Item'
|
newTitle: '新增 Event Item'
|
||||||
},
|
},
|
||||||
ancientArtifacts: {
|
ancientArtifacts: {
|
||||||
@@ -2068,7 +2074,7 @@ export const systemWordingMessages = {
|
|||||||
detailKicker: 'Ancient Artifact Detail',
|
detailKicker: 'Ancient Artifact Detail',
|
||||||
detailSubtitle: 'Ancient Artifact 详情',
|
detailSubtitle: 'Ancient Artifact 详情',
|
||||||
editKicker: 'Ancient Artifact Edit',
|
editKicker: 'Ancient Artifact Edit',
|
||||||
editSubtitle: '维护 Ancient Artifact 图片、介绍、分类、标签和翻译。',
|
editSubtitle: '维护物品介绍、基础价格、分类、用途、Ancient Artifact 分类和标签。',
|
||||||
newTitle: '新增 Ancient Artifact',
|
newTitle: '新增 Ancient Artifact',
|
||||||
editTitle: '编辑 {name}',
|
editTitle: '编辑 {name}',
|
||||||
fallbackName: 'Ancient Artifact',
|
fallbackName: 'Ancient Artifact',
|
||||||
|
|||||||
Reference in New Issue
Block a user