Compare commits

...

4 Commits

Author SHA1 Message Date
2919708cee refactor(items): merge ancient artifacts into items data model
Migrate ancient artifacts to items table using a category key.
Consolidate detail and edit views into ItemDetail and ItemEdit.
Update API, search, and data tools to reflect unified structure.
2026-05-05 10:46:14 +08:00
839a24566b refactor(items): improve edit form layout with responsive grid rows
Group related fields like name/price and category/usage.
Stack fields vertically on screens smaller than 720px.
2026-05-05 09:05:53 +08:00
9312156a3c feat(items): add base price and support usage in creation defaults
Add `base_price` to items schema, API, and edit history
Display and edit base price in item details and forms
Add `clearable` prop to TagsSelect for optional single selections
Include usage in item creation session defaults
2026-05-05 08:59:36 +08:00
8ee29e9549 feat(history): exclude sort order changes from edit history
Stop recording sort order changes in the backend edit log
Filter out existing sort order changes from the frontend edit history panel
2026-05-05 07:15:18 +08:00
14 changed files with 708 additions and 653 deletions

View File

@@ -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 主表重置自增 IDPokemon 内部 ID 不是 identity未关联官方 data 的自定义 Pokemon 系统分配区间仍按当前数据库最大值继续。 - 对被清空的 identity 主表重置自增 IDPokemon 内部 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`

View File

@@ -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);

View File

@@ -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 };

View File

@@ -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">

View File

@@ -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;

View File

@@ -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' }) } },

View File

@@ -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;

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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')"
/> />

View File

@@ -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',