Compare commits
10 Commits
7c8426651d
...
9fece8f54f
| Author | SHA1 | Date | |
|---|---|---|---|
| 9fece8f54f | |||
| ca3ca35dfc | |||
| 62406bdc84 | |||
| 6812ddc428 | |||
| bd068ce2f6 | |||
| 239a2ec3b5 | |||
| 27100fbd22 | |||
| 91dd834413 | |||
| 60cad3f5e8 | |||
| 14b13e479d |
14
DESIGN.md
14
DESIGN.md
@@ -65,12 +65,20 @@ Pokemon 可配置:
|
|||||||
- 配方(物品,数量)
|
- 配方(物品,数量)
|
||||||
- 可出现的宝可梦(可多选)
|
- 可出现的宝可梦(可多选)
|
||||||
|
|
||||||
|
列表顺序:
|
||||||
|
- 全局配置项、Pokemon、物品、材料单、地图、栖息地均可自定义排序
|
||||||
|
- 初始排序按创建时间旧到新
|
||||||
|
|
||||||
出现契机
|
出现契机
|
||||||
- 时间:早晨 / 中午 / 傍晚 / 晚上
|
- 时间:早晨 / 中午 / 傍晚 / 晚上
|
||||||
- 天气:晴天 / 阴天 / 雨天
|
- 天气:晴天 / 阴天 / 雨天
|
||||||
- 稀有度:1 ~ 3 星
|
- 稀有度:1 ~ 3 星
|
||||||
- 地图关联
|
- 地图关联
|
||||||
|
|
||||||
|
每日 CheckList 可配置:
|
||||||
|
- Task
|
||||||
|
- Task 顺序
|
||||||
|
|
||||||
## 功能
|
## 功能
|
||||||
|
|
||||||
- Pokemon 列表
|
- Pokemon 列表
|
||||||
@@ -116,6 +124,12 @@ Pokemon 可配置:
|
|||||||
- 基本信息
|
- 基本信息
|
||||||
- 入手方式
|
- 入手方式
|
||||||
- 需要材料列表
|
- 需要材料列表
|
||||||
|
- 每日 CheckList
|
||||||
|
- 展示每日做什么
|
||||||
|
- 每个 Task 可勾选
|
||||||
|
- 每天自动清空勾选状态,不删除 Task
|
||||||
|
- 管理中可新增 Task 到列表
|
||||||
|
- 管理中可通过 Handle 拖曳排序
|
||||||
|
|
||||||
## 用户系统
|
## 用户系统
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,55 @@
|
|||||||
CREATE TABLE IF NOT EXISTS environments (
|
CREATE TABLE IF NOT EXISTS environments (
|
||||||
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,
|
||||||
|
sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS languages (
|
||||||
|
code text PRIMARY KEY,
|
||||||
|
name text NOT NULL,
|
||||||
|
enabled boolean NOT NULL DEFAULT true,
|
||||||
|
is_default boolean NOT NULL DEFAULT false,
|
||||||
|
sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0),
|
||||||
|
CHECK (code ~ '^[a-z]{2}(-[A-Z]{2})?$'),
|
||||||
|
CHECK (length(name) BETWEEN 1 AND 80)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS languages_single_default_idx
|
||||||
|
ON languages (is_default)
|
||||||
|
WHERE is_default = true;
|
||||||
|
|
||||||
|
INSERT INTO languages (code, name, enabled, is_default, sort_order)
|
||||||
|
VALUES
|
||||||
|
('en', 'English', true, true, 10),
|
||||||
|
('zh-CN', '简体中文', true, false, 20)
|
||||||
|
ON CONFLICT (code) DO NOTHING;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS entity_translations (
|
||||||
|
entity_type text NOT NULL CHECK (
|
||||||
|
entity_type IN (
|
||||||
|
'pokemon',
|
||||||
|
'skills',
|
||||||
|
'environments',
|
||||||
|
'favorite-things',
|
||||||
|
'item-categories',
|
||||||
|
'item-usages',
|
||||||
|
'acquisition-methods',
|
||||||
|
'items',
|
||||||
|
'maps',
|
||||||
|
'habitats',
|
||||||
|
'daily-checklist-items'
|
||||||
|
)
|
||||||
|
),
|
||||||
|
entity_id integer NOT NULL,
|
||||||
|
locale text NOT NULL REFERENCES languages(code) ON DELETE CASCADE,
|
||||||
|
field_name text NOT NULL CHECK (field_name IN ('name', 'title')),
|
||||||
|
value text NOT NULL,
|
||||||
|
PRIMARY KEY (entity_type, entity_id, locale, field_name)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS entity_translations_lookup_idx
|
||||||
|
ON entity_translations (entity_type, entity_id, field_name, locale);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
email text NOT NULL UNIQUE,
|
email text NOT NULL UNIQUE,
|
||||||
@@ -38,10 +85,24 @@ CREATE TABLE IF NOT EXISTS user_sessions (
|
|||||||
CREATE INDEX IF NOT EXISTS user_sessions_user_id_idx
|
CREATE INDEX IF NOT EXISTS user_sessions_user_id_idx
|
||||||
ON user_sessions(user_id);
|
ON user_sessions(user_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS daily_checklist_items (
|
||||||
|
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
|
title text NOT NULL,
|
||||||
|
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 INDEX IF NOT EXISTS daily_checklist_items_sort_order_idx
|
||||||
|
ON daily_checklist_items(sort_order, id);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS skills (
|
CREATE TABLE IF NOT EXISTS skills (
|
||||||
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,
|
||||||
has_item_drop boolean NOT NULL DEFAULT false
|
has_item_drop boolean NOT NULL DEFAULT false,
|
||||||
|
sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0)
|
||||||
);
|
);
|
||||||
|
|
||||||
ALTER TABLE skills DROP COLUMN IF EXISTS subcategory;
|
ALTER TABLE skills DROP COLUMN IF EXISTS subcategory;
|
||||||
@@ -50,13 +111,15 @@ CREATE UNIQUE INDEX IF NOT EXISTS skills_name_key ON skills(name);
|
|||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS favorite_things (
|
CREATE TABLE IF NOT EXISTS favorite_things (
|
||||||
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,
|
||||||
|
sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0)
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS pokemon (
|
CREATE TABLE IF NOT EXISTS pokemon (
|
||||||
id integer PRIMARY KEY,
|
id integer PRIMARY KEY,
|
||||||
name text NOT NULL UNIQUE,
|
name text NOT NULL UNIQUE,
|
||||||
environment_id integer NOT NULL REFERENCES environments(id)
|
environment_id integer NOT NULL REFERENCES environments(id),
|
||||||
|
sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0)
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS pokemon_skills (
|
CREATE TABLE IF NOT EXISTS pokemon_skills (
|
||||||
@@ -73,17 +136,20 @@ CREATE TABLE IF NOT EXISTS pokemon_favorite_things (
|
|||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS item_categories (
|
CREATE TABLE IF NOT EXISTS item_categories (
|
||||||
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,
|
||||||
|
sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0)
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS item_usages (
|
CREATE TABLE IF NOT EXISTS item_usages (
|
||||||
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,
|
||||||
|
sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0)
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS acquisition_methods (
|
CREATE TABLE IF NOT EXISTS acquisition_methods (
|
||||||
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,
|
||||||
|
sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0)
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS items (
|
CREATE TABLE IF NOT EXISTS items (
|
||||||
@@ -94,7 +160,8 @@ CREATE TABLE IF NOT EXISTS items (
|
|||||||
dyeable boolean NOT NULL DEFAULT false,
|
dyeable boolean NOT NULL DEFAULT false,
|
||||||
dual_dyeable boolean NOT NULL DEFAULT false,
|
dual_dyeable boolean NOT NULL DEFAULT false,
|
||||||
pattern_editable boolean NOT NULL DEFAULT false,
|
pattern_editable boolean NOT NULL DEFAULT false,
|
||||||
no_recipe boolean NOT NULL DEFAULT false
|
no_recipe boolean NOT NULL DEFAULT false,
|
||||||
|
sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0)
|
||||||
);
|
);
|
||||||
|
|
||||||
ALTER TABLE items ALTER COLUMN usage_id DROP NOT NULL;
|
ALTER TABLE items ALTER COLUMN usage_id DROP NOT NULL;
|
||||||
@@ -103,7 +170,8 @@ ALTER TABLE items DROP COLUMN IF EXISTS no_habitat;
|
|||||||
|
|
||||||
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),
|
||||||
|
sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0)
|
||||||
);
|
);
|
||||||
|
|
||||||
ALTER TABLE recipes ADD COLUMN IF NOT EXISTS item_id integer REFERENCES items(id);
|
ALTER TABLE recipes ADD COLUMN IF NOT EXISTS item_id integer REFERENCES items(id);
|
||||||
@@ -176,12 +244,14 @@ CREATE TABLE IF NOT EXISTS recipe_materials (
|
|||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS maps (
|
CREATE TABLE IF NOT EXISTS maps (
|
||||||
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,
|
||||||
|
sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0)
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS habitats (
|
CREATE TABLE IF NOT EXISTS habitats (
|
||||||
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,
|
||||||
|
sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0)
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS habitat_recipe_items (
|
CREATE TABLE IF NOT EXISTS habitat_recipe_items (
|
||||||
@@ -205,56 +275,189 @@ ALTER TABLE environments ADD COLUMN IF NOT EXISTS created_by_user_id integer REF
|
|||||||
ALTER TABLE environments ADD COLUMN IF NOT EXISTS updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL;
|
ALTER TABLE environments ADD COLUMN IF NOT EXISTS updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL;
|
||||||
ALTER TABLE environments ADD COLUMN IF NOT EXISTS created_at timestamptz NOT NULL DEFAULT now();
|
ALTER TABLE environments ADD COLUMN IF NOT EXISTS created_at timestamptz NOT NULL DEFAULT now();
|
||||||
ALTER TABLE environments ADD COLUMN IF NOT EXISTS updated_at timestamptz NOT NULL DEFAULT now();
|
ALTER TABLE environments ADD COLUMN IF NOT EXISTS updated_at timestamptz NOT NULL DEFAULT now();
|
||||||
|
ALTER TABLE environments ADD COLUMN IF NOT EXISTS sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0);
|
||||||
|
|
||||||
ALTER TABLE skills ADD COLUMN IF NOT EXISTS created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL;
|
ALTER TABLE skills ADD COLUMN IF NOT EXISTS created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL;
|
||||||
ALTER TABLE skills ADD COLUMN IF NOT EXISTS updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL;
|
ALTER TABLE skills ADD COLUMN IF NOT EXISTS updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL;
|
||||||
ALTER TABLE skills ADD COLUMN IF NOT EXISTS created_at timestamptz NOT NULL DEFAULT now();
|
ALTER TABLE skills ADD COLUMN IF NOT EXISTS created_at timestamptz NOT NULL DEFAULT now();
|
||||||
ALTER TABLE skills ADD COLUMN IF NOT EXISTS updated_at timestamptz NOT NULL DEFAULT now();
|
ALTER TABLE skills ADD COLUMN IF NOT EXISTS updated_at timestamptz NOT NULL DEFAULT now();
|
||||||
|
ALTER TABLE skills ADD COLUMN IF NOT EXISTS sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0);
|
||||||
|
|
||||||
ALTER TABLE favorite_things ADD COLUMN IF NOT EXISTS created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL;
|
ALTER TABLE favorite_things ADD COLUMN IF NOT EXISTS created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL;
|
||||||
ALTER TABLE favorite_things ADD COLUMN IF NOT EXISTS updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL;
|
ALTER TABLE favorite_things ADD COLUMN IF NOT EXISTS updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL;
|
||||||
ALTER TABLE favorite_things ADD COLUMN IF NOT EXISTS created_at timestamptz NOT NULL DEFAULT now();
|
ALTER TABLE favorite_things ADD COLUMN IF NOT EXISTS created_at timestamptz NOT NULL DEFAULT now();
|
||||||
ALTER TABLE favorite_things ADD COLUMN IF NOT EXISTS updated_at timestamptz NOT NULL DEFAULT now();
|
ALTER TABLE favorite_things ADD COLUMN IF NOT EXISTS updated_at timestamptz NOT NULL DEFAULT now();
|
||||||
|
ALTER TABLE favorite_things ADD COLUMN IF NOT EXISTS sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0);
|
||||||
|
|
||||||
ALTER TABLE pokemon ADD COLUMN IF NOT EXISTS created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL;
|
ALTER TABLE pokemon ADD COLUMN IF NOT EXISTS created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL;
|
||||||
ALTER TABLE pokemon ADD COLUMN IF NOT EXISTS updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL;
|
ALTER TABLE pokemon ADD COLUMN IF NOT EXISTS updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL;
|
||||||
ALTER TABLE pokemon ADD COLUMN IF NOT EXISTS created_at timestamptz NOT NULL DEFAULT now();
|
ALTER TABLE pokemon ADD COLUMN IF NOT EXISTS created_at timestamptz NOT NULL DEFAULT now();
|
||||||
ALTER TABLE pokemon ADD COLUMN IF NOT EXISTS updated_at timestamptz NOT NULL DEFAULT now();
|
ALTER TABLE pokemon ADD COLUMN IF NOT EXISTS updated_at timestamptz NOT NULL DEFAULT now();
|
||||||
|
ALTER TABLE pokemon ADD COLUMN IF NOT EXISTS sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0);
|
||||||
|
|
||||||
ALTER TABLE item_categories ADD COLUMN IF NOT EXISTS created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL;
|
ALTER TABLE item_categories ADD COLUMN IF NOT EXISTS created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL;
|
||||||
ALTER TABLE item_categories ADD COLUMN IF NOT EXISTS updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL;
|
ALTER TABLE item_categories ADD COLUMN IF NOT EXISTS updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL;
|
||||||
ALTER TABLE item_categories ADD COLUMN IF NOT EXISTS created_at timestamptz NOT NULL DEFAULT now();
|
ALTER TABLE item_categories ADD COLUMN IF NOT EXISTS created_at timestamptz NOT NULL DEFAULT now();
|
||||||
ALTER TABLE item_categories ADD COLUMN IF NOT EXISTS updated_at timestamptz NOT NULL DEFAULT now();
|
ALTER TABLE item_categories ADD COLUMN IF NOT EXISTS updated_at timestamptz NOT NULL DEFAULT now();
|
||||||
|
ALTER TABLE item_categories ADD COLUMN IF NOT EXISTS sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0);
|
||||||
|
|
||||||
ALTER TABLE item_usages ADD COLUMN IF NOT EXISTS created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL;
|
ALTER TABLE item_usages ADD COLUMN IF NOT EXISTS created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL;
|
||||||
ALTER TABLE item_usages ADD COLUMN IF NOT EXISTS updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL;
|
ALTER TABLE item_usages ADD COLUMN IF NOT EXISTS updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL;
|
||||||
ALTER TABLE item_usages ADD COLUMN IF NOT EXISTS created_at timestamptz NOT NULL DEFAULT now();
|
ALTER TABLE item_usages ADD COLUMN IF NOT EXISTS created_at timestamptz NOT NULL DEFAULT now();
|
||||||
ALTER TABLE item_usages ADD COLUMN IF NOT EXISTS updated_at timestamptz NOT NULL DEFAULT now();
|
ALTER TABLE item_usages ADD COLUMN IF NOT EXISTS updated_at timestamptz NOT NULL DEFAULT now();
|
||||||
|
ALTER TABLE item_usages ADD COLUMN IF NOT EXISTS sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0);
|
||||||
|
|
||||||
ALTER TABLE acquisition_methods ADD COLUMN IF NOT EXISTS created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL;
|
ALTER TABLE acquisition_methods ADD COLUMN IF NOT EXISTS created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL;
|
||||||
ALTER TABLE acquisition_methods ADD COLUMN IF NOT EXISTS updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL;
|
ALTER TABLE acquisition_methods ADD COLUMN IF NOT EXISTS updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL;
|
||||||
ALTER TABLE acquisition_methods ADD COLUMN IF NOT EXISTS created_at timestamptz NOT NULL DEFAULT now();
|
ALTER TABLE acquisition_methods ADD COLUMN IF NOT EXISTS created_at timestamptz NOT NULL DEFAULT now();
|
||||||
ALTER TABLE acquisition_methods ADD COLUMN IF NOT EXISTS updated_at timestamptz NOT NULL DEFAULT now();
|
ALTER TABLE acquisition_methods ADD COLUMN IF NOT EXISTS updated_at timestamptz NOT NULL DEFAULT now();
|
||||||
|
ALTER TABLE acquisition_methods ADD COLUMN IF NOT EXISTS sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0);
|
||||||
|
|
||||||
ALTER TABLE items ADD COLUMN IF NOT EXISTS created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL;
|
ALTER TABLE items ADD COLUMN IF NOT EXISTS created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL;
|
||||||
ALTER TABLE items ADD COLUMN IF NOT EXISTS updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL;
|
ALTER TABLE items ADD COLUMN IF NOT EXISTS updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL;
|
||||||
ALTER TABLE items ADD COLUMN IF NOT EXISTS created_at timestamptz NOT NULL DEFAULT now();
|
ALTER TABLE items ADD COLUMN IF NOT EXISTS created_at timestamptz NOT NULL DEFAULT now();
|
||||||
ALTER TABLE items ADD COLUMN IF NOT EXISTS updated_at timestamptz NOT NULL DEFAULT now();
|
ALTER TABLE items ADD COLUMN IF NOT EXISTS updated_at timestamptz NOT NULL DEFAULT now();
|
||||||
|
ALTER TABLE items ADD COLUMN IF NOT EXISTS sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0);
|
||||||
|
|
||||||
ALTER TABLE recipes ADD COLUMN IF NOT EXISTS created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL;
|
ALTER TABLE recipes ADD COLUMN IF NOT EXISTS created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL;
|
||||||
ALTER TABLE recipes ADD COLUMN IF NOT EXISTS updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL;
|
ALTER TABLE recipes ADD COLUMN IF NOT EXISTS updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL;
|
||||||
ALTER TABLE recipes ADD COLUMN IF NOT EXISTS created_at timestamptz NOT NULL DEFAULT now();
|
ALTER TABLE recipes ADD COLUMN IF NOT EXISTS created_at timestamptz NOT NULL DEFAULT now();
|
||||||
ALTER TABLE recipes ADD COLUMN IF NOT EXISTS updated_at timestamptz NOT NULL DEFAULT now();
|
ALTER TABLE recipes ADD COLUMN IF NOT EXISTS updated_at timestamptz NOT NULL DEFAULT now();
|
||||||
|
ALTER TABLE recipes ADD COLUMN IF NOT EXISTS sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0);
|
||||||
|
|
||||||
ALTER TABLE maps ADD COLUMN IF NOT EXISTS created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL;
|
ALTER TABLE maps ADD COLUMN IF NOT EXISTS created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL;
|
||||||
ALTER TABLE maps ADD COLUMN IF NOT EXISTS updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL;
|
ALTER TABLE maps ADD COLUMN IF NOT EXISTS updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL;
|
||||||
ALTER TABLE maps ADD COLUMN IF NOT EXISTS created_at timestamptz NOT NULL DEFAULT now();
|
ALTER TABLE maps ADD COLUMN IF NOT EXISTS created_at timestamptz NOT NULL DEFAULT now();
|
||||||
ALTER TABLE maps ADD COLUMN IF NOT EXISTS updated_at timestamptz NOT NULL DEFAULT now();
|
ALTER TABLE maps ADD COLUMN IF NOT EXISTS updated_at timestamptz NOT NULL DEFAULT now();
|
||||||
|
ALTER TABLE maps ADD COLUMN IF NOT EXISTS sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0);
|
||||||
|
|
||||||
ALTER TABLE habitats ADD COLUMN IF NOT EXISTS created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL;
|
ALTER TABLE habitats ADD COLUMN IF NOT EXISTS created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL;
|
||||||
ALTER TABLE habitats ADD COLUMN IF NOT EXISTS updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL;
|
ALTER TABLE habitats ADD COLUMN IF NOT EXISTS updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL;
|
||||||
ALTER TABLE habitats ADD COLUMN IF NOT EXISTS created_at timestamptz NOT NULL DEFAULT now();
|
ALTER TABLE habitats ADD COLUMN IF NOT EXISTS created_at timestamptz NOT NULL DEFAULT now();
|
||||||
ALTER TABLE habitats ADD COLUMN IF NOT EXISTS updated_at timestamptz NOT NULL DEFAULT now();
|
ALTER TABLE habitats ADD COLUMN IF NOT EXISTS updated_at timestamptz NOT NULL DEFAULT now();
|
||||||
|
ALTER TABLE habitats ADD COLUMN IF NOT EXISTS sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0);
|
||||||
|
|
||||||
|
WITH ordered AS (
|
||||||
|
SELECT id, (row_number() OVER (ORDER BY created_at, id) * 10)::integer AS next_sort_order
|
||||||
|
FROM environments
|
||||||
|
WHERE sort_order = 0
|
||||||
|
)
|
||||||
|
UPDATE environments target
|
||||||
|
SET sort_order = ordered.next_sort_order
|
||||||
|
FROM ordered
|
||||||
|
WHERE target.id = ordered.id;
|
||||||
|
|
||||||
|
WITH ordered AS (
|
||||||
|
SELECT id, (row_number() OVER (ORDER BY created_at, id) * 10)::integer AS next_sort_order
|
||||||
|
FROM skills
|
||||||
|
WHERE sort_order = 0
|
||||||
|
)
|
||||||
|
UPDATE skills target
|
||||||
|
SET sort_order = ordered.next_sort_order
|
||||||
|
FROM ordered
|
||||||
|
WHERE target.id = ordered.id;
|
||||||
|
|
||||||
|
WITH ordered AS (
|
||||||
|
SELECT id, (row_number() OVER (ORDER BY created_at, id) * 10)::integer AS next_sort_order
|
||||||
|
FROM favorite_things
|
||||||
|
WHERE sort_order = 0
|
||||||
|
)
|
||||||
|
UPDATE favorite_things target
|
||||||
|
SET sort_order = ordered.next_sort_order
|
||||||
|
FROM ordered
|
||||||
|
WHERE target.id = ordered.id;
|
||||||
|
|
||||||
|
WITH ordered AS (
|
||||||
|
SELECT id, (row_number() OVER (ORDER BY created_at, id) * 10)::integer AS next_sort_order
|
||||||
|
FROM pokemon
|
||||||
|
WHERE sort_order = 0
|
||||||
|
)
|
||||||
|
UPDATE pokemon target
|
||||||
|
SET sort_order = ordered.next_sort_order
|
||||||
|
FROM ordered
|
||||||
|
WHERE target.id = ordered.id;
|
||||||
|
|
||||||
|
WITH ordered AS (
|
||||||
|
SELECT id, (row_number() OVER (ORDER BY created_at, id) * 10)::integer AS next_sort_order
|
||||||
|
FROM item_categories
|
||||||
|
WHERE sort_order = 0
|
||||||
|
)
|
||||||
|
UPDATE item_categories target
|
||||||
|
SET sort_order = ordered.next_sort_order
|
||||||
|
FROM ordered
|
||||||
|
WHERE target.id = ordered.id;
|
||||||
|
|
||||||
|
WITH ordered AS (
|
||||||
|
SELECT id, (row_number() OVER (ORDER BY created_at, id) * 10)::integer AS next_sort_order
|
||||||
|
FROM item_usages
|
||||||
|
WHERE sort_order = 0
|
||||||
|
)
|
||||||
|
UPDATE item_usages target
|
||||||
|
SET sort_order = ordered.next_sort_order
|
||||||
|
FROM ordered
|
||||||
|
WHERE target.id = ordered.id;
|
||||||
|
|
||||||
|
WITH ordered AS (
|
||||||
|
SELECT id, (row_number() OVER (ORDER BY created_at, id) * 10)::integer AS next_sort_order
|
||||||
|
FROM acquisition_methods
|
||||||
|
WHERE sort_order = 0
|
||||||
|
)
|
||||||
|
UPDATE acquisition_methods target
|
||||||
|
SET sort_order = ordered.next_sort_order
|
||||||
|
FROM ordered
|
||||||
|
WHERE target.id = ordered.id;
|
||||||
|
|
||||||
|
WITH ordered AS (
|
||||||
|
SELECT id, (row_number() OVER (ORDER BY created_at, id) * 10)::integer AS next_sort_order
|
||||||
|
FROM items
|
||||||
|
WHERE sort_order = 0
|
||||||
|
)
|
||||||
|
UPDATE items target
|
||||||
|
SET sort_order = ordered.next_sort_order
|
||||||
|
FROM ordered
|
||||||
|
WHERE target.id = ordered.id;
|
||||||
|
|
||||||
|
WITH ordered AS (
|
||||||
|
SELECT id, (row_number() OVER (ORDER BY created_at, id) * 10)::integer AS next_sort_order
|
||||||
|
FROM recipes
|
||||||
|
WHERE sort_order = 0
|
||||||
|
)
|
||||||
|
UPDATE recipes target
|
||||||
|
SET sort_order = ordered.next_sort_order
|
||||||
|
FROM ordered
|
||||||
|
WHERE target.id = ordered.id;
|
||||||
|
|
||||||
|
WITH ordered AS (
|
||||||
|
SELECT id, (row_number() OVER (ORDER BY created_at, id) * 10)::integer AS next_sort_order
|
||||||
|
FROM maps
|
||||||
|
WHERE sort_order = 0
|
||||||
|
)
|
||||||
|
UPDATE maps target
|
||||||
|
SET sort_order = ordered.next_sort_order
|
||||||
|
FROM ordered
|
||||||
|
WHERE target.id = ordered.id;
|
||||||
|
|
||||||
|
WITH ordered AS (
|
||||||
|
SELECT id, (row_number() OVER (ORDER BY created_at, id) * 10)::integer AS next_sort_order
|
||||||
|
FROM habitats
|
||||||
|
WHERE sort_order = 0
|
||||||
|
)
|
||||||
|
UPDATE habitats target
|
||||||
|
SET sort_order = ordered.next_sort_order
|
||||||
|
FROM ordered
|
||||||
|
WHERE target.id = ordered.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 favorite_things_sort_order_idx ON favorite_things(sort_order, id);
|
||||||
|
CREATE INDEX IF NOT EXISTS pokemon_sort_order_idx ON pokemon(sort_order, id);
|
||||||
|
CREATE INDEX IF NOT EXISTS item_categories_sort_order_idx ON item_categories(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 items_sort_order_idx ON items(sort_order, id);
|
||||||
|
CREATE INDEX IF NOT EXISTS recipes_sort_order_idx ON recipes(sort_order, id);
|
||||||
|
CREATE INDEX IF NOT EXISTS maps_sort_order_idx ON maps(sort_order, id);
|
||||||
|
CREATE INDEX IF NOT EXISTS habitats_sort_order_idx ON habitats(sort_order, id);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS wiki_edit_logs (
|
CREATE TABLE IF NOT EXISTS wiki_edit_logs (
|
||||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
@@ -262,9 +465,12 @@ CREATE TABLE IF NOT EXISTS wiki_edit_logs (
|
|||||||
entity_id integer NOT NULL,
|
entity_id integer NOT NULL,
|
||||||
action text NOT NULL CHECK (action IN ('create', 'update', 'delete')),
|
action text NOT NULL CHECK (action IN ('create', 'update', 'delete')),
|
||||||
user_id integer REFERENCES users(id) ON DELETE SET NULL,
|
user_id integer REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
changes jsonb NOT NULL DEFAULT '[]'::jsonb,
|
||||||
created_at timestamptz NOT NULL DEFAULT now()
|
created_at timestamptz NOT NULL DEFAULT now()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
ALTER TABLE wiki_edit_logs ADD COLUMN IF NOT EXISTS changes jsonb NOT NULL DEFAULT '[]'::jsonb;
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS wiki_edit_logs_entity_idx
|
CREATE INDEX IF NOT EXISTS wiki_edit_logs_entity_idx
|
||||||
ON wiki_edit_logs(entity_type, entity_id, created_at DESC);
|
ON wiki_edit_logs(entity_type, entity_id, created_at DESC);
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ const scrypt = promisify(scryptCallback);
|
|||||||
const passwordKeyLength = 64;
|
const passwordKeyLength = 64;
|
||||||
const verificationTokenHours = 24;
|
const verificationTokenHours = 24;
|
||||||
const sessionDays = 30;
|
const sessionDays = 30;
|
||||||
|
const defaultLocale = 'en';
|
||||||
|
|
||||||
type DbClient = PoolClient;
|
type DbClient = PoolClient;
|
||||||
|
|
||||||
@@ -23,6 +24,22 @@ type LoginUserRow = UserRow & {
|
|||||||
password_hash: string;
|
password_hash: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type AuthMessageKey =
|
||||||
|
| 'emailRequired'
|
||||||
|
| 'invalidEmail'
|
||||||
|
| 'displayNameRequired'
|
||||||
|
| 'displayNameLength'
|
||||||
|
| 'passwordLength'
|
||||||
|
| 'invalidToken'
|
||||||
|
| 'emailAlreadyRegistered'
|
||||||
|
| 'checkVerificationEmail'
|
||||||
|
| 'emailVerified'
|
||||||
|
| 'invalidCredentials'
|
||||||
|
| 'verifyEmailFirst'
|
||||||
|
| 'emailSubject'
|
||||||
|
| 'emailHtml'
|
||||||
|
| 'emailText';
|
||||||
|
|
||||||
export type AuthUser = {
|
export type AuthUser = {
|
||||||
id: number;
|
id: number;
|
||||||
email: string;
|
email: string;
|
||||||
@@ -36,43 +53,87 @@ function statusError(message: string, statusCode: number): StatusError {
|
|||||||
return error;
|
return error;
|
||||||
}
|
}
|
||||||
|
|
||||||
function cleanEmail(value: unknown): string {
|
function authMessage(locale: string, key: AuthMessageKey, params: Record<string, string | number> = {}): string {
|
||||||
|
const messages: Record<string, Record<AuthMessageKey, string>> = {
|
||||||
|
en: {
|
||||||
|
emailRequired: 'Email is required',
|
||||||
|
invalidEmail: 'Email format is invalid',
|
||||||
|
displayNameRequired: 'Display name is required',
|
||||||
|
displayNameLength: 'Display name must be 1 to 40 characters',
|
||||||
|
passwordLength: 'Password must be at least 8 characters',
|
||||||
|
invalidToken: 'The verification link is invalid or expired',
|
||||||
|
emailAlreadyRegistered: 'This email is already registered',
|
||||||
|
checkVerificationEmail: 'Please check your verification email',
|
||||||
|
emailVerified: 'Email verified',
|
||||||
|
invalidCredentials: 'Email or password is incorrect',
|
||||||
|
verifyEmailFirst: 'Please complete email verification first',
|
||||||
|
emailSubject: 'Verify your Pokopia Wiki email',
|
||||||
|
emailHtml:
|
||||||
|
'<p>Open the link below to verify your email:</p><p><a href="{url}">Verify email</a></p><p>The link expires in {hours} hours.</p>',
|
||||||
|
emailText: 'Open this link to verify your Pokopia Wiki email: {url}\nThe link expires in {hours} hours.'
|
||||||
|
},
|
||||||
|
'zh-CN': {
|
||||||
|
emailRequired: '请输入邮箱',
|
||||||
|
invalidEmail: '邮箱格式不正确',
|
||||||
|
displayNameRequired: '请输入显示名',
|
||||||
|
displayNameLength: '显示名长度需为 1 到 40 个字符',
|
||||||
|
passwordLength: '密码至少需要 8 个字符',
|
||||||
|
invalidToken: '验证链接无效或已过期',
|
||||||
|
emailAlreadyRegistered: '该邮箱已注册',
|
||||||
|
checkVerificationEmail: '请查收验证邮件',
|
||||||
|
emailVerified: '邮箱已验证',
|
||||||
|
invalidCredentials: '邮箱或密码不正确',
|
||||||
|
verifyEmailFirst: '请先完成邮箱验证',
|
||||||
|
emailSubject: '验证你的 Pokopia Wiki 邮箱',
|
||||||
|
emailHtml: '<p>请点击下面的链接完成邮箱验证:</p><p><a href="{url}">验证邮箱</a></p><p>链接将在 {hours} 小时后失效。</p>',
|
||||||
|
emailText: '请打开以下链接完成 Pokopia Wiki 邮箱验证:{url}\n链接将在 {hours} 小时后失效。'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let message = messages[locale]?.[key] ?? messages[defaultLocale][key];
|
||||||
|
for (const [paramKey, paramValue] of Object.entries(params)) {
|
||||||
|
message = message.replaceAll(`{${paramKey}}`, String(paramValue));
|
||||||
|
}
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanEmail(value: unknown, locale: string): string {
|
||||||
if (typeof value !== 'string') {
|
if (typeof value !== 'string') {
|
||||||
throw statusError('请输入邮箱', 400);
|
throw statusError(authMessage(locale, 'emailRequired'), 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const email = value.trim().toLowerCase();
|
const email = value.trim().toLowerCase();
|
||||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||||
throw statusError('邮箱格式不正确', 400);
|
throw statusError(authMessage(locale, 'invalidEmail'), 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
return email;
|
return email;
|
||||||
}
|
}
|
||||||
|
|
||||||
function cleanDisplayName(value: unknown): string {
|
function cleanDisplayName(value: unknown, locale: string): string {
|
||||||
if (typeof value !== 'string') {
|
if (typeof value !== 'string') {
|
||||||
throw statusError('请输入显示名', 400);
|
throw statusError(authMessage(locale, 'displayNameRequired'), 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const displayName = value.trim();
|
const displayName = value.trim();
|
||||||
if (displayName.length < 1 || displayName.length > 40) {
|
if (displayName.length < 1 || displayName.length > 40) {
|
||||||
throw statusError('显示名长度需为 1 到 40 个字符', 400);
|
throw statusError(authMessage(locale, 'displayNameLength'), 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
return displayName;
|
return displayName;
|
||||||
}
|
}
|
||||||
|
|
||||||
function cleanPassword(value: unknown): string {
|
function cleanPassword(value: unknown, locale: string): string {
|
||||||
if (typeof value !== 'string' || value.length < 8) {
|
if (typeof value !== 'string' || value.length < 8) {
|
||||||
throw statusError('密码至少需要 8 个字符', 400);
|
throw statusError(authMessage(locale, 'passwordLength'), 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
function cleanToken(value: unknown): string {
|
function cleanToken(value: unknown, locale: string): string {
|
||||||
if (typeof value !== 'string' || value.trim().length < 32) {
|
if (typeof value !== 'string' || value.trim().length < 32) {
|
||||||
throw statusError('验证链接无效或已过期', 400);
|
throw statusError(authMessage(locale, 'invalidToken'), 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
return value.trim();
|
return value.trim();
|
||||||
@@ -155,7 +216,7 @@ function buildVerificationUrl(token: string): string {
|
|||||||
return url.toString();
|
return url.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendVerificationEmail(email: string, token: string): Promise<void> {
|
async function sendVerificationEmail(email: string, token: string, locale: string): Promise<void> {
|
||||||
const { apiKey, from } = getEmailConfig();
|
const { apiKey, from } = getEmailConfig();
|
||||||
const verificationUrl = buildVerificationUrl(token);
|
const verificationUrl = buildVerificationUrl(token);
|
||||||
const response = await fetch('https://api.resend.com/emails', {
|
const response = await fetch('https://api.resend.com/emails', {
|
||||||
@@ -167,9 +228,9 @@ async function sendVerificationEmail(email: string, token: string): Promise<void
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
from,
|
from,
|
||||||
to: [email],
|
to: [email],
|
||||||
subject: '验证你的 Pokopia Wiki 邮箱',
|
subject: authMessage(locale, 'emailSubject'),
|
||||||
html: `<p>请点击下面的链接完成邮箱验证:</p><p><a href="${verificationUrl}">验证邮箱</a></p><p>链接将在 ${verificationTokenHours} 小时后失效。</p>`,
|
html: authMessage(locale, 'emailHtml', { url: verificationUrl, hours: verificationTokenHours }),
|
||||||
text: `请打开以下链接完成 Pokopia Wiki 邮箱验证:${verificationUrl}\n链接将在 ${verificationTokenHours} 小时后失效。`
|
text: authMessage(locale, 'emailText', { url: verificationUrl, hours: verificationTokenHours })
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -179,10 +240,10 @@ async function sendVerificationEmail(email: string, token: string): Promise<void
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function registerUser(payload: Record<string, unknown>) {
|
export async function registerUser(payload: Record<string, unknown>, locale = defaultLocale) {
|
||||||
const email = cleanEmail(payload.email);
|
const email = cleanEmail(payload.email, locale);
|
||||||
const displayName = cleanDisplayName(payload.displayName);
|
const displayName = cleanDisplayName(payload.displayName, locale);
|
||||||
const password = cleanPassword(payload.password);
|
const password = cleanPassword(payload.password, locale);
|
||||||
const passwordHash = await hashPassword(password);
|
const passwordHash = await hashPassword(password);
|
||||||
const verificationToken = createPlainToken();
|
const verificationToken = createPlainToken();
|
||||||
const verificationTokenHash = hashToken(verificationToken);
|
const verificationTokenHash = hashToken(verificationToken);
|
||||||
@@ -195,7 +256,7 @@ export async function registerUser(payload: Record<string, unknown>) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (existingUser?.email_verified_at) {
|
if (existingUser?.email_verified_at) {
|
||||||
throw statusError('该邮箱已注册', 409);
|
throw statusError(authMessage(locale, 'emailAlreadyRegistered'), 409);
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = existingUser
|
const user = existingUser
|
||||||
@@ -233,12 +294,12 @@ export async function registerUser(payload: Record<string, unknown>) {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
await sendVerificationEmail(email, verificationToken);
|
await sendVerificationEmail(email, verificationToken, locale);
|
||||||
return { message: '请查收验证邮件' };
|
return { message: authMessage(locale, 'checkVerificationEmail') };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function verifyEmail(payload: Record<string, unknown>) {
|
export async function verifyEmail(payload: Record<string, unknown>, locale = defaultLocale) {
|
||||||
const token = cleanToken(payload.token);
|
const token = cleanToken(payload.token, locale);
|
||||||
const tokenHash = hashToken(token);
|
const tokenHash = hashToken(token);
|
||||||
|
|
||||||
return withTransaction(async (client) => {
|
return withTransaction(async (client) => {
|
||||||
@@ -256,7 +317,7 @@ export async function verifyEmail(payload: Record<string, unknown>) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (!tokenRow) {
|
if (!tokenRow) {
|
||||||
throw statusError('验证链接无效或已过期', 400);
|
throw statusError(authMessage(locale, 'invalidToken'), 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await clientQueryOne<UserRow>(
|
const user = await clientQueryOne<UserRow>(
|
||||||
@@ -271,31 +332,31 @@ export async function verifyEmail(payload: Record<string, unknown>) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw statusError('验证链接无效或已过期', 400);
|
throw statusError(authMessage(locale, 'invalidToken'), 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
await client.query('UPDATE email_verification_tokens SET used_at = now() WHERE user_id = $1 AND used_at IS NULL', [
|
await client.query('UPDATE email_verification_tokens SET used_at = now() WHERE user_id = $1 AND used_at IS NULL', [
|
||||||
user.id
|
user.id
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return { message: '邮箱已验证', user: toPublicUser(user) };
|
return { message: authMessage(locale, 'emailVerified'), user: toPublicUser(user) };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loginUser(payload: Record<string, unknown>) {
|
export async function loginUser(payload: Record<string, unknown>, locale = defaultLocale) {
|
||||||
const email = cleanEmail(payload.email);
|
const email = cleanEmail(payload.email, locale);
|
||||||
const password = cleanPassword(payload.password);
|
const password = cleanPassword(payload.password, locale);
|
||||||
const user = await queryOne<LoginUserRow>(
|
const user = await queryOne<LoginUserRow>(
|
||||||
'SELECT id, email, display_name, email_verified_at, password_hash FROM users WHERE email = $1',
|
'SELECT id, email, display_name, email_verified_at, password_hash FROM users WHERE email = $1',
|
||||||
[email]
|
[email]
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!user || !(await verifyPassword(password, user.password_hash))) {
|
if (!user || !(await verifyPassword(password, user.password_hash))) {
|
||||||
throw statusError('邮箱或密码不正确', 401);
|
throw statusError(authMessage(locale, 'invalidCredentials'), 401);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!user.email_verified_at) {
|
if (!user.email_verified_at) {
|
||||||
throw statusError('请先完成邮箱验证', 403);
|
throw statusError(authMessage(locale, 'verifyEmailFirst'), 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
const sessionToken = createPlainToken();
|
const sessionToken = createPlainToken();
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -4,14 +4,19 @@ import type { FastifyReply, FastifyRequest } from 'fastify';
|
|||||||
import { getUserBySessionToken, loginUser, logoutSession, registerUser, verifyEmail, type AuthUser } from './auth.ts';
|
import { getUserBySessionToken, loginUser, logoutSession, registerUser, verifyEmail, type AuthUser } from './auth.ts';
|
||||||
import { initializeDatabase, pool } from './db.ts';
|
import { initializeDatabase, pool } from './db.ts';
|
||||||
import {
|
import {
|
||||||
|
cleanLocale,
|
||||||
createConfig,
|
createConfig,
|
||||||
|
createDailyChecklistItem,
|
||||||
createHabitat,
|
createHabitat,
|
||||||
createItem,
|
createItem,
|
||||||
|
createLanguage,
|
||||||
createPokemon,
|
createPokemon,
|
||||||
createRecipe,
|
createRecipe,
|
||||||
deleteConfig,
|
deleteConfig,
|
||||||
|
deleteDailyChecklistItem,
|
||||||
deleteHabitat,
|
deleteHabitat,
|
||||||
deleteItem,
|
deleteItem,
|
||||||
|
deleteLanguage,
|
||||||
deletePokemon,
|
deletePokemon,
|
||||||
deleteRecipe,
|
deleteRecipe,
|
||||||
getHabitat,
|
getHabitat,
|
||||||
@@ -21,13 +26,24 @@ import {
|
|||||||
getRecipe,
|
getRecipe,
|
||||||
isConfigType,
|
isConfigType,
|
||||||
listConfig,
|
listConfig,
|
||||||
|
listDailyChecklistItems,
|
||||||
listHabitats,
|
listHabitats,
|
||||||
listItems,
|
listItems,
|
||||||
|
listLanguages,
|
||||||
listPokemon,
|
listPokemon,
|
||||||
listRecipes,
|
listRecipes,
|
||||||
|
reorderConfig,
|
||||||
|
reorderDailyChecklistItems,
|
||||||
|
reorderHabitats,
|
||||||
|
reorderItems,
|
||||||
|
reorderLanguages,
|
||||||
|
reorderPokemon,
|
||||||
|
reorderRecipes,
|
||||||
updateConfig,
|
updateConfig,
|
||||||
|
updateDailyChecklistItem,
|
||||||
updateHabitat,
|
updateHabitat,
|
||||||
updateItem,
|
updateItem,
|
||||||
|
updateLanguage,
|
||||||
updatePokemon,
|
updatePokemon,
|
||||||
updateRecipe
|
updateRecipe
|
||||||
} from './queries.ts';
|
} from './queries.ts';
|
||||||
@@ -37,24 +53,25 @@ const app = Fastify({
|
|||||||
});
|
});
|
||||||
|
|
||||||
await app.register(cors, {
|
await app.register(cors, {
|
||||||
allowedHeaders: ['Authorization', 'Content-Type'],
|
allowedHeaders: ['Authorization', 'Content-Type', 'X-Locale'],
|
||||||
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
|
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
|
||||||
origin: process.env.FRONTEND_ORIGIN ?? true
|
origin: process.env.FRONTEND_ORIGIN ?? true
|
||||||
});
|
});
|
||||||
|
|
||||||
app.setErrorHandler(async (error, _request, reply) => {
|
app.setErrorHandler(async (error, _request, reply) => {
|
||||||
const pgError = error as Error & { code?: string; constraint?: string; detail?: string; statusCode?: number };
|
const pgError = error as Error & { code?: string; constraint?: string; detail?: string; statusCode?: number };
|
||||||
|
const locale = requestLocale(_request);
|
||||||
|
|
||||||
if (pgError.code === '23503') {
|
if (pgError.code === '23503') {
|
||||||
return reply.code(409).send({ message: '引用的数据不存在,或当前记录正在被使用' });
|
return reply.code(409).send({ message: serverMessage(locale, 'foreignKey') });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pgError.code === '23505') {
|
if (pgError.code === '23505') {
|
||||||
return reply.code(409).send({ message: '同名或相同 ID 的记录已存在' });
|
return reply.code(409).send({ message: serverMessage(locale, 'duplicate') });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pgError.code === '23514') {
|
if (pgError.code === '23514') {
|
||||||
return reply.code(400).send({ message: '字段值不合法' });
|
return reply.code(400).send({ message: serverMessage(locale, 'invalidField') });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pgError.statusCode && pgError.statusCode < 500) {
|
if (pgError.statusCode && pgError.statusCode < 500) {
|
||||||
@@ -62,7 +79,7 @@ app.setErrorHandler(async (error, _request, reply) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
app.log.error(error);
|
app.log.error(error);
|
||||||
return reply.code(500).send({ message: '服务器错误' });
|
return reply.code(500).send({ message: serverMessage(locale, 'serverError') });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/health', async () => ({ ok: true }));
|
app.get('/health', async () => ({ ok: true }));
|
||||||
@@ -72,17 +89,48 @@ function getBearerToken(authorization: string | undefined): string | null {
|
|||||||
return scheme === 'Bearer' && token ? token : null;
|
return scheme === 'Bearer' && token ? token : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function requestLocale(request: FastifyRequest): string {
|
||||||
|
const query = request.query as Record<string, string | string[] | undefined>;
|
||||||
|
const queryLocale = Array.isArray(query.locale) ? query.locale[0] : query.locale;
|
||||||
|
const headerLocale = request.headers['x-locale'];
|
||||||
|
return cleanLocale(queryLocale ?? (Array.isArray(headerLocale) ? headerLocale[0] : headerLocale));
|
||||||
|
}
|
||||||
|
|
||||||
|
function serverMessage(locale: string, key: 'foreignKey' | 'duplicate' | 'invalidField' | 'serverError' | 'loginRequired' | 'verifyEmailFirst'): string {
|
||||||
|
const messages = {
|
||||||
|
en: {
|
||||||
|
foreignKey: 'Referenced data does not exist or the record is currently in use',
|
||||||
|
duplicate: 'A record with the same name or ID already exists',
|
||||||
|
invalidField: 'Field value is invalid',
|
||||||
|
serverError: 'Server error',
|
||||||
|
loginRequired: 'Please log in first',
|
||||||
|
verifyEmailFirst: 'Please complete email verification first'
|
||||||
|
},
|
||||||
|
'zh-CN': {
|
||||||
|
foreignKey: '引用的数据不存在,或当前记录正在被使用',
|
||||||
|
duplicate: '同名或相同 ID 的记录已存在',
|
||||||
|
invalidField: '字段值不合法',
|
||||||
|
serverError: '服务器错误',
|
||||||
|
loginRequired: '请先登录',
|
||||||
|
verifyEmailFirst: '请先完成邮箱验证'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return messages[locale as keyof typeof messages]?.[key] ?? messages.en[key];
|
||||||
|
}
|
||||||
|
|
||||||
async function requireVerifiedUser(request: FastifyRequest, reply: FastifyReply): Promise<AuthUser | null> {
|
async function requireVerifiedUser(request: FastifyRequest, reply: FastifyReply): Promise<AuthUser | null> {
|
||||||
const token = getBearerToken(request.headers.authorization);
|
const token = getBearerToken(request.headers.authorization);
|
||||||
const user = token ? await getUserBySessionToken(token) : null;
|
const user = token ? await getUserBySessionToken(token) : null;
|
||||||
|
const locale = requestLocale(request);
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
reply.code(401).send({ message: '请先登录' });
|
reply.code(401).send({ message: serverMessage(locale, 'loginRequired') });
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!user.emailVerified) {
|
if (!user.emailVerified) {
|
||||||
reply.code(403).send({ message: '请先完成邮箱验证' });
|
reply.code(403).send({ message: serverMessage(locale, 'verifyEmailFirst') });
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,19 +138,19 @@ async function requireVerifiedUser(request: FastifyRequest, reply: FastifyReply)
|
|||||||
}
|
}
|
||||||
|
|
||||||
app.post('/api/auth/register', async (request, reply) =>
|
app.post('/api/auth/register', async (request, reply) =>
|
||||||
reply.code(201).send(await registerUser(request.body as Record<string, unknown>))
|
reply.code(201).send(await registerUser(request.body as Record<string, unknown>, requestLocale(request)))
|
||||||
);
|
);
|
||||||
|
|
||||||
app.post('/api/auth/verify-email', async (request) => verifyEmail(request.body as Record<string, unknown>));
|
app.post('/api/auth/verify-email', async (request) => verifyEmail(request.body as Record<string, unknown>, requestLocale(request)));
|
||||||
|
|
||||||
app.post('/api/auth/login', async (request) => loginUser(request.body as Record<string, unknown>));
|
app.post('/api/auth/login', async (request) => loginUser(request.body as Record<string, unknown>, requestLocale(request)));
|
||||||
|
|
||||||
app.get('/api/auth/me', async (request, reply) => {
|
app.get('/api/auth/me', async (request, reply) => {
|
||||||
const token = getBearerToken(request.headers.authorization);
|
const token = getBearerToken(request.headers.authorization);
|
||||||
const user = token ? await getUserBySessionToken(token) : null;
|
const user = token ? await getUserBySessionToken(token) : null;
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return reply.code(401).send({ message: '请先登录' });
|
return reply.code(401).send({ message: serverMessage(requestLocale(request), 'loginRequired') });
|
||||||
}
|
}
|
||||||
|
|
||||||
return { user };
|
return { user };
|
||||||
@@ -117,13 +165,19 @@ app.post('/api/auth/logout', async (request, reply) => {
|
|||||||
return reply.code(204).send();
|
return reply.code(204).send();
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/options', async () => getOptions());
|
app.get('/api/languages', async () => listLanguages());
|
||||||
|
|
||||||
app.get('/api/pokemon', async (request) => listPokemon(request.query as Record<string, string | string[] | undefined>));
|
app.get('/api/options', async (request) => getOptions(requestLocale(request)));
|
||||||
|
|
||||||
|
app.get('/api/daily-checklist', async (request) => listDailyChecklistItems(requestLocale(request)));
|
||||||
|
|
||||||
|
app.get('/api/pokemon', async (request) =>
|
||||||
|
listPokemon(request.query as Record<string, string | string[] | undefined>, requestLocale(request))
|
||||||
|
);
|
||||||
|
|
||||||
app.get('/api/pokemon/:id', async (request, reply) => {
|
app.get('/api/pokemon/:id', async (request, reply) => {
|
||||||
const { id } = request.params as { id: string };
|
const { id } = request.params as { id: string };
|
||||||
const pokemon = await getPokemon(Number(id));
|
const pokemon = await getPokemon(Number(id), requestLocale(request));
|
||||||
|
|
||||||
if (!pokemon) {
|
if (!pokemon) {
|
||||||
return reply.code(404).send({ message: 'Not found' });
|
return reply.code(404).send({ message: 'Not found' });
|
||||||
@@ -134,7 +188,9 @@ app.get('/api/pokemon/:id', async (request, reply) => {
|
|||||||
|
|
||||||
app.post('/api/pokemon', async (request, reply) => {
|
app.post('/api/pokemon', async (request, reply) => {
|
||||||
const user = await requireVerifiedUser(request, reply);
|
const user = await requireVerifiedUser(request, reply);
|
||||||
return user ? reply.code(201).send(await createPokemon(request.body as Record<string, unknown>, user.id)) : undefined;
|
return user
|
||||||
|
? reply.code(201).send(await createPokemon(request.body as Record<string, unknown>, user.id, requestLocale(request)))
|
||||||
|
: undefined;
|
||||||
});
|
});
|
||||||
|
|
||||||
app.put('/api/pokemon/:id', async (request, reply) => {
|
app.put('/api/pokemon/:id', async (request, reply) => {
|
||||||
@@ -143,7 +199,7 @@ app.put('/api/pokemon/:id', async (request, reply) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const { id } = request.params as { id: string };
|
const { id } = request.params as { id: string };
|
||||||
const pokemon = await updatePokemon(Number(id), request.body as Record<string, unknown>, user.id);
|
const pokemon = await updatePokemon(Number(id), request.body as Record<string, unknown>, user.id, requestLocale(request));
|
||||||
|
|
||||||
if (!pokemon) {
|
if (!pokemon) {
|
||||||
return reply.code(404).send({ message: 'Not found' });
|
return reply.code(404).send({ message: 'Not found' });
|
||||||
@@ -162,11 +218,11 @@ app.delete('/api/pokemon/:id', async (request, reply) => {
|
|||||||
return deleted ? reply.code(204).send() : reply.code(404).send({ message: 'Not found' });
|
return deleted ? reply.code(204).send() : reply.code(404).send({ message: 'Not found' });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/habitats', async () => listHabitats());
|
app.get('/api/habitats', async (request) => listHabitats(requestLocale(request)));
|
||||||
|
|
||||||
app.get('/api/habitats/:id', async (request, reply) => {
|
app.get('/api/habitats/:id', async (request, reply) => {
|
||||||
const { id } = request.params as { id: string };
|
const { id } = request.params as { id: string };
|
||||||
const habitat = await getHabitat(Number(id));
|
const habitat = await getHabitat(Number(id), requestLocale(request));
|
||||||
|
|
||||||
if (!habitat) {
|
if (!habitat) {
|
||||||
return reply.code(404).send({ message: 'Not found' });
|
return reply.code(404).send({ message: 'Not found' });
|
||||||
@@ -177,7 +233,9 @@ app.get('/api/habitats/:id', async (request, reply) => {
|
|||||||
|
|
||||||
app.post('/api/habitats', async (request, reply) => {
|
app.post('/api/habitats', async (request, reply) => {
|
||||||
const user = await requireVerifiedUser(request, reply);
|
const user = await requireVerifiedUser(request, reply);
|
||||||
return user ? reply.code(201).send(await createHabitat(request.body as Record<string, unknown>, user.id)) : undefined;
|
return user
|
||||||
|
? reply.code(201).send(await createHabitat(request.body as Record<string, unknown>, user.id, requestLocale(request)))
|
||||||
|
: undefined;
|
||||||
});
|
});
|
||||||
|
|
||||||
app.put('/api/habitats/:id', async (request, reply) => {
|
app.put('/api/habitats/:id', async (request, reply) => {
|
||||||
@@ -186,7 +244,7 @@ app.put('/api/habitats/:id', async (request, reply) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const { id } = request.params as { id: string };
|
const { id } = request.params as { id: string };
|
||||||
const habitat = await updateHabitat(Number(id), request.body as Record<string, unknown>, user.id);
|
const habitat = await updateHabitat(Number(id), request.body as Record<string, unknown>, user.id, requestLocale(request));
|
||||||
|
|
||||||
if (!habitat) {
|
if (!habitat) {
|
||||||
return reply.code(404).send({ message: 'Not found' });
|
return reply.code(404).send({ message: 'Not found' });
|
||||||
@@ -205,11 +263,13 @@ app.delete('/api/habitats/:id', async (request, reply) => {
|
|||||||
return deleted ? reply.code(204).send() : reply.code(404).send({ message: 'Not found' });
|
return deleted ? reply.code(204).send() : reply.code(404).send({ message: 'Not found' });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/items', async (request) => listItems(request.query as Record<string, string | string[] | undefined>));
|
app.get('/api/items', async (request) =>
|
||||||
|
listItems(request.query as Record<string, string | string[] | undefined>, requestLocale(request))
|
||||||
|
);
|
||||||
|
|
||||||
app.get('/api/items/:id', async (request, reply) => {
|
app.get('/api/items/:id', async (request, reply) => {
|
||||||
const { id } = request.params as { id: string };
|
const { id } = request.params as { id: string };
|
||||||
const item = await getItem(Number(id));
|
const item = await getItem(Number(id), requestLocale(request));
|
||||||
|
|
||||||
if (!item) {
|
if (!item) {
|
||||||
return reply.code(404).send({ message: 'Not found' });
|
return reply.code(404).send({ message: 'Not found' });
|
||||||
@@ -220,7 +280,9 @@ app.get('/api/items/:id', async (request, reply) => {
|
|||||||
|
|
||||||
app.post('/api/items', async (request, reply) => {
|
app.post('/api/items', async (request, reply) => {
|
||||||
const user = await requireVerifiedUser(request, reply);
|
const user = await requireVerifiedUser(request, reply);
|
||||||
return user ? reply.code(201).send(await createItem(request.body as Record<string, unknown>, user.id)) : undefined;
|
return user
|
||||||
|
? reply.code(201).send(await createItem(request.body as Record<string, unknown>, user.id, requestLocale(request)))
|
||||||
|
: undefined;
|
||||||
});
|
});
|
||||||
|
|
||||||
app.put('/api/items/:id', async (request, reply) => {
|
app.put('/api/items/:id', async (request, reply) => {
|
||||||
@@ -229,7 +291,7 @@ app.put('/api/items/:id', async (request, reply) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const { id } = request.params as { id: string };
|
const { id } = request.params as { id: string };
|
||||||
const item = await updateItem(Number(id), request.body as Record<string, unknown>, user.id);
|
const item = await updateItem(Number(id), request.body as Record<string, unknown>, user.id, requestLocale(request));
|
||||||
|
|
||||||
if (!item) {
|
if (!item) {
|
||||||
return reply.code(404).send({ message: 'Not found' });
|
return reply.code(404).send({ message: 'Not found' });
|
||||||
@@ -248,11 +310,13 @@ app.delete('/api/items/:id', async (request, reply) => {
|
|||||||
return deleted ? reply.code(204).send() : reply.code(404).send({ message: 'Not found' });
|
return deleted ? reply.code(204).send() : reply.code(404).send({ message: 'Not found' });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/recipes', async (request) => listRecipes(request.query as Record<string, string | string[] | undefined>));
|
app.get('/api/recipes', async (request) =>
|
||||||
|
listRecipes(request.query as Record<string, string | string[] | undefined>, requestLocale(request))
|
||||||
|
);
|
||||||
|
|
||||||
app.get('/api/recipes/:id', async (request, reply) => {
|
app.get('/api/recipes/:id', async (request, reply) => {
|
||||||
const { id } = request.params as { id: string };
|
const { id } = request.params as { id: string };
|
||||||
const recipe = await getRecipe(Number(id));
|
const recipe = await getRecipe(Number(id), requestLocale(request));
|
||||||
|
|
||||||
if (!recipe) {
|
if (!recipe) {
|
||||||
return reply.code(404).send({ message: 'Not found' });
|
return reply.code(404).send({ message: 'Not found' });
|
||||||
@@ -263,7 +327,9 @@ app.get('/api/recipes/:id', async (request, reply) => {
|
|||||||
|
|
||||||
app.post('/api/recipes', async (request, reply) => {
|
app.post('/api/recipes', async (request, reply) => {
|
||||||
const user = await requireVerifiedUser(request, reply);
|
const user = await requireVerifiedUser(request, reply);
|
||||||
return user ? reply.code(201).send(await createRecipe(request.body as Record<string, unknown>, user.id)) : undefined;
|
return user
|
||||||
|
? reply.code(201).send(await createRecipe(request.body as Record<string, unknown>, user.id, requestLocale(request)))
|
||||||
|
: undefined;
|
||||||
});
|
});
|
||||||
|
|
||||||
app.put('/api/recipes/:id', async (request, reply) => {
|
app.put('/api/recipes/:id', async (request, reply) => {
|
||||||
@@ -272,7 +338,7 @@ app.put('/api/recipes/:id', async (request, reply) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const { id } = request.params as { id: string };
|
const { id } = request.params as { id: string };
|
||||||
const recipe = await updateRecipe(Number(id), request.body as Record<string, unknown>, user.id);
|
const recipe = await updateRecipe(Number(id), request.body as Record<string, unknown>, user.id, requestLocale(request));
|
||||||
|
|
||||||
if (!recipe) {
|
if (!recipe) {
|
||||||
return reply.code(404).send({ message: 'Not found' });
|
return reply.code(404).send({ message: 'Not found' });
|
||||||
@@ -291,6 +357,99 @@ app.delete('/api/recipes/:id', async (request, reply) => {
|
|||||||
return deleted ? reply.code(204).send() : reply.code(404).send({ message: 'Not found' });
|
return deleted ? reply.code(204).send() : reply.code(404).send({ message: 'Not found' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.post('/api/admin/daily-checklist', async (request, reply) => {
|
||||||
|
const user = await requireVerifiedUser(request, reply);
|
||||||
|
return user
|
||||||
|
? reply
|
||||||
|
.code(201)
|
||||||
|
.send(await createDailyChecklistItem(request.body as Record<string, unknown>, user.id, requestLocale(request)))
|
||||||
|
: undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put('/api/admin/daily-checklist/order', async (request, reply) => {
|
||||||
|
const user = await requireVerifiedUser(request, reply);
|
||||||
|
return user ? reorderDailyChecklistItems(request.body as Record<string, unknown>, user.id, requestLocale(request)) : undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put('/api/admin/daily-checklist/:id', async (request, reply) => {
|
||||||
|
const user = await requireVerifiedUser(request, reply);
|
||||||
|
if (!user) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
const item = await updateDailyChecklistItem(
|
||||||
|
Number(id),
|
||||||
|
request.body as Record<string, unknown>,
|
||||||
|
user.id,
|
||||||
|
requestLocale(request)
|
||||||
|
);
|
||||||
|
return item ? item : reply.code(404).send({ message: 'Not found' });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/admin/daily-checklist/:id', async (request, reply) => {
|
||||||
|
const user = await requireVerifiedUser(request, reply);
|
||||||
|
if (!user) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
const deleted = await deleteDailyChecklistItem(Number(id), user.id);
|
||||||
|
return deleted ? reply.code(204).send() : reply.code(404).send({ message: 'Not found' });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put('/api/admin/pokemon/order', async (request, reply) => {
|
||||||
|
const user = await requireVerifiedUser(request, reply);
|
||||||
|
return user ? reorderPokemon(request.body as Record<string, unknown>, user.id, requestLocale(request)) : undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put('/api/admin/items/order', async (request, reply) => {
|
||||||
|
const user = await requireVerifiedUser(request, reply);
|
||||||
|
return user ? reorderItems(request.body as Record<string, unknown>, user.id, requestLocale(request)) : undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put('/api/admin/recipes/order', async (request, reply) => {
|
||||||
|
const user = await requireVerifiedUser(request, reply);
|
||||||
|
return user ? reorderRecipes(request.body as Record<string, unknown>, user.id, requestLocale(request)) : undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put('/api/admin/habitats/order', async (request, reply) => {
|
||||||
|
const user = await requireVerifiedUser(request, reply);
|
||||||
|
return user ? reorderHabitats(request.body as Record<string, unknown>, user.id, requestLocale(request)) : undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/admin/languages', async (request, reply) => {
|
||||||
|
const user = await requireVerifiedUser(request, reply);
|
||||||
|
return user ? listLanguages(true) : undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/admin/languages', async (request, reply) => {
|
||||||
|
const user = await requireVerifiedUser(request, reply);
|
||||||
|
return user ? reply.code(201).send(await createLanguage(request.body as Record<string, unknown>)) : undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put('/api/admin/languages/order', async (request, reply) => {
|
||||||
|
const user = await requireVerifiedUser(request, reply);
|
||||||
|
return user ? reorderLanguages(request.body as Record<string, unknown>) : undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put('/api/admin/languages/:code', async (request, reply) => {
|
||||||
|
const user = await requireVerifiedUser(request, reply);
|
||||||
|
if (!user) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { code } = request.params as { code: string };
|
||||||
|
return updateLanguage(code, request.body as Record<string, unknown>);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/admin/languages/:code', async (request, reply) => {
|
||||||
|
const user = await requireVerifiedUser(request, reply);
|
||||||
|
if (!user) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { code } = request.params as { code: string };
|
||||||
|
const deleted = await deleteLanguage(code);
|
||||||
|
return deleted ? reply.code(204).send() : reply.code(404).send({ message: 'Not found' });
|
||||||
|
});
|
||||||
|
|
||||||
app.get('/api/admin/config/:type', async (request, reply) => {
|
app.get('/api/admin/config/:type', async (request, reply) => {
|
||||||
const user = await requireVerifiedUser(request, reply);
|
const user = await requireVerifiedUser(request, reply);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
@@ -300,7 +459,7 @@ app.get('/api/admin/config/:type', async (request, reply) => {
|
|||||||
if (!isConfigType(type)) {
|
if (!isConfigType(type)) {
|
||||||
return reply.code(404).send({ message: 'Not found' });
|
return reply.code(404).send({ message: 'Not found' });
|
||||||
}
|
}
|
||||||
return listConfig(type);
|
return listConfig(type, requestLocale(request));
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/api/admin/config/:type', async (request, reply) => {
|
app.post('/api/admin/config/:type', async (request, reply) => {
|
||||||
@@ -312,7 +471,21 @@ app.post('/api/admin/config/:type', async (request, reply) => {
|
|||||||
if (!isConfigType(type)) {
|
if (!isConfigType(type)) {
|
||||||
return reply.code(404).send({ message: 'Not found' });
|
return reply.code(404).send({ message: 'Not found' });
|
||||||
}
|
}
|
||||||
return reply.code(201).send(await createConfig(type, request.body as Record<string, unknown>, user.id));
|
return reply
|
||||||
|
.code(201)
|
||||||
|
.send(await createConfig(type, request.body as Record<string, unknown>, user.id, requestLocale(request)));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put('/api/admin/config/:type/order', async (request, reply) => {
|
||||||
|
const user = await requireVerifiedUser(request, reply);
|
||||||
|
if (!user) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { type } = request.params as { type: string };
|
||||||
|
if (!isConfigType(type)) {
|
||||||
|
return reply.code(404).send({ message: 'Not found' });
|
||||||
|
}
|
||||||
|
return reorderConfig(type, request.body as Record<string, unknown>, user.id, requestLocale(request));
|
||||||
});
|
});
|
||||||
|
|
||||||
app.put('/api/admin/config/:type/:id', async (request, reply) => {
|
app.put('/api/admin/config/:type/:id', async (request, reply) => {
|
||||||
@@ -324,7 +497,7 @@ app.put('/api/admin/config/:type/:id', async (request, reply) => {
|
|||||||
if (!isConfigType(type)) {
|
if (!isConfigType(type)) {
|
||||||
return reply.code(404).send({ message: 'Not found' });
|
return reply.code(404).send({ message: 'Not found' });
|
||||||
}
|
}
|
||||||
const config = await updateConfig(type, Number(id), request.body as Record<string, unknown>, user.id);
|
const config = await updateConfig(type, Number(id), request.body as Record<string, unknown>, user.id, requestLocale(request));
|
||||||
return config ? config : reply.code(404).send({ message: 'Not found' });
|
return config ? config : reply.code(404).send({ message: 'Not found' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="zh-CN">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
|||||||
@@ -12,9 +12,11 @@
|
|||||||
"test": "vitest run"
|
"test": "vitest run"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@iconify/vue": "^5.0.0",
|
||||||
"@vitejs/plugin-vue": "latest",
|
"@vitejs/plugin-vue": "latest",
|
||||||
"vite": "latest",
|
"vite": "latest",
|
||||||
"vue": "latest",
|
"vue": "latest",
|
||||||
|
"vue-i18n": "^11.4.0",
|
||||||
"vue-router": "latest"
|
"vue-router": "latest"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -1,20 +1,31 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, onUnmounted, ref } from 'vue';
|
import { computed, onMounted, onUnmounted, ref } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
import AppShell from './components/AppShell.vue';
|
import AppShell from './components/AppShell.vue';
|
||||||
import { api, getAuthToken, onAuthTokenChange, setAuthToken, type AuthUser } from './services/api';
|
import { iconAdmin, iconChecklist, iconHabitat, iconItem, iconPokemon, iconRecipe } from './icons';
|
||||||
|
import { getCurrentLocale, onLocaleChange, setCurrentLocale } from './i18n';
|
||||||
|
import { api, getAuthToken, onAuthTokenChange, setAuthToken, type AuthUser, type Language } from './services/api';
|
||||||
|
|
||||||
const navItems = [
|
const { t, locale } = useI18n();
|
||||||
{ label: 'Pokemon', to: '/pokemon' },
|
|
||||||
{ label: '栖息地', to: '/habitats' },
|
|
||||||
{ label: '物品', to: '/items' },
|
|
||||||
{ label: '材料单', to: '/recipes' },
|
|
||||||
{ label: '管理', to: '/admin' }
|
|
||||||
];
|
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const currentUser = ref<AuthUser | null>(null);
|
const currentUser = ref<AuthUser | null>(null);
|
||||||
|
const languages = ref<Language[]>([
|
||||||
|
{ code: 'en', name: 'English', enabled: true, isDefault: true, sortOrder: 10 },
|
||||||
|
{ code: 'zh-CN', name: '简体中文', enabled: true, isDefault: false, sortOrder: 20 }
|
||||||
|
]);
|
||||||
let removeAuthListener: (() => void) | null = null;
|
let removeAuthListener: (() => void) | null = null;
|
||||||
|
let removeLocaleListener: (() => void) | null = null;
|
||||||
|
|
||||||
|
const navItems = computed(() => [
|
||||||
|
{ label: t('nav.pokemon'), to: '/pokemon', icon: iconPokemon },
|
||||||
|
{ label: t('nav.habitats'), to: '/habitats', icon: iconHabitat },
|
||||||
|
{ label: t('nav.items'), to: '/items', icon: iconItem },
|
||||||
|
{ label: t('nav.recipes'), to: '/recipes', icon: iconRecipe },
|
||||||
|
{ label: t('nav.checklist'), to: '/checklist', icon: iconChecklist },
|
||||||
|
{ label: t('nav.admin'), to: '/admin', icon: iconAdmin }
|
||||||
|
]);
|
||||||
|
|
||||||
async function loadCurrentUser() {
|
async function loadCurrentUser() {
|
||||||
if (!getAuthToken()) {
|
if (!getAuthToken()) {
|
||||||
@@ -43,20 +54,51 @@ async function logout() {
|
|||||||
await router.push('/pokemon');
|
await router.push('/pokemon');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadLanguages() {
|
||||||
|
try {
|
||||||
|
const loadedLanguages = await api.languages();
|
||||||
|
if (loadedLanguages.length) {
|
||||||
|
languages.value = loadedLanguages;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!languages.value.some((language) => language.code === getCurrentLocale() && language.enabled)) {
|
||||||
|
setCurrentLocale('en');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Keep the built-in language list when the API is not ready yet.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateLocale(value: string) {
|
||||||
|
setCurrentLocale(value);
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
void loadLanguages();
|
||||||
void loadCurrentUser();
|
void loadCurrentUser();
|
||||||
removeAuthListener = onAuthTokenChange(() => {
|
removeAuthListener = onAuthTokenChange(() => {
|
||||||
void loadCurrentUser();
|
void loadCurrentUser();
|
||||||
});
|
});
|
||||||
|
removeLocaleListener = onLocaleChange(() => {
|
||||||
|
void loadLanguages();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
removeAuthListener?.();
|
removeAuthListener?.();
|
||||||
|
removeLocaleListener?.();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AppShell :current-user="currentUser" :nav-items="navItems" @logout="logout">
|
<AppShell
|
||||||
<RouterView />
|
:current-user="currentUser"
|
||||||
|
:languages="languages"
|
||||||
|
:locale="locale"
|
||||||
|
:nav-items="navItems"
|
||||||
|
@logout="logout"
|
||||||
|
@update:locale="updateLocale"
|
||||||
|
>
|
||||||
|
<RouterView :key="locale" />
|
||||||
</AppShell>
|
</AppShell>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,15 +1,63 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { AuthUser } from '../services/api';
|
import { Icon } from '@iconify/vue';
|
||||||
|
import { onBeforeUnmount, onMounted, ref } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { iconLogin, iconLogout, iconRegister, iconTranslate, type AppIcon } from '../icons';
|
||||||
|
import type { AuthUser, Language } from '../services/api';
|
||||||
import PokeBallMark from './PokeBallMark.vue';
|
import PokeBallMark from './PokeBallMark.vue';
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
currentUser: AuthUser | null;
|
currentUser: AuthUser | null;
|
||||||
navItems: Array<{ label: string; to: string }>;
|
languages: Language[];
|
||||||
|
locale: string;
|
||||||
|
navItems: Array<{ label: string; to: string; icon?: AppIcon }>;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
defineEmits<{
|
const emit = defineEmits<{
|
||||||
logout: [];
|
logout: [];
|
||||||
|
'update:locale': [value: string];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
const languageMenu = ref<HTMLElement | null>(null);
|
||||||
|
const languageMenuButton = ref<HTMLButtonElement | null>(null);
|
||||||
|
const languageMenuOpen = ref(false);
|
||||||
|
|
||||||
|
function closeLanguageMenu() {
|
||||||
|
languageMenuOpen.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleLanguageMenu() {
|
||||||
|
languageMenuOpen.value = !languageMenuOpen.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectLocale(value: string) {
|
||||||
|
emit('update:locale', value);
|
||||||
|
closeLanguageMenu();
|
||||||
|
languageMenuButton.value?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDocumentPointerDown(event: PointerEvent) {
|
||||||
|
if (languageMenu.value && !languageMenu.value.contains(event.target as Node)) {
|
||||||
|
closeLanguageMenu();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onLanguageMenuKeydown(event: KeyboardEvent) {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
event.preventDefault();
|
||||||
|
closeLanguageMenu();
|
||||||
|
languageMenuButton.value?.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
document.addEventListener('pointerdown', onDocumentPointerDown);
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
document.removeEventListener('pointerdown', onDocumentPointerDown);
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -24,20 +72,60 @@ defineEmits<{
|
|||||||
</span>
|
</span>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
|
|
||||||
<nav class="nav-links" aria-label="主导航">
|
<nav class="nav-links" :aria-label="t('nav.main')">
|
||||||
<RouterLink v-for="item in navItems" :key="item.to" :to="item.to">
|
<RouterLink v-for="item in navItems" :key="item.to" :to="item.to">
|
||||||
|
<Icon v-if="item.icon" :icon="item.icon" class="ui-icon nav-links__icon" aria-hidden="true" />
|
||||||
{{ item.label }}
|
{{ item.label }}
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div class="auth-actions">
|
<div class="auth-actions">
|
||||||
|
<div ref="languageMenu" class="language-menu" @keydown="onLanguageMenuKeydown">
|
||||||
|
<button
|
||||||
|
ref="languageMenuButton"
|
||||||
|
class="language-menu__trigger"
|
||||||
|
type="button"
|
||||||
|
:aria-label="t('nav.language')"
|
||||||
|
:aria-expanded="languageMenuOpen"
|
||||||
|
aria-haspopup="menu"
|
||||||
|
@click="toggleLanguageMenu"
|
||||||
|
>
|
||||||
|
<Icon :icon="iconTranslate" class="language-menu__icon" aria-hidden="true" />
|
||||||
|
<span class="language-menu__glyph" aria-hidden="true">文/A</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div v-if="languageMenuOpen" class="language-menu__dropdown" role="menu">
|
||||||
|
<button
|
||||||
|
v-for="language in languages"
|
||||||
|
:key="language.code"
|
||||||
|
class="language-menu__item"
|
||||||
|
:class="{ active: language.code === locale }"
|
||||||
|
type="button"
|
||||||
|
role="menuitemradio"
|
||||||
|
:aria-checked="language.code === locale"
|
||||||
|
@click="selectLocale(language.code)"
|
||||||
|
>
|
||||||
|
<span>{{ language.name }}</span>
|
||||||
|
<span class="language-menu__code">{{ language.code }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<template v-if="currentUser">
|
<template v-if="currentUser">
|
||||||
<span class="auth-user">{{ currentUser.displayName || currentUser.email }}</span>
|
<span class="auth-user">{{ currentUser.displayName || currentUser.email }}</span>
|
||||||
<button class="ui-button ui-button--ghost ui-button--small" type="button" @click="$emit('logout')">退出</button>
|
<button class="ui-button ui-button--ghost ui-button--small" type="button" @click="$emit('logout')">
|
||||||
|
<Icon :icon="iconLogout" class="ui-icon" aria-hidden="true" />
|
||||||
|
{{ t('nav.logout') }}
|
||||||
|
</button>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<RouterLink class="ui-button ui-button--ghost ui-button--small" to="/login">登录</RouterLink>
|
<RouterLink class="ui-button ui-button--ghost ui-button--small" to="/login">
|
||||||
<RouterLink class="ui-button ui-button--primary ui-button--small" to="/register">注册</RouterLink>
|
<Icon :icon="iconLogin" class="ui-icon" aria-hidden="true" />
|
||||||
|
{{ t('nav.login') }}
|
||||||
|
</RouterLink>
|
||||||
|
<RouterLink class="ui-button ui-button--primary ui-button--small" to="/register">
|
||||||
|
<Icon :icon="iconRegister" class="ui-icon" aria-hidden="true" />
|
||||||
|
{{ t('nav.register') }}
|
||||||
|
</RouterLink>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
167
frontend/src/components/EditHistoryPanel.vue
Normal file
167
frontend/src/components/EditHistoryPanel.vue
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import type { EditHistoryAction, EditHistoryEntry, EditInfo, UserSummary } from '../services/api';
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
entity: EditInfo;
|
||||||
|
history: EditHistoryEntry[];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const { locale, t } = useI18n();
|
||||||
|
const changeLabelKeys: Record<string, string> = {
|
||||||
|
Name: 'common.name',
|
||||||
|
名字: 'common.name',
|
||||||
|
名称: 'common.name',
|
||||||
|
'Ideal Habitat': 'pages.pokemon.environment',
|
||||||
|
'Favorite environment': 'pages.pokemon.environment',
|
||||||
|
喜欢的环境: 'pages.pokemon.environment',
|
||||||
|
Specialities: 'pages.pokemon.skills',
|
||||||
|
Skills: 'pages.pokemon.skills',
|
||||||
|
特长: 'pages.pokemon.skills',
|
||||||
|
Favourites: 'pages.pokemon.favoriteThings',
|
||||||
|
'Favorite things': 'pages.pokemon.favoriteThings',
|
||||||
|
喜欢的东西: 'pages.pokemon.favoriteThings',
|
||||||
|
'Speciality drops': 'pages.pokemon.skillDrops',
|
||||||
|
'Skill drops': 'pages.pokemon.skillDrops',
|
||||||
|
特长掉落物: 'pages.pokemon.skillDrops',
|
||||||
|
Category: 'pages.items.category',
|
||||||
|
分类: 'pages.items.category',
|
||||||
|
Usage: 'pages.items.usage',
|
||||||
|
用途: 'pages.items.usage',
|
||||||
|
Dyeable: 'pages.items.dyeable',
|
||||||
|
可染色: 'pages.items.dyeable',
|
||||||
|
'Dual dyeable': 'pages.items.dualDyeable',
|
||||||
|
可双区染色: 'pages.items.dualDyeable',
|
||||||
|
'Pattern editable': 'pages.items.patternEditable',
|
||||||
|
可改花纹: 'pages.items.patternEditable',
|
||||||
|
'No recipe': 'pages.items.noRecipe',
|
||||||
|
无材料单: 'pages.items.noRecipe',
|
||||||
|
'Acquisition methods': 'pages.items.acquisitionMethods',
|
||||||
|
入手方式: 'pages.items.acquisitionMethods',
|
||||||
|
Tags: 'pages.items.tags',
|
||||||
|
标签: 'pages.items.tags',
|
||||||
|
Recipe: 'pages.habitats.recipe',
|
||||||
|
配方: 'pages.habitats.recipe',
|
||||||
|
'Possible Pokemon': 'pages.habitats.possiblePokemon',
|
||||||
|
可能出现的宝可梦: 'pages.habitats.possiblePokemon',
|
||||||
|
Item: 'pages.recipes.item',
|
||||||
|
物品: 'pages.recipes.item',
|
||||||
|
Materials: 'pages.recipes.materials',
|
||||||
|
需要材料: 'pages.recipes.materials'
|
||||||
|
};
|
||||||
|
|
||||||
|
function displayName(user: UserSummary | null): string {
|
||||||
|
return user?.displayName ?? t('common.system');
|
||||||
|
}
|
||||||
|
|
||||||
|
function actionLabel(action: EditHistoryAction): string {
|
||||||
|
return t(`history.${action}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function actionMark(action: EditHistoryAction): string {
|
||||||
|
return actionLabel(action).charAt(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function changeLabel(label: string): string {
|
||||||
|
const key = changeLabelKeys[label];
|
||||||
|
return key ? t(key) : label;
|
||||||
|
}
|
||||||
|
|
||||||
|
function changeValue(value: string): string {
|
||||||
|
const values: Record<string, string> = {
|
||||||
|
None: t('common.none'),
|
||||||
|
无: t('common.none'),
|
||||||
|
Yes: locale.value === 'zh-CN' ? '是' : 'Yes',
|
||||||
|
是: locale.value === 'zh-CN' ? '是' : 'Yes',
|
||||||
|
No: locale.value === 'zh-CN' ? '否' : 'No',
|
||||||
|
否: locale.value === 'zh-CN' ? '否' : 'No'
|
||||||
|
};
|
||||||
|
return values[value] ?? value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function historySummary(entry: EditHistoryEntry): string {
|
||||||
|
if (!entry.changes.length) {
|
||||||
|
return actionLabel(entry.action);
|
||||||
|
}
|
||||||
|
|
||||||
|
return entry.changes.map((change) => changeLabel(change.label)).join(locale.value === 'zh-CN' ? '、' : ', ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(value: string): string {
|
||||||
|
return new Intl.DateTimeFormat(locale.value, {
|
||||||
|
dateStyle: 'medium',
|
||||||
|
timeStyle: 'short'
|
||||||
|
}).format(new Date(value));
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<aside class="edit-history-panel" aria-labelledby="edit-history-panel-title">
|
||||||
|
<div class="edit-history-panel__header">
|
||||||
|
<h2 id="edit-history-panel-title">{{ t('history.title') }}</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dl class="edit-history-summary">
|
||||||
|
<div>
|
||||||
|
<dt>{{ t('history.createdBy') }}</dt>
|
||||||
|
<dd>
|
||||||
|
<strong>{{ displayName(entity.createdBy) }}</strong>
|
||||||
|
<time :datetime="entity.createdAt">{{ formatDateTime(entity.createdAt) }}</time>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>{{ t('history.lastEdited') }}</dt>
|
||||||
|
<dd>
|
||||||
|
<strong>{{ displayName(entity.updatedBy) }}</strong>
|
||||||
|
<time :datetime="entity.updatedAt">{{ formatDateTime(entity.updatedAt) }}</time>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<section class="edit-history-list" aria-labelledby="edit-history-list-title">
|
||||||
|
<h3 id="edit-history-list-title">{{ t('history.editHistory') }}</h3>
|
||||||
|
<ol v-if="history.length" class="edit-timeline">
|
||||||
|
<li v-for="entry in history" :key="`${entry.action}-${entry.createdAt}-${entry.user?.id ?? 'system'}`">
|
||||||
|
<span class="edit-timeline__avatar" aria-hidden="true">{{ actionMark(entry.action) }}</span>
|
||||||
|
<div class="edit-timeline__body">
|
||||||
|
<details class="edit-history-entry">
|
||||||
|
<summary>
|
||||||
|
<span class="edit-history-entry__title">{{ historySummary(entry) }}</span>
|
||||||
|
</summary>
|
||||||
|
|
||||||
|
<div class="edit-history-entry__content">
|
||||||
|
<dl v-if="entry.changes.length" class="edit-change-list">
|
||||||
|
<div v-for="change in entry.changes" :key="`${change.label}-${change.before}-${change.after}`">
|
||||||
|
<dt>{{ changeLabel(change.label) }}</dt>
|
||||||
|
<dd>
|
||||||
|
<span class="edit-change-list__label">{{ t('history.before') }}</span>
|
||||||
|
<span>{{ changeValue(change.before) }}</span>
|
||||||
|
<span class="edit-change-list__label">{{ t('history.after') }}</span>
|
||||||
|
<span>{{ changeValue(change.after) }}</span>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<dl class="edit-history-detail-meta">
|
||||||
|
<div>
|
||||||
|
<dt>{{ t('history.author') }}</dt>
|
||||||
|
<dd>{{ displayName(entry.user) }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>{{ t('history.time') }}</dt>
|
||||||
|
<dd><time :datetime="entry.createdAt">{{ formatDateTime(entry.createdAt) }}</time></dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>{{ t('history.action') }}</dt>
|
||||||
|
<dd>{{ actionLabel(entry.action) }}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
<p v-else class="meta-line">{{ t('history.empty') }}</p>
|
||||||
|
</section>
|
||||||
|
</aside>
|
||||||
|
</template>
|
||||||
@@ -1,12 +1,15 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
import type { EditInfo } from '../services/api';
|
import type { EditInfo } from '../services/api';
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
entity: EditInfo;
|
entity: EditInfo;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
const { locale, t } = useI18n();
|
||||||
|
|
||||||
function formatDateTime(value: string): string {
|
function formatDateTime(value: string): string {
|
||||||
return new Intl.DateTimeFormat('zh-CN', {
|
return new Intl.DateTimeFormat(locale.value, {
|
||||||
dateStyle: 'medium',
|
dateStyle: 'medium',
|
||||||
timeStyle: 'short'
|
timeStyle: 'short'
|
||||||
}).format(new Date(value));
|
}).format(new Date(value));
|
||||||
@@ -15,6 +18,6 @@ function formatDateTime(value: string): string {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<p class="edit-meta">
|
<p class="edit-meta">
|
||||||
最后编辑:{{ entity.updatedBy?.displayName ?? '系统' }} / {{ formatDateTime(entity.updatedAt) }}
|
{{ t('history.lastEdited') }}: {{ entity.updatedBy?.displayName ?? t('common.system') }} / {{ formatDateTime(entity.updatedAt) }}
|
||||||
</p>
|
</p>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { Icon } from '@iconify/vue';
|
||||||
|
import type { AppIcon } from '../icons';
|
||||||
import PokeBallMark from './PokeBallMark.vue';
|
import PokeBallMark from './PokeBallMark.vue';
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
title: string;
|
title: string;
|
||||||
subtitle?: string;
|
subtitle?: string;
|
||||||
to?: string;
|
to?: string;
|
||||||
|
icon?: AppIcon;
|
||||||
marker?: string;
|
marker?: string;
|
||||||
}>();
|
}>();
|
||||||
</script>
|
</script>
|
||||||
@@ -12,7 +15,8 @@ defineProps<{
|
|||||||
<template>
|
<template>
|
||||||
<RouterLink v-if="to" class="entity-card entity-card--link" :to="to">
|
<RouterLink v-if="to" class="entity-card entity-card--link" :to="to">
|
||||||
<span class="entity-card__mark">
|
<span class="entity-card__mark">
|
||||||
<PokeBallMark v-if="!marker" size="30px" />
|
<Icon v-if="icon" :icon="icon" class="entity-card__icon" aria-hidden="true" />
|
||||||
|
<PokeBallMark v-else-if="!marker" size="30px" />
|
||||||
<span v-else>{{ marker }}</span>
|
<span v-else>{{ marker }}</span>
|
||||||
</span>
|
</span>
|
||||||
<div class="entity-card__content">
|
<div class="entity-card__content">
|
||||||
@@ -24,7 +28,8 @@ defineProps<{
|
|||||||
|
|
||||||
<article v-else class="entity-card">
|
<article v-else class="entity-card">
|
||||||
<span class="entity-card__mark">
|
<span class="entity-card__mark">
|
||||||
<PokeBallMark v-if="!marker" size="30px" />
|
<Icon v-if="icon" :icon="icon" class="entity-card__icon" aria-hidden="true" />
|
||||||
|
<PokeBallMark v-else-if="!marker" size="30px" />
|
||||||
<span v-else>{{ marker }}</span>
|
<span v-else>{{ marker }}</span>
|
||||||
</span>
|
</span>
|
||||||
<div class="entity-card__content">
|
<div class="entity-card__content">
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="filter-panel" aria-label="筛选">
|
<section class="filter-panel" :aria-label="t('common.filters')">
|
||||||
<slot></slot>
|
<slot></slot>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
254
frontend/src/components/Modal.vue
Normal file
254
frontend/src/components/Modal.vue
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Icon } from '@iconify/vue';
|
||||||
|
import { nextTick, onBeforeUnmount, onMounted, onUpdated, ref, watch } from 'vue';
|
||||||
|
import { iconClose } from '../icons';
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
open?: boolean;
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
closeLabel: string;
|
||||||
|
size?: 'default' | 'wide';
|
||||||
|
closeOnBackdrop?: boolean;
|
||||||
|
closeOnEscape?: boolean;
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
open: true,
|
||||||
|
size: 'default',
|
||||||
|
closeOnBackdrop: true,
|
||||||
|
closeOnEscape: true
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
close: [];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const titleId = `modal-title-${Math.random().toString(36).slice(2)}`;
|
||||||
|
const dialog = ref<HTMLElement | null>(null);
|
||||||
|
const modalBody = ref<HTMLElement | null>(null);
|
||||||
|
const closeButton = ref<HTMLButtonElement | null>(null);
|
||||||
|
const previousActiveElement = ref<HTMLElement | null>(null);
|
||||||
|
const focusedBodyControl = ref(false);
|
||||||
|
let bodyObserver: MutationObserver | null = null;
|
||||||
|
|
||||||
|
const focusableSelector = [
|
||||||
|
'a[href]',
|
||||||
|
'button:not([disabled])',
|
||||||
|
'input:not([disabled])',
|
||||||
|
'select:not([disabled])',
|
||||||
|
'textarea:not([disabled])',
|
||||||
|
'[tabindex]:not([tabindex="-1"])'
|
||||||
|
].join(',');
|
||||||
|
const bodyInputSelector = [
|
||||||
|
'[autofocus]',
|
||||||
|
'input:not([disabled]):not([readonly]):not([type="hidden"])',
|
||||||
|
'textarea:not([disabled]):not([readonly])',
|
||||||
|
'select:not([disabled])'
|
||||||
|
].join(',');
|
||||||
|
const bodyFallbackSelector = [
|
||||||
|
'.tags-select__trigger:not([disabled])',
|
||||||
|
'button:not([disabled])',
|
||||||
|
'[tabindex]:not([tabindex="-1"])'
|
||||||
|
].join(',');
|
||||||
|
|
||||||
|
function lockPage() {
|
||||||
|
document.body.classList.add('lock-scroll');
|
||||||
|
}
|
||||||
|
|
||||||
|
function unlockPage() {
|
||||||
|
document.body.classList.remove('lock-scroll');
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreFocus() {
|
||||||
|
const target = previousActiveElement.value;
|
||||||
|
if (target?.isConnected) {
|
||||||
|
target.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopBodyObserver() {
|
||||||
|
bodyObserver?.disconnect();
|
||||||
|
bodyObserver = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isVisible(element: HTMLElement) {
|
||||||
|
const style = window.getComputedStyle(element);
|
||||||
|
return style.display !== 'none' && style.visibility !== 'hidden' && element.getClientRects().length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstVisibleElement(root: HTMLElement, selector: string) {
|
||||||
|
return Array.from(root.querySelectorAll<HTMLElement>(selector)).find(isVisible);
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstBodyControl() {
|
||||||
|
const body = modalBody.value;
|
||||||
|
if (!body) return undefined;
|
||||||
|
|
||||||
|
return firstVisibleElement(body, bodyInputSelector) ?? firstVisibleElement(body, bodyFallbackSelector);
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldMoveFocusIntoBody() {
|
||||||
|
const activeElement = document.activeElement;
|
||||||
|
return activeElement === closeButton.value || activeElement === dialog.value || activeElement === document.body;
|
||||||
|
}
|
||||||
|
|
||||||
|
function watchBodyForControls() {
|
||||||
|
stopBodyObserver();
|
||||||
|
if (!modalBody.value) return;
|
||||||
|
|
||||||
|
bodyObserver = new MutationObserver(() => {
|
||||||
|
if (!props.open || focusedBodyControl.value || !shouldMoveFocusIntoBody()) return;
|
||||||
|
focusFirstControl();
|
||||||
|
});
|
||||||
|
bodyObserver.observe(modalBody.value, { childList: true, subtree: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function focusFirstControl() {
|
||||||
|
void nextTick(() => {
|
||||||
|
const target = firstBodyControl();
|
||||||
|
if (target) {
|
||||||
|
target.focus();
|
||||||
|
focusedBodyControl.value = true;
|
||||||
|
stopBodyObserver();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
watchBodyForControls();
|
||||||
|
closeButton.value?.focus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleOpen() {
|
||||||
|
previousActiveElement.value = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||||
|
focusedBodyControl.value = false;
|
||||||
|
lockPage();
|
||||||
|
focusFirstControl();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClose() {
|
||||||
|
stopBodyObserver();
|
||||||
|
unlockPage();
|
||||||
|
restoreFocus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestClose() {
|
||||||
|
emit('close');
|
||||||
|
}
|
||||||
|
|
||||||
|
function onBackdropClick(event: MouseEvent) {
|
||||||
|
if (props.closeOnBackdrop && event.target === event.currentTarget) {
|
||||||
|
requestClose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function focusableElements() {
|
||||||
|
return Array.from(dialog.value?.querySelectorAll<HTMLElement>(focusableSelector) ?? []).filter(isVisible);
|
||||||
|
}
|
||||||
|
|
||||||
|
function keepFocusInside(event: KeyboardEvent) {
|
||||||
|
const elements = focusableElements();
|
||||||
|
if (!elements.length) {
|
||||||
|
event.preventDefault();
|
||||||
|
dialog.value?.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const first = elements[0];
|
||||||
|
const last = elements[elements.length - 1];
|
||||||
|
const current = document.activeElement;
|
||||||
|
|
||||||
|
if (event.shiftKey && current === first) {
|
||||||
|
event.preventDefault();
|
||||||
|
last.focus();
|
||||||
|
} else if (!event.shiftKey && current === last) {
|
||||||
|
event.preventDefault();
|
||||||
|
first.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDocumentKeydown(event: KeyboardEvent) {
|
||||||
|
if (!props.open) return;
|
||||||
|
|
||||||
|
if (event.key === 'Escape' && props.closeOnEscape) {
|
||||||
|
event.preventDefault();
|
||||||
|
requestClose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === 'Tab') {
|
||||||
|
keepFocusInside(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
document.addEventListener('keydown', onDocumentKeydown);
|
||||||
|
if (props.open) {
|
||||||
|
handleOpen();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
document.removeEventListener('keydown', onDocumentKeydown);
|
||||||
|
stopBodyObserver();
|
||||||
|
if (props.open) {
|
||||||
|
handleClose();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onUpdated(() => {
|
||||||
|
if (!props.open || focusedBodyControl.value) return;
|
||||||
|
|
||||||
|
if (shouldMoveFocusIntoBody()) {
|
||||||
|
focusFirstControl();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.open,
|
||||||
|
(open, wasOpen) => {
|
||||||
|
if (open && !wasOpen) {
|
||||||
|
handleOpen();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!open && wasOpen) {
|
||||||
|
handleClose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div v-if="open" class="modal-backdrop is-open" role="presentation" @click="onBackdropClick">
|
||||||
|
<section
|
||||||
|
ref="dialog"
|
||||||
|
class="modal"
|
||||||
|
:class="{ 'modal--wide': size === 'wide' }"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
:aria-labelledby="titleId"
|
||||||
|
tabindex="-1"
|
||||||
|
>
|
||||||
|
<div class="modal-header">
|
||||||
|
<div class="modal-header__copy">
|
||||||
|
<h2 :id="titleId">{{ title }}</h2>
|
||||||
|
<p v-if="subtitle">{{ subtitle }}</p>
|
||||||
|
</div>
|
||||||
|
<button ref="closeButton" class="modal-close-button" type="button" :aria-label="closeLabel" @click="requestClose">
|
||||||
|
<Icon :icon="iconClose" class="ui-icon" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div ref="modalBody" class="modal-body">
|
||||||
|
<slot></slot>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="$slots.footer" class="modal-footer">
|
||||||
|
<slot name="footer"></slot>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
219
frontend/src/components/ReorderableList.vue
Normal file
219
frontend/src/components/ReorderableList.vue
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
<script setup lang="ts" generic="T">
|
||||||
|
import { Icon } from '@iconify/vue';
|
||||||
|
import { ref, shallowRef } from 'vue';
|
||||||
|
import { iconDragHandle } from '../icons';
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
items: T[];
|
||||||
|
itemKey: (item: T) => string | number;
|
||||||
|
itemLabel: (item: T) => string;
|
||||||
|
listKeyPrefix?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
handleLabel: (name: string) => string;
|
||||||
|
handleTitle: string;
|
||||||
|
}>(), {
|
||||||
|
listKeyPrefix: '',
|
||||||
|
disabled: false
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
reorder: [items: T[], originalItems: T[]];
|
||||||
|
preview: [items: T[]];
|
||||||
|
cancel: [items: T[]];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const draggingKey = ref<string | number | null>(null);
|
||||||
|
const dropTargetKey = ref<string | number | null>(null);
|
||||||
|
const insertAfterTarget = ref(false);
|
||||||
|
const sourceItems = shallowRef<T[]>([]);
|
||||||
|
const dropCommitted = ref(false);
|
||||||
|
|
||||||
|
function keyFor(item: T): string | number {
|
||||||
|
return props.itemKey(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
function transitionKeyFor(item: T): string {
|
||||||
|
return props.listKeyPrefix ? `${props.listKeyPrefix}:${String(keyFor(item))}` : String(keyFor(item));
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameKey(first: string | number, second: string | number): boolean {
|
||||||
|
return String(first) === String(second);
|
||||||
|
}
|
||||||
|
|
||||||
|
function reorderedItems(items: T[], draggedKeyValue: string | number, targetKeyValue: string | number, insertAfter: boolean): T[] {
|
||||||
|
if (sameKey(draggedKeyValue, targetKeyValue)) {
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
const draggedItem = items.find((item) => sameKey(keyFor(item), draggedKeyValue));
|
||||||
|
if (!draggedItem) {
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextItems = items.filter((item) => !sameKey(keyFor(item), draggedKeyValue));
|
||||||
|
const targetIndex = nextItems.findIndex((item) => sameKey(keyFor(item), targetKeyValue));
|
||||||
|
if (targetIndex < 0) {
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
nextItems.splice(targetIndex + (insertAfter ? 1 : 0), 0, draggedItem);
|
||||||
|
return nextItems;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasOrderChanged(currentItems: T[], nextItems: T[]): boolean {
|
||||||
|
return currentItems.length !== nextItems.length || currentItems.some((item, index) => !sameKey(keyFor(item), keyFor(nextItems[index])));
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearDragState() {
|
||||||
|
draggingKey.value = null;
|
||||||
|
dropTargetKey.value = null;
|
||||||
|
insertAfterTarget.value = false;
|
||||||
|
sourceItems.value = [];
|
||||||
|
dropCommitted.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function startDrag(item: T, event: Event) {
|
||||||
|
if (props.disabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = keyFor(item);
|
||||||
|
draggingKey.value = key;
|
||||||
|
sourceItems.value = [...props.items];
|
||||||
|
dropCommitted.value = false;
|
||||||
|
|
||||||
|
const dragEvent = event instanceof DragEvent ? event : null;
|
||||||
|
dragEvent?.dataTransfer?.setData('text/plain', String(key));
|
||||||
|
if (dragEvent?.dataTransfer) {
|
||||||
|
dragEvent.dataTransfer.effectAllowed = 'move';
|
||||||
|
dragEvent.dataTransfer.dropEffect = 'move';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function endDrag() {
|
||||||
|
if (draggingKey.value !== null && !dropCommitted.value && sourceItems.value.length) {
|
||||||
|
emit('cancel', [...sourceItems.value]);
|
||||||
|
}
|
||||||
|
|
||||||
|
clearDragState();
|
||||||
|
}
|
||||||
|
|
||||||
|
function previewDrop(targetItem: T, event: Event) {
|
||||||
|
if (props.disabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dragEvent = event instanceof DragEvent ? event : null;
|
||||||
|
const draggedKey = draggingKey.value ?? dragEvent?.dataTransfer?.getData('text/plain');
|
||||||
|
const targetKey = keyFor(targetItem);
|
||||||
|
if (draggedKey === null || draggedKey === undefined || draggedKey === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sameKey(draggedKey, targetKey)) {
|
||||||
|
dropTargetKey.value = null;
|
||||||
|
insertAfterTarget.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dragEvent?.dataTransfer) {
|
||||||
|
dragEvent.dataTransfer.dropEffect = 'move';
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetElement = event.currentTarget instanceof HTMLElement ? event.currentTarget : null;
|
||||||
|
const insertAfter = targetElement
|
||||||
|
? (dragEvent?.clientY ?? 0) > targetElement.getBoundingClientRect().top + targetElement.getBoundingClientRect().height / 2
|
||||||
|
: false;
|
||||||
|
|
||||||
|
dropTargetKey.value = targetKey;
|
||||||
|
insertAfterTarget.value = insertAfter;
|
||||||
|
|
||||||
|
const nextItems = reorderedItems(props.items, draggedKey, targetKey, insertAfter);
|
||||||
|
if (hasOrderChanged(props.items, nextItems)) {
|
||||||
|
emit('preview', nextItems);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function dropItem(targetItem: T, event: Event) {
|
||||||
|
if (props.disabled || draggingKey.value === null) {
|
||||||
|
endDrag();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
previewDrop(targetItem, event);
|
||||||
|
|
||||||
|
const nextItems = [...props.items];
|
||||||
|
const originalItems = sourceItems.value.length ? [...sourceItems.value] : nextItems;
|
||||||
|
dropCommitted.value = true;
|
||||||
|
clearDragState();
|
||||||
|
|
||||||
|
if (!hasOrderChanged(originalItems, nextItems)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
emit('reorder', nextItems, originalItems);
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveByKeyboard(item: T, offset: -1 | 1) {
|
||||||
|
if (props.disabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = keyFor(item);
|
||||||
|
const currentIndex = props.items.findIndex((row) => sameKey(keyFor(row), key));
|
||||||
|
const targetIndex = currentIndex + offset;
|
||||||
|
if (currentIndex < 0 || targetIndex < 0 || targetIndex >= props.items.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextItems = [...props.items];
|
||||||
|
const [movedItem] = nextItems.splice(currentIndex, 1);
|
||||||
|
nextItems.splice(targetIndex, 0, movedItem);
|
||||||
|
emit('reorder', nextItems, [...props.items]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeydown(item: T, event: KeyboardEvent) {
|
||||||
|
if (event.key === 'ArrowUp') {
|
||||||
|
event.preventDefault();
|
||||||
|
moveByKeyboard(item, -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === 'ArrowDown') {
|
||||||
|
event.preventDefault();
|
||||||
|
moveByKeyboard(item, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<TransitionGroup name="reorderable-list" tag="ul" class="row-list reorderable-list">
|
||||||
|
<li
|
||||||
|
v-for="item in items"
|
||||||
|
:key="transitionKeyFor(item)"
|
||||||
|
class="reorderable-row"
|
||||||
|
:class="{
|
||||||
|
'is-dragging': draggingKey === keyFor(item),
|
||||||
|
'is-drop-target': dropTargetKey === keyFor(item),
|
||||||
|
'is-drop-after': dropTargetKey === keyFor(item) && insertAfterTarget,
|
||||||
|
'is-drop-before': dropTargetKey === keyFor(item) && !insertAfterTarget
|
||||||
|
}"
|
||||||
|
@dragover.prevent="previewDrop(item, $event)"
|
||||||
|
@drop.prevent="dropItem(item, $event)"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="drag-handle"
|
||||||
|
draggable="true"
|
||||||
|
:aria-label="handleLabel(itemLabel(item))"
|
||||||
|
:title="handleTitle"
|
||||||
|
:disabled="disabled"
|
||||||
|
@dragstart="startDrag(item, $event)"
|
||||||
|
@dragend="endDrag"
|
||||||
|
@keydown="handleKeydown(item, $event)"
|
||||||
|
>
|
||||||
|
<Icon :icon="iconDragHandle" class="ui-icon" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
<slot :item="item" />
|
||||||
|
</li>
|
||||||
|
</TransitionGroup>
|
||||||
|
</template>
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
import { Icon } from '@iconify/vue';
|
||||||
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||||
|
import { iconError, iconInfo, iconSuccess, iconWarning } from '../icons';
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
@@ -14,6 +16,12 @@ const props = withDefaults(
|
|||||||
|
|
||||||
const visible = ref(true);
|
const visible = ref(true);
|
||||||
let timer: number | null = null;
|
let timer: number | null = null;
|
||||||
|
const statusIcon = computed(() => {
|
||||||
|
if (props.variant === 'success') return iconSuccess;
|
||||||
|
if (props.variant === 'warning') return iconWarning;
|
||||||
|
if (props.variant === 'danger') return iconError;
|
||||||
|
return iconInfo;
|
||||||
|
});
|
||||||
|
|
||||||
function clearTimer() {
|
function clearTimer() {
|
||||||
if (!timer) return;
|
if (!timer) return;
|
||||||
@@ -38,6 +46,7 @@ watch(() => props.duration, scheduleDismiss);
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<p class="status-message" :class="[`status-message--${variant}`, { 'status-message--hidden': !visible }]">
|
<p class="status-message" :class="[`status-message--${variant}`, { 'status-message--hidden': !visible }]">
|
||||||
|
<Icon :icon="statusIcon" class="status-message__icon" aria-hidden="true" />
|
||||||
<slot></slot>
|
<slot></slot>
|
||||||
</p>
|
</p>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { Icon } from '@iconify/vue';
|
||||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { iconCheck, iconChevronDown, iconClose } from '../icons';
|
||||||
|
|
||||||
export type TagsSelectOption = {
|
export type TagsSelectOption = {
|
||||||
id: number | string;
|
id: number | string;
|
||||||
@@ -32,12 +35,8 @@ const props = withDefaults(
|
|||||||
{
|
{
|
||||||
multiple: true,
|
multiple: true,
|
||||||
max: 0,
|
max: 0,
|
||||||
placeholder: '搜索或选择',
|
|
||||||
searchPlaceholder: '搜索',
|
|
||||||
emptyText: '没有匹配项',
|
|
||||||
allowCreate: false,
|
allowCreate: false,
|
||||||
creating: false,
|
creating: false
|
||||||
createLabel: '添加「{name}」'
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -46,6 +45,7 @@ const emit = defineEmits<{
|
|||||||
create: [name: string];
|
create: [name: string];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
const root = ref<HTMLElement | null>(null);
|
const root = ref<HTMLElement | null>(null);
|
||||||
const searchInput = ref<HTMLInputElement | null>(null);
|
const searchInput = ref<HTMLInputElement | null>(null);
|
||||||
const isOpen = ref(false);
|
const isOpen = ref(false);
|
||||||
@@ -85,7 +85,10 @@ const hasExactMatch = computed(() => {
|
|||||||
return optionRows.value.some((option) => option.label.toLowerCase() === keyword);
|
return optionRows.value.some((option) => option.label.toLowerCase() === keyword);
|
||||||
});
|
});
|
||||||
const canCreate = computed(() => props.allowCreate && createName.value !== '' && !hasExactMatch.value && !maxReached.value);
|
const canCreate = computed(() => props.allowCreate && createName.value !== '' && !hasExactMatch.value && !maxReached.value);
|
||||||
const createText = computed(() => props.createLabel.replace('{name}', createName.value));
|
const placeholderText = computed(() => props.placeholder ?? t('common.searchOrSelect'));
|
||||||
|
const searchPlaceholderText = computed(() => props.searchPlaceholder ?? t('common.search'));
|
||||||
|
const emptyTextValue = computed(() => props.emptyText ?? t('common.noMatches'));
|
||||||
|
const createText = computed(() => props.createLabel?.replace('{name}', createName.value) ?? t('common.createNamed', { name: createName.value }));
|
||||||
const optionsListId = computed(() => `${props.id}-options`);
|
const optionsListId = computed(() => `${props.id}-options`);
|
||||||
const createOptionId = computed(() => `${props.id}-create`);
|
const createOptionId = computed(() => `${props.id}-create`);
|
||||||
const candidateRows = computed<CandidateRow[]>(() => {
|
const candidateRows = computed<CandidateRow[]>(() => {
|
||||||
@@ -210,7 +213,8 @@ function commitSearch() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onRootKeydown(event: KeyboardEvent) {
|
function onRootKeydown(event: KeyboardEvent) {
|
||||||
if (event.key === 'Escape') {
|
if (event.key === 'Escape' && isOpen.value) {
|
||||||
|
event.stopPropagation();
|
||||||
closeDropdown();
|
closeDropdown();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -252,19 +256,19 @@ watch(candidateRows, clampActiveIndex);
|
|||||||
class="tags-select__remove"
|
class="tags-select__remove"
|
||||||
role="button"
|
role="button"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
:aria-label="`移除${option.label}`"
|
:aria-label="t('common.removeNamed', { name: option.label })"
|
||||||
@click.stop="remove(option.value)"
|
@click.stop="remove(option.value)"
|
||||||
@keydown.enter.stop.prevent="remove(option.value)"
|
@keydown.enter.stop.prevent="remove(option.value)"
|
||||||
@keydown.space.stop.prevent="remove(option.value)"
|
@keydown.space.stop.prevent="remove(option.value)"
|
||||||
>
|
>
|
||||||
×
|
<Icon :icon="iconClose" class="ui-icon" aria-hidden="true" />
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
<span v-else class="tags-select__single-value">{{ selectedLabel }}</span>
|
<span v-else class="tags-select__single-value">{{ selectedLabel }}</span>
|
||||||
</span>
|
</span>
|
||||||
<span v-else class="tags-select__placeholder">{{ placeholder }}</span>
|
<span v-else class="tags-select__placeholder">{{ placeholderText }}</span>
|
||||||
<span class="tags-select__arrow" aria-hidden="true">⌄</span>
|
<Icon :icon="iconChevronDown" class="tags-select__arrow" aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div v-if="isOpen" class="tags-select__dropdown">
|
<div v-if="isOpen" class="tags-select__dropdown">
|
||||||
@@ -273,7 +277,7 @@ watch(candidateRows, clampActiveIndex);
|
|||||||
v-model="search"
|
v-model="search"
|
||||||
class="tags-select__search"
|
class="tags-select__search"
|
||||||
type="search"
|
type="search"
|
||||||
:placeholder="searchPlaceholder"
|
:placeholder="searchPlaceholderText"
|
||||||
:aria-activedescendant="activeDescendant"
|
:aria-activedescendant="activeDescendant"
|
||||||
:aria-controls="optionsListId"
|
:aria-controls="optionsListId"
|
||||||
aria-autocomplete="list"
|
aria-autocomplete="list"
|
||||||
@@ -297,7 +301,10 @@ watch(candidateRows, clampActiveIndex);
|
|||||||
@click="selectOption(option.value)"
|
@click="selectOption(option.value)"
|
||||||
>
|
>
|
||||||
<span>{{ option.label }}</span>
|
<span>{{ option.label }}</span>
|
||||||
<span v-if="selectedValues.has(option.value)" class="tags-select__state">已选</span>
|
<span v-if="selectedValues.has(option.value)" class="tags-select__state">
|
||||||
|
<Icon :icon="iconCheck" class="ui-icon" aria-hidden="true" />
|
||||||
|
{{ t('common.selected') }}
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
v-if="canCreate"
|
v-if="canCreate"
|
||||||
@@ -309,9 +316,9 @@ watch(candidateRows, clampActiveIndex);
|
|||||||
@click="createOption"
|
@click="createOption"
|
||||||
>
|
>
|
||||||
<span>{{ createText }}</span>
|
<span>{{ createText }}</span>
|
||||||
<span v-if="creating" class="tags-select__state">添加中</span>
|
<span v-if="creating" class="tags-select__state">{{ t('common.creating') }}</span>
|
||||||
</button>
|
</button>
|
||||||
<p v-if="!filteredRows.length && !canCreate" class="tags-select__empty">{{ emptyText }}</p>
|
<p v-if="!filteredRows.length && !canCreate" class="tags-select__empty">{{ emptyTextValue }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
89
frontend/src/components/TranslationFields.vue
Normal file
89
frontend/src/components/TranslationFields.vue
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import type { Language, TranslationField, TranslationMap } from '../services/api';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
idPrefix: string;
|
||||||
|
field: TranslationField;
|
||||||
|
label: string;
|
||||||
|
baseValue: string;
|
||||||
|
translations: TranslationMap;
|
||||||
|
languages: Language[];
|
||||||
|
required?: boolean;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:baseValue': [value: string];
|
||||||
|
'update:translations': [value: TranslationMap];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const { locale, t } = useI18n();
|
||||||
|
const fallbackLanguage: Language = { code: 'en', name: 'English', enabled: true, isDefault: true, sortOrder: 0 };
|
||||||
|
const visibleLanguages = computed(() => props.languages.filter((language) => language.enabled));
|
||||||
|
const defaultLanguage = computed(() => visibleLanguages.value.find((language) => language.isDefault) ?? visibleLanguages.value[0] ?? fallbackLanguage);
|
||||||
|
const currentLanguage = computed(() => {
|
||||||
|
const currentLocale = String(locale.value || defaultLanguage.value.code);
|
||||||
|
return visibleLanguages.value.find((language) => language.code === currentLocale) ?? defaultLanguage.value;
|
||||||
|
});
|
||||||
|
const isDefaultLanguage = computed(() => currentLanguage.value.code === defaultLanguage.value.code);
|
||||||
|
const currentValue = computed({
|
||||||
|
get: () => fieldValue(currentLanguage.value),
|
||||||
|
set: (value: string) => updateField(currentLanguage.value, value)
|
||||||
|
});
|
||||||
|
const currentPlaceholder = computed(() => fieldPlaceholder(currentLanguage.value));
|
||||||
|
const currentRequired = computed(() => Boolean(props.required && (isDefaultLanguage.value || props.baseValue.trim() === '')));
|
||||||
|
|
||||||
|
function fieldValue(language: Language): string {
|
||||||
|
if (language.code === defaultLanguage.value?.code) {
|
||||||
|
return props.baseValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return props.translations[language.code]?.[props.field] ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function fieldPlaceholder(language: Language): string {
|
||||||
|
return language.code === defaultLanguage.value?.code ? '' : props.baseValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateField(language: Language, value: string) {
|
||||||
|
if (language.code === defaultLanguage.value?.code) {
|
||||||
|
emit('update:baseValue', value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextTranslations: TranslationMap = { ...props.translations };
|
||||||
|
const nextFields = { ...(nextTranslations[language.code] ?? {}) };
|
||||||
|
|
||||||
|
if (value.trim() === '') {
|
||||||
|
delete nextFields[props.field];
|
||||||
|
} else {
|
||||||
|
nextFields[props.field] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(nextFields).length) {
|
||||||
|
nextTranslations[language.code] = nextFields;
|
||||||
|
} else {
|
||||||
|
delete nextTranslations[language.code];
|
||||||
|
}
|
||||||
|
|
||||||
|
emit('update:translations', nextTranslations);
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="translation-fields">
|
||||||
|
<div class="field">
|
||||||
|
<label :for="`${idPrefix}-${currentLanguage.code}`">
|
||||||
|
{{ t('common.fieldForLanguage', { field: label, language: currentLanguage.name }) }}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
:id="`${idPrefix}-${currentLanguage.code}`"
|
||||||
|
v-model="currentValue"
|
||||||
|
:placeholder="currentPlaceholder"
|
||||||
|
:required="currentRequired"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
567
frontend/src/i18n.ts
Normal file
567
frontend/src/i18n.ts
Normal file
@@ -0,0 +1,567 @@
|
|||||||
|
import { createI18n } from 'vue-i18n';
|
||||||
|
|
||||||
|
export const defaultLocale = 'en';
|
||||||
|
const localeStorageKey = 'pokopia_locale';
|
||||||
|
const localeChangeEvent = 'pokopia-locale-change';
|
||||||
|
|
||||||
|
const messages = {
|
||||||
|
en: {
|
||||||
|
common: {
|
||||||
|
add: 'Add',
|
||||||
|
admin: 'Admin',
|
||||||
|
all: 'All',
|
||||||
|
back: 'Back',
|
||||||
|
backToList: 'Back to list',
|
||||||
|
cancel: 'Cancel',
|
||||||
|
close: 'Close',
|
||||||
|
create: 'Create',
|
||||||
|
delete: 'Delete',
|
||||||
|
edit: 'Edit',
|
||||||
|
filters: 'Filters',
|
||||||
|
loading: 'Loading',
|
||||||
|
name: 'Name',
|
||||||
|
new: 'New',
|
||||||
|
none: 'None',
|
||||||
|
save: 'Save',
|
||||||
|
saving: 'Saving',
|
||||||
|
search: 'Search',
|
||||||
|
select: 'Select',
|
||||||
|
selected: 'Selected',
|
||||||
|
system: 'System',
|
||||||
|
noRecords: 'No records',
|
||||||
|
fieldForLanguage: '{field} ({language})',
|
||||||
|
searchOrSelect: 'Search or select',
|
||||||
|
noMatches: 'No matches',
|
||||||
|
createNamed: 'Add "{name}"',
|
||||||
|
creating: 'Adding',
|
||||||
|
removeNamed: 'Remove {name}',
|
||||||
|
quantity: 'Quantity',
|
||||||
|
required: 'Required'
|
||||||
|
},
|
||||||
|
nav: {
|
||||||
|
pokemon: 'Pokemon',
|
||||||
|
habitats: 'Habitats',
|
||||||
|
items: 'Items',
|
||||||
|
recipes: 'Recipes',
|
||||||
|
checklist: 'CheckList',
|
||||||
|
admin: 'Admin',
|
||||||
|
main: 'Main navigation',
|
||||||
|
language: 'Language',
|
||||||
|
login: 'Log in',
|
||||||
|
logout: 'Log out',
|
||||||
|
register: 'Register'
|
||||||
|
},
|
||||||
|
auth: {
|
||||||
|
email: 'Email',
|
||||||
|
password: 'Password',
|
||||||
|
displayName: 'Display name',
|
||||||
|
loginTitle: 'Log in',
|
||||||
|
loginSubtitle: 'Use a verified email to enter Pokopia Wiki.',
|
||||||
|
loggingIn: 'Logging in',
|
||||||
|
loginFailed: 'Login failed',
|
||||||
|
noAccount: 'No account yet?',
|
||||||
|
registerTitle: 'Register',
|
||||||
|
registerSubtitle: 'Verify your email after creating an account.',
|
||||||
|
registerFailed: 'Registration failed',
|
||||||
|
sending: 'Sending',
|
||||||
|
sendVerification: 'Send verification email',
|
||||||
|
hasAccount: 'Already have an account?',
|
||||||
|
verifyTitle: 'Email verification',
|
||||||
|
verifySubtitle: 'You can log in after verification is complete.',
|
||||||
|
verifyingEmail: 'Verifying email',
|
||||||
|
invalidVerification: 'The verification link is invalid or expired.',
|
||||||
|
verifyFailed: 'Email verification failed',
|
||||||
|
goLogin: 'Go to login'
|
||||||
|
},
|
||||||
|
errors: {
|
||||||
|
requestFailed: 'Request failed ({status})',
|
||||||
|
operationFailed: 'Operation failed',
|
||||||
|
loadFailed: 'Load failed',
|
||||||
|
addFailed: 'Add failed',
|
||||||
|
saveFailed: 'Save failed',
|
||||||
|
completeEmailVerification: 'Please complete email verification first.'
|
||||||
|
},
|
||||||
|
pages: {
|
||||||
|
pokemon: {
|
||||||
|
title: 'Pokemon',
|
||||||
|
subtitle: 'Search Pokemon and filter by specialities, ideal habitat, and favourites.',
|
||||||
|
detailKicker: 'Pokédex Detail',
|
||||||
|
editKicker: 'Pokédex Edit',
|
||||||
|
editSubtitle: 'Maintain Pokemon profile, specialities, and favourites.',
|
||||||
|
newTitle: 'New Pokemon',
|
||||||
|
editTitle: 'Edit #{id} {name}',
|
||||||
|
loadingList: 'Loading Pokemon list',
|
||||||
|
loadingDetail: 'Loading Pokemon detail',
|
||||||
|
loadingEdit: 'Loading Pokemon editor',
|
||||||
|
environmentPrefix: 'Ideal Habitat: {name}',
|
||||||
|
environment: 'Ideal Habitat',
|
||||||
|
skills: 'Specialities',
|
||||||
|
skillMatchMode: 'Speciality match mode',
|
||||||
|
any: 'Any',
|
||||||
|
all: 'All',
|
||||||
|
favoriteThings: 'Favourites',
|
||||||
|
favoriteThingMatchMode: 'Favourites match mode',
|
||||||
|
skillDrops: 'Speciality drops',
|
||||||
|
skillDrop: '{name} drop',
|
||||||
|
dropItem: 'Drop item',
|
||||||
|
searchPokemon: 'Search Pokemon',
|
||||||
|
relatedItems: 'Related items',
|
||||||
|
relatedItemCategory: 'Related item category',
|
||||||
|
habitats: 'Habitats',
|
||||||
|
namePlaceholder: 'Name',
|
||||||
|
searchEnvironment: 'Search ideal habitats',
|
||||||
|
searchSkills: 'Search specialities',
|
||||||
|
searchFavoriteThings: 'Search favourites',
|
||||||
|
searchItems: 'Search items'
|
||||||
|
},
|
||||||
|
habitats: {
|
||||||
|
title: 'Habitats',
|
||||||
|
subtitle: 'View recipes and Pokemon that may appear.',
|
||||||
|
detailSubtitle: 'Habitat detail',
|
||||||
|
editSubtitle: 'Maintain habitat recipes and possible Pokemon appearances.',
|
||||||
|
newTitle: 'New habitat',
|
||||||
|
editTitle: 'Edit {name}',
|
||||||
|
fallbackName: 'Habitat',
|
||||||
|
loadingList: 'Loading habitat list',
|
||||||
|
loadingDetail: 'Loading habitat detail',
|
||||||
|
loadingEdit: 'Loading habitat editor',
|
||||||
|
recipe: 'Recipe',
|
||||||
|
recipeList: 'Recipe list',
|
||||||
|
possiblePokemon: 'Possible Pokemon',
|
||||||
|
addItem: 'Add item',
|
||||||
|
addPokemon: 'Add Pokemon',
|
||||||
|
maps: 'Maps',
|
||||||
|
searchMaps: 'Search maps'
|
||||||
|
},
|
||||||
|
items: {
|
||||||
|
title: 'Items',
|
||||||
|
subtitle: 'Browse items by category, usage, and tags.',
|
||||||
|
detailKicker: 'Item Detail',
|
||||||
|
detailSubtitle: 'Item detail',
|
||||||
|
editKicker: 'Item Edit',
|
||||||
|
editSubtitle: 'Maintain item category, usage, acquisition methods, customization, and tags.',
|
||||||
|
newTitle: 'New item',
|
||||||
|
editTitle: 'Edit {name}',
|
||||||
|
fallbackName: 'Item',
|
||||||
|
loadingList: 'Loading item list',
|
||||||
|
loadingDetail: 'Loading item detail',
|
||||||
|
loadingEdit: 'Loading item editor',
|
||||||
|
category: 'Category',
|
||||||
|
usage: 'Usage',
|
||||||
|
tags: 'Tags',
|
||||||
|
acquisitionMethods: 'Acquisition methods',
|
||||||
|
customization: 'Customization',
|
||||||
|
dyeable: 'Dyeable',
|
||||||
|
dualDyeable: 'Dual dyeable',
|
||||||
|
patternEditable: 'Pattern editable',
|
||||||
|
noRecipe: 'No recipe',
|
||||||
|
recipeInfo: 'Recipe info',
|
||||||
|
relatedRecipes: 'Related recipes',
|
||||||
|
relatedHabitats: 'Related habitats',
|
||||||
|
pokemonDrops: 'Pokemon drops',
|
||||||
|
createRecipe: 'Create recipe',
|
||||||
|
searchCategory: 'Search categories',
|
||||||
|
searchUsage: 'Search usages',
|
||||||
|
searchMethods: 'Search acquisition methods',
|
||||||
|
searchTags: 'Search tags'
|
||||||
|
},
|
||||||
|
recipes: {
|
||||||
|
title: 'Recipes',
|
||||||
|
subtitle: 'Browse recipes by category, usage, and tags.',
|
||||||
|
detailKicker: 'Recipe Detail',
|
||||||
|
detailSubtitle: 'Recipe detail',
|
||||||
|
editKicker: 'Recipe Edit',
|
||||||
|
editSubtitle: 'Maintain result item, acquisition methods, and materials.',
|
||||||
|
newTitle: 'New recipe',
|
||||||
|
editTitle: 'Edit {name}',
|
||||||
|
fallbackName: 'Recipe',
|
||||||
|
loadingList: 'Loading recipe list',
|
||||||
|
loadingDetail: 'Loading recipe detail',
|
||||||
|
loadingEdit: 'Loading recipe editor',
|
||||||
|
item: 'Item',
|
||||||
|
materials: 'Materials',
|
||||||
|
addMaterial: 'Add material'
|
||||||
|
},
|
||||||
|
checklist: {
|
||||||
|
title: 'Daily checklist',
|
||||||
|
subtitle: 'See what can be completed each day.',
|
||||||
|
sectionTitle: 'Daily tasks',
|
||||||
|
empty: 'No daily checklist',
|
||||||
|
loading: 'Loading daily checklist',
|
||||||
|
task: 'Task',
|
||||||
|
newTask: 'New task',
|
||||||
|
editTask: 'Edit task'
|
||||||
|
},
|
||||||
|
admin: {
|
||||||
|
title: 'Admin',
|
||||||
|
subtitle: 'Maintain system configuration and manage Wiki records.',
|
||||||
|
modules: 'Admin modules',
|
||||||
|
loading: 'Loading admin list',
|
||||||
|
config: 'System config',
|
||||||
|
configType: 'System config type',
|
||||||
|
checklist: 'CheckList',
|
||||||
|
pokemonList: 'Pokemon list',
|
||||||
|
itemList: 'Item list',
|
||||||
|
recipeList: 'Recipe list',
|
||||||
|
habitatList: 'Habitat list',
|
||||||
|
languages: 'Languages',
|
||||||
|
newConfig: 'New {name}',
|
||||||
|
editConfig: 'Edit {name}',
|
||||||
|
hasItemDrop: 'Has item drop',
|
||||||
|
dragSort: 'Drag to reorder: {name}',
|
||||||
|
dragSortTitle: 'Drag to reorder',
|
||||||
|
languageCode: 'Code',
|
||||||
|
languageName: 'Language name',
|
||||||
|
enabled: 'Enabled',
|
||||||
|
defaultLanguage: 'Default language',
|
||||||
|
sortOrder: 'Sort order',
|
||||||
|
newLanguage: 'New language',
|
||||||
|
editLanguage: 'Edit language'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
config: {
|
||||||
|
skills: 'Specialities',
|
||||||
|
environments: 'Ideal Habitats',
|
||||||
|
favoriteThings: 'Favourites / tags',
|
||||||
|
itemCategories: 'Item categories',
|
||||||
|
itemUsages: 'Item usages',
|
||||||
|
acquisitionMethods: 'Acquisition methods',
|
||||||
|
maps: 'Maps'
|
||||||
|
},
|
||||||
|
appearance: {
|
||||||
|
time: 'Time',
|
||||||
|
weather: 'Weather',
|
||||||
|
rarity: 'Rarity',
|
||||||
|
map: 'Map',
|
||||||
|
maps: 'Maps',
|
||||||
|
morning: 'Morning',
|
||||||
|
noon: 'Noon',
|
||||||
|
evening: 'Evening',
|
||||||
|
night: 'Night',
|
||||||
|
sunny: 'Sunny',
|
||||||
|
cloudy: 'Cloudy',
|
||||||
|
rainy: 'Rainy',
|
||||||
|
stars: '{count} stars'
|
||||||
|
},
|
||||||
|
history: {
|
||||||
|
title: 'Contribution records',
|
||||||
|
createdBy: 'Created by',
|
||||||
|
lastEdited: 'Last edited',
|
||||||
|
editHistory: 'Edit history',
|
||||||
|
before: 'Before',
|
||||||
|
after: 'After',
|
||||||
|
author: 'Author',
|
||||||
|
time: 'Time',
|
||||||
|
action: 'Action',
|
||||||
|
create: 'Create',
|
||||||
|
update: 'Edit',
|
||||||
|
delete: 'Delete',
|
||||||
|
empty: 'No edit history'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'zh-CN': {
|
||||||
|
common: {
|
||||||
|
add: '添加',
|
||||||
|
admin: '管理',
|
||||||
|
all: '全部',
|
||||||
|
back: '返回',
|
||||||
|
backToList: '返回列表',
|
||||||
|
cancel: '取消',
|
||||||
|
close: '关闭',
|
||||||
|
create: '创建',
|
||||||
|
delete: '删除',
|
||||||
|
edit: '编辑',
|
||||||
|
filters: '筛选',
|
||||||
|
loading: '加载中',
|
||||||
|
name: '名称',
|
||||||
|
new: '新建',
|
||||||
|
none: '无',
|
||||||
|
save: '保存',
|
||||||
|
saving: '保存中',
|
||||||
|
search: '搜索',
|
||||||
|
select: '请选择',
|
||||||
|
selected: '已选',
|
||||||
|
system: '系统',
|
||||||
|
noRecords: '暂无记录',
|
||||||
|
fieldForLanguage: '{field}({language})',
|
||||||
|
searchOrSelect: '搜索或选择',
|
||||||
|
noMatches: '没有匹配项',
|
||||||
|
createNamed: '添加「{name}」',
|
||||||
|
creating: '添加中',
|
||||||
|
removeNamed: '移除{name}',
|
||||||
|
quantity: '数量',
|
||||||
|
required: '必填'
|
||||||
|
},
|
||||||
|
nav: {
|
||||||
|
pokemon: 'Pokemon',
|
||||||
|
habitats: '栖息地',
|
||||||
|
items: '物品',
|
||||||
|
recipes: '材料单',
|
||||||
|
checklist: 'CheckList',
|
||||||
|
admin: '管理',
|
||||||
|
main: '主导航',
|
||||||
|
language: '语言',
|
||||||
|
login: '登录',
|
||||||
|
logout: '退出',
|
||||||
|
register: '注册'
|
||||||
|
},
|
||||||
|
auth: {
|
||||||
|
email: '邮箱',
|
||||||
|
password: '密码',
|
||||||
|
displayName: '显示名',
|
||||||
|
loginTitle: '登录',
|
||||||
|
loginSubtitle: '使用已验证邮箱进入 Pokopia Wiki',
|
||||||
|
loggingIn: '登录中',
|
||||||
|
loginFailed: '登录失败',
|
||||||
|
noAccount: '还没有账号?',
|
||||||
|
registerTitle: '注册',
|
||||||
|
registerSubtitle: '创建账号后需要完成邮箱验证',
|
||||||
|
registerFailed: '注册失败',
|
||||||
|
sending: '发送中',
|
||||||
|
sendVerification: '发送验证邮件',
|
||||||
|
hasAccount: '已有账号?',
|
||||||
|
verifyTitle: '邮箱验证',
|
||||||
|
verifySubtitle: '完成验证后即可登录',
|
||||||
|
verifyingEmail: '正在验证邮箱',
|
||||||
|
invalidVerification: '验证链接无效或已过期',
|
||||||
|
verifyFailed: '邮箱验证失败',
|
||||||
|
goLogin: '去登录'
|
||||||
|
},
|
||||||
|
errors: {
|
||||||
|
requestFailed: '请求失败({status})',
|
||||||
|
operationFailed: '操作失败',
|
||||||
|
loadFailed: '加载失败',
|
||||||
|
addFailed: '添加失败',
|
||||||
|
saveFailed: '保存失败',
|
||||||
|
completeEmailVerification: '请先完成邮箱验证'
|
||||||
|
},
|
||||||
|
pages: {
|
||||||
|
pokemon: {
|
||||||
|
title: 'Pokemon',
|
||||||
|
subtitle: '搜索宝可梦,并按特长、环境、喜欢的东西筛选。',
|
||||||
|
detailKicker: 'Pokédex Detail',
|
||||||
|
editKicker: 'Pokédex Edit',
|
||||||
|
editSubtitle: '维护 Pokemon 基本资料、特长和喜欢的东西。',
|
||||||
|
newTitle: '新增 Pokemon',
|
||||||
|
editTitle: '编辑 #{id} {name}',
|
||||||
|
loadingList: '正在加载 Pokemon 列表',
|
||||||
|
loadingDetail: '正在加载 Pokemon 详情',
|
||||||
|
loadingEdit: '正在加载 Pokemon 编辑内容',
|
||||||
|
environmentPrefix: '喜欢的环境:{name}',
|
||||||
|
environment: '喜欢的环境',
|
||||||
|
skills: '特长',
|
||||||
|
skillMatchMode: '特长匹配方式',
|
||||||
|
any: '任意',
|
||||||
|
all: '全部',
|
||||||
|
favoriteThings: '喜欢的东西',
|
||||||
|
favoriteThingMatchMode: '喜欢的东西匹配方式',
|
||||||
|
skillDrops: '特长掉落物',
|
||||||
|
skillDrop: '{name}掉落物',
|
||||||
|
dropItem: '掉落物',
|
||||||
|
searchPokemon: '搜索 Pokemon',
|
||||||
|
relatedItems: '关联物品',
|
||||||
|
relatedItemCategory: '关联物品分类',
|
||||||
|
habitats: '栖息地',
|
||||||
|
namePlaceholder: '名字',
|
||||||
|
searchEnvironment: '搜索喜欢的环境',
|
||||||
|
searchSkills: '搜索特长',
|
||||||
|
searchFavoriteThings: '搜索喜欢的东西',
|
||||||
|
searchItems: '搜索物品'
|
||||||
|
},
|
||||||
|
habitats: {
|
||||||
|
title: '栖息地',
|
||||||
|
subtitle: '查看配方和可能出现的宝可梦。',
|
||||||
|
detailSubtitle: '栖息地详情',
|
||||||
|
editSubtitle: '维护栖息地配方和可能出现的 Pokemon。',
|
||||||
|
newTitle: '新增栖息地',
|
||||||
|
editTitle: '编辑 {name}',
|
||||||
|
fallbackName: '栖息地',
|
||||||
|
loadingList: '正在加载栖息地列表',
|
||||||
|
loadingDetail: '正在加载栖息地详情',
|
||||||
|
loadingEdit: '正在加载栖息地编辑内容',
|
||||||
|
recipe: '配方',
|
||||||
|
recipeList: '配方列表',
|
||||||
|
possiblePokemon: '可能出现的宝可梦',
|
||||||
|
addItem: '添加物品',
|
||||||
|
addPokemon: '添加 Pokemon',
|
||||||
|
maps: '地图',
|
||||||
|
searchMaps: '搜索地图'
|
||||||
|
},
|
||||||
|
items: {
|
||||||
|
title: '物品',
|
||||||
|
subtitle: '按分类、用途、标签查看物品。',
|
||||||
|
detailKicker: 'Item Detail',
|
||||||
|
detailSubtitle: '物品详情',
|
||||||
|
editKicker: 'Item Edit',
|
||||||
|
editSubtitle: '维护物品分类、用途、入手方式、自定义和标签。',
|
||||||
|
newTitle: '新增物品',
|
||||||
|
editTitle: '编辑 {name}',
|
||||||
|
fallbackName: '物品',
|
||||||
|
loadingList: '正在加载列表',
|
||||||
|
loadingDetail: '正在加载物品详情',
|
||||||
|
loadingEdit: '正在加载物品编辑内容',
|
||||||
|
category: '分类',
|
||||||
|
usage: '用途',
|
||||||
|
tags: '标签',
|
||||||
|
acquisitionMethods: '入手方式',
|
||||||
|
customization: '自定义',
|
||||||
|
dyeable: '可染色',
|
||||||
|
dualDyeable: '可双区染色',
|
||||||
|
patternEditable: '可改花纹',
|
||||||
|
noRecipe: '无材料单',
|
||||||
|
recipeInfo: '材料单信息',
|
||||||
|
relatedRecipes: '相关材料单',
|
||||||
|
relatedHabitats: '相关栖息地',
|
||||||
|
pokemonDrops: 'Pokemon 掉落',
|
||||||
|
createRecipe: '创建材料单',
|
||||||
|
searchCategory: '搜索分类',
|
||||||
|
searchUsage: '搜索用途',
|
||||||
|
searchMethods: '搜索入手方式',
|
||||||
|
searchTags: '搜索标签'
|
||||||
|
},
|
||||||
|
recipes: {
|
||||||
|
title: '材料单',
|
||||||
|
subtitle: '按分类、用途、标签查看材料单。',
|
||||||
|
detailKicker: 'Recipe Detail',
|
||||||
|
detailSubtitle: '材料单详情',
|
||||||
|
editKicker: 'Recipe Edit',
|
||||||
|
editSubtitle: '维护材料单结果物品、入手方式和需要材料。',
|
||||||
|
newTitle: '新增材料单',
|
||||||
|
editTitle: '编辑 {name}',
|
||||||
|
fallbackName: '材料单',
|
||||||
|
loadingList: '正在加载材料单列表',
|
||||||
|
loadingDetail: '正在加载材料单详情',
|
||||||
|
loadingEdit: '正在加载材料单编辑内容',
|
||||||
|
item: '物品',
|
||||||
|
materials: '需要材料',
|
||||||
|
addMaterial: '添加材料'
|
||||||
|
},
|
||||||
|
checklist: {
|
||||||
|
title: '每日清单',
|
||||||
|
subtitle: '查看每天可以完成的事项。',
|
||||||
|
sectionTitle: '每日做什么',
|
||||||
|
empty: '暂无每日清单',
|
||||||
|
loading: '正在加载每日清单',
|
||||||
|
task: 'Task',
|
||||||
|
newTask: '新增 Task',
|
||||||
|
editTask: '编辑 Task'
|
||||||
|
},
|
||||||
|
admin: {
|
||||||
|
title: '管理',
|
||||||
|
subtitle: '维护系统配置,查看并删除 Wiki 数据记录。',
|
||||||
|
modules: '管理模块',
|
||||||
|
loading: '正在加载管理列表',
|
||||||
|
config: '系统配置',
|
||||||
|
configType: '系统配置类型',
|
||||||
|
checklist: 'CheckList',
|
||||||
|
pokemonList: 'Pokemon 列表',
|
||||||
|
itemList: '物品列表',
|
||||||
|
recipeList: '材料单列表',
|
||||||
|
habitatList: '栖息地列表',
|
||||||
|
languages: '语言',
|
||||||
|
newConfig: '新增{name}',
|
||||||
|
editConfig: '编辑{name}',
|
||||||
|
hasItemDrop: '有掉落物',
|
||||||
|
dragSort: '拖曳排序:{name}',
|
||||||
|
dragSortTitle: '拖曳排序',
|
||||||
|
languageCode: 'Code',
|
||||||
|
languageName: '语言名称',
|
||||||
|
enabled: '启用',
|
||||||
|
defaultLanguage: '默认语言',
|
||||||
|
sortOrder: '排序',
|
||||||
|
newLanguage: '新增语言',
|
||||||
|
editLanguage: '编辑语言'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
config: {
|
||||||
|
skills: '特长',
|
||||||
|
environments: '喜欢的环境',
|
||||||
|
favoriteThings: '喜欢的东西 / 标签',
|
||||||
|
itemCategories: '物品分类',
|
||||||
|
itemUsages: '物品用途',
|
||||||
|
acquisitionMethods: '入手方式',
|
||||||
|
maps: '地图'
|
||||||
|
},
|
||||||
|
appearance: {
|
||||||
|
time: '时段',
|
||||||
|
weather: '天气',
|
||||||
|
rarity: '稀有度',
|
||||||
|
map: '地图',
|
||||||
|
maps: '出现地图',
|
||||||
|
morning: '早晨',
|
||||||
|
noon: '中午',
|
||||||
|
evening: '傍晚',
|
||||||
|
night: '晚上',
|
||||||
|
sunny: '晴天',
|
||||||
|
cloudy: '阴天',
|
||||||
|
rainy: '雨天',
|
||||||
|
stars: '{count} 星'
|
||||||
|
},
|
||||||
|
history: {
|
||||||
|
title: '贡献记录',
|
||||||
|
createdBy: '由谁创建',
|
||||||
|
lastEdited: '最后编辑',
|
||||||
|
editHistory: '编辑历史',
|
||||||
|
before: '修改前',
|
||||||
|
after: '修改后',
|
||||||
|
author: '作者',
|
||||||
|
time: '时间',
|
||||||
|
action: '动作',
|
||||||
|
create: '创建',
|
||||||
|
update: '编辑',
|
||||||
|
delete: '删除',
|
||||||
|
empty: '暂无编辑历史'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MessageKey = keyof typeof messages.en;
|
||||||
|
|
||||||
|
export const i18n = createI18n({
|
||||||
|
legacy: false,
|
||||||
|
globalInjection: true,
|
||||||
|
locale: readStoredLocale(),
|
||||||
|
fallbackLocale: defaultLocale,
|
||||||
|
messages
|
||||||
|
});
|
||||||
|
|
||||||
|
function readStoredLocale(): string {
|
||||||
|
if (typeof localStorage === 'undefined') {
|
||||||
|
return defaultLocale;
|
||||||
|
}
|
||||||
|
|
||||||
|
const storedLocale = localStorage.getItem(localeStorageKey);
|
||||||
|
return storedLocale && storedLocale.trim() !== '' ? storedLocale : defaultLocale;
|
||||||
|
}
|
||||||
|
|
||||||
|
function globalLocaleRef() {
|
||||||
|
return i18n.global.locale as unknown as { value: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCurrentLocale(): string {
|
||||||
|
return globalLocaleRef().value || defaultLocale;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setCurrentLocale(locale: string): void {
|
||||||
|
const nextLocale = locale || defaultLocale;
|
||||||
|
globalLocaleRef().value = nextLocale;
|
||||||
|
|
||||||
|
if (typeof document !== 'undefined') {
|
||||||
|
document.documentElement.lang = nextLocale;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof localStorage !== 'undefined') {
|
||||||
|
localStorage.setItem(localeStorageKey, nextLocale);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.dispatchEvent(new Event(localeChangeEvent));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function onLocaleChange(callback: () => void): () => void {
|
||||||
|
window.addEventListener(localeChangeEvent, callback);
|
||||||
|
return () => window.removeEventListener(localeChangeEvent, callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
setCurrentLocale(getCurrentLocale());
|
||||||
28
frontend/src/icons.ts
Normal file
28
frontend/src/icons.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
export type AppIcon = string;
|
||||||
|
|
||||||
|
export const iconAdd: AppIcon = 'mdi:plus';
|
||||||
|
export const iconAdmin: AppIcon = 'mdi:tune-variant';
|
||||||
|
export const iconBack: AppIcon = 'mdi:arrow-left';
|
||||||
|
export const iconCancel: AppIcon = 'mdi:close';
|
||||||
|
export const iconCheck: AppIcon = 'mdi:check';
|
||||||
|
export const iconChecklist: AppIcon = 'mdi:checkbox-marked-outline';
|
||||||
|
export const iconChevronDown: AppIcon = 'mdi:chevron-down';
|
||||||
|
export const iconClose: AppIcon = 'mdi:close';
|
||||||
|
export const iconDelete: AppIcon = 'mdi:trash-can-outline';
|
||||||
|
export const iconDragHandle: AppIcon = 'mdi:drag';
|
||||||
|
export const iconEdit: AppIcon = 'mdi:pencil-outline';
|
||||||
|
export const iconError: AppIcon = 'mdi:close-circle-outline';
|
||||||
|
export const iconHabitat: AppIcon = 'mdi:pine-tree';
|
||||||
|
export const iconInfo: AppIcon = 'mdi:information-outline';
|
||||||
|
export const iconItem: AppIcon = 'mdi:bag-personal-outline';
|
||||||
|
export const iconLogin: AppIcon = 'mdi:login';
|
||||||
|
export const iconLogout: AppIcon = 'mdi:logout';
|
||||||
|
export const iconMail: AppIcon = 'mdi:email-fast-outline';
|
||||||
|
export const iconNoRecipe: AppIcon = 'mdi:file-document-remove-outline';
|
||||||
|
export const iconPokemon: AppIcon = 'mdi:pokeball';
|
||||||
|
export const iconRecipe: AppIcon = 'mdi:book-open-page-variant-outline';
|
||||||
|
export const iconRegister: AppIcon = 'mdi:account-plus-outline';
|
||||||
|
export const iconSave: AppIcon = 'mdi:content-save-outline';
|
||||||
|
export const iconSuccess: AppIcon = 'mdi:check-circle-outline';
|
||||||
|
export const iconTranslate: AppIcon = 'mdi:translate';
|
||||||
|
export const iconWarning: AppIcon = 'mdi:alert-outline';
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { createApp } from 'vue';
|
import { createApp } from 'vue';
|
||||||
import App from './App.vue';
|
import App from './App.vue';
|
||||||
|
import { i18n } from './i18n';
|
||||||
import { router } from './router';
|
import { router } from './router';
|
||||||
import './styles/main.css';
|
import './styles/main.css';
|
||||||
|
|
||||||
createApp(App).use(router).mount('#app');
|
createApp(App).use(i18n).use(router).mount('#app');
|
||||||
|
|||||||
@@ -1,16 +1,13 @@
|
|||||||
import { createRouter, createWebHistory } from 'vue-router';
|
import { createRouter, createWebHistory } from 'vue-router';
|
||||||
import PokemonList from '../views/PokemonList.vue';
|
import PokemonList from '../views/PokemonList.vue';
|
||||||
import PokemonDetail from '../views/PokemonDetail.vue';
|
import PokemonDetail from '../views/PokemonDetail.vue';
|
||||||
import PokemonEdit from '../views/PokemonEdit.vue';
|
|
||||||
import HabitatList from '../views/HabitatList.vue';
|
import HabitatList from '../views/HabitatList.vue';
|
||||||
import HabitatDetail from '../views/HabitatDetail.vue';
|
import HabitatDetail from '../views/HabitatDetail.vue';
|
||||||
import HabitatEdit from '../views/HabitatEdit.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 ItemEdit from '../views/ItemEdit.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 RecipeEdit from '../views/RecipeEdit.vue';
|
import DailyChecklistView from '../views/DailyChecklistView.vue';
|
||||||
import AdminView from '../views/AdminView.vue';
|
import AdminView from '../views/AdminView.vue';
|
||||||
import LoginView from '../views/LoginView.vue';
|
import LoginView from '../views/LoginView.vue';
|
||||||
import RegisterView from '../views/RegisterView.vue';
|
import RegisterView from '../views/RegisterView.vue';
|
||||||
@@ -21,28 +18,33 @@ export const router = createRouter({
|
|||||||
history: createWebHistory(),
|
history: createWebHistory(),
|
||||||
routes: [
|
routes: [
|
||||||
{ path: '/', redirect: '/pokemon' },
|
{ path: '/', redirect: '/pokemon' },
|
||||||
{ path: '/pokemon', component: PokemonList },
|
{ path: '/pokemon', name: 'pokemon-list', component: PokemonList },
|
||||||
{ path: '/pokemon/new', component: PokemonEdit, meta: { requiresVerified: true } },
|
{ path: '/pokemon/new', name: 'pokemon-new', component: PokemonList, meta: { requiresVerified: true, editorModal: true } },
|
||||||
{ path: '/pokemon/:id/edit', component: PokemonEdit, meta: { requiresVerified: true } },
|
{ path: '/pokemon/:id/edit', name: 'pokemon-edit', component: PokemonDetail, meta: { requiresVerified: true, editorModal: true } },
|
||||||
{ path: '/pokemon/:id', component: PokemonDetail },
|
{ path: '/pokemon/:id', name: 'pokemon-detail', component: PokemonDetail },
|
||||||
{ path: '/habitats', component: HabitatList },
|
{ path: '/habitats', name: 'habitat-list', component: HabitatList },
|
||||||
{ path: '/habitats/new', component: HabitatEdit, meta: { requiresVerified: true } },
|
{ path: '/habitats/new', name: 'habitat-new', component: HabitatList, meta: { requiresVerified: true, editorModal: true } },
|
||||||
{ path: '/habitats/:id/edit', component: HabitatEdit, meta: { requiresVerified: true } },
|
{ path: '/habitats/:id/edit', name: 'habitat-edit', component: HabitatDetail, meta: { requiresVerified: true, editorModal: true } },
|
||||||
{ path: '/habitats/:id', component: HabitatDetail },
|
{ path: '/habitats/:id', name: 'habitat-detail', component: HabitatDetail },
|
||||||
{ path: '/items', component: ItemsList },
|
{ path: '/items', name: 'item-list', component: ItemsList },
|
||||||
{ path: '/items/new', component: ItemEdit, meta: { requiresVerified: true } },
|
{ path: '/items/new', name: 'item-new', component: ItemsList, meta: { requiresVerified: true, editorModal: true } },
|
||||||
{ path: '/items/:id/edit', component: ItemEdit, meta: { requiresVerified: true } },
|
{ path: '/items/:id/edit', name: 'item-edit', component: ItemDetail, meta: { requiresVerified: true, editorModal: true } },
|
||||||
{ path: '/items/:id', component: ItemDetail },
|
{ path: '/items/:id', name: 'item-detail', component: ItemDetail },
|
||||||
{ path: '/recipes', component: RecipeList },
|
{ path: '/recipes', name: 'recipe-list', component: RecipeList },
|
||||||
{ path: '/recipes/new', component: RecipeEdit, meta: { requiresVerified: true } },
|
{ path: '/recipes/new', name: 'recipe-new', component: RecipeList, meta: { requiresVerified: true, editorModal: true } },
|
||||||
{ path: '/recipes/:id/edit', component: RecipeEdit, meta: { requiresVerified: true } },
|
{ path: '/recipes/:id/edit', name: 'recipe-edit', component: RecipeDetail, meta: { requiresVerified: true, editorModal: true } },
|
||||||
{ path: '/recipes/:id', component: RecipeDetail },
|
{ path: '/recipes/:id', name: 'recipe-detail', component: RecipeDetail },
|
||||||
|
{ path: '/checklist', component: DailyChecklistView },
|
||||||
{ path: '/admin', component: AdminView, meta: { requiresVerified: true } },
|
{ path: '/admin', component: AdminView, meta: { requiresVerified: true } },
|
||||||
{ path: '/login', component: LoginView },
|
{ path: '/login', component: LoginView },
|
||||||
{ path: '/register', component: RegisterView },
|
{ path: '/register', component: RegisterView },
|
||||||
{ path: '/verify-email', component: VerifyEmailView }
|
{ path: '/verify-email', component: VerifyEmailView }
|
||||||
],
|
],
|
||||||
scrollBehavior: () => ({ top: 0 })
|
scrollBehavior(to, from, savedPosition) {
|
||||||
|
if (savedPosition) return savedPosition;
|
||||||
|
if (to.meta.editorModal === true || from.meta.editorModal === true) return false;
|
||||||
|
return { top: 0 };
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.beforeEach(async (to) => {
|
router.beforeEach(async (to) => {
|
||||||
|
|||||||
@@ -1,10 +1,25 @@
|
|||||||
|
import { getCurrentLocale } from '../i18n';
|
||||||
|
|
||||||
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:3001';
|
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:3001';
|
||||||
const authTokenKey = 'pokopia_auth_token';
|
const authTokenKey = 'pokopia_auth_token';
|
||||||
const authChangeEvent = 'pokopia-auth-change';
|
const authChangeEvent = 'pokopia-auth-change';
|
||||||
|
|
||||||
|
export type TranslationField = 'name' | 'title';
|
||||||
|
export type TranslationMap = Record<string, Partial<Record<TranslationField, string>>>;
|
||||||
|
|
||||||
|
export interface Language {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
enabled: boolean;
|
||||||
|
isDefault: boolean;
|
||||||
|
sortOrder: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface NamedEntity {
|
export interface NamedEntity {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
|
baseName?: string;
|
||||||
|
translations?: TranslationMap;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Skill extends NamedEntity {
|
export interface Skill extends NamedEntity {
|
||||||
@@ -23,9 +38,26 @@ export interface EditInfo {
|
|||||||
updatedBy: UserSummary | null;
|
updatedBy: UserSummary | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type EditHistoryAction = 'create' | 'update' | 'delete';
|
||||||
|
|
||||||
|
export interface EditChange {
|
||||||
|
label: string;
|
||||||
|
before: string;
|
||||||
|
after: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EditHistoryEntry {
|
||||||
|
action: EditHistoryAction;
|
||||||
|
changes: EditChange[];
|
||||||
|
createdAt: string;
|
||||||
|
user: UserSummary | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Pokemon extends EditInfo {
|
export interface Pokemon extends EditInfo {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
|
baseName?: string;
|
||||||
|
translations?: TranslationMap;
|
||||||
environment: NamedEntity;
|
environment: NamedEntity;
|
||||||
skills: Skill[];
|
skills: Skill[];
|
||||||
favorite_things: NamedEntity[];
|
favorite_things: NamedEntity[];
|
||||||
@@ -34,6 +66,7 @@ export interface Pokemon extends EditInfo {
|
|||||||
export interface PokemonDetail extends Pokemon {
|
export interface PokemonDetail extends Pokemon {
|
||||||
skills: Array<Skill & { itemDrop: NamedEntity | null }>;
|
skills: Array<Skill & { itemDrop: NamedEntity | null }>;
|
||||||
favoriteThingItems: Array<NamedEntity & { category: NamedEntity; tags: NamedEntity[] }>;
|
favoriteThingItems: Array<NamedEntity & { category: NamedEntity; tags: NamedEntity[] }>;
|
||||||
|
editHistory: EditHistoryEntry[];
|
||||||
habitats: Array<{
|
habitats: Array<{
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -47,11 +80,14 @@ export interface PokemonDetail extends Pokemon {
|
|||||||
export interface Habitat extends EditInfo {
|
export interface Habitat extends EditInfo {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
|
baseName?: string;
|
||||||
|
translations?: TranslationMap;
|
||||||
recipe: Array<NamedEntity & { quantity: number }>;
|
recipe: Array<NamedEntity & { quantity: number }>;
|
||||||
pokemon?: NamedEntity[];
|
pokemon?: NamedEntity[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HabitatDetail extends Habitat {
|
export interface HabitatDetail extends Habitat {
|
||||||
|
editHistory: EditHistoryEntry[];
|
||||||
pokemon: Array<NamedEntity & {
|
pokemon: Array<NamedEntity & {
|
||||||
time_of_day: string;
|
time_of_day: string;
|
||||||
weather: string;
|
weather: string;
|
||||||
@@ -79,6 +115,8 @@ export interface HabitatUsage {
|
|||||||
export interface Item extends EditInfo {
|
export interface Item extends EditInfo {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
|
baseName?: string;
|
||||||
|
translations?: TranslationMap;
|
||||||
category: NamedEntity;
|
category: NamedEntity;
|
||||||
usage: NamedEntity | null;
|
usage: NamedEntity | null;
|
||||||
customization: {
|
customization: {
|
||||||
@@ -96,6 +134,7 @@ export interface ItemDetail extends Item {
|
|||||||
recipe: RecipeDetail | null;
|
recipe: RecipeDetail | null;
|
||||||
relatedRecipes: RecipeUsage[];
|
relatedRecipes: RecipeUsage[];
|
||||||
relatedHabitats: HabitatUsage[];
|
relatedHabitats: HabitatUsage[];
|
||||||
|
editHistory: EditHistoryEntry[];
|
||||||
droppedByPokemon: Array<{
|
droppedByPokemon: Array<{
|
||||||
pokemon: NamedEntity;
|
pokemon: NamedEntity;
|
||||||
skill: NamedEntity;
|
skill: NamedEntity;
|
||||||
@@ -108,8 +147,16 @@ export interface Recipe extends EditInfo {
|
|||||||
materials: Array<NamedEntity & { quantity: number }>;
|
materials: Array<NamedEntity & { quantity: number }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DailyChecklistItem {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
baseTitle?: string;
|
||||||
|
translations?: TranslationMap;
|
||||||
|
}
|
||||||
|
|
||||||
export interface RecipeDetail extends Recipe {
|
export interface RecipeDetail extends Recipe {
|
||||||
acquisition_methods: NamedEntity[];
|
acquisition_methods: NamedEntity[];
|
||||||
|
editHistory: EditHistoryEntry[];
|
||||||
item: NamedEntity;
|
item: NamedEntity;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,6 +204,7 @@ export type ConfigType =
|
|||||||
export interface PokemonPayload {
|
export interface PokemonPayload {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
|
translations?: TranslationMap;
|
||||||
environmentId: number;
|
environmentId: number;
|
||||||
skillIds: number[];
|
skillIds: number[];
|
||||||
favoriteThingIds: number[];
|
favoriteThingIds: number[];
|
||||||
@@ -165,6 +213,7 @@ export interface PokemonPayload {
|
|||||||
|
|
||||||
export interface ItemPayload {
|
export interface ItemPayload {
|
||||||
name: string;
|
name: string;
|
||||||
|
translations?: TranslationMap;
|
||||||
categoryId: number;
|
categoryId: number;
|
||||||
usageId: number | null;
|
usageId: number | null;
|
||||||
dyeable: boolean;
|
dyeable: boolean;
|
||||||
@@ -183,6 +232,7 @@ export interface RecipePayload {
|
|||||||
|
|
||||||
export interface HabitatPayload {
|
export interface HabitatPayload {
|
||||||
name: string;
|
name: string;
|
||||||
|
translations?: TranslationMap;
|
||||||
recipeItems: Array<{ itemId: number; quantity: number }>;
|
recipeItems: Array<{ itemId: number; quantity: number }>;
|
||||||
pokemonAppearances: Array<{
|
pokemonAppearances: Array<{
|
||||||
pokemonId: number;
|
pokemonId: number;
|
||||||
@@ -193,6 +243,11 @@ export interface HabitatPayload {
|
|||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DailyChecklistPayload {
|
||||||
|
title: string;
|
||||||
|
translations?: TranslationMap;
|
||||||
|
}
|
||||||
|
|
||||||
export function buildQuery(params: Record<string, string | number | undefined>): string {
|
export function buildQuery(params: Record<string, string | number | undefined>): string {
|
||||||
const search = new URLSearchParams();
|
const search = new URLSearchParams();
|
||||||
|
|
||||||
@@ -233,9 +288,12 @@ export function onAuthTokenChange(callback: () => void): () => void {
|
|||||||
return () => window.removeEventListener(authChangeEvent, callback);
|
return () => window.removeEventListener(authChangeEvent, callback);
|
||||||
}
|
}
|
||||||
|
|
||||||
function authHeaders(): HeadersInit {
|
function requestHeaders(): HeadersInit {
|
||||||
const token = getAuthToken();
|
const token = getAuthToken();
|
||||||
return token ? { Authorization: `Bearer ${token}` } : {};
|
return {
|
||||||
|
'X-Locale': getCurrentLocale(),
|
||||||
|
...(token ? { Authorization: `Bearer ${token}` } : {})
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getErrorMessage(response: Response): Promise<string> {
|
async function getErrorMessage(response: Response): Promise<string> {
|
||||||
@@ -248,12 +306,12 @@ async function getErrorMessage(response: Response): Promise<string> {
|
|||||||
// Ignore invalid or empty error bodies and use the status fallback.
|
// Ignore invalid or empty error bodies and use the status fallback.
|
||||||
}
|
}
|
||||||
|
|
||||||
return `请求失败(${response.status})`;
|
return `Request failed (${response.status})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getJson<T>(path: string): Promise<T> {
|
async function getJson<T>(path: string): Promise<T> {
|
||||||
const response = await fetch(`${apiBaseUrl}${path}`, {
|
const response = await fetch(`${apiBaseUrl}${path}`, {
|
||||||
headers: authHeaders()
|
headers: requestHeaders()
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -268,7 +326,7 @@ async function sendJson<T>(path: string, method: 'POST' | 'PUT', body: unknown):
|
|||||||
method,
|
method,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
...authHeaders()
|
...requestHeaders()
|
||||||
},
|
},
|
||||||
body: JSON.stringify(body)
|
body: JSON.stringify(body)
|
||||||
});
|
});
|
||||||
@@ -283,7 +341,7 @@ async function sendJson<T>(path: string, method: 'POST' | 'PUT', body: unknown):
|
|||||||
async function postEmpty(path: string): Promise<void> {
|
async function postEmpty(path: string): Promise<void> {
|
||||||
const response = await fetch(`${apiBaseUrl}${path}`, {
|
const response = await fetch(`${apiBaseUrl}${path}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: authHeaders()
|
headers: requestHeaders()
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -294,7 +352,7 @@ async function postEmpty(path: string): Promise<void> {
|
|||||||
async function deleteJson(path: string): Promise<void> {
|
async function deleteJson(path: string): Promise<void> {
|
||||||
const response = await fetch(`${apiBaseUrl}${path}`, {
|
const response = await fetch(`${apiBaseUrl}${path}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: authHeaders()
|
headers: requestHeaders()
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -303,6 +361,14 @@ async function deleteJson(path: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const api = {
|
export const api = {
|
||||||
|
languages: () => getJson<Language[]>('/api/languages'),
|
||||||
|
adminLanguages: () => getJson<Language[]>('/api/admin/languages'),
|
||||||
|
createLanguage: (payload: Omit<Language, 'sortOrder'> & { sortOrder?: number }) =>
|
||||||
|
sendJson<Language[]>('/api/admin/languages', 'POST', payload),
|
||||||
|
updateLanguage: (code: string, payload: Partial<Language> & { name: string }) =>
|
||||||
|
sendJson<Language[]>(`/api/admin/languages/${code}`, 'PUT', payload),
|
||||||
|
reorderLanguages: (codes: string[]) => sendJson<Language[]>('/api/admin/languages/order', 'PUT', { codes }),
|
||||||
|
deleteLanguage: (code: string) => deleteJson(`/api/admin/languages/${code}`),
|
||||||
register: (payload: RegisterPayload) => sendJson<{ message: string }>('/api/auth/register', 'POST', payload),
|
register: (payload: RegisterPayload) => sendJson<{ message: string }>('/api/auth/register', 'POST', payload),
|
||||||
verifyEmail: (token: string) =>
|
verifyEmail: (token: string) =>
|
||||||
sendJson<{ message: string; user: AuthUser }>('/api/auth/verify-email', 'POST', { token }),
|
sendJson<{ message: string; user: AuthUser }>('/api/auth/verify-email', 'POST', { token }),
|
||||||
@@ -310,10 +376,20 @@ export const api = {
|
|||||||
me: () => getJson<{ user: AuthUser }>('/api/auth/me'),
|
me: () => getJson<{ user: AuthUser }>('/api/auth/me'),
|
||||||
logout: () => postEmpty('/api/auth/logout'),
|
logout: () => postEmpty('/api/auth/logout'),
|
||||||
options: () => getJson<Options>('/api/options'),
|
options: () => getJson<Options>('/api/options'),
|
||||||
|
dailyChecklist: () => getJson<DailyChecklistItem[]>('/api/daily-checklist'),
|
||||||
|
createDailyChecklistItem: (payload: DailyChecklistPayload) =>
|
||||||
|
sendJson<DailyChecklistItem>('/api/admin/daily-checklist', 'POST', payload),
|
||||||
|
updateDailyChecklistItem: (id: string | number, payload: DailyChecklistPayload) =>
|
||||||
|
sendJson<DailyChecklistItem>(`/api/admin/daily-checklist/${id}`, 'PUT', payload),
|
||||||
|
reorderDailyChecklistItems: (ids: number[]) =>
|
||||||
|
sendJson<DailyChecklistItem[]>('/api/admin/daily-checklist/order', 'PUT', { ids }),
|
||||||
|
deleteDailyChecklistItem: (id: string | number) => deleteJson(`/api/admin/daily-checklist/${id}`),
|
||||||
config: (type: ConfigType) => getJson<Array<Skill | NamedEntity>>(`/api/admin/config/${type}`),
|
config: (type: ConfigType) => getJson<Array<Skill | NamedEntity>>(`/api/admin/config/${type}`),
|
||||||
createConfig: (type: ConfigType, payload: { name: string; hasItemDrop?: boolean }) =>
|
createConfig: (type: ConfigType, payload: { name: string; translations?: TranslationMap; hasItemDrop?: boolean }) =>
|
||||||
sendJson<Skill | NamedEntity>(`/api/admin/config/${type}`, 'POST', payload),
|
sendJson<Skill | NamedEntity>(`/api/admin/config/${type}`, 'POST', payload),
|
||||||
updateConfig: (type: ConfigType, id: number, payload: { name: string; hasItemDrop?: boolean }) =>
|
reorderConfig: (type: ConfigType, ids: number[]) =>
|
||||||
|
sendJson<Array<Skill | NamedEntity>>(`/api/admin/config/${type}/order`, 'PUT', { ids }),
|
||||||
|
updateConfig: (type: ConfigType, id: number, payload: { name: string; translations?: TranslationMap; hasItemDrop?: boolean }) =>
|
||||||
sendJson<Skill | NamedEntity>(`/api/admin/config/${type}/${id}`, 'PUT', payload),
|
sendJson<Skill | NamedEntity>(`/api/admin/config/${type}/${id}`, 'PUT', payload),
|
||||||
deleteConfig: (type: ConfigType, id: number) => deleteJson(`/api/admin/config/${type}/${id}`),
|
deleteConfig: (type: ConfigType, id: number) => deleteJson(`/api/admin/config/${type}/${id}`),
|
||||||
pokemon: (params: Record<string, string | number | undefined>) =>
|
pokemon: (params: Record<string, string | number | undefined>) =>
|
||||||
@@ -323,23 +399,27 @@ export const api = {
|
|||||||
updatePokemon: (id: string | number, payload: PokemonPayload) =>
|
updatePokemon: (id: string | number, payload: PokemonPayload) =>
|
||||||
sendJson<PokemonDetail>(`/api/pokemon/${id}`, 'PUT', payload),
|
sendJson<PokemonDetail>(`/api/pokemon/${id}`, 'PUT', payload),
|
||||||
deletePokemon: (id: string | number) => deleteJson(`/api/pokemon/${id}`),
|
deletePokemon: (id: string | number) => deleteJson(`/api/pokemon/${id}`),
|
||||||
|
reorderPokemon: (ids: number[]) => sendJson<Pokemon[]>('/api/admin/pokemon/order', 'PUT', { ids }),
|
||||||
habitats: () => getJson<Habitat[]>('/api/habitats'),
|
habitats: () => getJson<Habitat[]>('/api/habitats'),
|
||||||
habitatDetail: (id: string | number) => getJson<HabitatDetail>(`/api/habitats/${id}`),
|
habitatDetail: (id: string | number) => getJson<HabitatDetail>(`/api/habitats/${id}`),
|
||||||
createHabitat: (payload: HabitatPayload) => sendJson<HabitatDetail>('/api/habitats', 'POST', payload),
|
createHabitat: (payload: HabitatPayload) => sendJson<HabitatDetail>('/api/habitats', 'POST', payload),
|
||||||
updateHabitat: (id: string | number, payload: HabitatPayload) =>
|
updateHabitat: (id: string | number, payload: HabitatPayload) =>
|
||||||
sendJson<HabitatDetail>(`/api/habitats/${id}`, 'PUT', payload),
|
sendJson<HabitatDetail>(`/api/habitats/${id}`, 'PUT', payload),
|
||||||
deleteHabitat: (id: string | number) => deleteJson(`/api/habitats/${id}`),
|
deleteHabitat: (id: string | number) => deleteJson(`/api/habitats/${id}`),
|
||||||
|
reorderHabitats: (ids: number[]) => sendJson<Habitat[]>('/api/admin/habitats/order', 'PUT', { ids }),
|
||||||
items: (params: Record<string, string | number | undefined>) =>
|
items: (params: Record<string, string | number | undefined>) =>
|
||||||
getJson<Item[]>(`/api/items${buildQuery(params)}`),
|
getJson<Item[]>(`/api/items${buildQuery(params)}`),
|
||||||
itemDetail: (id: string | number) => getJson<ItemDetail>(`/api/items/${id}`),
|
itemDetail: (id: string | number) => getJson<ItemDetail>(`/api/items/${id}`),
|
||||||
createItem: (payload: ItemPayload) => sendJson<ItemDetail>('/api/items', 'POST', payload),
|
createItem: (payload: ItemPayload) => sendJson<ItemDetail>('/api/items', 'POST', payload),
|
||||||
updateItem: (id: string | number, payload: ItemPayload) => sendJson<ItemDetail>(`/api/items/${id}`, 'PUT', payload),
|
updateItem: (id: string | number, payload: ItemPayload) => sendJson<ItemDetail>(`/api/items/${id}`, 'PUT', payload),
|
||||||
deleteItem: (id: string | number) => deleteJson(`/api/items/${id}`),
|
deleteItem: (id: string | number) => deleteJson(`/api/items/${id}`),
|
||||||
|
reorderItems: (ids: number[]) => sendJson<Item[]>('/api/admin/items/order', 'PUT', { ids }),
|
||||||
recipes: (params: Record<string, string | number | undefined> = {}) =>
|
recipes: (params: Record<string, string | number | undefined> = {}) =>
|
||||||
getJson<Recipe[]>(`/api/recipes${buildQuery(params)}`),
|
getJson<Recipe[]>(`/api/recipes${buildQuery(params)}`),
|
||||||
recipeDetail: (id: string | number) => getJson<RecipeDetail>(`/api/recipes/${id}`),
|
recipeDetail: (id: string | number) => getJson<RecipeDetail>(`/api/recipes/${id}`),
|
||||||
createRecipe: (payload: RecipePayload) => sendJson<RecipeDetail>('/api/recipes', 'POST', payload),
|
createRecipe: (payload: RecipePayload) => sendJson<RecipeDetail>('/api/recipes', 'POST', payload),
|
||||||
updateRecipe: (id: string | number, payload: RecipePayload) =>
|
updateRecipe: (id: string | number, payload: RecipePayload) =>
|
||||||
sendJson<RecipeDetail>(`/api/recipes/${id}`, 'PUT', payload),
|
sendJson<RecipeDetail>(`/api/recipes/${id}`, 'PUT', payload),
|
||||||
deleteRecipe: (id: string | number) => deleteJson(`/api/recipes/${id}`)
|
deleteRecipe: (id: string | number) => deleteJson(`/api/recipes/${id}`),
|
||||||
|
reorderRecipes: (ids: number[]) => sendJson<Recipe[]>('/api/admin/recipes/order', 'PUT', { ids })
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -66,6 +66,10 @@ body {
|
|||||||
linear-gradient(180deg, var(--bg) 0%, var(--bg-alt) 100%);
|
linear-gradient(180deg, var(--bg) 0%, var(--bg-alt) 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
body.lock-scroll {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
color: inherit;
|
color: inherit;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
@@ -88,6 +92,12 @@ svg {
|
|||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ui-icon {
|
||||||
|
width: 1.1em;
|
||||||
|
height: 1.1em;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
:focus-visible {
|
:focus-visible {
|
||||||
outline: 3px solid var(--focus);
|
outline: 3px solid var(--focus);
|
||||||
outline-offset: 3px;
|
outline-offset: 3px;
|
||||||
@@ -159,6 +169,7 @@ svg {
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
padding: 8px 10px;
|
padding: 8px 10px;
|
||||||
border-radius: var(--radius-control);
|
border-radius: var(--radius-control);
|
||||||
color: var(--ink-soft);
|
color: var(--ink-soft);
|
||||||
@@ -177,6 +188,11 @@ svg {
|
|||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nav-links__icon {
|
||||||
|
width: 17px;
|
||||||
|
height: 17px;
|
||||||
|
}
|
||||||
|
|
||||||
.auth-actions {
|
.auth-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -184,6 +200,109 @@ svg {
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.language-menu {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.language-menu__trigger {
|
||||||
|
min-height: 38px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 7px 10px;
|
||||||
|
border: 2px solid var(--line);
|
||||||
|
border-radius: var(--radius-control);
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--ink-soft);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 850;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
background 0.14s ease,
|
||||||
|
border-color 0.14s ease,
|
||||||
|
box-shadow 0.14s ease,
|
||||||
|
color 0.14s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.language-menu__trigger:hover,
|
||||||
|
.language-menu__trigger[aria-expanded="true"] {
|
||||||
|
border-color: var(--pokemon-blue);
|
||||||
|
background: rgba(255, 203, 5, 0.22);
|
||||||
|
color: var(--pokemon-blue-deep);
|
||||||
|
}
|
||||||
|
|
||||||
|
.language-menu__trigger:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--pokemon-blue);
|
||||||
|
box-shadow: 0 0 0 4px rgba(42, 117, 187, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.language-menu__icon {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.language-menu__glyph {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.language-menu__dropdown {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 6px);
|
||||||
|
right: 0;
|
||||||
|
z-index: 60;
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
min-width: 180px;
|
||||||
|
padding: 8px;
|
||||||
|
border: 2px solid var(--line-strong);
|
||||||
|
border-radius: var(--radius-card);
|
||||||
|
background: var(--surface);
|
||||||
|
box-shadow: var(--shadow-raised);
|
||||||
|
}
|
||||||
|
|
||||||
|
.language-menu__item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 38px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: var(--radius-small);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 800;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.language-menu__item:hover,
|
||||||
|
.language-menu__item.active {
|
||||||
|
background: rgba(255, 203, 5, 0.22);
|
||||||
|
color: var(--pokemon-blue-deep);
|
||||||
|
}
|
||||||
|
|
||||||
|
.language-menu__item:focus-visible {
|
||||||
|
outline: 3px solid var(--focus);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.language-menu__item.active {
|
||||||
|
box-shadow: inset 0 0 0 2px rgba(42, 117, 187, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.language-menu__code {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 900;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
.auth-user {
|
.auth-user {
|
||||||
max-width: 180px;
|
max-width: 180px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -442,6 +561,108 @@ button:disabled,
|
|||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.modal-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 65;
|
||||||
|
display: none;
|
||||||
|
place-items: center;
|
||||||
|
padding: 22px;
|
||||||
|
background: rgba(8, 13, 22, 0.56);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-backdrop.is-open {
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
width: min(var(--modal-width, 560px), 100%);
|
||||||
|
max-height: min(100%, calc(100vh - 44px));
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto minmax(0, 1fr) auto;
|
||||||
|
border: 2px solid var(--line-strong);
|
||||||
|
border-radius: var(--radius-card);
|
||||||
|
background: var(--surface);
|
||||||
|
box-shadow: var(--shadow-raised);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal--wide {
|
||||||
|
--modal-width: 980px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header,
|
||||||
|
.modal-footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
background: var(--surface-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header__copy {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--ink);
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 950;
|
||||||
|
line-height: 1.15;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close-button {
|
||||||
|
width: 38px;
|
||||||
|
min-width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
display: inline-grid;
|
||||||
|
place-items: center;
|
||||||
|
border: 2px solid var(--line);
|
||||||
|
border-radius: var(--radius-control);
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--ink);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close-button .ui-icon {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-body {
|
||||||
|
min-width: 0;
|
||||||
|
padding: 16px;
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-edit-form {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-footer {
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
.tags-select {
|
.tags-select {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -520,6 +741,11 @@ button:disabled,
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tags-select__remove .ui-icon {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
.tags-select__remove:hover {
|
.tags-select__remove:hover {
|
||||||
background: rgba(42, 117, 187, 0.14);
|
background: rgba(42, 117, 187, 0.14);
|
||||||
}
|
}
|
||||||
@@ -531,8 +757,13 @@ button:disabled,
|
|||||||
.tags-select__arrow {
|
.tags-select__arrow {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
font-size: 18px;
|
width: 18px;
|
||||||
line-height: 1;
|
height: 18px;
|
||||||
|
transition: transform 0.14s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tags-select__trigger.open .tags-select__arrow {
|
||||||
|
transform: rotate(180deg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tags-select__dropdown {
|
.tags-select__dropdown {
|
||||||
@@ -601,12 +832,20 @@ button:disabled,
|
|||||||
}
|
}
|
||||||
|
|
||||||
.tags-select__state {
|
.tags-select__state {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
color: var(--pokemon-blue);
|
color: var(--pokemon-blue);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 850;
|
font-weight: 850;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tags-select__state .ui-icon {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
.tags-select__empty {
|
.tags-select__empty {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 8px 10px;
|
padding: 8px 10px;
|
||||||
@@ -845,6 +1084,11 @@ button:disabled,
|
|||||||
font-weight: 950;
|
font-weight: 950;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.entity-card__icon {
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
}
|
||||||
|
|
||||||
.entity-card__content {
|
.entity-card__content {
|
||||||
display: grid;
|
display: grid;
|
||||||
align-content: start;
|
align-content: start;
|
||||||
@@ -874,6 +1118,160 @@ button:disabled,
|
|||||||
font-weight: 750;
|
font-weight: 750;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.checklist-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checklist-item {
|
||||||
|
padding: 14px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-card);
|
||||||
|
background: var(--surface-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.checklist-check {
|
||||||
|
min-height: 34px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr);
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
color: var(--ink);
|
||||||
|
font-weight: 850;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checklist-check input {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
accent-color: var(--pokemon-blue);
|
||||||
|
}
|
||||||
|
|
||||||
|
.checklist-check span {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checklist-item.is-checked .checklist-check span {
|
||||||
|
color: var(--muted);
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checklist-skeleton-list li {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reorderable-row {
|
||||||
|
position: relative;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: flex-start;
|
||||||
|
border-radius: var(--radius-card);
|
||||||
|
transition:
|
||||||
|
background 0.16s ease,
|
||||||
|
box-shadow 0.16s ease,
|
||||||
|
opacity 0.16s ease,
|
||||||
|
transform 0.16s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reorderable-row.is-dragging {
|
||||||
|
z-index: 2;
|
||||||
|
background: color-mix(in srgb, var(--pokemon-yellow) 12%, var(--surface));
|
||||||
|
box-shadow: var(--shadow-soft);
|
||||||
|
opacity: 0.68;
|
||||||
|
transform: scale(0.99);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reorderable-row.is-drop-target::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
left: 0;
|
||||||
|
height: 3px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--pokemon-blue);
|
||||||
|
box-shadow: 0 0 0 3px color-mix(in srgb, var(--pokemon-blue) 18%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reorderable-row.is-drop-before::before {
|
||||||
|
top: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reorderable-row.is-drop-after::before {
|
||||||
|
bottom: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reorderable-list-move {
|
||||||
|
transition: transform 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drag-handle {
|
||||||
|
width: 44px;
|
||||||
|
min-height: 44px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
display: inline-grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-control);
|
||||||
|
background: var(--surface-soft);
|
||||||
|
color: var(--muted);
|
||||||
|
cursor: grab;
|
||||||
|
touch-action: manipulation;
|
||||||
|
transition:
|
||||||
|
background 0.14s ease,
|
||||||
|
border-color 0.14s ease,
|
||||||
|
color 0.14s ease,
|
||||||
|
transform 0.14s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drag-handle:hover,
|
||||||
|
.drag-handle:focus-visible {
|
||||||
|
border-color: var(--pokemon-blue);
|
||||||
|
background: color-mix(in srgb, var(--pokemon-blue) 9%, var(--surface));
|
||||||
|
color: var(--pokemon-blue-deep);
|
||||||
|
}
|
||||||
|
|
||||||
|
.drag-handle:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
transform: scale(0.96);
|
||||||
|
}
|
||||||
|
|
||||||
|
.drag-handle:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.54;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drag-handle .ui-icon {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reorderable-row-title {
|
||||||
|
flex: 1 1 180px;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
font-weight: 850;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.reorderable-row,
|
||||||
|
.reorderable-list-move,
|
||||||
|
.drag-handle {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reorderable-row.is-dragging,
|
||||||
|
.drag-handle:active {
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.config-flag {
|
.config-flag {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -913,6 +1311,17 @@ button:disabled,
|
|||||||
gap: 16px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.detail-grid--stack {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-with-sidebar {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(260px, 320px);
|
||||||
|
gap: 16px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
.habitat-detail-stack {
|
.habitat-detail-stack {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
@@ -962,6 +1371,255 @@ button:disabled,
|
|||||||
font-weight: 850;
|
font-weight: 850;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.edit-history-panel {
|
||||||
|
position: sticky;
|
||||||
|
top: 92px;
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 18px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-card);
|
||||||
|
background: var(--surface);
|
||||||
|
box-shadow: var(--shadow-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-history-panel__header h2,
|
||||||
|
.edit-history-list h3 {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--ink);
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-weight: 950;
|
||||||
|
line-height: 1.12;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-history-panel__header h2 {
|
||||||
|
font-size: 21px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-history-list h3 {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-history-summary {
|
||||||
|
display: grid;
|
||||||
|
gap: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-history-summary div {
|
||||||
|
display: grid;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 11px 0;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-history-summary div:first-child {
|
||||||
|
padding-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-history-summary div:last-child {
|
||||||
|
padding-bottom: 0;
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-history-summary dt {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 850;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-history-summary dd {
|
||||||
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
|
margin: 0;
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-history-summary time,
|
||||||
|
.edit-timeline time {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 750;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-history-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-timeline {
|
||||||
|
display: grid;
|
||||||
|
gap: 0;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-timeline li {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 38px minmax(0, 1fr);
|
||||||
|
gap: 10px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-timeline li:not(:last-child)::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 38px;
|
||||||
|
bottom: 0;
|
||||||
|
left: 17px;
|
||||||
|
width: 2px;
|
||||||
|
background: var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-timeline__avatar {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border: 2px solid var(--line-strong);
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--pokemon-yellow);
|
||||||
|
box-shadow: 0 2px 0 var(--line-strong);
|
||||||
|
color: #172036;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 950;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-timeline__body {
|
||||||
|
min-width: 0;
|
||||||
|
display: grid;
|
||||||
|
padding-bottom: 13px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-timeline li:last-child .edit-timeline__body {
|
||||||
|
padding-bottom: 0;
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-history-entry {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-history-entry summary {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) 18px;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 34px;
|
||||||
|
margin: 0;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 850;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-history-entry summary::-webkit-details-marker {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-history-entry summary::after {
|
||||||
|
content: "";
|
||||||
|
width: 9px;
|
||||||
|
height: 9px;
|
||||||
|
justify-self: center;
|
||||||
|
border-right: 2px solid var(--muted);
|
||||||
|
border-bottom: 2px solid var(--muted);
|
||||||
|
transform: rotate(-45deg);
|
||||||
|
transition: transform 0.16s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-history-entry[open] summary::after {
|
||||||
|
transform: rotate(45deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-history-entry__title {
|
||||||
|
min-width: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-history-entry__content {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
padding-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-change-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-change-list div {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 8px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-small);
|
||||||
|
background: var(--surface-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-change-list dt {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 850;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-change-list dd {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 52px minmax(0, 1fr);
|
||||||
|
gap: 3px 8px;
|
||||||
|
margin: 0;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-change-list dd span {
|
||||||
|
min-width: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-change-list__label {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-history-detail-meta {
|
||||||
|
display: grid;
|
||||||
|
gap: 5px;
|
||||||
|
margin: 0;
|
||||||
|
padding-top: 8px;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-history-detail-meta div {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 42px minmax(0, 1fr);
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-history-detail-meta dt,
|
||||||
|
.edit-history-detail-meta dd {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-history-detail-meta dt {
|
||||||
|
color: var(--muted);
|
||||||
|
font-weight: 850;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-history-detail-meta dd {
|
||||||
|
color: var(--ink-soft);
|
||||||
|
font-weight: 800;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
.row-list {
|
.row-list {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0;
|
gap: 0;
|
||||||
@@ -1069,14 +1727,12 @@ button:disabled,
|
|||||||
visibility: hidden;
|
visibility: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-message::before {
|
.status-message__icon {
|
||||||
content: "";
|
width: 20px;
|
||||||
width: 12px;
|
height: 20px;
|
||||||
height: 12px;
|
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
margin-top: 6px;
|
margin-top: 2px;
|
||||||
border-radius: 50%;
|
color: var(--status-accent, var(--pokemon-blue));
|
||||||
background: var(--status-accent, var(--pokemon-blue));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-message--success {
|
.status-message--success {
|
||||||
@@ -1221,6 +1877,10 @@ button:disabled,
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.translation-fields {
|
||||||
|
display: contents;
|
||||||
|
}
|
||||||
|
|
||||||
.skill-drop-row {
|
.skill-drop-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
@@ -1425,10 +2085,15 @@ button:disabled,
|
|||||||
}
|
}
|
||||||
|
|
||||||
.detail-grid,
|
.detail-grid,
|
||||||
|
.detail-with-sidebar,
|
||||||
.admin-layout {
|
.admin-layout {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.edit-history-panel {
|
||||||
|
position: static;
|
||||||
|
}
|
||||||
|
|
||||||
.appearance-row__main {
|
.appearance-row__main {
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
@@ -1487,4 +2152,14 @@ button:disabled,
|
|||||||
.appearance-row__main {
|
.appearance-row__main {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.modal-footer {
|
||||||
|
align-items: stretch;
|
||||||
|
flex-direction: column-reverse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-footer .link-button,
|
||||||
|
.modal-footer .plain-button {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,45 +1,85 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { Icon } from '@iconify/vue';
|
||||||
import { computed, onMounted, ref } from 'vue';
|
import { computed, onMounted, ref } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import Modal from '../components/Modal.vue';
|
||||||
import PageHeader from '../components/PageHeader.vue';
|
import PageHeader from '../components/PageHeader.vue';
|
||||||
|
import ReorderableList from '../components/ReorderableList.vue';
|
||||||
import Skeleton from '../components/Skeleton.vue';
|
import Skeleton from '../components/Skeleton.vue';
|
||||||
import StatusMessage from '../components/StatusMessage.vue';
|
import StatusMessage from '../components/StatusMessage.vue';
|
||||||
import Tabs, { type TabOption } from '../components/Tabs.vue';
|
import Tabs, { type TabOption } from '../components/Tabs.vue';
|
||||||
|
import TranslationFields from '../components/TranslationFields.vue';
|
||||||
|
import {
|
||||||
|
iconAdd,
|
||||||
|
iconAdmin,
|
||||||
|
iconCancel,
|
||||||
|
iconChecklist,
|
||||||
|
iconDelete,
|
||||||
|
iconEdit,
|
||||||
|
iconHabitat,
|
||||||
|
iconItem,
|
||||||
|
iconPokemon,
|
||||||
|
iconRecipe,
|
||||||
|
iconSave,
|
||||||
|
iconTranslate,
|
||||||
|
type AppIcon
|
||||||
|
} from '../icons';
|
||||||
|
import { defaultLocale, getCurrentLocale, setCurrentLocale } from '../i18n';
|
||||||
import {
|
import {
|
||||||
api,
|
api,
|
||||||
type AuthUser,
|
type AuthUser,
|
||||||
type ConfigType,
|
type ConfigType,
|
||||||
|
type DailyChecklistItem,
|
||||||
type Habitat,
|
type Habitat,
|
||||||
type Item,
|
type Item,
|
||||||
|
type Language,
|
||||||
type NamedEntity,
|
type NamedEntity,
|
||||||
type Pokemon,
|
type Pokemon,
|
||||||
type Recipe,
|
type Recipe,
|
||||||
type Skill
|
type Skill,
|
||||||
|
type TranslationMap
|
||||||
} from '../services/api';
|
} from '../services/api';
|
||||||
|
|
||||||
type AdminTab = 'config' | 'pokemon' | 'items' | 'recipes' | 'habitats';
|
type AdminTab = 'config' | 'languages' | 'checklist' | 'pokemon' | 'items' | 'recipes' | 'habitats';
|
||||||
type EditableConfig = (NamedEntity | Skill) & { hasItemDrop?: boolean };
|
type EditableConfig = (NamedEntity | Skill) & { hasItemDrop?: boolean };
|
||||||
|
|
||||||
const tabs: Array<{ key: AdminTab; label: string }> = [
|
const adminTabIcons: Record<AdminTab, AppIcon> = {
|
||||||
{ key: 'config', label: '系统配置' },
|
config: iconAdmin,
|
||||||
{ key: 'pokemon', label: 'Pokemon' },
|
languages: iconTranslate,
|
||||||
{ key: 'items', label: '物品' },
|
checklist: iconChecklist,
|
||||||
{ key: 'recipes', label: '材料单' },
|
pokemon: iconPokemon,
|
||||||
{ key: 'habitats', label: '栖息地' }
|
items: iconItem,
|
||||||
];
|
recipes: iconRecipe,
|
||||||
|
habitats: iconHabitat
|
||||||
|
};
|
||||||
|
|
||||||
const configTypes: Array<{ key: ConfigType; label: string; supportsItemDrop?: boolean }> = [
|
const { locale, t } = useI18n();
|
||||||
{ key: 'skills', label: '特长', supportsItemDrop: true },
|
|
||||||
{ key: 'environments', label: '喜欢的环境' },
|
const tabs = computed<Array<{ key: AdminTab; label: string }>>(() => [
|
||||||
{ key: 'favorite-things', label: '喜欢的东西 / 标签' },
|
{ key: 'config', label: t('pages.admin.config') },
|
||||||
{ key: 'item-categories', label: '物品分类' },
|
{ key: 'languages', label: t('pages.admin.languages') },
|
||||||
{ key: 'item-usages', label: '物品用途' },
|
{ key: 'checklist', label: t('pages.admin.checklist') },
|
||||||
{ key: 'acquisition-methods', label: '入手方式' },
|
{ key: 'pokemon', label: 'Pokemon' },
|
||||||
{ key: 'maps', label: '地图' }
|
{ key: 'items', label: t('pages.items.title') },
|
||||||
];
|
{ key: 'recipes', label: t('pages.recipes.title') },
|
||||||
|
{ key: 'habitats', label: t('pages.habitats.title') }
|
||||||
|
]);
|
||||||
|
|
||||||
|
const configTypes = computed<Array<{ key: ConfigType; label: string; supportsItemDrop?: boolean }>>(() => [
|
||||||
|
{ key: 'skills', label: t('config.skills'), supportsItemDrop: true },
|
||||||
|
{ key: 'environments', label: t('config.environments') },
|
||||||
|
{ key: 'favorite-things', label: t('config.favoriteThings') },
|
||||||
|
{ key: 'item-categories', label: t('config.itemCategories') },
|
||||||
|
{ key: 'item-usages', label: t('config.itemUsages') },
|
||||||
|
{ key: 'acquisition-methods', label: t('config.acquisitionMethods') },
|
||||||
|
{ key: 'maps', label: t('config.maps') }
|
||||||
|
]);
|
||||||
|
|
||||||
const activeTab = ref<AdminTab>('config');
|
const activeTab = ref<AdminTab>('config');
|
||||||
const activeConfigType = ref<ConfigType>('skills');
|
const activeConfigType = ref<ConfigType>('skills');
|
||||||
const configRows = ref<EditableConfig[]>([]);
|
const configRows = ref<EditableConfig[]>([]);
|
||||||
|
const languageRows = ref<Language[]>([]);
|
||||||
|
const checklistRows = ref<DailyChecklistItem[]>([]);
|
||||||
const pokemonRows = ref<Pokemon[]>([]);
|
const pokemonRows = ref<Pokemon[]>([]);
|
||||||
const itemRows = ref<Item[]>([]);
|
const itemRows = ref<Item[]>([]);
|
||||||
const recipeRows = ref<Recipe[]>([]);
|
const recipeRows = ref<Recipe[]>([]);
|
||||||
@@ -48,23 +88,74 @@ const currentUser = ref<AuthUser | null>(null);
|
|||||||
const busy = ref(false);
|
const busy = ref(false);
|
||||||
const contentLoading = ref(false);
|
const contentLoading = ref(false);
|
||||||
const message = ref('');
|
const message = ref('');
|
||||||
const configForm = ref({ id: 0, name: '', hasItemDrop: false });
|
const configForm = ref({ id: 0, name: '', translations: {} as TranslationMap, hasItemDrop: false });
|
||||||
|
const checklistForm = ref({ id: 0, title: '', translations: {} as TranslationMap });
|
||||||
|
const languageForm = ref({ code: '', name: '', enabled: true, isDefault: false, sortOrder: 0 });
|
||||||
|
const editingLanguageCode = ref('');
|
||||||
|
const configModalOpen = ref(false);
|
||||||
|
const checklistModalOpen = ref(false);
|
||||||
|
const languageModalOpen = ref(false);
|
||||||
|
|
||||||
const selectedConfig = computed(() => configTypes.find((item) => item.key === activeConfigType.value) ?? configTypes[0]);
|
const selectedConfig = computed(() => configTypes.value.find((item) => item.key === activeConfigType.value) ?? configTypes.value[0]);
|
||||||
const configTabs = computed<TabOption[]>(() => configTypes.map((item) => ({ value: item.key, label: item.label })));
|
const configTabs = computed<TabOption[]>(() => configTypes.value.map((item) => ({ value: item.key, label: item.label })));
|
||||||
|
const currentConfigLocale = computed(() => String(locale.value || defaultLocale));
|
||||||
|
const isConfigDefaultLocale = computed(() => currentConfigLocale.value === defaultLocale);
|
||||||
|
const configNameRequired = computed(() => isConfigDefaultLocale.value || !configForm.value.id);
|
||||||
|
const configNameInput = computed({
|
||||||
|
get: () => {
|
||||||
|
if (isConfigDefaultLocale.value) {
|
||||||
|
return configForm.value.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
return configForm.value.translations[currentConfigLocale.value]?.name ?? '';
|
||||||
|
},
|
||||||
|
set: (value: string) => {
|
||||||
|
if (isConfigDefaultLocale.value) {
|
||||||
|
configForm.value.name = value;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateConfigTranslation(currentConfigLocale.value, value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const configNamePlaceholder = computed(() => (isConfigDefaultLocale.value ? '' : configForm.value.name));
|
||||||
const activeConfigTab = computed({
|
const activeConfigTab = computed({
|
||||||
get: () => activeConfigType.value,
|
get: () => activeConfigType.value,
|
||||||
set: (value: string) => {
|
set: (value: string) => {
|
||||||
const nextConfig = configTypes.find((item) => item.key === value);
|
const nextConfig = configTypes.value.find((item) => item.key === value);
|
||||||
if (!nextConfig || nextConfig.key === activeConfigType.value) return;
|
if (!nextConfig || nextConfig.key === activeConfigType.value) return;
|
||||||
|
|
||||||
activeConfigType.value = nextConfig.key;
|
activeConfigType.value = nextConfig.key;
|
||||||
resetConfigForm();
|
closeConfigModal();
|
||||||
void run(loadConfig);
|
void run(loadConfig);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
const canEdit = computed(() => currentUser.value?.emailVerified === true);
|
const canEdit = computed(() => currentUser.value?.emailVerified === true);
|
||||||
const showAdminSkeleton = computed(() => busy.value && !message.value && (!currentUser.value || contentLoading.value));
|
const showAdminSkeleton = computed(() => busy.value && !message.value && (!currentUser.value || contentLoading.value));
|
||||||
|
const canSetLanguageDefault = computed(() => languageForm.value.code === 'en');
|
||||||
|
const configModalTitle = computed(() =>
|
||||||
|
configForm.value.id ? t('pages.admin.editConfig', { name: selectedConfig.value.label }) : t('pages.admin.newConfig', { name: selectedConfig.value.label })
|
||||||
|
);
|
||||||
|
const checklistModalTitle = computed(() => (checklistForm.value.id ? t('pages.checklist.editTask') : t('pages.checklist.newTask')));
|
||||||
|
const languageModalTitle = computed(() => (editingLanguageCode.value ? t('pages.admin.editLanguage') : t('pages.admin.newLanguage')));
|
||||||
|
const checklistKey = (item: DailyChecklistItem) => item.id;
|
||||||
|
const checklistLabel = (item: DailyChecklistItem) => item.title;
|
||||||
|
const languageKey = (item: Language) => item.code;
|
||||||
|
const languageLabel = (item: Language) => item.name;
|
||||||
|
const configKey = (item: EditableConfig) => item.id;
|
||||||
|
const configLabel = (item: EditableConfig) => item.name;
|
||||||
|
const pokemonKey = (item: Pokemon) => item.id;
|
||||||
|
const pokemonLabel = (item: Pokemon) => `#${item.id} ${item.name}`;
|
||||||
|
const itemKey = (item: Item) => item.id;
|
||||||
|
const itemLabel = (item: Item) => item.name;
|
||||||
|
const recipeKey = (item: Recipe) => item.id;
|
||||||
|
const recipeLabel = (item: Recipe) => item.name;
|
||||||
|
const habitatKey = (item: Habitat) => item.id;
|
||||||
|
const habitatLabel = (item: Habitat) => item.name;
|
||||||
|
|
||||||
|
function dragSortLabel(name: string) {
|
||||||
|
return t('pages.admin.dragSort', { name });
|
||||||
|
}
|
||||||
|
|
||||||
function errorText(error: unknown, fallback: string) {
|
function errorText(error: unknown, fallback: string) {
|
||||||
return error instanceof Error && error.message ? error.message : fallback;
|
return error instanceof Error && error.message ? error.message : fallback;
|
||||||
@@ -76,28 +167,239 @@ async function run(action: () => Promise<void>) {
|
|||||||
try {
|
try {
|
||||||
await action();
|
await action();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
message.value = errorText(error, '操作失败');
|
message.value = errorText(error, t('errors.operationFailed'));
|
||||||
} finally {
|
} finally {
|
||||||
busy.value = false;
|
busy.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadConfig() {
|
async function loadConfig() {
|
||||||
|
await loadLanguages();
|
||||||
configRows.value = (await api.config(activeConfigType.value)) as EditableConfig[];
|
configRows.value = (await api.config(activeConfigType.value)) as EditableConfig[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadLanguages() {
|
||||||
|
languageRows.value = await api.adminLanguages();
|
||||||
|
}
|
||||||
|
|
||||||
function resetConfigForm() {
|
function resetConfigForm() {
|
||||||
configForm.value = { id: 0, name: '', hasItemDrop: false };
|
configForm.value = { id: 0, name: '', translations: {}, hasItemDrop: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetChecklistForm() {
|
||||||
|
checklistForm.value = { id: 0, title: '', translations: {} };
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetLanguageForm() {
|
||||||
|
languageForm.value = { code: '', name: '', enabled: true, isDefault: false, sortOrder: 0 };
|
||||||
|
editingLanguageCode.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function openNewConfig() {
|
||||||
|
resetConfigForm();
|
||||||
|
configModalOpen.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeConfigModal() {
|
||||||
|
configModalOpen.value = false;
|
||||||
|
resetConfigForm();
|
||||||
}
|
}
|
||||||
|
|
||||||
function editConfig(item: EditableConfig) {
|
function editConfig(item: EditableConfig) {
|
||||||
configForm.value = { id: item.id, name: item.name, hasItemDrop: item.hasItemDrop === true };
|
configForm.value = { id: item.id, name: item.baseName ?? item.name, translations: item.translations ?? {}, hasItemDrop: item.hasItemDrop === true };
|
||||||
|
configModalOpen.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openNewChecklistItem() {
|
||||||
|
resetChecklistForm();
|
||||||
|
checklistModalOpen.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeChecklistModal() {
|
||||||
|
checklistModalOpen.value = false;
|
||||||
|
resetChecklistForm();
|
||||||
|
}
|
||||||
|
|
||||||
|
function editChecklistItem(item: DailyChecklistItem) {
|
||||||
|
checklistForm.value = { id: item.id, title: item.baseTitle ?? item.title, translations: item.translations ?? {} };
|
||||||
|
checklistModalOpen.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openNewLanguage() {
|
||||||
|
resetLanguageForm();
|
||||||
|
languageModalOpen.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeLanguageModal() {
|
||||||
|
languageModalOpen.value = false;
|
||||||
|
resetLanguageForm();
|
||||||
|
}
|
||||||
|
|
||||||
|
function editLanguage(item: Language) {
|
||||||
|
editingLanguageCode.value = item.code;
|
||||||
|
languageForm.value = {
|
||||||
|
code: item.code,
|
||||||
|
name: item.name,
|
||||||
|
enabled: item.enabled,
|
||||||
|
isDefault: item.isDefault,
|
||||||
|
sortOrder: item.sortOrder
|
||||||
|
};
|
||||||
|
languageModalOpen.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateConfigTranslation(localeCode: string, value: string) {
|
||||||
|
const nextTranslations: TranslationMap = { ...configForm.value.translations };
|
||||||
|
const nextFields = { ...(nextTranslations[localeCode] ?? {}) };
|
||||||
|
|
||||||
|
if (value.trim() === '') {
|
||||||
|
delete nextFields.name;
|
||||||
|
} else {
|
||||||
|
nextFields.name = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(nextFields).length) {
|
||||||
|
nextTranslations[localeCode] = nextFields;
|
||||||
|
} else {
|
||||||
|
delete nextTranslations[localeCode];
|
||||||
|
}
|
||||||
|
|
||||||
|
configForm.value.translations = nextTranslations;
|
||||||
|
}
|
||||||
|
|
||||||
|
function configBaseNameForSave() {
|
||||||
|
if (configForm.value.name.trim() !== '') {
|
||||||
|
return configForm.value.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
return configForm.value.translations[currentConfigLocale.value]?.name ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function checklistTitleForSave() {
|
||||||
|
if (checklistForm.value.title.trim() !== '') {
|
||||||
|
return checklistForm.value.title;
|
||||||
|
}
|
||||||
|
|
||||||
|
return checklistForm.value.translations[currentConfigLocale.value]?.title ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function previewChecklistOrder(rows: DailyChecklistItem[]) {
|
||||||
|
checklistRows.value = rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
function previewLanguageOrder(rows: Language[]) {
|
||||||
|
languageRows.value = rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
function previewConfigOrder(rows: EditableConfig[]) {
|
||||||
|
configRows.value = rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
function previewPokemonOrder(rows: Pokemon[]) {
|
||||||
|
pokemonRows.value = rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
function previewItemOrder(rows: Item[]) {
|
||||||
|
itemRows.value = rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
function previewRecipeOrder(rows: Recipe[]) {
|
||||||
|
recipeRows.value = rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
function previewHabitatOrder(rows: Habitat[]) {
|
||||||
|
habitatRows.value = rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function persistChecklistOrder(nextRows: DailyChecklistItem[], fallbackRows: DailyChecklistItem[]) {
|
||||||
|
checklistRows.value = nextRows;
|
||||||
|
await run(async () => {
|
||||||
|
try {
|
||||||
|
checklistRows.value = await api.reorderDailyChecklistItems(nextRows.map((item) => item.id));
|
||||||
|
} catch (error) {
|
||||||
|
checklistRows.value = fallbackRows;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function persistLanguageOrder(nextRows: Language[], fallbackRows: Language[]) {
|
||||||
|
languageRows.value = nextRows;
|
||||||
|
await run(async () => {
|
||||||
|
try {
|
||||||
|
languageRows.value = await api.reorderLanguages(nextRows.map((item) => item.code));
|
||||||
|
setCurrentLocale(getCurrentLocale());
|
||||||
|
} catch (error) {
|
||||||
|
languageRows.value = fallbackRows;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function persistConfigOrder(nextRows: EditableConfig[], fallbackRows: EditableConfig[]) {
|
||||||
|
configRows.value = nextRows;
|
||||||
|
await run(async () => {
|
||||||
|
try {
|
||||||
|
configRows.value = (await api.reorderConfig(activeConfigType.value, nextRows.map((item) => item.id))) as EditableConfig[];
|
||||||
|
} catch (error) {
|
||||||
|
configRows.value = fallbackRows;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function persistPokemonOrder(nextRows: Pokemon[], fallbackRows: Pokemon[]) {
|
||||||
|
pokemonRows.value = nextRows;
|
||||||
|
await run(async () => {
|
||||||
|
try {
|
||||||
|
pokemonRows.value = await api.reorderPokemon(nextRows.map((item) => item.id));
|
||||||
|
} catch (error) {
|
||||||
|
pokemonRows.value = fallbackRows;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function persistItemOrder(nextRows: Item[], fallbackRows: Item[]) {
|
||||||
|
itemRows.value = nextRows;
|
||||||
|
await run(async () => {
|
||||||
|
try {
|
||||||
|
itemRows.value = await api.reorderItems(nextRows.map((item) => item.id));
|
||||||
|
} catch (error) {
|
||||||
|
itemRows.value = fallbackRows;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function persistRecipeOrder(nextRows: Recipe[], fallbackRows: Recipe[]) {
|
||||||
|
recipeRows.value = nextRows;
|
||||||
|
await run(async () => {
|
||||||
|
try {
|
||||||
|
recipeRows.value = await api.reorderRecipes(nextRows.map((item) => item.id));
|
||||||
|
} catch (error) {
|
||||||
|
recipeRows.value = fallbackRows;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function persistHabitatOrder(nextRows: Habitat[], fallbackRows: Habitat[]) {
|
||||||
|
habitatRows.value = nextRows;
|
||||||
|
await run(async () => {
|
||||||
|
try {
|
||||||
|
habitatRows.value = await api.reorderHabitats(nextRows.map((item) => item.id));
|
||||||
|
} catch (error) {
|
||||||
|
habitatRows.value = fallbackRows;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveConfig() {
|
async function saveConfig() {
|
||||||
await run(async () => {
|
await run(async () => {
|
||||||
const payload = {
|
const payload = {
|
||||||
name: configForm.value.name,
|
name: configBaseNameForSave(),
|
||||||
|
translations: configForm.value.translations,
|
||||||
hasItemDrop: selectedConfig.value.supportsItemDrop ? configForm.value.hasItemDrop : undefined
|
hasItemDrop: selectedConfig.value.supportsItemDrop ? configForm.value.hasItemDrop : undefined
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -107,11 +409,63 @@ async function saveConfig() {
|
|||||||
await api.createConfig(activeConfigType.value, payload);
|
await api.createConfig(activeConfigType.value, payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
resetConfigForm();
|
|
||||||
await loadConfig();
|
await loadConfig();
|
||||||
|
closeConfigModal();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadChecklist() {
|
||||||
|
await loadLanguages();
|
||||||
|
checklistRows.value = await api.dailyChecklist();
|
||||||
|
if (!checklistForm.value.id && checklistForm.value.title.trim() === '') {
|
||||||
|
resetChecklistForm();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveChecklistItem() {
|
||||||
|
await run(async () => {
|
||||||
|
const payload = {
|
||||||
|
title: checklistTitleForSave(),
|
||||||
|
translations: checklistForm.value.translations
|
||||||
|
};
|
||||||
|
|
||||||
|
if (checklistForm.value.id) {
|
||||||
|
await api.updateDailyChecklistItem(checklistForm.value.id, payload);
|
||||||
|
} else {
|
||||||
|
await api.createDailyChecklistItem(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
await loadChecklist();
|
||||||
|
closeChecklistModal();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveLanguage() {
|
||||||
|
await run(async () => {
|
||||||
|
const payload = {
|
||||||
|
code: languageForm.value.code,
|
||||||
|
name: languageForm.value.name,
|
||||||
|
enabled: languageForm.value.enabled,
|
||||||
|
isDefault: languageForm.value.isDefault,
|
||||||
|
sortOrder: languageSortOrderForSave()
|
||||||
|
};
|
||||||
|
|
||||||
|
languageRows.value = editingLanguageCode.value
|
||||||
|
? await api.updateLanguage(editingLanguageCode.value, payload)
|
||||||
|
: await api.createLanguage(payload);
|
||||||
|
closeLanguageModal();
|
||||||
|
setCurrentLocale(getCurrentLocale());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function languageSortOrderForSave() {
|
||||||
|
if (editingLanguageCode.value) {
|
||||||
|
return languageRows.value.find((item) => item.code === editingLanguageCode.value)?.sortOrder ?? languageForm.value.sortOrder;
|
||||||
|
}
|
||||||
|
|
||||||
|
return languageRows.value.reduce((maxOrder, item) => Math.max(maxOrder, item.sortOrder), 0) + 10;
|
||||||
|
}
|
||||||
|
|
||||||
async function loadPokemon() {
|
async function loadPokemon() {
|
||||||
pokemonRows.value = await api.pokemon({});
|
pokemonRows.value = await api.pokemon({});
|
||||||
}
|
}
|
||||||
@@ -135,6 +489,8 @@ async function loadCurrentTab(showSkeleton = false) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
if (activeTab.value === 'config') await loadConfig();
|
if (activeTab.value === 'config') await loadConfig();
|
||||||
|
if (activeTab.value === 'languages') await loadLanguages();
|
||||||
|
if (activeTab.value === 'checklist') await loadChecklist();
|
||||||
if (activeTab.value === 'pokemon') await loadPokemon();
|
if (activeTab.value === 'pokemon') await loadPokemon();
|
||||||
if (activeTab.value === 'items') await loadItems();
|
if (activeTab.value === 'items') await loadItems();
|
||||||
if (activeTab.value === 'recipes') await loadRecipes();
|
if (activeTab.value === 'recipes') await loadRecipes();
|
||||||
@@ -148,7 +504,7 @@ async function loadCurrentTab(showSkeleton = false) {
|
|||||||
|
|
||||||
function setTab(tab: AdminTab) {
|
function setTab(tab: AdminTab) {
|
||||||
if (!canEdit.value) {
|
if (!canEdit.value) {
|
||||||
message.value = '请先完成邮箱验证';
|
message.value = t('errors.completeEmailVerification');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,23 +517,44 @@ async function loadAdmin() {
|
|||||||
currentUser.value = response.user;
|
currentUser.value = response.user;
|
||||||
|
|
||||||
if (!response.user.emailVerified) {
|
if (!response.user.emailVerified) {
|
||||||
message.value = '请先完成邮箱验证';
|
message.value = t('errors.completeEmailVerification');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await loadCurrentTab(true);
|
await loadCurrentTab(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function removeLanguage(code: string) {
|
||||||
|
await run(async () => {
|
||||||
|
await api.deleteLanguage(code);
|
||||||
|
if (editingLanguageCode.value === code) {
|
||||||
|
closeLanguageModal();
|
||||||
|
}
|
||||||
|
await loadLanguages();
|
||||||
|
setCurrentLocale(getCurrentLocale());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function removeConfig(id: number) {
|
async function removeConfig(id: number) {
|
||||||
await run(async () => {
|
await run(async () => {
|
||||||
await api.deleteConfig(activeConfigType.value, id);
|
await api.deleteConfig(activeConfigType.value, id);
|
||||||
if (configForm.value.id === id) {
|
if (configForm.value.id === id) {
|
||||||
resetConfigForm();
|
closeConfigModal();
|
||||||
}
|
}
|
||||||
await loadConfig();
|
await loadConfig();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function removeChecklistItem(id: number) {
|
||||||
|
await run(async () => {
|
||||||
|
await api.deleteDailyChecklistItem(id);
|
||||||
|
if (checklistForm.value.id === id) {
|
||||||
|
closeChecklistModal();
|
||||||
|
}
|
||||||
|
await loadChecklist();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function removePokemon(id: number) {
|
async function removePokemon(id: number) {
|
||||||
await run(async () => {
|
await run(async () => {
|
||||||
await api.deletePokemon(id);
|
await api.deletePokemon(id);
|
||||||
@@ -213,19 +590,20 @@ onMounted(() => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="page-stack">
|
<section class="page-stack">
|
||||||
<PageHeader title="管理" subtitle="维护系统配置,查看并删除 Wiki 数据记录。">
|
<PageHeader :title="t('pages.admin.title')" :subtitle="t('pages.admin.subtitle')">
|
||||||
<template #kicker>Admin</template>
|
<template #kicker>Admin</template>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
<div v-if="canEdit" class="tabs" role="tablist" aria-label="管理模块">
|
<div v-if="canEdit" class="tabs" role="tablist" :aria-label="t('pages.admin.modules')">
|
||||||
<button v-for="tab in tabs" :key="tab.key" :class="{ active: activeTab === tab.key }" type="button" @click="setTab(tab.key)">
|
<button v-for="tab in tabs" :key="tab.key" :class="{ active: activeTab === tab.key }" type="button" @click="setTab(tab.key)">
|
||||||
|
<Icon :icon="adminTabIcons[tab.key]" class="ui-icon" aria-hidden="true" />
|
||||||
{{ tab.label }}
|
{{ tab.label }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<StatusMessage v-if="message" variant="warning">{{ message }}</StatusMessage>
|
<StatusMessage v-if="message" variant="warning">{{ message }}</StatusMessage>
|
||||||
|
|
||||||
<section v-if="showAdminSkeleton" class="detail-section skeleton-detail-section" aria-busy="true" aria-label="正在加载管理列表">
|
<section v-if="showAdminSkeleton" class="detail-section skeleton-detail-section" aria-busy="true" :aria-label="t('pages.admin.loading')">
|
||||||
<h2><Skeleton width="120px" height="24px" /></h2>
|
<h2><Skeleton width="120px" height="24px" /></h2>
|
||||||
<ul class="row-list skeleton-row-list">
|
<ul class="row-list skeleton-row-list">
|
||||||
<li v-for="index in 6" :key="index">
|
<li v-for="index in 6" :key="index">
|
||||||
@@ -237,91 +615,320 @@ onMounted(() => {
|
|||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section v-else-if="canEdit && activeTab === 'config'" class="detail-section">
|
<section v-else-if="canEdit && activeTab === 'checklist'" class="detail-section">
|
||||||
<h2>系统配置</h2>
|
<div class="detail-section__header">
|
||||||
<Tabs id="admin-config-type" v-model="activeConfigTab" :tabs="configTabs" label="系统配置类型" />
|
<h2>{{ t('pages.admin.checklist') }}</h2>
|
||||||
|
<button type="button" class="ui-button ui-button--primary ui-button--small" :disabled="busy" @click="openNewChecklistItem">
|
||||||
|
<Icon :icon="iconAdd" class="ui-icon" aria-hidden="true" />
|
||||||
|
{{ t('common.new') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<h3 class="section-subtitle">{{ t('pages.checklist.sectionTitle') }}</h3>
|
||||||
|
<ReorderableList
|
||||||
|
v-if="checklistRows.length"
|
||||||
|
:items="checklistRows"
|
||||||
|
:item-key="checklistKey"
|
||||||
|
:item-label="checklistLabel"
|
||||||
|
list-key-prefix="checklist"
|
||||||
|
:disabled="busy"
|
||||||
|
:handle-label="dragSortLabel"
|
||||||
|
:handle-title="t('pages.admin.dragSortTitle')"
|
||||||
|
@preview="previewChecklistOrder"
|
||||||
|
@cancel="previewChecklistOrder"
|
||||||
|
@reorder="persistChecklistOrder"
|
||||||
|
>
|
||||||
|
<template #default="{ item }">
|
||||||
|
<span class="reorderable-row-title">{{ item.title }}</span>
|
||||||
|
<span class="row-actions">
|
||||||
|
<button type="button" :disabled="busy" @click="editChecklistItem(item)">
|
||||||
|
<Icon :icon="iconEdit" class="ui-icon" aria-hidden="true" />
|
||||||
|
{{ t('common.edit') }}
|
||||||
|
</button>
|
||||||
|
<button type="button" :disabled="busy" @click="removeChecklistItem(item.id)">
|
||||||
|
<Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" />
|
||||||
|
{{ t('common.delete') }}
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</ReorderableList>
|
||||||
|
<p v-else class="meta-line">{{ t('common.noRecords') }}</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
<form class="detail-section__body" @submit.prevent="saveConfig">
|
<section v-else-if="canEdit && activeTab === 'config'" class="detail-section">
|
||||||
<h3 class="section-subtitle">{{ configForm.id ? `编辑${selectedConfig.label}` : `新增${selectedConfig.label}` }}</h3>
|
<div class="detail-section__header">
|
||||||
|
<h2>{{ t('pages.admin.config') }}</h2>
|
||||||
|
<button type="button" class="ui-button ui-button--primary ui-button--small" :disabled="busy" @click="openNewConfig">
|
||||||
|
<Icon :icon="iconAdd" class="ui-icon" aria-hidden="true" />
|
||||||
|
{{ t('common.new') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<Tabs id="admin-config-type" v-model="activeConfigTab" :tabs="configTabs" :label="t('pages.admin.configType')" />
|
||||||
|
<h3 class="section-subtitle">{{ selectedConfig.label }}</h3>
|
||||||
|
<ReorderableList
|
||||||
|
v-if="configRows.length"
|
||||||
|
:items="configRows"
|
||||||
|
:item-key="configKey"
|
||||||
|
:item-label="configLabel"
|
||||||
|
:list-key-prefix="`config-${activeConfigType}`"
|
||||||
|
:disabled="busy"
|
||||||
|
:handle-label="dragSortLabel"
|
||||||
|
:handle-title="t('pages.admin.dragSortTitle')"
|
||||||
|
@preview="previewConfigOrder"
|
||||||
|
@cancel="previewConfigOrder"
|
||||||
|
@reorder="persistConfigOrder"
|
||||||
|
>
|
||||||
|
<template #default="{ item }">
|
||||||
|
<span class="reorderable-row-title">
|
||||||
|
{{ item.name }}<span v-if="item.hasItemDrop" class="config-flag">{{ t('pages.admin.hasItemDrop') }}</span>
|
||||||
|
</span>
|
||||||
|
<span class="row-actions">
|
||||||
|
<button type="button" :disabled="busy" @click="editConfig(item)">
|
||||||
|
<Icon :icon="iconEdit" class="ui-icon" aria-hidden="true" />
|
||||||
|
{{ t('common.edit') }}
|
||||||
|
</button>
|
||||||
|
<button type="button" :disabled="busy" @click="removeConfig(item.id)">
|
||||||
|
<Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" />
|
||||||
|
{{ t('common.delete') }}
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</ReorderableList>
|
||||||
|
<p v-else class="meta-line">{{ t('common.noRecords') }}</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-else-if="canEdit && activeTab === 'languages'" class="detail-section">
|
||||||
|
<div class="detail-section__header">
|
||||||
|
<h2>{{ t('pages.admin.languages') }}</h2>
|
||||||
|
<button type="button" class="ui-button ui-button--primary ui-button--small" :disabled="busy" @click="openNewLanguage">
|
||||||
|
<Icon :icon="iconAdd" class="ui-icon" aria-hidden="true" />
|
||||||
|
{{ t('common.new') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<ReorderableList
|
||||||
|
v-if="languageRows.length"
|
||||||
|
:items="languageRows"
|
||||||
|
:item-key="languageKey"
|
||||||
|
:item-label="languageLabel"
|
||||||
|
list-key-prefix="languages"
|
||||||
|
:disabled="busy"
|
||||||
|
:handle-label="dragSortLabel"
|
||||||
|
:handle-title="t('pages.admin.dragSortTitle')"
|
||||||
|
@preview="previewLanguageOrder"
|
||||||
|
@cancel="previewLanguageOrder"
|
||||||
|
@reorder="persistLanguageOrder"
|
||||||
|
>
|
||||||
|
<template #default="{ item }">
|
||||||
|
<span class="reorderable-row-title">
|
||||||
|
{{ item.name }} <span class="meta-line">{{ item.code }}</span>
|
||||||
|
<span v-if="item.isDefault" class="config-flag">{{ t('pages.admin.defaultLanguage') }}</span>
|
||||||
|
</span>
|
||||||
|
<span class="row-actions">
|
||||||
|
<button type="button" @click="editLanguage(item)">
|
||||||
|
<Icon :icon="iconEdit" class="ui-icon" aria-hidden="true" />
|
||||||
|
{{ t('common.edit') }}
|
||||||
|
</button>
|
||||||
|
<button type="button" :disabled="item.isDefault" @click="removeLanguage(item.code)">
|
||||||
|
<Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" />
|
||||||
|
{{ t('common.delete') }}
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</ReorderableList>
|
||||||
|
<p v-else class="meta-line">{{ t('common.noRecords') }}</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-else-if="canEdit && activeTab === 'pokemon'" class="detail-section">
|
||||||
|
<h2>{{ t('pages.admin.pokemonList') }}</h2>
|
||||||
|
<ReorderableList
|
||||||
|
v-if="pokemonRows.length"
|
||||||
|
:items="pokemonRows"
|
||||||
|
:item-key="pokemonKey"
|
||||||
|
:item-label="pokemonLabel"
|
||||||
|
list-key-prefix="pokemon"
|
||||||
|
:disabled="busy"
|
||||||
|
:handle-label="dragSortLabel"
|
||||||
|
:handle-title="t('pages.admin.dragSortTitle')"
|
||||||
|
@preview="previewPokemonOrder"
|
||||||
|
@cancel="previewPokemonOrder"
|
||||||
|
@reorder="persistPokemonOrder"
|
||||||
|
>
|
||||||
|
<template #default="{ item }">
|
||||||
|
<RouterLink :to="`/pokemon/${item.id}`">#{{ item.id }} {{ item.name }}</RouterLink>
|
||||||
|
<span class="row-actions">
|
||||||
|
<button type="button" :disabled="busy" @click="removePokemon(item.id)">
|
||||||
|
<Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" />
|
||||||
|
{{ t('common.delete') }}
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</ReorderableList>
|
||||||
|
<p v-else class="meta-line">{{ t('common.noRecords') }}</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-else-if="canEdit && activeTab === 'items'" class="detail-section">
|
||||||
|
<h2>{{ t('pages.admin.itemList') }}</h2>
|
||||||
|
<ReorderableList
|
||||||
|
v-if="itemRows.length"
|
||||||
|
:items="itemRows"
|
||||||
|
:item-key="itemKey"
|
||||||
|
:item-label="itemLabel"
|
||||||
|
list-key-prefix="items"
|
||||||
|
:disabled="busy"
|
||||||
|
:handle-label="dragSortLabel"
|
||||||
|
:handle-title="t('pages.admin.dragSortTitle')"
|
||||||
|
@preview="previewItemOrder"
|
||||||
|
@cancel="previewItemOrder"
|
||||||
|
@reorder="persistItemOrder"
|
||||||
|
>
|
||||||
|
<template #default="{ item }">
|
||||||
|
<RouterLink :to="`/items/${item.id}`">{{ item.name }}</RouterLink>
|
||||||
|
<span class="row-actions">
|
||||||
|
<button type="button" :disabled="busy" @click="removeItem(item.id)">
|
||||||
|
<Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" />
|
||||||
|
{{ t('common.delete') }}
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</ReorderableList>
|
||||||
|
<p v-else class="meta-line">{{ t('common.noRecords') }}</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-else-if="canEdit && activeTab === 'recipes'" class="detail-section">
|
||||||
|
<h2>{{ t('pages.admin.recipeList') }}</h2>
|
||||||
|
<ReorderableList
|
||||||
|
v-if="recipeRows.length"
|
||||||
|
:items="recipeRows"
|
||||||
|
:item-key="recipeKey"
|
||||||
|
:item-label="recipeLabel"
|
||||||
|
list-key-prefix="recipes"
|
||||||
|
:disabled="busy"
|
||||||
|
:handle-label="dragSortLabel"
|
||||||
|
:handle-title="t('pages.admin.dragSortTitle')"
|
||||||
|
@preview="previewRecipeOrder"
|
||||||
|
@cancel="previewRecipeOrder"
|
||||||
|
@reorder="persistRecipeOrder"
|
||||||
|
>
|
||||||
|
<template #default="{ item }">
|
||||||
|
<RouterLink :to="`/recipes/${item.id}`">{{ item.name }}</RouterLink>
|
||||||
|
<span class="row-actions">
|
||||||
|
<button type="button" :disabled="busy" @click="removeRecipe(item.id)">
|
||||||
|
<Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" />
|
||||||
|
{{ t('common.delete') }}
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</ReorderableList>
|
||||||
|
<p v-else class="meta-line">{{ t('common.noRecords') }}</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-else-if="canEdit && activeTab === 'habitats'" class="detail-section">
|
||||||
|
<h2>{{ t('pages.admin.habitatList') }}</h2>
|
||||||
|
<ReorderableList
|
||||||
|
v-if="habitatRows.length"
|
||||||
|
:items="habitatRows"
|
||||||
|
:item-key="habitatKey"
|
||||||
|
:item-label="habitatLabel"
|
||||||
|
list-key-prefix="habitats"
|
||||||
|
:disabled="busy"
|
||||||
|
:handle-label="dragSortLabel"
|
||||||
|
:handle-title="t('pages.admin.dragSortTitle')"
|
||||||
|
@preview="previewHabitatOrder"
|
||||||
|
@cancel="previewHabitatOrder"
|
||||||
|
@reorder="persistHabitatOrder"
|
||||||
|
>
|
||||||
|
<template #default="{ item }">
|
||||||
|
<RouterLink :to="`/habitats/${item.id}`">{{ item.name }}</RouterLink>
|
||||||
|
<span class="row-actions">
|
||||||
|
<button type="button" :disabled="busy" @click="removeHabitat(item.id)">
|
||||||
|
<Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" />
|
||||||
|
{{ t('common.delete') }}
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</ReorderableList>
|
||||||
|
<p v-else class="meta-line">{{ t('common.noRecords') }}</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<Modal v-if="checklistModalOpen" :title="checklistModalTitle" :close-label="t('common.close')" size="wide" @close="closeChecklistModal">
|
||||||
|
<form id="admin-checklist-form" class="modal-edit-form" @submit.prevent="saveChecklistItem">
|
||||||
|
<TranslationFields
|
||||||
|
id-prefix="checklist-title"
|
||||||
|
v-model:base-value="checklistForm.title"
|
||||||
|
v-model:translations="checklistForm.translations"
|
||||||
|
field="title"
|
||||||
|
:label="t('pages.checklist.task')"
|
||||||
|
:languages="languageRows"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<button type="submit" form="admin-checklist-form" class="link-button" :disabled="busy">
|
||||||
|
<Icon :icon="iconSave" class="ui-icon" aria-hidden="true" />
|
||||||
|
{{ busy ? t('common.saving') : t('common.save') }}
|
||||||
|
</button>
|
||||||
|
<button type="button" class="plain-button" :disabled="busy" @click="closeChecklistModal">
|
||||||
|
<Icon :icon="iconCancel" class="ui-icon" aria-hidden="true" />
|
||||||
|
{{ t('common.cancel') }}
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal v-if="configModalOpen" :title="configModalTitle" :close-label="t('common.close')" @close="closeConfigModal">
|
||||||
|
<form id="admin-config-form" class="modal-edit-form" @submit.prevent="saveConfig">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="config-name">名称</label>
|
<label for="config-name">{{ t('common.name') }}</label>
|
||||||
<input id="config-name" v-model="configForm.name" required />
|
<input id="config-name" v-model="configNameInput" :placeholder="configNamePlaceholder" :required="configNameRequired" />
|
||||||
</div>
|
</div>
|
||||||
<div v-if="selectedConfig.supportsItemDrop" class="check-row">
|
<div v-if="selectedConfig.supportsItemDrop" class="check-row">
|
||||||
<label>
|
<label>
|
||||||
<input v-model="configForm.hasItemDrop" type="checkbox" />
|
<input v-model="configForm.hasItemDrop" type="checkbox" />
|
||||||
有掉落物
|
{{ t('pages.admin.hasItemDrop') }}
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-actions">
|
|
||||||
<button type="submit" class="link-button" :disabled="busy">{{ busy ? '保存中' : '保存' }}</button>
|
|
||||||
<button type="button" class="plain-button" :disabled="busy" @click="resetConfigForm">新建</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<h3 class="section-subtitle">{{ selectedConfig.label }}</h3>
|
<template #footer>
|
||||||
<ul v-if="configRows.length" class="row-list">
|
<button type="submit" form="admin-config-form" class="link-button" :disabled="busy">
|
||||||
<li v-for="item in configRows" :key="item.id">
|
<Icon :icon="iconSave" class="ui-icon" aria-hidden="true" />
|
||||||
<span>{{ item.name }}<span v-if="item.hasItemDrop" class="config-flag">有掉落物</span></span>
|
{{ busy ? t('common.saving') : t('common.save') }}
|
||||||
<span class="row-actions">
|
</button>
|
||||||
<button type="button" @click="editConfig(item)">编辑</button>
|
<button type="button" class="plain-button" :disabled="busy" @click="closeConfigModal">
|
||||||
<button type="button" @click="removeConfig(item.id)">删除</button>
|
<Icon :icon="iconCancel" class="ui-icon" aria-hidden="true" />
|
||||||
</span>
|
{{ t('common.cancel') }}
|
||||||
</li>
|
</button>
|
||||||
</ul>
|
</template>
|
||||||
<p v-else class="meta-line">暂无记录</p>
|
</Modal>
|
||||||
</section>
|
|
||||||
|
|
||||||
<section v-else-if="canEdit && activeTab === 'pokemon'" class="detail-section">
|
<Modal v-if="languageModalOpen" :title="languageModalTitle" :close-label="t('common.close')" @close="closeLanguageModal">
|
||||||
<h2>Pokemon 列表</h2>
|
<form id="admin-language-form" class="modal-edit-form" @submit.prevent="saveLanguage">
|
||||||
<ul v-if="pokemonRows.length" class="row-list">
|
<div class="field">
|
||||||
<li v-for="item in pokemonRows" :key="item.id">
|
<label for="language-code">{{ t('pages.admin.languageCode') }}</label>
|
||||||
<RouterLink :to="`/pokemon/${item.id}`">#{{ item.id }} {{ item.name }}</RouterLink>
|
<input id="language-code" v-model="languageForm.code" :disabled="Boolean(editingLanguageCode)" required />
|
||||||
<span class="row-actions">
|
</div>
|
||||||
<button type="button" @click="removePokemon(item.id)">删除</button>
|
<div class="field">
|
||||||
</span>
|
<label for="language-name">{{ t('pages.admin.languageName') }}</label>
|
||||||
</li>
|
<input id="language-name" v-model="languageForm.name" required />
|
||||||
</ul>
|
</div>
|
||||||
<p v-else class="meta-line">暂无记录</p>
|
<div class="check-row">
|
||||||
</section>
|
<label><input v-model="languageForm.enabled" type="checkbox" /> {{ t('pages.admin.enabled') }}</label>
|
||||||
|
<label>
|
||||||
|
<input v-model="languageForm.isDefault" type="checkbox" :disabled="!canSetLanguageDefault" />
|
||||||
|
{{ t('pages.admin.defaultLanguage') }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
<section v-else-if="canEdit && activeTab === 'items'" class="detail-section">
|
<template #footer>
|
||||||
<h2>物品列表</h2>
|
<button type="submit" form="admin-language-form" class="link-button" :disabled="busy">
|
||||||
<ul v-if="itemRows.length" class="row-list">
|
<Icon :icon="iconSave" class="ui-icon" aria-hidden="true" />
|
||||||
<li v-for="item in itemRows" :key="item.id">
|
{{ busy ? t('common.saving') : t('common.save') }}
|
||||||
<RouterLink :to="`/items/${item.id}`">{{ item.name }}</RouterLink>
|
</button>
|
||||||
<span class="row-actions">
|
<button type="button" class="plain-button" :disabled="busy" @click="closeLanguageModal">
|
||||||
<button type="button" @click="removeItem(item.id)">删除</button>
|
<Icon :icon="iconCancel" class="ui-icon" aria-hidden="true" />
|
||||||
</span>
|
{{ t('common.cancel') }}
|
||||||
</li>
|
</button>
|
||||||
</ul>
|
</template>
|
||||||
<p v-else class="meta-line">暂无记录</p>
|
</Modal>
|
||||||
</section>
|
|
||||||
|
|
||||||
<section v-else-if="canEdit && activeTab === 'recipes'" class="detail-section">
|
|
||||||
<h2>材料单列表</h2>
|
|
||||||
<ul v-if="recipeRows.length" class="row-list">
|
|
||||||
<li v-for="item in recipeRows" :key="item.id">
|
|
||||||
<RouterLink :to="`/recipes/${item.id}`">{{ item.name }}</RouterLink>
|
|
||||||
<span class="row-actions">
|
|
||||||
<button type="button" @click="removeRecipe(item.id)">删除</button>
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
<p v-else class="meta-line">暂无记录</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section v-else-if="canEdit && activeTab === 'habitats'" class="detail-section">
|
|
||||||
<h2>栖息地列表</h2>
|
|
||||||
<ul v-if="habitatRows.length" class="row-list">
|
|
||||||
<li v-for="item in habitatRows" :key="item.id">
|
|
||||||
<RouterLink :to="`/habitats/${item.id}`">{{ item.name }}</RouterLink>
|
|
||||||
<span class="row-actions">
|
|
||||||
<button type="button" @click="removeHabitat(item.id)">删除</button>
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
<p v-else class="meta-line">暂无记录</p>
|
|
||||||
</section>
|
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
143
frontend/src/views/DailyChecklistView.vue
Normal file
143
frontend/src/views/DailyChecklistView.vue
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, onUnmounted, ref } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import PageHeader from '../components/PageHeader.vue';
|
||||||
|
import Skeleton from '../components/Skeleton.vue';
|
||||||
|
import { api, type DailyChecklistItem } from '../services/api';
|
||||||
|
|
||||||
|
type ChecklistState = {
|
||||||
|
date: string;
|
||||||
|
checkedIds: number[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const checklistStateKey = 'pokopia_daily_checklist_state';
|
||||||
|
const { t } = useI18n();
|
||||||
|
const stateRefreshIntervalMs = 60_000;
|
||||||
|
const checklistItems = ref<DailyChecklistItem[]>([]);
|
||||||
|
const checkedTaskIds = ref<Set<number>>(new Set());
|
||||||
|
const loading = ref(true);
|
||||||
|
const skeletonRows = 5;
|
||||||
|
let stateRefreshTimer: number | null = null;
|
||||||
|
|
||||||
|
function todayKey() {
|
||||||
|
const today = new Date();
|
||||||
|
const year = today.getFullYear();
|
||||||
|
const month = String(today.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(today.getDate()).padStart(2, '0');
|
||||||
|
return `${year}-${month}-${day}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistChecklistState() {
|
||||||
|
if (typeof localStorage === 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const state: ChecklistState = {
|
||||||
|
date: todayKey(),
|
||||||
|
checkedIds: [...checkedTaskIds.value]
|
||||||
|
};
|
||||||
|
localStorage.setItem(checklistStateKey, JSON.stringify(state));
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadChecklistState() {
|
||||||
|
if (typeof localStorage === 'undefined') {
|
||||||
|
checkedTaskIds.value = new Set();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const state = JSON.parse(localStorage.getItem(checklistStateKey) ?? 'null') as Partial<ChecklistState> | null;
|
||||||
|
checkedTaskIds.value = state?.date === todayKey() ? new Set(state.checkedIds?.filter((id) => Number.isInteger(id))) : new Set();
|
||||||
|
} catch {
|
||||||
|
checkedTaskIds.value = new Set();
|
||||||
|
}
|
||||||
|
|
||||||
|
persistChecklistState();
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncChecklistState() {
|
||||||
|
const checklistIds = new Set(checklistItems.value.map((item) => item.id));
|
||||||
|
const nextCheckedIds = new Set([...checkedTaskIds.value].filter((id) => checklistIds.has(id)));
|
||||||
|
if (nextCheckedIds.size !== checkedTaskIds.value.size) {
|
||||||
|
checkedTaskIds.value = nextCheckedIds;
|
||||||
|
persistChecklistState();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTaskChecked(id: number) {
|
||||||
|
return checkedTaskIds.value.has(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleTask(id: number, checked: boolean) {
|
||||||
|
const nextCheckedIds = new Set(checkedTaskIds.value);
|
||||||
|
if (checked) {
|
||||||
|
nextCheckedIds.add(id);
|
||||||
|
} else {
|
||||||
|
nextCheckedIds.delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
checkedTaskIds.value = nextCheckedIds;
|
||||||
|
persistChecklistState();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTaskChange(id: number, event: Event) {
|
||||||
|
const checkbox = event.target instanceof HTMLInputElement ? event.target : null;
|
||||||
|
toggleTask(id, checkbox?.checked === true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDailyChecklist() {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
checklistItems.value = await api.dailyChecklist();
|
||||||
|
syncChecklistState();
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadChecklistState();
|
||||||
|
stateRefreshTimer = window.setInterval(loadChecklistState, stateRefreshIntervalMs);
|
||||||
|
void loadDailyChecklist();
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (stateRefreshTimer !== null) {
|
||||||
|
window.clearInterval(stateRefreshTimer);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="page-stack">
|
||||||
|
<PageHeader :title="t('pages.checklist.title')" :subtitle="t('pages.checklist.subtitle')">
|
||||||
|
<template #kicker>CheckList</template>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<section class="detail-section" :aria-busy="loading">
|
||||||
|
<h2>{{ t('pages.checklist.sectionTitle') }}</h2>
|
||||||
|
|
||||||
|
<ul v-if="loading" class="row-list skeleton-row-list checklist-skeleton-list" :aria-label="t('pages.checklist.loading')">
|
||||||
|
<li v-for="index in skeletonRows" :key="index">
|
||||||
|
<Skeleton variant="box" width="34px" height="34px" />
|
||||||
|
<Skeleton :width="index % 2 === 0 ? '220px' : '160px'" />
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<ul v-else-if="checklistItems.length" class="checklist-list">
|
||||||
|
<li v-for="item in checklistItems" :key="item.id" class="checklist-item" :class="{ 'is-checked': isTaskChecked(item.id) }">
|
||||||
|
<label class="checklist-check">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
:checked="isTaskChecked(item.id)"
|
||||||
|
@change="handleTaskChange(item.id, $event)"
|
||||||
|
/>
|
||||||
|
<span>{{ item.title }}</span>
|
||||||
|
</label>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p v-else class="meta-line">{{ t('pages.checklist.empty') }}</p>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -1,17 +1,23 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref } from 'vue';
|
import { Icon } from '@iconify/vue';
|
||||||
|
import { computed, onMounted, ref, watch } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
import { useRoute } from 'vue-router';
|
import { useRoute } from 'vue-router';
|
||||||
import DetailSection from '../components/DetailSection.vue';
|
import DetailSection from '../components/DetailSection.vue';
|
||||||
import EditMeta from '../components/EditMeta.vue';
|
import EditHistoryPanel from '../components/EditHistoryPanel.vue';
|
||||||
import EntityChips from '../components/EntityChips.vue';
|
import EntityChips from '../components/EntityChips.vue';
|
||||||
import PageHeader from '../components/PageHeader.vue';
|
import PageHeader from '../components/PageHeader.vue';
|
||||||
import Skeleton from '../components/Skeleton.vue';
|
import Skeleton from '../components/Skeleton.vue';
|
||||||
|
import { iconBack, iconEdit } from '../icons';
|
||||||
import { api, type HabitatDetail } from '../services/api';
|
import { api, type HabitatDetail } from '../services/api';
|
||||||
|
import HabitatEdit from './HabitatEdit.vue';
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
|
const { t } = useI18n();
|
||||||
const habitat = ref<HabitatDetail | null>(null);
|
const habitat = ref<HabitatDetail | null>(null);
|
||||||
const timeOfDays = ['早晨', '中午', '傍晚', '晚上'];
|
const timeOfDays = ['早晨', '中午', '傍晚', '晚上'];
|
||||||
const weathers = ['晴天', '阴天', '雨天'];
|
const weathers = ['晴天', '阴天', '雨天'];
|
||||||
|
const showEditor = computed(() => route.name === 'habitat-edit');
|
||||||
|
|
||||||
type PokemonRow = {
|
type PokemonRow = {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -33,6 +39,25 @@ function sortByOrder(values: Set<string>, order: string[]) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function timeLabel(value: string): string {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
早晨: t('appearance.morning'),
|
||||||
|
中午: t('appearance.noon'),
|
||||||
|
傍晚: t('appearance.evening'),
|
||||||
|
晚上: t('appearance.night')
|
||||||
|
};
|
||||||
|
return labels[value] ?? value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function weatherLabel(value: string): string {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
晴天: t('appearance.sunny'),
|
||||||
|
阴天: t('appearance.cloudy'),
|
||||||
|
雨天: t('appearance.rainy')
|
||||||
|
};
|
||||||
|
return labels[value] ?? value;
|
||||||
|
}
|
||||||
|
|
||||||
const pokemonRows = computed<PokemonRow[]>(() => {
|
const pokemonRows = computed<PokemonRow[]>(() => {
|
||||||
if (!habitat.value) return [];
|
if (!habitat.value) return [];
|
||||||
|
|
||||||
@@ -71,17 +96,38 @@ const pokemonRows = computed<PokemonRow[]>(() => {
|
|||||||
timeOfDays: sortByOrder(row.timeOfDays, timeOfDays),
|
timeOfDays: sortByOrder(row.timeOfDays, timeOfDays),
|
||||||
weathers: sortByOrder(row.weathers, weathers),
|
weathers: sortByOrder(row.weathers, weathers),
|
||||||
rarity: row.rarity,
|
rarity: row.rarity,
|
||||||
maps: [...row.maps].sort((a, b) => a.localeCompare(b))
|
maps: [...row.maps]
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
onMounted(async () => {
|
async function loadHabitatDetail() {
|
||||||
habitat.value = await api.habitatDetail(String(route.params.id));
|
habitat.value = await api.habitatDetail(String(route.params.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await loadHabitatDetail();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => route.name,
|
||||||
|
(name, oldName) => {
|
||||||
|
if (oldName === 'habitat-edit' && name === 'habitat-detail') {
|
||||||
|
void loadHabitatDetail();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => route.params.id,
|
||||||
|
() => {
|
||||||
|
habitat.value = null;
|
||||||
|
void loadHabitatDetail();
|
||||||
|
}
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section v-if="!habitat" class="page-stack" aria-busy="true" aria-label="正在加载栖息地详情">
|
<section v-if="!habitat" class="page-stack" aria-busy="true" :aria-label="t('pages.habitats.loadingDetail')">
|
||||||
<div class="page-header page-header--skeleton" aria-hidden="true">
|
<div class="page-header page-header--skeleton" aria-hidden="true">
|
||||||
<div class="page-header__copy">
|
<div class="page-header__copy">
|
||||||
<Skeleton width="132px" />
|
<Skeleton width="132px" />
|
||||||
@@ -127,41 +173,45 @@ onMounted(async () => {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section v-else class="page-stack">
|
<section v-else class="page-stack">
|
||||||
<PageHeader :title="habitat.name" subtitle="栖息地详情">
|
<PageHeader :title="habitat.name" :subtitle="t('pages.habitats.detailSubtitle')">
|
||||||
<template #kicker>Habitat Detail</template>
|
<template #kicker>Habitat Detail</template>
|
||||||
<template #meta>
|
|
||||||
<EditMeta :entity="habitat" />
|
|
||||||
</template>
|
|
||||||
<template #actions>
|
<template #actions>
|
||||||
<RouterLink class="ui-button ui-button--primary ui-button--small" :to="`/habitats/${habitat.id}/edit`">编辑</RouterLink>
|
<RouterLink class="ui-button ui-button--primary ui-button--small" :to="`/habitats/${habitat.id}/edit`">
|
||||||
<RouterLink class="ui-button ui-button--blue ui-button--small" to="/habitats">返回列表</RouterLink>
|
<Icon :icon="iconEdit" class="ui-icon" aria-hidden="true" />
|
||||||
|
{{ t('common.edit') }}
|
||||||
|
</RouterLink>
|
||||||
|
<RouterLink class="ui-button ui-button--blue ui-button--small" to="/habitats">
|
||||||
|
<Icon :icon="iconBack" class="ui-icon" aria-hidden="true" />
|
||||||
|
{{ t('common.backToList') }}
|
||||||
|
</RouterLink>
|
||||||
</template>
|
</template>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
|
<div class="detail-with-sidebar">
|
||||||
<div class="habitat-detail-stack">
|
<div class="habitat-detail-stack">
|
||||||
<DetailSection title="配方列表">
|
<DetailSection :title="t('pages.habitats.recipeList')">
|
||||||
<EntityChips :items="habitat.recipe" />
|
<EntityChips :items="habitat.recipe" />
|
||||||
</DetailSection>
|
</DetailSection>
|
||||||
|
|
||||||
<DetailSection title="可能出现的宝可梦">
|
<DetailSection :title="t('pages.habitats.possiblePokemon')">
|
||||||
<ul class="row-list appearance-list">
|
<ul class="row-list appearance-list">
|
||||||
<li v-for="item in pokemonRows" :key="`${item.id}-${item.rarity}`">
|
<li v-for="item in pokemonRows" :key="`${item.id}-${item.rarity}`">
|
||||||
<RouterLink class="appearance-name" :to="`/pokemon/${item.id}`">{{ item.name }}</RouterLink>
|
<RouterLink class="appearance-name" :to="`/pokemon/${item.id}`">{{ item.name }}</RouterLink>
|
||||||
<dl class="appearance-summary">
|
<dl class="appearance-summary">
|
||||||
<div>
|
<div>
|
||||||
<dt>时段</dt>
|
<dt>{{ t('appearance.time') }}</dt>
|
||||||
<dd>{{ item.timeOfDays.join(' / ') }}</dd>
|
<dd>{{ item.timeOfDays.map(timeLabel).join(' / ') }}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt>天气</dt>
|
<dt>{{ t('appearance.weather') }}</dt>
|
||||||
<dd>{{ item.weathers.join(' / ') }}</dd>
|
<dd>{{ item.weathers.map(weatherLabel).join(' / ') }}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt>稀有度</dt>
|
<dt>{{ t('appearance.rarity') }}</dt>
|
||||||
<dd>{{ item.rarity }} 星</dd>
|
<dd>{{ t('appearance.stars', { count: item.rarity }) }}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt>出现地图</dt>
|
<dt>{{ t('appearance.maps') }}</dt>
|
||||||
<dd>{{ item.maps.join(' / ') }}</dd>
|
<dd>{{ item.maps.join(' / ') }}</dd>
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
@@ -169,5 +219,10 @@ onMounted(async () => {
|
|||||||
</ul>
|
</ul>
|
||||||
</DetailSection>
|
</DetailSection>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<EditHistoryPanel :entity="habitat" :history="habitat.editHistory" />
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<HabitatEdit v-if="showEditor" />
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,19 +1,25 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { Icon } from '@iconify/vue';
|
||||||
import { computed, onMounted, ref } from 'vue';
|
import { computed, onMounted, ref } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
import { useRoute, useRouter } from 'vue-router';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
import PageHeader from '../components/PageHeader.vue';
|
import Modal from '../components/Modal.vue';
|
||||||
import Skeleton from '../components/Skeleton.vue';
|
import Skeleton from '../components/Skeleton.vue';
|
||||||
import StatusMessage from '../components/StatusMessage.vue';
|
import StatusMessage from '../components/StatusMessage.vue';
|
||||||
import SwitchGroup from '../components/SwitchGroup.vue';
|
import SwitchGroup from '../components/SwitchGroup.vue';
|
||||||
import TagsSelect from '../components/TagsSelect.vue';
|
import TagsSelect from '../components/TagsSelect.vue';
|
||||||
|
import TranslationFields from '../components/TranslationFields.vue';
|
||||||
|
import { iconAdd, iconCancel, iconDelete, iconPokemon, iconSave } from '../icons';
|
||||||
import {
|
import {
|
||||||
api,
|
api,
|
||||||
type ConfigType,
|
type ConfigType,
|
||||||
type HabitatDetail,
|
type HabitatDetail,
|
||||||
type HabitatPayload,
|
type HabitatPayload,
|
||||||
type Item,
|
type Item,
|
||||||
|
type Language,
|
||||||
type Options,
|
type Options,
|
||||||
type Pokemon
|
type Pokemon,
|
||||||
|
type TranslationMap
|
||||||
} from '../services/api';
|
} from '../services/api';
|
||||||
|
|
||||||
type HabitatAppearanceForm = {
|
type HabitatAppearanceForm = {
|
||||||
@@ -26,30 +32,46 @@ type HabitatAppearanceForm = {
|
|||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { locale, t } = useI18n();
|
||||||
const options = ref<Options | null>(null);
|
const options = ref<Options | null>(null);
|
||||||
const itemRows = ref<Item[]>([]);
|
const itemRows = ref<Item[]>([]);
|
||||||
const pokemonRows = ref<Pokemon[]>([]);
|
const pokemonRows = ref<Pokemon[]>([]);
|
||||||
|
const languages = ref<Language[]>([]);
|
||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
const busy = ref(false);
|
const busy = ref(false);
|
||||||
const message = ref('');
|
const message = ref('');
|
||||||
const creatingSelect = ref('');
|
const creatingSelect = ref('');
|
||||||
const habitatForm = ref({
|
const habitatForm = ref({
|
||||||
name: '',
|
name: '',
|
||||||
|
translations: {} as TranslationMap,
|
||||||
recipeItems: [] as Array<{ itemId: string; quantity: number }>,
|
recipeItems: [] as Array<{ itemId: string; quantity: number }>,
|
||||||
pokemonAppearances: [] as HabitatAppearanceForm[]
|
pokemonAppearances: [] as HabitatAppearanceForm[]
|
||||||
});
|
});
|
||||||
|
|
||||||
const timeOfDays = ['早晨', '中午', '傍晚', '晚上'];
|
const timeOfDays = ['早晨', '中午', '傍晚', '晚上'];
|
||||||
const weathers = ['晴天', '阴天', '雨天'];
|
const weathers = ['晴天', '阴天', '雨天'];
|
||||||
const timeOfDayOptions = timeOfDays.map((value) => ({ value, label: value }));
|
const timeOfDayOptions = computed(() => [
|
||||||
const weatherOptions = weathers.map((value) => ({ value, label: value }));
|
{ value: '早晨', label: t('appearance.morning') },
|
||||||
|
{ value: '中午', label: t('appearance.noon') },
|
||||||
|
{ value: '傍晚', label: t('appearance.evening') },
|
||||||
|
{ value: '晚上', label: t('appearance.night') }
|
||||||
|
]);
|
||||||
|
const weatherOptions = computed(() => [
|
||||||
|
{ value: '晴天', label: t('appearance.sunny') },
|
||||||
|
{ value: '阴天', label: t('appearance.cloudy') },
|
||||||
|
{ value: '雨天', label: t('appearance.rainy') }
|
||||||
|
]);
|
||||||
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 itemSelectOptions = computed(() => itemRows.value.map((item) => ({ id: item.id, name: item.name })));
|
const itemSelectOptions = computed(() => itemRows.value.map((item) => ({ id: item.id, name: item.name })));
|
||||||
const pokemonSelectOptions = computed(() =>
|
const pokemonSelectOptions = computed(() =>
|
||||||
pokemonRows.value.map((pokemon) => ({ id: pokemon.id, name: pokemon.name, label: `#${pokemon.id} ${pokemon.name}` }))
|
pokemonRows.value.map((pokemon) => ({ id: pokemon.id, name: pokemon.name, label: `#${pokemon.id} ${pokemon.name}` }))
|
||||||
);
|
);
|
||||||
const pageTitle = computed(() => (isEditing.value ? `编辑 ${habitatForm.value.name || '栖息地'}` : '新增栖息地'));
|
const pageTitle = computed(() =>
|
||||||
|
isEditing.value
|
||||||
|
? t('pages.habitats.editTitle', { name: habitatForm.value.name || t('pages.habitats.fallbackName') })
|
||||||
|
: t('pages.habitats.newTitle')
|
||||||
|
);
|
||||||
const cancelTo = computed(() => (isEditing.value ? `/habitats/${routeId.value}` : '/habitats'));
|
const cancelTo = computed(() => (isEditing.value ? `/habitats/${routeId.value}` : '/habitats'));
|
||||||
|
|
||||||
function toIds(values: string[]): number[] {
|
function toIds(values: string[]): number[] {
|
||||||
@@ -103,26 +125,46 @@ function groupPokemonAppearances(detail: HabitatDetail): HabitatAppearanceForm[]
|
|||||||
return [...rows.values()];
|
return [...rows.values()];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function closeEditor() {
|
||||||
|
void router.push(cancelTo.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function habitatNameForSave() {
|
||||||
|
const baseName = habitatForm.value.name.trim();
|
||||||
|
if (baseName !== '') {
|
||||||
|
return habitatForm.value.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
return habitatForm.value.translations[String(locale.value || '')]?.name ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
async function loadEditor() {
|
async function loadEditor() {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
message.value = '';
|
message.value = '';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [loadedOptions, loadedItems, loadedPokemon] = await Promise.all([api.options(), api.items({}), api.pokemon({})]);
|
const [loadedOptions, loadedItems, loadedPokemon, loadedLanguages] = await Promise.all([
|
||||||
|
api.options(),
|
||||||
|
api.items({}),
|
||||||
|
api.pokemon({}),
|
||||||
|
api.languages()
|
||||||
|
]);
|
||||||
options.value = loadedOptions;
|
options.value = loadedOptions;
|
||||||
itemRows.value = loadedItems;
|
itemRows.value = loadedItems;
|
||||||
pokemonRows.value = loadedPokemon;
|
pokemonRows.value = loadedPokemon;
|
||||||
|
languages.value = loadedLanguages;
|
||||||
|
|
||||||
if (isEditing.value) {
|
if (isEditing.value) {
|
||||||
const habitat = await api.habitatDetail(routeId.value);
|
const habitat = await api.habitatDetail(routeId.value);
|
||||||
habitatForm.value = {
|
habitatForm.value = {
|
||||||
name: habitat.name,
|
name: habitat.baseName ?? habitat.name,
|
||||||
|
translations: habitat.translations ?? {},
|
||||||
recipeItems: habitat.recipe.map((recipeItem) => ({ itemId: String(recipeItem.id), quantity: recipeItem.quantity })),
|
recipeItems: habitat.recipe.map((recipeItem) => ({ itemId: String(recipeItem.id), quantity: recipeItem.quantity })),
|
||||||
pokemonAppearances: groupPokemonAppearances(habitat)
|
pokemonAppearances: groupPokemonAppearances(habitat)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
message.value = errorText(error, '加载失败');
|
message.value = errorText(error, t('errors.loadFailed'));
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
@@ -146,7 +188,7 @@ async function createMultiOption(selectKey: string, type: ConfigType, name: stri
|
|||||||
values.push(value);
|
values.push(value);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
message.value = errorText(error, '添加失败');
|
message.value = errorText(error, t('errors.addFailed'));
|
||||||
} finally {
|
} finally {
|
||||||
creatingSelect.value = '';
|
creatingSelect.value = '';
|
||||||
}
|
}
|
||||||
@@ -158,7 +200,8 @@ async function saveHabitat() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const payload: HabitatPayload = {
|
const payload: HabitatPayload = {
|
||||||
name: habitatForm.value.name,
|
name: habitatNameForSave(),
|
||||||
|
translations: habitatForm.value.translations,
|
||||||
recipeItems: toQuantityRows(habitatForm.value.recipeItems),
|
recipeItems: toQuantityRows(habitatForm.value.recipeItems),
|
||||||
pokemonAppearances: habitatForm.value.pokemonAppearances
|
pokemonAppearances: habitatForm.value.pokemonAppearances
|
||||||
.map((item) => ({
|
.map((item) => ({
|
||||||
@@ -173,7 +216,7 @@ async function saveHabitat() {
|
|||||||
const saved = isEditing.value ? await api.updateHabitat(routeId.value, payload) : await api.createHabitat(payload);
|
const saved = isEditing.value ? await api.updateHabitat(routeId.value, payload) : await api.createHabitat(payload);
|
||||||
await router.push(`/habitats/${saved.id}`);
|
await router.push(`/habitats/${saved.id}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
message.value = errorText(error, '保存失败');
|
message.value = errorText(error, t('errors.saveFailed'));
|
||||||
} finally {
|
} finally {
|
||||||
busy.value = false;
|
busy.value = false;
|
||||||
}
|
}
|
||||||
@@ -185,41 +228,45 @@ onMounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="page-stack">
|
<Modal :title="pageTitle" :subtitle="t('pages.habitats.editSubtitle')" :close-label="t('common.close')" size="wide" @close="closeEditor">
|
||||||
<PageHeader :title="pageTitle" subtitle="维护栖息地配方和可能出现的 Pokemon。">
|
|
||||||
<template #kicker>Habitat Edit</template>
|
|
||||||
<template #actions>
|
|
||||||
<RouterLink class="ui-button ui-button--blue ui-button--small" :to="cancelTo">返回</RouterLink>
|
|
||||||
</template>
|
|
||||||
</PageHeader>
|
|
||||||
|
|
||||||
<StatusMessage v-if="message" variant="danger">{{ message }}</StatusMessage>
|
<StatusMessage v-if="message" variant="danger">{{ message }}</StatusMessage>
|
||||||
|
|
||||||
<form v-if="!loading && options" class="detail-section" @submit.prevent="saveHabitat">
|
<form v-if="!loading && options" id="habitat-edit-form" class="modal-edit-form" @submit.prevent="saveHabitat">
|
||||||
<div class="field">
|
<TranslationFields
|
||||||
<label for="habitat-name">名称</label>
|
id-prefix="habitat-name"
|
||||||
<input id="habitat-name" v-model="habitatForm.name" required />
|
v-model:base-value="habitatForm.name"
|
||||||
</div>
|
v-model:translations="habitatForm.translations"
|
||||||
|
field="name"
|
||||||
|
:label="t('common.name')"
|
||||||
|
:languages="languages"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>配方</label>
|
<label>{{ t('pages.habitats.recipe') }}</label>
|
||||||
<div v-for="(row, index) in habitatForm.recipeItems" :key="index" class="inline-row">
|
<div v-for="(row, index) in habitatForm.recipeItems" :key="index" class="inline-row">
|
||||||
<TagsSelect
|
<TagsSelect
|
||||||
:id="`habitat-recipe-item-${index}`"
|
:id="`habitat-recipe-item-${index}`"
|
||||||
v-model="row.itemId"
|
v-model="row.itemId"
|
||||||
:options="itemSelectOptions"
|
:options="itemSelectOptions"
|
||||||
:multiple="false"
|
:multiple="false"
|
||||||
placeholder="请选择"
|
:placeholder="t('common.select')"
|
||||||
search-placeholder="搜索物品"
|
:search-placeholder="t('pages.pokemon.searchItems')"
|
||||||
/>
|
/>
|
||||||
<input v-model.number="row.quantity" aria-label="数量" type="number" min="1" />
|
<input v-model.number="row.quantity" :aria-label="t('common.quantity')" type="number" min="1" />
|
||||||
<button type="button" @click="habitatForm.recipeItems.splice(index, 1)">删除</button>
|
<button type="button" @click="habitatForm.recipeItems.splice(index, 1)">
|
||||||
|
<Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" />
|
||||||
|
{{ t('common.delete') }}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" class="plain-button" @click="addHabitatRecipeItem">添加物品</button>
|
<button type="button" class="plain-button" @click="addHabitatRecipeItem">
|
||||||
|
<Icon :icon="iconAdd" class="ui-icon" aria-hidden="true" />
|
||||||
|
{{ t('pages.habitats.addItem') }}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>可出现的 Pokemon</label>
|
<label>{{ t('pages.habitats.possiblePokemon') }}</label>
|
||||||
<div v-for="(row, index) in habitatForm.pokemonAppearances" :key="index" class="appearance-row">
|
<div v-for="(row, index) in habitatForm.pokemonAppearances" :key="index" class="appearance-row">
|
||||||
<div class="appearance-row__main">
|
<div class="appearance-row__main">
|
||||||
<div class="field appearance-row__pokemon">
|
<div class="field appearance-row__pokemon">
|
||||||
@@ -230,47 +277,59 @@ onMounted(() => {
|
|||||||
:options="pokemonSelectOptions"
|
:options="pokemonSelectOptions"
|
||||||
:multiple="false"
|
:multiple="false"
|
||||||
placeholder="Pokemon"
|
placeholder="Pokemon"
|
||||||
search-placeholder="搜索 Pokemon"
|
:search-placeholder="t('pages.pokemon.searchPokemon')"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<SwitchGroup :id="`appearance-times-${index}`" v-model="row.timeOfDays" label="时间" :options="timeOfDayOptions" />
|
<SwitchGroup :id="`appearance-times-${index}`" v-model="row.timeOfDays" :label="t('appearance.time')" :options="timeOfDayOptions" />
|
||||||
<SwitchGroup :id="`appearance-weathers-${index}`" v-model="row.weathers" label="天气" :options="weatherOptions" />
|
<SwitchGroup :id="`appearance-weathers-${index}`" v-model="row.weathers" :label="t('appearance.weather')" :options="weatherOptions" />
|
||||||
|
|
||||||
<div class="field appearance-row__rarity">
|
<div class="field appearance-row__rarity">
|
||||||
<label :for="`appearance-rarity-${index}`">稀有度</label>
|
<label :for="`appearance-rarity-${index}`">{{ t('appearance.rarity') }}</label>
|
||||||
<input :id="`appearance-rarity-${index}`" v-model.number="row.rarity" type="number" min="1" max="3" />
|
<input :id="`appearance-rarity-${index}`" v-model.number="row.rarity" type="number" min="1" max="3" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="button" class="appearance-row__delete" @click="habitatForm.pokemonAppearances.splice(index, 1)">删除</button>
|
<button type="button" class="appearance-row__delete" @click="habitatForm.pokemonAppearances.splice(index, 1)">
|
||||||
|
<Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" />
|
||||||
|
{{ t('common.delete') }}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field appearance-row__maps">
|
<div class="field appearance-row__maps">
|
||||||
<label :for="`appearance-maps-${index}`">地图</label>
|
<label :for="`appearance-maps-${index}`">{{ t('appearance.map') }}</label>
|
||||||
<TagsSelect
|
<TagsSelect
|
||||||
:id="`appearance-maps-${index}`"
|
:id="`appearance-maps-${index}`"
|
||||||
v-model="row.mapIds"
|
v-model="row.mapIds"
|
||||||
:options="options.maps"
|
:options="options.maps"
|
||||||
allow-create
|
allow-create
|
||||||
:creating="creatingSelect === `appearance-maps-${index}`"
|
:creating="creatingSelect === `appearance-maps-${index}`"
|
||||||
placeholder="搜索地图"
|
:placeholder="t('pages.habitats.searchMaps')"
|
||||||
@create="createMultiOption(`appearance-maps-${index}`, 'maps', $event, row.mapIds)"
|
@create="createMultiOption(`appearance-maps-${index}`, 'maps', $event, row.mapIds)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" class="plain-button" @click="addPokemonAppearance">添加 Pokemon</button>
|
<button type="button" class="plain-button" @click="addPokemonAppearance">
|
||||||
</div>
|
<Icon :icon="iconPokemon" class="ui-icon" aria-hidden="true" />
|
||||||
|
{{ t('pages.habitats.addPokemon') }}
|
||||||
<div class="form-actions">
|
</button>
|
||||||
<button type="submit" class="link-button" :disabled="busy">{{ busy ? '保存中' : '保存' }}</button>
|
|
||||||
<RouterLink class="plain-button" :to="cancelTo">取消</RouterLink>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<section v-else class="detail-section skeleton-detail-section" aria-busy="true" aria-label="正在加载栖息地编辑内容">
|
<section v-else class="modal-edit-form skeleton-detail-section" aria-busy="true" :aria-label="t('pages.habitats.loadingEdit')">
|
||||||
<div v-for="index in 5" :key="index" class="field">
|
<div v-for="index in 5" :key="index" class="field">
|
||||||
<Skeleton :width="index === 1 ? '52px' : '112px'" />
|
<Skeleton :width="index === 1 ? '52px' : '112px'" />
|
||||||
<Skeleton variant="box" height="44px" />
|
<Skeleton variant="box" height="44px" />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</section>
|
|
||||||
|
<template v-if="!loading && options" #footer>
|
||||||
|
<button type="submit" form="habitat-edit-form" class="link-button" :disabled="busy">
|
||||||
|
<Icon :icon="iconSave" class="ui-icon" aria-hidden="true" />
|
||||||
|
{{ busy ? t('common.saving') : t('common.save') }}
|
||||||
|
</button>
|
||||||
|
<button type="button" class="plain-button" :disabled="busy" @click="closeEditor">
|
||||||
|
<Icon :icon="iconCancel" class="ui-icon" aria-hidden="true" />
|
||||||
|
{{ t('common.cancel') }}
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</Modal>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,15 +1,23 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, ref } from 'vue';
|
import { Icon } from '@iconify/vue';
|
||||||
|
import { computed, onMounted, ref } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { useRoute } from 'vue-router';
|
||||||
import EditMeta from '../components/EditMeta.vue';
|
import EditMeta from '../components/EditMeta.vue';
|
||||||
import EntityChips from '../components/EntityChips.vue';
|
import EntityChips from '../components/EntityChips.vue';
|
||||||
import EntityCard from '../components/EntityCard.vue';
|
import EntityCard from '../components/EntityCard.vue';
|
||||||
import PageHeader from '../components/PageHeader.vue';
|
import PageHeader from '../components/PageHeader.vue';
|
||||||
import Skeleton from '../components/Skeleton.vue';
|
import Skeleton from '../components/Skeleton.vue';
|
||||||
|
import { iconAdd, iconHabitat } from '../icons';
|
||||||
import { api, type Habitat } from '../services/api';
|
import { api, type Habitat } from '../services/api';
|
||||||
|
import HabitatEdit from './HabitatEdit.vue';
|
||||||
|
|
||||||
const habitats = ref<Habitat[]>([]);
|
const habitats = ref<Habitat[]>([]);
|
||||||
|
const route = useRoute();
|
||||||
|
const { t } = useI18n();
|
||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
const skeletonCardCount = 6;
|
const skeletonCardCount = 6;
|
||||||
|
const showEditor = computed(() => route.name === 'habitat-new');
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
habitats.value = await api.habitats();
|
habitats.value = await api.habitats();
|
||||||
@@ -19,14 +27,17 @@ onMounted(async () => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="page-stack">
|
<section class="page-stack">
|
||||||
<PageHeader title="栖息地" subtitle="查看配方和可能出现的宝可梦。">
|
<PageHeader :title="t('pages.habitats.title')" :subtitle="t('pages.habitats.subtitle')">
|
||||||
<template #kicker>Habitats</template>
|
<template #kicker>Habitats</template>
|
||||||
<template #actions>
|
<template #actions>
|
||||||
<RouterLink class="ui-button ui-button--primary ui-button--small" to="/habitats/new">新增</RouterLink>
|
<RouterLink class="ui-button ui-button--primary ui-button--small" to="/habitats/new">
|
||||||
|
<Icon :icon="iconAdd" class="ui-icon" aria-hidden="true" />
|
||||||
|
{{ t('common.add') }}
|
||||||
|
</RouterLink>
|
||||||
</template>
|
</template>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
<div v-if="loading" class="entity-grid" aria-busy="true" aria-label="正在加载栖息地列表">
|
<div v-if="loading" class="entity-grid" aria-busy="true" :aria-label="t('pages.habitats.loadingList')">
|
||||||
<article v-for="index in skeletonCardCount" :key="index" class="entity-card entity-card--skeleton">
|
<article v-for="index in skeletonCardCount" :key="index" class="entity-card entity-card--skeleton">
|
||||||
<Skeleton variant="box" width="42px" height="42px" class="skeleton-entity-mark" />
|
<Skeleton variant="box" width="42px" height="42px" class="skeleton-entity-mark" />
|
||||||
<div class="entity-card__content">
|
<div class="entity-card__content">
|
||||||
@@ -42,11 +53,13 @@ onMounted(async () => {
|
|||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="entity-grid">
|
<div v-else class="entity-grid">
|
||||||
<EntityCard v-for="item in habitats" :key="item.id" :title="item.name" :to="`/habitats/${item.id}`" marker="◎">
|
<EntityCard v-for="item in habitats" :key="item.id" :title="item.name" :to="`/habitats/${item.id}`" :icon="iconHabitat">
|
||||||
<EditMeta :entity="item" />
|
<EditMeta :entity="item" />
|
||||||
<EntityChips :items="item.recipe" />
|
<EntityChips :items="item.recipe" />
|
||||||
<EntityChips :items="item.pokemon ?? []" />
|
<EntityChips :items="item.pokemon ?? []" />
|
||||||
</EntityCard>
|
</EntityCard>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<HabitatEdit v-if="showEditor" />
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,15 +1,21 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref } from 'vue';
|
import { Icon } from '@iconify/vue';
|
||||||
|
import { computed, onMounted, ref, watch } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
import { useRoute } from 'vue-router';
|
import { useRoute } from 'vue-router';
|
||||||
import DetailSection from '../components/DetailSection.vue';
|
import DetailSection from '../components/DetailSection.vue';
|
||||||
import EditMeta from '../components/EditMeta.vue';
|
import EditHistoryPanel from '../components/EditHistoryPanel.vue';
|
||||||
import EntityChips from '../components/EntityChips.vue';
|
import EntityChips from '../components/EntityChips.vue';
|
||||||
import PageHeader from '../components/PageHeader.vue';
|
import PageHeader from '../components/PageHeader.vue';
|
||||||
import Skeleton from '../components/Skeleton.vue';
|
import Skeleton from '../components/Skeleton.vue';
|
||||||
|
import { iconAdd, iconBack, iconEdit } from '../icons';
|
||||||
import { api, type ItemDetail } from '../services/api';
|
import { api, type ItemDetail } from '../services/api';
|
||||||
|
import ItemEdit from './ItemEdit.vue';
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
|
const { t } = useI18n();
|
||||||
const item = ref<ItemDetail | null>(null);
|
const item = ref<ItemDetail | null>(null);
|
||||||
|
const showEditor = computed(() => route.name === 'item-edit');
|
||||||
|
|
||||||
const customization = computed(() => {
|
const customization = computed(() => {
|
||||||
if (!item.value) {
|
if (!item.value) {
|
||||||
@@ -17,19 +23,40 @@ const customization = computed(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
item.value.customization.dyeable ? '可染色' : '',
|
item.value.customization.dyeable ? t('pages.items.dyeable') : '',
|
||||||
item.value.customization.dualDyeable ? '可双区染色' : '',
|
item.value.customization.dualDyeable ? t('pages.items.dualDyeable') : '',
|
||||||
item.value.customization.patternEditable ? '可改花纹' : ''
|
item.value.customization.patternEditable ? t('pages.items.patternEditable') : ''
|
||||||
].filter(Boolean);
|
].filter(Boolean);
|
||||||
});
|
});
|
||||||
|
|
||||||
onMounted(async () => {
|
async function loadItemDetail() {
|
||||||
item.value = await api.itemDetail(String(route.params.id));
|
item.value = await api.itemDetail(String(route.params.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await loadItemDetail();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => route.name,
|
||||||
|
(name, oldName) => {
|
||||||
|
if (oldName === 'item-edit' && name === 'item-detail') {
|
||||||
|
void loadItemDetail();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => route.params.id,
|
||||||
|
() => {
|
||||||
|
item.value = null;
|
||||||
|
void loadItemDetail();
|
||||||
|
}
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section v-if="!item" class="page-stack" aria-busy="true" aria-label="正在加载物品详情">
|
<section v-if="!item" class="page-stack" aria-busy="true" :aria-label="t('pages.items.loadingDetail')">
|
||||||
<div class="page-header page-header--skeleton" aria-hidden="true">
|
<div class="page-header page-header--skeleton" aria-hidden="true">
|
||||||
<div class="page-header__copy">
|
<div class="page-header__copy">
|
||||||
<Skeleton width="96px" />
|
<Skeleton width="96px" />
|
||||||
@@ -84,74 +111,84 @@ onMounted(async () => {
|
|||||||
<section v-else class="page-stack">
|
<section v-else class="page-stack">
|
||||||
<PageHeader :title="item.name" :subtitle="item.usage ? `${item.category.name} · ${item.usage.name}` : item.category.name">
|
<PageHeader :title="item.name" :subtitle="item.usage ? `${item.category.name} · ${item.usage.name}` : item.category.name">
|
||||||
<template #kicker>Item Detail</template>
|
<template #kicker>Item Detail</template>
|
||||||
<template #meta>
|
|
||||||
<EditMeta :entity="item" />
|
|
||||||
</template>
|
|
||||||
<template #actions>
|
<template #actions>
|
||||||
<RouterLink class="ui-button ui-button--primary ui-button--small" :to="`/items/${item.id}/edit`">编辑</RouterLink>
|
<RouterLink class="ui-button ui-button--primary ui-button--small" :to="`/items/${item.id}/edit`">
|
||||||
<RouterLink class="ui-button ui-button--blue ui-button--small" to="/items">返回列表</RouterLink>
|
<Icon :icon="iconEdit" class="ui-icon" aria-hidden="true" />
|
||||||
|
{{ t('common.edit') }}
|
||||||
|
</RouterLink>
|
||||||
|
<RouterLink class="ui-button ui-button--blue ui-button--small" to="/items">
|
||||||
|
<Icon :icon="iconBack" class="ui-icon" aria-hidden="true" />
|
||||||
|
{{ t('common.backToList') }}
|
||||||
|
</RouterLink>
|
||||||
</template>
|
</template>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
|
<div class="detail-with-sidebar">
|
||||||
<div class="detail-grid">
|
<div class="detail-grid">
|
||||||
<DetailSection title="入手方式">
|
<DetailSection :title="t('pages.items.acquisitionMethods')">
|
||||||
<EntityChips :items="item.acquisitionMethods" />
|
<EntityChips :items="item.acquisitionMethods" />
|
||||||
</DetailSection>
|
</DetailSection>
|
||||||
|
|
||||||
<DetailSection title="自定义">
|
<DetailSection :title="t('pages.items.customization')">
|
||||||
<div v-if="customization.length" class="chips">
|
<div v-if="customization.length" class="chips">
|
||||||
<span v-for="entry in customization" :key="entry" class="chip">{{ entry }}</span>
|
<span v-for="entry in customization" :key="entry" class="chip">{{ entry }}</span>
|
||||||
</div>
|
</div>
|
||||||
<p v-else class="meta-line">无</p>
|
<p v-else class="meta-line">{{ t('common.none') }}</p>
|
||||||
</DetailSection>
|
</DetailSection>
|
||||||
|
|
||||||
<DetailSection title="标签">
|
<DetailSection :title="t('pages.items.tags')">
|
||||||
<EntityChips :items="item.tags" />
|
<EntityChips :items="item.tags" />
|
||||||
</DetailSection>
|
</DetailSection>
|
||||||
|
|
||||||
<DetailSection title="材料单信息">
|
<DetailSection :title="t('pages.items.recipeInfo')">
|
||||||
<template v-if="item.recipe">
|
<template v-if="item.recipe">
|
||||||
<RouterLink :to="`/recipes/${item.recipe.id}`">{{ item.recipe.name }}</RouterLink>
|
<RouterLink :to="`/recipes/${item.recipe.id}`">{{ item.recipe.name }}</RouterLink>
|
||||||
<EntityChips :items="item.recipe.materials" />
|
<EntityChips :items="item.recipe.materials" />
|
||||||
</template>
|
</template>
|
||||||
<p v-else-if="item.noRecipe" class="meta-line">无材料单</p>
|
<p v-else-if="item.noRecipe" class="meta-line">{{ t('pages.items.noRecipe') }}</p>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<p class="meta-line">无</p>
|
<p class="meta-line">{{ t('common.none') }}</p>
|
||||||
<RouterLink class="ui-button ui-button--primary ui-button--small" :to="`/recipes/new?itemId=${item.id}`">
|
<RouterLink class="ui-button ui-button--primary ui-button--small" :to="`/recipes/new?itemId=${item.id}`">
|
||||||
创建材料单
|
<Icon :icon="iconAdd" class="ui-icon" aria-hidden="true" />
|
||||||
|
{{ t('pages.items.createRecipe') }}
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
</template>
|
</template>
|
||||||
</DetailSection>
|
</DetailSection>
|
||||||
|
|
||||||
<DetailSection title="相关材料单">
|
<DetailSection :title="t('pages.items.relatedRecipes')">
|
||||||
<ul v-if="item.relatedRecipes.length" class="row-list">
|
<ul v-if="item.relatedRecipes.length" class="row-list">
|
||||||
<li v-for="recipe in item.relatedRecipes" :key="recipe.id">
|
<li v-for="recipe in item.relatedRecipes" :key="recipe.id">
|
||||||
<RouterLink :to="`/recipes/${recipe.id}`">{{ recipe.name }}</RouterLink>
|
<RouterLink :to="`/recipes/${recipe.id}`">{{ recipe.name }}</RouterLink>
|
||||||
<EntityChips :items="recipe.materials" />
|
<EntityChips :items="recipe.materials" />
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p v-else class="meta-line">无</p>
|
<p v-else class="meta-line">{{ t('common.none') }}</p>
|
||||||
</DetailSection>
|
</DetailSection>
|
||||||
|
|
||||||
<DetailSection title="相关栖息地">
|
<DetailSection :title="t('pages.items.relatedHabitats')">
|
||||||
<ul v-if="item.relatedHabitats.length" class="row-list">
|
<ul v-if="item.relatedHabitats.length" class="row-list">
|
||||||
<li v-for="habitat in item.relatedHabitats" :key="habitat.id">
|
<li v-for="habitat in item.relatedHabitats" :key="habitat.id">
|
||||||
<RouterLink :to="`/habitats/${habitat.id}`">{{ habitat.name }}</RouterLink>
|
<RouterLink :to="`/habitats/${habitat.id}`">{{ habitat.name }}</RouterLink>
|
||||||
<EntityChips :items="habitat.recipe" />
|
<EntityChips :items="habitat.recipe" />
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p v-else class="meta-line">无</p>
|
<p v-else class="meta-line">{{ t('common.none') }}</p>
|
||||||
</DetailSection>
|
</DetailSection>
|
||||||
|
|
||||||
<DetailSection title="Pokemon 掉落">
|
<DetailSection :title="t('pages.items.pokemonDrops')">
|
||||||
<ul v-if="item.droppedByPokemon.length" class="row-list">
|
<ul v-if="item.droppedByPokemon.length" class="row-list">
|
||||||
<li v-for="entry in item.droppedByPokemon" :key="`${entry.pokemon.id}-${entry.skill.id}`">
|
<li v-for="entry in item.droppedByPokemon" :key="`${entry.pokemon.id}-${entry.skill.id}`">
|
||||||
<RouterLink :to="`/pokemon/${entry.pokemon.id}`">#{{ entry.pokemon.id }} {{ entry.pokemon.name }}</RouterLink>
|
<RouterLink :to="`/pokemon/${entry.pokemon.id}`">#{{ entry.pokemon.id }} {{ entry.pokemon.name }}</RouterLink>
|
||||||
<span>{{ entry.skill.name }}掉落物</span>
|
<span>{{ t('pages.pokemon.skillDrop', { name: entry.skill.name }) }}</span>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p v-else class="meta-line">无</p>
|
<p v-else class="meta-line">{{ t('common.none') }}</p>
|
||||||
</DetailSection>
|
</DetailSection>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<EditHistoryPanel :entity="item" :history="item.editHistory" />
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<ItemEdit v-if="showEditor" />
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,21 +1,28 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { Icon } from '@iconify/vue';
|
||||||
import { computed, onMounted, ref } from 'vue';
|
import { computed, onMounted, ref } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
import { useRoute, useRouter } from 'vue-router';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
import PageHeader from '../components/PageHeader.vue';
|
import Modal from '../components/Modal.vue';
|
||||||
import Skeleton from '../components/Skeleton.vue';
|
import Skeleton from '../components/Skeleton.vue';
|
||||||
import StatusMessage from '../components/StatusMessage.vue';
|
import StatusMessage from '../components/StatusMessage.vue';
|
||||||
import TagsSelect from '../components/TagsSelect.vue';
|
import TagsSelect from '../components/TagsSelect.vue';
|
||||||
import { api, type ConfigType, type ItemPayload, type Options } from '../services/api';
|
import TranslationFields from '../components/TranslationFields.vue';
|
||||||
|
import { iconCancel, iconSave } from '../icons';
|
||||||
|
import { api, type ConfigType, type ItemPayload, type Language, type Options, type TranslationMap } from '../services/api';
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { locale, t } = useI18n();
|
||||||
const options = ref<Options | null>(null);
|
const options = ref<Options | null>(null);
|
||||||
|
const languages = ref<Language[]>([]);
|
||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
const busy = ref(false);
|
const busy = ref(false);
|
||||||
const message = ref('');
|
const message = ref('');
|
||||||
const creatingSelect = ref('');
|
const creatingSelect = ref('');
|
||||||
const itemForm = ref({
|
const itemForm = ref({
|
||||||
name: '',
|
name: '',
|
||||||
|
translations: {} as TranslationMap,
|
||||||
categoryId: '',
|
categoryId: '',
|
||||||
usageId: '',
|
usageId: '',
|
||||||
dyeable: false,
|
dyeable: false,
|
||||||
@@ -28,7 +35,11 @@ const itemForm = ref({
|
|||||||
|
|
||||||
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 pageTitle = computed(() => (isEditing.value ? `编辑 ${itemForm.value.name || '物品'}` : '新增物品'));
|
const pageTitle = computed(() =>
|
||||||
|
isEditing.value
|
||||||
|
? t('pages.items.editTitle', { name: itemForm.value.name || t('pages.items.fallbackName') })
|
||||||
|
: t('pages.items.newTitle')
|
||||||
|
);
|
||||||
const cancelTo = computed(() => (isEditing.value ? `/items/${routeId.value}` : '/items'));
|
const cancelTo = computed(() => (isEditing.value ? `/items/${routeId.value}` : '/items'));
|
||||||
const hasRecipe = ref(false);
|
const hasRecipe = ref(false);
|
||||||
|
|
||||||
@@ -40,8 +51,23 @@ function errorText(error: unknown, fallback: string) {
|
|||||||
return error instanceof Error && error.message ? error.message : fallback;
|
return error instanceof Error && error.message ? error.message : fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function closeEditor() {
|
||||||
|
void router.push(cancelTo.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function itemNameForSave() {
|
||||||
|
const baseName = itemForm.value.name.trim();
|
||||||
|
if (baseName !== '') {
|
||||||
|
return itemForm.value.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
return itemForm.value.translations[String(locale.value || '')]?.name ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
async function loadOptions() {
|
async function loadOptions() {
|
||||||
options.value = await api.options();
|
const [loadedOptions, loadedLanguages] = await Promise.all([api.options(), api.languages()]);
|
||||||
|
options.value = loadedOptions;
|
||||||
|
languages.value = loadedLanguages;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadEditor() {
|
async function loadEditor() {
|
||||||
@@ -53,7 +79,8 @@ async function loadEditor() {
|
|||||||
if (isEditing.value) {
|
if (isEditing.value) {
|
||||||
const item = await api.itemDetail(routeId.value);
|
const item = await api.itemDetail(routeId.value);
|
||||||
itemForm.value = {
|
itemForm.value = {
|
||||||
name: item.name,
|
name: item.baseName ?? item.name,
|
||||||
|
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) : '',
|
||||||
dyeable: item.customization.dyeable,
|
dyeable: item.customization.dyeable,
|
||||||
@@ -66,7 +93,7 @@ async function loadEditor() {
|
|||||||
hasRecipe.value = item.recipe !== null;
|
hasRecipe.value = item.recipe !== null;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
message.value = errorText(error, '加载失败');
|
message.value = errorText(error, t('errors.loadFailed'));
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
@@ -83,7 +110,7 @@ async function createSingleOption(selectKey: string, type: ConfigType, name: str
|
|||||||
await loadOptions();
|
await loadOptions();
|
||||||
assign(String(created.id));
|
assign(String(created.id));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
message.value = errorText(error, '添加失败');
|
message.value = errorText(error, t('errors.addFailed'));
|
||||||
} finally {
|
} finally {
|
||||||
creatingSelect.value = '';
|
creatingSelect.value = '';
|
||||||
}
|
}
|
||||||
@@ -103,7 +130,7 @@ async function createMultiOption(selectKey: string, type: ConfigType, name: stri
|
|||||||
values.push(value);
|
values.push(value);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
message.value = errorText(error, '添加失败');
|
message.value = errorText(error, t('errors.addFailed'));
|
||||||
} finally {
|
} finally {
|
||||||
creatingSelect.value = '';
|
creatingSelect.value = '';
|
||||||
}
|
}
|
||||||
@@ -115,7 +142,8 @@ async function saveItem() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const payload: ItemPayload = {
|
const payload: ItemPayload = {
|
||||||
name: itemForm.value.name,
|
name: itemNameForSave(),
|
||||||
|
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,
|
||||||
dyeable: itemForm.value.dyeable,
|
dyeable: itemForm.value.dyeable,
|
||||||
@@ -128,7 +156,7 @@ async function saveItem() {
|
|||||||
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(`/items/${saved.id}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
message.value = errorText(error, '保存失败');
|
message.value = errorText(error, t('errors.saveFailed'));
|
||||||
} finally {
|
} finally {
|
||||||
busy.value = false;
|
busy.value = false;
|
||||||
}
|
}
|
||||||
@@ -140,24 +168,22 @@ onMounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="page-stack">
|
<Modal :title="pageTitle" :subtitle="t('pages.items.editSubtitle')" :close-label="t('common.close')" size="wide" @close="closeEditor">
|
||||||
<PageHeader :title="pageTitle" subtitle="维护物品分类、用途、入手方式、自定义和标签。">
|
|
||||||
<template #kicker>Item Edit</template>
|
|
||||||
<template #actions>
|
|
||||||
<RouterLink class="ui-button ui-button--blue ui-button--small" :to="cancelTo">返回</RouterLink>
|
|
||||||
</template>
|
|
||||||
</PageHeader>
|
|
||||||
|
|
||||||
<StatusMessage v-if="message" variant="danger">{{ message }}</StatusMessage>
|
<StatusMessage v-if="message" variant="danger">{{ message }}</StatusMessage>
|
||||||
|
|
||||||
<form v-if="!loading && options" class="detail-section" @submit.prevent="saveItem">
|
<form v-if="!loading && options" id="item-edit-form" class="modal-edit-form" @submit.prevent="saveItem">
|
||||||
<div class="field">
|
<TranslationFields
|
||||||
<label for="item-name">名称</label>
|
id-prefix="item-name"
|
||||||
<input id="item-name" v-model="itemForm.name" required />
|
v-model:base-value="itemForm.name"
|
||||||
</div>
|
v-model:translations="itemForm.translations"
|
||||||
|
field="name"
|
||||||
|
:label="t('common.name')"
|
||||||
|
:languages="languages"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="item-category">分类</label>
|
<label for="item-category">{{ t('pages.items.category') }}</label>
|
||||||
<TagsSelect
|
<TagsSelect
|
||||||
id="item-category"
|
id="item-category"
|
||||||
v-model="itemForm.categoryId"
|
v-model="itemForm.categoryId"
|
||||||
@@ -165,14 +191,14 @@ onMounted(() => {
|
|||||||
:multiple="false"
|
:multiple="false"
|
||||||
allow-create
|
allow-create
|
||||||
:creating="creatingSelect === 'item-category'"
|
:creating="creatingSelect === 'item-category'"
|
||||||
placeholder="请选择"
|
:placeholder="t('common.select')"
|
||||||
search-placeholder="搜索分类"
|
:search-placeholder="t('pages.items.searchCategory')"
|
||||||
@create="createSingleOption('item-category', 'item-categories', $event, (value) => (itemForm.categoryId = value))"
|
@create="createSingleOption('item-category', 'item-categories', $event, (value) => (itemForm.categoryId = value))"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="item-usage">用途</label>
|
<label for="item-usage">{{ t('pages.items.usage') }}</label>
|
||||||
<TagsSelect
|
<TagsSelect
|
||||||
id="item-usage"
|
id="item-usage"
|
||||||
v-model="itemForm.usageId"
|
v-model="itemForm.usageId"
|
||||||
@@ -180,56 +206,62 @@ onMounted(() => {
|
|||||||
:multiple="false"
|
:multiple="false"
|
||||||
allow-create
|
allow-create
|
||||||
:creating="creatingSelect === 'item-usage'"
|
:creating="creatingSelect === 'item-usage'"
|
||||||
placeholder="无"
|
:placeholder="t('common.none')"
|
||||||
search-placeholder="搜索用途"
|
:search-placeholder="t('pages.items.searchUsage')"
|
||||||
@create="createSingleOption('item-usage', 'item-usages', $event, (value) => (itemForm.usageId = value))"
|
@create="createSingleOption('item-usage', 'item-usages', $event, (value) => (itemForm.usageId = value))"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="check-row">
|
<div class="check-row">
|
||||||
<label><input v-model="itemForm.dyeable" type="checkbox" /> 可染色</label>
|
<label><input v-model="itemForm.dyeable" type="checkbox" /> {{ t('pages.items.dyeable') }}</label>
|
||||||
<label><input v-model="itemForm.dualDyeable" type="checkbox" /> 可双区染色</label>
|
<label><input v-model="itemForm.dualDyeable" type="checkbox" /> {{ t('pages.items.dualDyeable') }}</label>
|
||||||
<label><input v-model="itemForm.patternEditable" type="checkbox" /> 可改花纹</label>
|
<label><input v-model="itemForm.patternEditable" type="checkbox" /> {{ t('pages.items.patternEditable') }}</label>
|
||||||
<label><input v-model="itemForm.noRecipe" type="checkbox" :disabled="hasRecipe" /> 无材料单</label>
|
<label><input v-model="itemForm.noRecipe" type="checkbox" :disabled="hasRecipe" /> {{ t('pages.items.noRecipe') }}</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="item-methods">入手方式</label>
|
<label for="item-methods">{{ t('pages.items.acquisitionMethods') }}</label>
|
||||||
<TagsSelect
|
<TagsSelect
|
||||||
id="item-methods"
|
id="item-methods"
|
||||||
v-model="itemForm.acquisitionMethodIds"
|
v-model="itemForm.acquisitionMethodIds"
|
||||||
:options="options.acquisitionMethods"
|
:options="options.acquisitionMethods"
|
||||||
allow-create
|
allow-create
|
||||||
:creating="creatingSelect === 'item-methods'"
|
:creating="creatingSelect === 'item-methods'"
|
||||||
placeholder="搜索入手方式"
|
:placeholder="t('pages.items.searchMethods')"
|
||||||
@create="createMultiOption('item-methods', 'acquisition-methods', $event, itemForm.acquisitionMethodIds)"
|
@create="createMultiOption('item-methods', 'acquisition-methods', $event, itemForm.acquisitionMethodIds)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="item-tags">标签</label>
|
<label for="item-tags">{{ t('pages.items.tags') }}</label>
|
||||||
<TagsSelect
|
<TagsSelect
|
||||||
id="item-tags"
|
id="item-tags"
|
||||||
v-model="itemForm.tagIds"
|
v-model="itemForm.tagIds"
|
||||||
:options="options.itemTags"
|
:options="options.itemTags"
|
||||||
allow-create
|
allow-create
|
||||||
:creating="creatingSelect === 'item-tags'"
|
:creating="creatingSelect === 'item-tags'"
|
||||||
placeholder="搜索标签"
|
:placeholder="t('pages.items.searchTags')"
|
||||||
@create="createMultiOption('item-tags', 'favorite-things', $event, itemForm.tagIds)"
|
@create="createMultiOption('item-tags', 'favorite-things', $event, itemForm.tagIds)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-actions">
|
|
||||||
<button type="submit" class="link-button" :disabled="busy">{{ busy ? '保存中' : '保存' }}</button>
|
|
||||||
<RouterLink class="plain-button" :to="cancelTo">取消</RouterLink>
|
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<section v-else class="detail-section skeleton-detail-section" aria-busy="true" aria-label="正在加载物品编辑内容">
|
<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 6" :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>
|
||||||
</section>
|
</section>
|
||||||
</section>
|
|
||||||
|
<template v-if="!loading && options" #footer>
|
||||||
|
<button type="submit" form="item-edit-form" class="link-button" :disabled="busy">
|
||||||
|
<Icon :icon="iconSave" class="ui-icon" aria-hidden="true" />
|
||||||
|
{{ busy ? t('common.saving') : t('common.save') }}
|
||||||
|
</button>
|
||||||
|
<button type="button" class="plain-button" :disabled="busy" @click="closeEditor">
|
||||||
|
<Icon :icon="iconCancel" class="ui-icon" aria-hidden="true" />
|
||||||
|
{{ t('common.cancel') }}
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</Modal>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
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 { useRoute } from 'vue-router';
|
||||||
import EditMeta from '../components/EditMeta.vue';
|
import EditMeta from '../components/EditMeta.vue';
|
||||||
import EntityChips from '../components/EntityChips.vue';
|
import EntityChips from '../components/EntityChips.vue';
|
||||||
import EntityCard from '../components/EntityCard.vue';
|
import EntityCard from '../components/EntityCard.vue';
|
||||||
@@ -8,9 +11,13 @@ import PageHeader from '../components/PageHeader.vue';
|
|||||||
import Skeleton from '../components/Skeleton.vue';
|
import Skeleton from '../components/Skeleton.vue';
|
||||||
import Tabs, { type TabOption } from '../components/Tabs.vue';
|
import Tabs, { type TabOption } from '../components/Tabs.vue';
|
||||||
import TagsSelect from '../components/TagsSelect.vue';
|
import TagsSelect from '../components/TagsSelect.vue';
|
||||||
|
import { iconAdd, iconItem } from '../icons';
|
||||||
import { api, type Item, type Options } from '../services/api';
|
import { api, type Item, type Options } from '../services/api';
|
||||||
|
import ItemEdit from './ItemEdit.vue';
|
||||||
|
|
||||||
const options = ref<Options | null>(null);
|
const options = ref<Options | null>(null);
|
||||||
|
const route = useRoute();
|
||||||
|
const { t } = useI18n();
|
||||||
const items = ref<Item[]>([]);
|
const items = ref<Item[]>([]);
|
||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
const search = ref('');
|
const search = ref('');
|
||||||
@@ -23,7 +30,7 @@ const filterSkeletonWidths = ['52px', '48px', '48px'];
|
|||||||
const skeletonCardCount = 6;
|
const skeletonCardCount = 6;
|
||||||
|
|
||||||
const categoryTabs = computed<TabOption[]>(() => [
|
const categoryTabs = computed<TabOption[]>(() => [
|
||||||
{ value: '', label: '全部' },
|
{ value: '', label: t('common.all') },
|
||||||
...(options.value?.itemCategories.map((item) => ({ value: String(item.id), label: item.name })) ?? [])
|
...(options.value?.itemCategories.map((item) => ({ value: String(item.id), label: item.name })) ?? [])
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -33,6 +40,7 @@ const itemQuery = computed(() => ({
|
|||||||
usageId: usageId.value,
|
usageId: usageId.value,
|
||||||
tagIds: tagIds.value.join(',')
|
tagIds: tagIds.value.join(',')
|
||||||
}));
|
}));
|
||||||
|
const showEditor = computed(() => route.name === 'item-new');
|
||||||
|
|
||||||
async function loadItems() {
|
async function loadItems() {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
@@ -50,14 +58,17 @@ watch(itemQuery, loadItems);
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="page-stack">
|
<section class="page-stack">
|
||||||
<PageHeader title="物品" subtitle="按分类、用途、标签查看物品。">
|
<PageHeader :title="t('pages.items.title')" :subtitle="t('pages.items.subtitle')">
|
||||||
<template #kicker>Bag</template>
|
<template #kicker>Bag</template>
|
||||||
<template #actions>
|
<template #actions>
|
||||||
<RouterLink class="ui-button ui-button--primary ui-button--small" to="/items/new">新增</RouterLink>
|
<RouterLink class="ui-button ui-button--primary ui-button--small" to="/items/new">
|
||||||
|
<Icon :icon="iconAdd" class="ui-icon" aria-hidden="true" />
|
||||||
|
{{ t('common.add') }}
|
||||||
|
</RouterLink>
|
||||||
</template>
|
</template>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
<Tabs v-if="options" id="item-category" v-model="categoryId" :tabs="categoryTabs" label="分类" />
|
<Tabs v-if="options" id="item-category" v-model="categoryId" :tabs="categoryTabs" :label="t('pages.items.category')" />
|
||||||
<div v-else class="tabs tabs--component" aria-hidden="true">
|
<div v-else class="tabs tabs--component" aria-hidden="true">
|
||||||
<div class="tab-list tab-list--skeleton">
|
<div class="tab-list tab-list--skeleton">
|
||||||
<Skeleton
|
<Skeleton
|
||||||
@@ -73,25 +84,25 @@ watch(itemQuery, loadItems);
|
|||||||
|
|
||||||
<FilterPanel v-if="options">
|
<FilterPanel v-if="options">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="item-search">搜索</label>
|
<label for="item-search">{{ t('common.search') }}</label>
|
||||||
<input id="item-search" v-model="search" type="search" placeholder="名称" />
|
<input id="item-search" v-model="search" type="search" :placeholder="t('common.name')" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="usage">用途</label>
|
<label for="usage">{{ t('pages.items.usage') }}</label>
|
||||||
<TagsSelect
|
<TagsSelect
|
||||||
id="usage"
|
id="usage"
|
||||||
v-model="usageId"
|
v-model="usageId"
|
||||||
:options="options.itemUsages"
|
:options="options.itemUsages"
|
||||||
:multiple="false"
|
:multiple="false"
|
||||||
placeholder="全部"
|
:placeholder="t('common.all')"
|
||||||
search-placeholder="搜索用途"
|
:search-placeholder="t('pages.items.searchUsage')"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="tags">标签</label>
|
<label for="tags">{{ t('pages.items.tags') }}</label>
|
||||||
<TagsSelect id="tags" v-model="tagIds" :options="options.itemTags" placeholder="搜索标签" />
|
<TagsSelect id="tags" v-model="tagIds" :options="options.itemTags" :placeholder="t('pages.items.searchTags')" />
|
||||||
</div>
|
</div>
|
||||||
</FilterPanel>
|
</FilterPanel>
|
||||||
<FilterPanel v-else class="filter-panel--skeleton" aria-hidden="true">
|
<FilterPanel v-else class="filter-panel--skeleton" aria-hidden="true">
|
||||||
@@ -101,7 +112,7 @@ watch(itemQuery, loadItems);
|
|||||||
</div>
|
</div>
|
||||||
</FilterPanel>
|
</FilterPanel>
|
||||||
|
|
||||||
<div v-if="loading" class="entity-grid" aria-busy="true" aria-label="正在加载列表">
|
<div v-if="loading" class="entity-grid" aria-busy="true" :aria-label="t('pages.items.loadingList')">
|
||||||
<article v-for="index in skeletonCardCount" :key="`item-skeleton-${index}`" class="entity-card entity-card--skeleton">
|
<article v-for="index in skeletonCardCount" :key="`item-skeleton-${index}`" class="entity-card entity-card--skeleton">
|
||||||
<Skeleton variant="box" width="42px" height="42px" class="skeleton-entity-mark" />
|
<Skeleton variant="box" width="42px" height="42px" class="skeleton-entity-mark" />
|
||||||
<div class="entity-card__content">
|
<div class="entity-card__content">
|
||||||
@@ -126,11 +137,13 @@ watch(itemQuery, loadItems);
|
|||||||
:title="item.name"
|
:title="item.name"
|
||||||
:subtitle="item.usage ? `${item.category.name} · ${item.usage.name}` : item.category.name"
|
:subtitle="item.usage ? `${item.category.name} · ${item.usage.name}` : item.category.name"
|
||||||
:to="`/items/${item.id}`"
|
:to="`/items/${item.id}`"
|
||||||
marker="+"
|
:icon="iconItem"
|
||||||
>
|
>
|
||||||
<EditMeta :entity="item" />
|
<EditMeta :entity="item" />
|
||||||
<EntityChips :items="item.tags" />
|
<EntityChips :items="item.tags" />
|
||||||
</EntityCard>
|
</EntityCard>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ItemEdit v-if="showEditor" />
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { Icon } from '@iconify/vue';
|
||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
import { useRoute, useRouter } from 'vue-router';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
import PageHeader from '../components/PageHeader.vue';
|
import PageHeader from '../components/PageHeader.vue';
|
||||||
import StatusMessage from '../components/StatusMessage.vue';
|
import StatusMessage from '../components/StatusMessage.vue';
|
||||||
|
import { iconLogin } from '../icons';
|
||||||
import { api, setAuthToken } from '../services/api';
|
import { api, setAuthToken } from '../services/api';
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { t } = useI18n();
|
||||||
const email = ref('');
|
const email = ref('');
|
||||||
const password = ref('');
|
const password = ref('');
|
||||||
const busy = ref(false);
|
const busy = ref(false);
|
||||||
@@ -29,7 +33,7 @@ async function submitLogin() {
|
|||||||
: '/pokemon';
|
: '/pokemon';
|
||||||
await router.push(redirect);
|
await router.push(redirect);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
errorMessage.value = error instanceof Error && error.message ? error.message : '登录失败';
|
errorMessage.value = error instanceof Error && error.message ? error.message : t('auth.loginFailed');
|
||||||
} finally {
|
} finally {
|
||||||
busy.value = false;
|
busy.value = false;
|
||||||
}
|
}
|
||||||
@@ -39,31 +43,32 @@ async function submitLogin() {
|
|||||||
<template>
|
<template>
|
||||||
<section class="auth-page">
|
<section class="auth-page">
|
||||||
<div class="auth-panel">
|
<div class="auth-panel">
|
||||||
<PageHeader title="登录" subtitle="使用已验证邮箱进入 Pokopia Wiki">
|
<PageHeader :title="t('auth.loginTitle')" :subtitle="t('auth.loginSubtitle')">
|
||||||
<template #kicker>Trainer Pass</template>
|
<template #kicker>Trainer Pass</template>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
<form class="auth-form" @submit.prevent="submitLogin">
|
<form class="auth-form" @submit.prevent="submitLogin">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="login-email">邮箱</label>
|
<label for="login-email">{{ t('auth.email') }}</label>
|
||||||
<input id="login-email" v-model="email" autocomplete="email" required type="email" />
|
<input id="login-email" v-model="email" autocomplete="email" required type="email" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="login-password">密码</label>
|
<label for="login-password">{{ t('auth.password') }}</label>
|
||||||
<input id="login-password" v-model="password" autocomplete="current-password" required type="password" />
|
<input id="login-password" v-model="password" autocomplete="current-password" required type="password" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<StatusMessage v-if="errorMessage" variant="danger">{{ errorMessage }}</StatusMessage>
|
<StatusMessage v-if="errorMessage" variant="danger">{{ errorMessage }}</StatusMessage>
|
||||||
|
|
||||||
<button class="ui-button ui-button--primary" :disabled="busy" type="submit">
|
<button class="ui-button ui-button--primary" :disabled="busy" type="submit">
|
||||||
{{ busy ? '登录中' : '登录' }}
|
<Icon :icon="iconLogin" class="ui-icon" aria-hidden="true" />
|
||||||
|
{{ busy ? t('auth.loggingIn') : t('nav.login') }}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<p class="auth-switch">
|
<p class="auth-switch">
|
||||||
还没有账号?
|
{{ t('auth.noAccount') }}
|
||||||
<RouterLink to="/register">注册</RouterLink>
|
<RouterLink to="/register">{{ t('nav.register') }}</RouterLink>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -1,15 +1,20 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref } from 'vue';
|
import { Icon } from '@iconify/vue';
|
||||||
|
import { computed, onMounted, ref, watch } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
import { useRoute } from 'vue-router';
|
import { useRoute } from 'vue-router';
|
||||||
import DetailSection from '../components/DetailSection.vue';
|
import DetailSection from '../components/DetailSection.vue';
|
||||||
import EditMeta from '../components/EditMeta.vue';
|
import EditHistoryPanel from '../components/EditHistoryPanel.vue';
|
||||||
import EntityChips from '../components/EntityChips.vue';
|
import EntityChips from '../components/EntityChips.vue';
|
||||||
import PageHeader from '../components/PageHeader.vue';
|
import PageHeader from '../components/PageHeader.vue';
|
||||||
import Skeleton from '../components/Skeleton.vue';
|
import Skeleton from '../components/Skeleton.vue';
|
||||||
import Tabs, { type TabOption } from '../components/Tabs.vue';
|
import Tabs, { type TabOption } from '../components/Tabs.vue';
|
||||||
|
import { iconBack, iconEdit } from '../icons';
|
||||||
import { api, type PokemonDetail } from '../services/api';
|
import { api, type PokemonDetail } from '../services/api';
|
||||||
|
import PokemonEdit from './PokemonEdit.vue';
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
|
const { t } = useI18n();
|
||||||
const pokemon = ref<PokemonDetail | null>(null);
|
const pokemon = ref<PokemonDetail | null>(null);
|
||||||
const itemCategoryTab = ref('');
|
const itemCategoryTab = ref('');
|
||||||
const timeOfDays = ['早晨', '中午', '傍晚', '晚上'];
|
const timeOfDays = ['早晨', '中午', '傍晚', '晚上'];
|
||||||
@@ -35,6 +40,25 @@ function sortByOrder(values: Set<string>, order: string[]) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function timeLabel(value: string): string {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
早晨: t('appearance.morning'),
|
||||||
|
中午: t('appearance.noon'),
|
||||||
|
傍晚: t('appearance.evening'),
|
||||||
|
晚上: t('appearance.night')
|
||||||
|
};
|
||||||
|
return labels[value] ?? value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function weatherLabel(value: string): string {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
晴天: t('appearance.sunny'),
|
||||||
|
阴天: t('appearance.cloudy'),
|
||||||
|
雨天: t('appearance.rainy')
|
||||||
|
};
|
||||||
|
return labels[value] ?? value;
|
||||||
|
}
|
||||||
|
|
||||||
const habitatRows = computed<HabitatRow[]>(() => {
|
const habitatRows = computed<HabitatRow[]>(() => {
|
||||||
if (!pokemon.value) return [];
|
if (!pokemon.value) return [];
|
||||||
|
|
||||||
@@ -73,10 +97,11 @@ const habitatRows = computed<HabitatRow[]>(() => {
|
|||||||
timeOfDays: sortByOrder(row.timeOfDays, timeOfDays),
|
timeOfDays: sortByOrder(row.timeOfDays, timeOfDays),
|
||||||
weathers: sortByOrder(row.weathers, weathers),
|
weathers: sortByOrder(row.weathers, weathers),
|
||||||
rarity: row.rarity,
|
rarity: row.rarity,
|
||||||
maps: [...row.maps].sort((a, b) => a.localeCompare(b))
|
maps: [...row.maps]
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
const skillDropRows = computed(() => pokemon.value?.skills.filter((skill) => skill.itemDrop) ?? []);
|
const skillDropRows = computed(() => pokemon.value?.skills.filter((skill) => skill.itemDrop) ?? []);
|
||||||
|
const showEditor = computed(() => route.name === 'pokemon-edit');
|
||||||
const itemCategoryTabs = computed<TabOption[]>(() => {
|
const itemCategoryTabs = computed<TabOption[]>(() => {
|
||||||
const categories = new Map<string, string>();
|
const categories = new Map<string, string>();
|
||||||
|
|
||||||
@@ -84,11 +109,9 @@ const itemCategoryTabs = computed<TabOption[]>(() => {
|
|||||||
categories.set(String(item.category.id), item.category.name);
|
categories.set(String(item.category.id), item.category.name);
|
||||||
});
|
});
|
||||||
|
|
||||||
const tabs = [...categories.entries()]
|
const tabs = [...categories.entries()].map(([value, label]) => ({ value, label }));
|
||||||
.sort(([, nameA], [, nameB]) => nameA.localeCompare(nameB))
|
|
||||||
.map(([value, label]) => ({ value, label }));
|
|
||||||
|
|
||||||
return tabs.length > 1 ? [{ value: '', label: '全部' }, ...tabs] : [];
|
return tabs.length > 1 ? [{ value: '', label: t('common.all') }, ...tabs] : [];
|
||||||
});
|
});
|
||||||
const favoriteThingItems = computed(() => {
|
const favoriteThingItems = computed(() => {
|
||||||
const items = pokemon.value?.favoriteThingItems ?? [];
|
const items = pokemon.value?.favoriteThingItems ?? [];
|
||||||
@@ -100,13 +123,34 @@ const favoriteThingItems = computed(() => {
|
|||||||
return items.filter((item) => String(item.category.id) === itemCategoryTab.value);
|
return items.filter((item) => String(item.category.id) === itemCategoryTab.value);
|
||||||
});
|
});
|
||||||
|
|
||||||
onMounted(async () => {
|
async function loadPokemonDetail() {
|
||||||
pokemon.value = await api.pokemonDetail(String(route.params.id));
|
pokemon.value = await api.pokemonDetail(String(route.params.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await loadPokemonDetail();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => route.name,
|
||||||
|
(name, oldName) => {
|
||||||
|
if (oldName === 'pokemon-edit' && name === 'pokemon-detail') {
|
||||||
|
void loadPokemonDetail();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => route.params.id,
|
||||||
|
() => {
|
||||||
|
pokemon.value = null;
|
||||||
|
void loadPokemonDetail();
|
||||||
|
}
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section v-if="!pokemon" class="page-stack" aria-busy="true" aria-label="正在加载 Pokemon 详情">
|
<section v-if="!pokemon" class="page-stack" aria-busy="true" :aria-label="t('pages.pokemon.loadingDetail')">
|
||||||
<div class="page-header page-header--skeleton" aria-hidden="true">
|
<div class="page-header page-header--skeleton" aria-hidden="true">
|
||||||
<div class="page-header__copy">
|
<div class="page-header__copy">
|
||||||
<Skeleton width="142px" />
|
<Skeleton width="142px" />
|
||||||
@@ -119,7 +163,7 @@ onMounted(async () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="detail-grid" aria-hidden="true">
|
<div class="detail-grid detail-grid--stack" aria-hidden="true">
|
||||||
<section class="detail-section skeleton-detail-section">
|
<section class="detail-section skeleton-detail-section">
|
||||||
<div class="detail-section__header">
|
<div class="detail-section__header">
|
||||||
<Skeleton width="56px" height="24px" />
|
<Skeleton width="56px" height="24px" />
|
||||||
@@ -163,43 +207,47 @@ onMounted(async () => {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section v-else class="page-stack">
|
<section v-else class="page-stack">
|
||||||
<PageHeader :title="`#${pokemon.id} ${pokemon.name}`" :subtitle="`喜欢的环境:${pokemon.environment.name}`">
|
<PageHeader :title="`#${pokemon.id} ${pokemon.name}`" :subtitle="t('pages.pokemon.environmentPrefix', { name: pokemon.environment.name })">
|
||||||
<template #kicker>Pokédex Detail</template>
|
<template #kicker>Pokédex Detail</template>
|
||||||
<template #meta>
|
|
||||||
<EditMeta :entity="pokemon" />
|
|
||||||
</template>
|
|
||||||
<template #actions>
|
<template #actions>
|
||||||
<RouterLink class="ui-button ui-button--primary ui-button--small" :to="`/pokemon/${pokemon.id}/edit`">编辑</RouterLink>
|
<RouterLink class="ui-button ui-button--primary ui-button--small" :to="`/pokemon/${pokemon.id}/edit`">
|
||||||
<RouterLink class="ui-button ui-button--blue ui-button--small" to="/pokemon">返回列表</RouterLink>
|
<Icon :icon="iconEdit" class="ui-icon" aria-hidden="true" />
|
||||||
|
{{ t('common.edit') }}
|
||||||
|
</RouterLink>
|
||||||
|
<RouterLink class="ui-button ui-button--blue ui-button--small" to="/pokemon">
|
||||||
|
<Icon :icon="iconBack" class="ui-icon" aria-hidden="true" />
|
||||||
|
{{ t('common.backToList') }}
|
||||||
|
</RouterLink>
|
||||||
</template>
|
</template>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
<div class="detail-grid">
|
<div class="detail-with-sidebar">
|
||||||
<DetailSection title="特长">
|
<div class="detail-grid detail-grid--stack">
|
||||||
|
<DetailSection :title="t('pages.pokemon.skills')">
|
||||||
<EntityChips :items="pokemon.skills" />
|
<EntityChips :items="pokemon.skills" />
|
||||||
</DetailSection>
|
</DetailSection>
|
||||||
|
|
||||||
<DetailSection v-if="skillDropRows.length" title="特长掉落物">
|
<DetailSection v-if="skillDropRows.length" :title="t('pages.pokemon.skillDrops')">
|
||||||
<ul class="row-list skill-drop-summary">
|
<ul class="row-list skill-drop-summary">
|
||||||
<li v-for="skill in skillDropRows" :key="skill.id">
|
<li v-for="skill in skillDropRows" :key="skill.id">
|
||||||
<span>{{ skill.name }}掉落物</span>
|
<span>{{ t('pages.pokemon.skillDrop', { name: skill.name }) }}</span>
|
||||||
<RouterLink v-if="skill.itemDrop" :to="`/items/${skill.itemDrop.id}`">{{ skill.itemDrop.name }}</RouterLink>
|
<RouterLink v-if="skill.itemDrop" :to="`/items/${skill.itemDrop.id}`">{{ skill.itemDrop.name }}</RouterLink>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</DetailSection>
|
</DetailSection>
|
||||||
|
|
||||||
<DetailSection title="喜欢的东西">
|
<DetailSection :title="t('pages.pokemon.favoriteThings')">
|
||||||
<EntityChips :items="pokemon.favorite_things" />
|
<EntityChips :items="pokemon.favorite_things" />
|
||||||
</DetailSection>
|
</DetailSection>
|
||||||
|
|
||||||
<DetailSection title="喜欢的东西关联物品">
|
<DetailSection :title="t('pages.pokemon.relatedItems')">
|
||||||
<template v-if="pokemon.favoriteThingItems.length">
|
<template v-if="pokemon.favoriteThingItems.length">
|
||||||
<Tabs
|
<Tabs
|
||||||
v-if="itemCategoryTabs.length"
|
v-if="itemCategoryTabs.length"
|
||||||
id="pokemon-favorite-items"
|
id="pokemon-favorite-items"
|
||||||
v-model="itemCategoryTab"
|
v-model="itemCategoryTab"
|
||||||
:tabs="itemCategoryTabs"
|
:tabs="itemCategoryTabs"
|
||||||
label="关联物品分类"
|
:label="t('pages.pokemon.relatedItemCategory')"
|
||||||
/>
|
/>
|
||||||
<ul v-if="favoriteThingItems.length" class="row-list">
|
<ul v-if="favoriteThingItems.length" class="row-list">
|
||||||
<li v-for="item in favoriteThingItems" :key="item.id">
|
<li v-for="item in favoriteThingItems" :key="item.id">
|
||||||
@@ -207,30 +255,30 @@ onMounted(async () => {
|
|||||||
<EntityChips :items="item.tags" />
|
<EntityChips :items="item.tags" />
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p v-else class="meta-line">无</p>
|
<p v-else class="meta-line">{{ t('common.none') }}</p>
|
||||||
</template>
|
</template>
|
||||||
<p v-else class="meta-line">无</p>
|
<p v-else class="meta-line">{{ t('common.none') }}</p>
|
||||||
</DetailSection>
|
</DetailSection>
|
||||||
|
|
||||||
<DetailSection title="栖息地">
|
<DetailSection :title="t('pages.pokemon.habitats')">
|
||||||
<ul class="row-list appearance-list">
|
<ul class="row-list appearance-list">
|
||||||
<li v-for="habitat in habitatRows" :key="`${habitat.id}-${habitat.rarity}`">
|
<li v-for="habitat in habitatRows" :key="`${habitat.id}-${habitat.rarity}`">
|
||||||
<RouterLink class="appearance-name" :to="`/habitats/${habitat.id}`">{{ habitat.name }}</RouterLink>
|
<RouterLink class="appearance-name" :to="`/habitats/${habitat.id}`">{{ habitat.name }}</RouterLink>
|
||||||
<dl class="appearance-summary">
|
<dl class="appearance-summary">
|
||||||
<div>
|
<div>
|
||||||
<dt>时段</dt>
|
<dt>{{ t('appearance.time') }}</dt>
|
||||||
<dd>{{ habitat.timeOfDays.join(' / ') }}</dd>
|
<dd>{{ habitat.timeOfDays.map(timeLabel).join(' / ') }}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt>天气</dt>
|
<dt>{{ t('appearance.weather') }}</dt>
|
||||||
<dd>{{ habitat.weathers.join(' / ') }}</dd>
|
<dd>{{ habitat.weathers.map(weatherLabel).join(' / ') }}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt>稀有度</dt>
|
<dt>{{ t('appearance.rarity') }}</dt>
|
||||||
<dd>{{ habitat.rarity }} 星</dd>
|
<dd>{{ t('appearance.stars', { count: habitat.rarity }) }}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt>出现地图</dt>
|
<dt>{{ t('appearance.maps') }}</dt>
|
||||||
<dd>{{ habitat.maps.join(' / ') }}</dd>
|
<dd>{{ habitat.maps.join(' / ') }}</dd>
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
@@ -238,5 +286,10 @@ onMounted(async () => {
|
|||||||
</ul>
|
</ul>
|
||||||
</DetailSection>
|
</DetailSection>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<EditHistoryPanel :entity="pokemon" :history="pokemon.editHistory" />
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<PokemonEdit v-if="showEditor" />
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
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 { useRoute, useRouter } from 'vue-router';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
import PageHeader from '../components/PageHeader.vue';
|
import Modal from '../components/Modal.vue';
|
||||||
import Skeleton from '../components/Skeleton.vue';
|
import Skeleton from '../components/Skeleton.vue';
|
||||||
import StatusMessage from '../components/StatusMessage.vue';
|
import StatusMessage from '../components/StatusMessage.vue';
|
||||||
import TagsSelect from '../components/TagsSelect.vue';
|
import TagsSelect from '../components/TagsSelect.vue';
|
||||||
import { api, type ConfigType, type NamedEntity, type Options, type PokemonPayload } from '../services/api';
|
import TranslationFields from '../components/TranslationFields.vue';
|
||||||
|
import { iconCancel, iconSave } from '../icons';
|
||||||
|
import { api, type ConfigType, type Language, type NamedEntity, type Options, type PokemonPayload, type TranslationMap } from '../services/api';
|
||||||
|
|
||||||
type SkillItemDropForm = {
|
type SkillItemDropForm = {
|
||||||
skillId: string;
|
skillId: string;
|
||||||
@@ -14,8 +18,10 @@ type SkillItemDropForm = {
|
|||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { locale, t } = useI18n();
|
||||||
const options = ref<Options | null>(null);
|
const options = ref<Options | null>(null);
|
||||||
const itemOptions = ref<NamedEntity[]>([]);
|
const itemOptions = ref<NamedEntity[]>([]);
|
||||||
|
const languages = ref<Language[]>([]);
|
||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
const busy = ref(false);
|
const busy = ref(false);
|
||||||
const message = ref('');
|
const message = ref('');
|
||||||
@@ -23,6 +29,7 @@ const creatingSelect = ref('');
|
|||||||
const pokemonForm = ref({
|
const pokemonForm = ref({
|
||||||
id: '',
|
id: '',
|
||||||
name: '',
|
name: '',
|
||||||
|
translations: {} as TranslationMap,
|
||||||
environmentId: '',
|
environmentId: '',
|
||||||
skillIds: [] as string[],
|
skillIds: [] as string[],
|
||||||
favoriteThingIds: [] as string[],
|
favoriteThingIds: [] as string[],
|
||||||
@@ -31,7 +38,11 @@ const pokemonForm = ref({
|
|||||||
|
|
||||||
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 pageTitle = computed(() => (isEditing.value ? `编辑 #${pokemonForm.value.id || routeId.value} ${pokemonForm.value.name}` : '新增 Pokemon'));
|
const pageTitle = computed(() =>
|
||||||
|
isEditing.value
|
||||||
|
? t('pages.pokemon.editTitle', { id: pokemonForm.value.id || routeId.value, name: pokemonForm.value.name })
|
||||||
|
: t('pages.pokemon.newTitle')
|
||||||
|
);
|
||||||
const cancelTo = computed(() => (isEditing.value ? `/pokemon/${routeId.value}` : '/pokemon'));
|
const cancelTo = computed(() => (isEditing.value ? `/pokemon/${routeId.value}` : '/pokemon'));
|
||||||
const selectedSkillDropRows = computed(() =>
|
const selectedSkillDropRows = computed(() =>
|
||||||
pokemonForm.value.skillItemDrops.filter((row) => pokemonForm.value.skillIds.includes(row.skillId) && skillSupportsItemDrop(row.skillId))
|
pokemonForm.value.skillItemDrops.filter((row) => pokemonForm.value.skillIds.includes(row.skillId) && skillSupportsItemDrop(row.skillId))
|
||||||
@@ -46,9 +57,10 @@ function errorText(error: unknown, fallback: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadOptions() {
|
async function loadOptions() {
|
||||||
const [loadedOptions, loadedItems] = await Promise.all([api.options(), api.items({})]);
|
const [loadedOptions, loadedItems, loadedLanguages] = await Promise.all([api.options(), api.items({}), api.languages()]);
|
||||||
options.value = loadedOptions;
|
options.value = loadedOptions;
|
||||||
itemOptions.value = loadedItems.map((item) => ({ id: item.id, name: item.name }));
|
itemOptions.value = loadedItems.map((item) => ({ id: item.id, name: item.name }));
|
||||||
|
languages.value = loadedLanguages;
|
||||||
}
|
}
|
||||||
|
|
||||||
function syncSkillItemDrops() {
|
function syncSkillItemDrops() {
|
||||||
@@ -74,7 +86,20 @@ function skillSupportsItemDrop(skillId: string) {
|
|||||||
|
|
||||||
function skillDropLabel(skillId: string) {
|
function skillDropLabel(skillId: string) {
|
||||||
const name = skillName(skillId);
|
const name = skillName(skillId);
|
||||||
return name ? `${name}掉落物` : '掉落物';
|
return name ? t('pages.pokemon.skillDrop', { name }) : t('pages.pokemon.dropItem');
|
||||||
|
}
|
||||||
|
|
||||||
|
function pokemonNameForSave() {
|
||||||
|
const baseName = pokemonForm.value.name.trim();
|
||||||
|
if (baseName !== '') {
|
||||||
|
return pokemonForm.value.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
return pokemonForm.value.translations[String(locale.value || '')]?.name ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeEditor() {
|
||||||
|
void router.push(cancelTo.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadEditor() {
|
async function loadEditor() {
|
||||||
@@ -87,7 +112,8 @@ async function loadEditor() {
|
|||||||
const pokemon = await api.pokemonDetail(routeId.value);
|
const pokemon = await api.pokemonDetail(routeId.value);
|
||||||
pokemonForm.value = {
|
pokemonForm.value = {
|
||||||
id: String(pokemon.id),
|
id: String(pokemon.id),
|
||||||
name: pokemon.name,
|
name: pokemon.baseName ?? pokemon.name,
|
||||||
|
translations: pokemon.translations ?? {},
|
||||||
environmentId: String(pokemon.environment.id),
|
environmentId: String(pokemon.environment.id),
|
||||||
skillIds: pokemon.skills.map((skill) => String(skill.id)),
|
skillIds: pokemon.skills.map((skill) => String(skill.id)),
|
||||||
favoriteThingIds: pokemon.favorite_things.map((thing) => String(thing.id)),
|
favoriteThingIds: pokemon.favorite_things.map((thing) => String(thing.id)),
|
||||||
@@ -99,7 +125,7 @@ async function loadEditor() {
|
|||||||
syncSkillItemDrops();
|
syncSkillItemDrops();
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
message.value = errorText(error, '加载失败');
|
message.value = errorText(error, t('errors.loadFailed'));
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
@@ -116,7 +142,7 @@ async function createSingleOption(selectKey: string, type: ConfigType, name: str
|
|||||||
await loadOptions();
|
await loadOptions();
|
||||||
assign(String(created.id));
|
assign(String(created.id));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
message.value = errorText(error, '添加失败');
|
message.value = errorText(error, t('errors.addFailed'));
|
||||||
} finally {
|
} finally {
|
||||||
creatingSelect.value = '';
|
creatingSelect.value = '';
|
||||||
}
|
}
|
||||||
@@ -136,7 +162,7 @@ async function createMultiOption(selectKey: string, type: ConfigType, name: stri
|
|||||||
values.push(value);
|
values.push(value);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
message.value = errorText(error, '添加失败');
|
message.value = errorText(error, t('errors.addFailed'));
|
||||||
} finally {
|
} finally {
|
||||||
creatingSelect.value = '';
|
creatingSelect.value = '';
|
||||||
}
|
}
|
||||||
@@ -149,7 +175,8 @@ async function savePokemon() {
|
|||||||
try {
|
try {
|
||||||
const payload: PokemonPayload = {
|
const payload: PokemonPayload = {
|
||||||
id: Number(isEditing.value ? routeId.value : pokemonForm.value.id),
|
id: Number(isEditing.value ? routeId.value : pokemonForm.value.id),
|
||||||
name: pokemonForm.value.name,
|
name: pokemonNameForSave(),
|
||||||
|
translations: pokemonForm.value.translations,
|
||||||
environmentId: Number(pokemonForm.value.environmentId),
|
environmentId: Number(pokemonForm.value.environmentId),
|
||||||
skillIds: toIds(pokemonForm.value.skillIds.slice(0, 2)),
|
skillIds: toIds(pokemonForm.value.skillIds.slice(0, 2)),
|
||||||
favoriteThingIds: toIds(pokemonForm.value.favoriteThingIds.slice(0, 6)),
|
favoriteThingIds: toIds(pokemonForm.value.favoriteThingIds.slice(0, 6)),
|
||||||
@@ -160,7 +187,7 @@ async function savePokemon() {
|
|||||||
const saved = isEditing.value ? await api.updatePokemon(routeId.value, payload) : await api.createPokemon(payload);
|
const saved = isEditing.value ? await api.updatePokemon(routeId.value, payload) : await api.createPokemon(payload);
|
||||||
await router.push(`/pokemon/${saved.id}`);
|
await router.push(`/pokemon/${saved.id}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
message.value = errorText(error, '保存失败');
|
message.value = errorText(error, t('errors.saveFailed'));
|
||||||
} finally {
|
} finally {
|
||||||
busy.value = false;
|
busy.value = false;
|
||||||
}
|
}
|
||||||
@@ -174,29 +201,27 @@ watch(() => pokemonForm.value.skillIds.slice(), syncSkillItemDrops);
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="page-stack">
|
<Modal :title="pageTitle" :subtitle="t('pages.pokemon.editSubtitle')" :close-label="t('common.close')" size="wide" @close="closeEditor">
|
||||||
<PageHeader :title="pageTitle" subtitle="维护 Pokemon 基本资料、特长和喜欢的东西。">
|
|
||||||
<template #kicker>Pokédex Edit</template>
|
|
||||||
<template #actions>
|
|
||||||
<RouterLink class="ui-button ui-button--blue ui-button--small" :to="cancelTo">返回</RouterLink>
|
|
||||||
</template>
|
|
||||||
</PageHeader>
|
|
||||||
|
|
||||||
<StatusMessage v-if="message" variant="danger">{{ message }}</StatusMessage>
|
<StatusMessage v-if="message" variant="danger">{{ message }}</StatusMessage>
|
||||||
|
|
||||||
<form v-if="!loading && options" class="detail-section" @submit.prevent="savePokemon">
|
<form v-if="!loading && options" id="pokemon-edit-form" class="modal-edit-form" @submit.prevent="savePokemon">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="pokemon-id">ID</label>
|
<label for="pokemon-id">ID</label>
|
||||||
<input id="pokemon-id" v-model="pokemonForm.id" :disabled="isEditing" min="1" required type="number" />
|
<input id="pokemon-id" v-model="pokemonForm.id" :disabled="isEditing" min="1" required type="number" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<TranslationFields
|
||||||
<label for="pokemon-name">名字</label>
|
id-prefix="pokemon-name"
|
||||||
<input id="pokemon-name" v-model="pokemonForm.name" required />
|
v-model:base-value="pokemonForm.name"
|
||||||
</div>
|
v-model:translations="pokemonForm.translations"
|
||||||
|
field="name"
|
||||||
|
:label="t('common.name')"
|
||||||
|
:languages="languages"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="pokemon-environment">喜欢的环境</label>
|
<label for="pokemon-environment">{{ t('pages.pokemon.environment') }}</label>
|
||||||
<TagsSelect
|
<TagsSelect
|
||||||
id="pokemon-environment"
|
id="pokemon-environment"
|
||||||
v-model="pokemonForm.environmentId"
|
v-model="pokemonForm.environmentId"
|
||||||
@@ -204,14 +229,14 @@ watch(() => pokemonForm.value.skillIds.slice(), syncSkillItemDrops);
|
|||||||
:multiple="false"
|
:multiple="false"
|
||||||
allow-create
|
allow-create
|
||||||
:creating="creatingSelect === 'pokemon-environment'"
|
:creating="creatingSelect === 'pokemon-environment'"
|
||||||
placeholder="请选择"
|
:placeholder="t('common.select')"
|
||||||
search-placeholder="搜索喜欢的环境"
|
:search-placeholder="t('pages.pokemon.searchEnvironment')"
|
||||||
@create="createSingleOption('pokemon-environment', 'environments', $event, (value) => (pokemonForm.environmentId = value))"
|
@create="createSingleOption('pokemon-environment', 'environments', $event, (value) => (pokemonForm.environmentId = value))"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="pokemon-skills">特长</label>
|
<label for="pokemon-skills">{{ t('pages.pokemon.skills') }}</label>
|
||||||
<TagsSelect
|
<TagsSelect
|
||||||
id="pokemon-skills"
|
id="pokemon-skills"
|
||||||
v-model="pokemonForm.skillIds"
|
v-model="pokemonForm.skillIds"
|
||||||
@@ -219,13 +244,13 @@ watch(() => pokemonForm.value.skillIds.slice(), syncSkillItemDrops);
|
|||||||
:max="2"
|
:max="2"
|
||||||
allow-create
|
allow-create
|
||||||
:creating="creatingSelect === 'pokemon-skills'"
|
:creating="creatingSelect === 'pokemon-skills'"
|
||||||
placeholder="搜索特长"
|
:placeholder="t('pages.pokemon.searchSkills')"
|
||||||
@create="createMultiOption('pokemon-skills', 'skills', $event, pokemonForm.skillIds, 2)"
|
@create="createMultiOption('pokemon-skills', 'skills', $event, pokemonForm.skillIds, 2)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="pokemon-things">喜欢的东西</label>
|
<label for="pokemon-things">{{ t('pages.pokemon.favoriteThings') }}</label>
|
||||||
<TagsSelect
|
<TagsSelect
|
||||||
id="pokemon-things"
|
id="pokemon-things"
|
||||||
v-model="pokemonForm.favoriteThingIds"
|
v-model="pokemonForm.favoriteThingIds"
|
||||||
@@ -233,13 +258,13 @@ watch(() => pokemonForm.value.skillIds.slice(), syncSkillItemDrops);
|
|||||||
:max="6"
|
:max="6"
|
||||||
allow-create
|
allow-create
|
||||||
:creating="creatingSelect === 'pokemon-things'"
|
:creating="creatingSelect === 'pokemon-things'"
|
||||||
placeholder="搜索喜欢的东西"
|
:placeholder="t('pages.pokemon.searchFavoriteThings')"
|
||||||
@create="createMultiOption('pokemon-things', 'favorite-things', $event, pokemonForm.favoriteThingIds, 6)"
|
@create="createMultiOption('pokemon-things', 'favorite-things', $event, pokemonForm.favoriteThingIds, 6)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="selectedSkillDropRows.length" class="field">
|
<div v-if="selectedSkillDropRows.length" class="field">
|
||||||
<span class="field-label">特长掉落物</span>
|
<span class="field-label">{{ t('pages.pokemon.skillDrops') }}</span>
|
||||||
<div class="skill-drop-list">
|
<div class="skill-drop-list">
|
||||||
<div v-for="row in selectedSkillDropRows" :key="row.skillId" class="skill-drop-row">
|
<div v-for="row in selectedSkillDropRows" :key="row.skillId" class="skill-drop-row">
|
||||||
<label :for="`pokemon-skill-drops-${row.skillId}`">{{ skillDropLabel(row.skillId) }}</label>
|
<label :for="`pokemon-skill-drops-${row.skillId}`">{{ skillDropLabel(row.skillId) }}</label>
|
||||||
@@ -248,24 +273,30 @@ watch(() => pokemonForm.value.skillIds.slice(), syncSkillItemDrops);
|
|||||||
v-model="row.itemId"
|
v-model="row.itemId"
|
||||||
:options="itemOptions"
|
:options="itemOptions"
|
||||||
:multiple="false"
|
:multiple="false"
|
||||||
placeholder="选择掉落物品"
|
:placeholder="t('pages.pokemon.dropItem')"
|
||||||
search-placeholder="搜索物品"
|
:search-placeholder="t('pages.pokemon.searchItems')"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-actions">
|
|
||||||
<button type="submit" class="link-button" :disabled="busy">{{ busy ? '保存中' : '保存' }}</button>
|
|
||||||
<RouterLink class="plain-button" :to="cancelTo">取消</RouterLink>
|
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<section v-else class="detail-section skeleton-detail-section" aria-busy="true" aria-label="正在加载 Pokemon 编辑内容">
|
<section v-else class="modal-edit-form skeleton-detail-section" aria-busy="true" :aria-label="t('pages.pokemon.loadingEdit')">
|
||||||
<div v-for="index in 5" :key="index" class="field">
|
<div v-for="index in 5" :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>
|
||||||
</section>
|
</section>
|
||||||
</section>
|
|
||||||
|
<template v-if="!loading && options" #footer>
|
||||||
|
<button type="submit" form="pokemon-edit-form" class="link-button" :disabled="busy">
|
||||||
|
<Icon :icon="iconSave" class="ui-icon" aria-hidden="true" />
|
||||||
|
{{ busy ? t('common.saving') : t('common.save') }}
|
||||||
|
</button>
|
||||||
|
<button type="button" class="plain-button" :disabled="busy" @click="closeEditor">
|
||||||
|
<Icon :icon="iconCancel" class="ui-icon" aria-hidden="true" />
|
||||||
|
{{ t('common.cancel') }}
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</Modal>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
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 { useRoute } from 'vue-router';
|
||||||
import EditMeta from '../components/EditMeta.vue';
|
import EditMeta from '../components/EditMeta.vue';
|
||||||
import EntityChips from '../components/EntityChips.vue';
|
import EntityChips from '../components/EntityChips.vue';
|
||||||
import EntityCard from '../components/EntityCard.vue';
|
import EntityCard from '../components/EntityCard.vue';
|
||||||
@@ -7,9 +10,13 @@ import FilterPanel from '../components/FilterPanel.vue';
|
|||||||
import PageHeader from '../components/PageHeader.vue';
|
import PageHeader from '../components/PageHeader.vue';
|
||||||
import Skeleton from '../components/Skeleton.vue';
|
import Skeleton from '../components/Skeleton.vue';
|
||||||
import TagsSelect from '../components/TagsSelect.vue';
|
import TagsSelect from '../components/TagsSelect.vue';
|
||||||
|
import { iconAdd } from '../icons';
|
||||||
import { api, type Options, type Pokemon } from '../services/api';
|
import { api, type Options, type Pokemon } from '../services/api';
|
||||||
|
import PokemonEdit from './PokemonEdit.vue';
|
||||||
|
|
||||||
const options = ref<Options | null>(null);
|
const options = ref<Options | null>(null);
|
||||||
|
const route = useRoute();
|
||||||
|
const { t } = useI18n();
|
||||||
const pokemon = ref<Pokemon[]>([]);
|
const pokemon = ref<Pokemon[]>([]);
|
||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
const search = ref('');
|
const search = ref('');
|
||||||
@@ -29,6 +36,7 @@ const query = computed(() => ({
|
|||||||
favoriteThingIds: favoriteThingIds.value.join(','),
|
favoriteThingIds: favoriteThingIds.value.join(','),
|
||||||
favoriteThingMode: favoriteThingMode.value
|
favoriteThingMode: favoriteThingMode.value
|
||||||
}));
|
}));
|
||||||
|
const showEditor = computed(() => route.name === 'pokemon-new');
|
||||||
|
|
||||||
async function loadPokemon() {
|
async function loadPokemon() {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
@@ -46,49 +54,57 @@ watch(query, loadPokemon);
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="page-stack">
|
<section class="page-stack">
|
||||||
<PageHeader title="Pokemon" subtitle="搜索宝可梦,并按特长、环境、喜欢的东西筛选。">
|
<PageHeader :title="t('pages.pokemon.title')" :subtitle="t('pages.pokemon.subtitle')">
|
||||||
<template #kicker>Pokédex</template>
|
<template #kicker>Pokédex</template>
|
||||||
<template #actions>
|
<template #actions>
|
||||||
<RouterLink class="ui-button ui-button--primary ui-button--small" to="/pokemon/new">新增</RouterLink>
|
<RouterLink class="ui-button ui-button--primary ui-button--small" to="/pokemon/new">
|
||||||
|
<Icon :icon="iconAdd" class="ui-icon" aria-hidden="true" />
|
||||||
|
{{ t('common.add') }}
|
||||||
|
</RouterLink>
|
||||||
</template>
|
</template>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
<FilterPanel v-if="options">
|
<FilterPanel v-if="options">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="pokemon-search">搜索</label>
|
<label for="pokemon-search">{{ t('common.search') }}</label>
|
||||||
<input id="pokemon-search" v-model="search" type="search" placeholder="名字" />
|
<input id="pokemon-search" v-model="search" type="search" :placeholder="t('pages.pokemon.namePlaceholder')" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="environment">喜欢的环境</label>
|
<label for="environment">{{ t('pages.pokemon.environment') }}</label>
|
||||||
<TagsSelect
|
<TagsSelect
|
||||||
id="environment"
|
id="environment"
|
||||||
v-model="environmentId"
|
v-model="environmentId"
|
||||||
:options="options.environments"
|
:options="options.environments"
|
||||||
:multiple="false"
|
:multiple="false"
|
||||||
placeholder="全部"
|
:placeholder="t('common.all')"
|
||||||
search-placeholder="搜索喜欢的环境"
|
:search-placeholder="t('pages.pokemon.searchEnvironment')"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="skills">特长</label>
|
<label for="skills">{{ t('pages.pokemon.skills') }}</label>
|
||||||
<TagsSelect id="skills" v-model="skillIds" :options="options.skills" placeholder="搜索特长" />
|
<TagsSelect id="skills" v-model="skillIds" :options="options.skills" :placeholder="t('pages.pokemon.searchSkills')" />
|
||||||
<div class="segmented" aria-label="特长匹配方式">
|
<div class="segmented" :aria-label="t('pages.pokemon.skillMatchMode')">
|
||||||
<button :class="{ active: skillMode === 'any' }" type="button" @click="skillMode = 'any'">任意</button>
|
<button :class="{ active: skillMode === 'any' }" type="button" @click="skillMode = 'any'">{{ t('pages.pokemon.any') }}</button>
|
||||||
<button :class="{ active: skillMode === 'all' }" type="button" @click="skillMode = 'all'">全部</button>
|
<button :class="{ active: skillMode === 'all' }" type="button" @click="skillMode = 'all'">{{ t('pages.pokemon.all') }}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="favorite-things">喜欢的东西</label>
|
<label for="favorite-things">{{ t('pages.pokemon.favoriteThings') }}</label>
|
||||||
<TagsSelect id="favorite-things" v-model="favoriteThingIds" :options="options.favoriteThings" placeholder="搜索喜欢的东西" />
|
<TagsSelect
|
||||||
<div class="segmented" aria-label="喜欢的东西匹配方式">
|
id="favorite-things"
|
||||||
|
v-model="favoriteThingIds"
|
||||||
|
:options="options.favoriteThings"
|
||||||
|
:placeholder="t('pages.pokemon.searchFavoriteThings')"
|
||||||
|
/>
|
||||||
|
<div class="segmented" :aria-label="t('pages.pokemon.favoriteThingMatchMode')">
|
||||||
<button :class="{ active: favoriteThingMode === 'any' }" type="button" @click="favoriteThingMode = 'any'">
|
<button :class="{ active: favoriteThingMode === 'any' }" type="button" @click="favoriteThingMode = 'any'">
|
||||||
任意
|
{{ t('pages.pokemon.any') }}
|
||||||
</button>
|
</button>
|
||||||
<button :class="{ active: favoriteThingMode === 'all' }" type="button" @click="favoriteThingMode = 'all'">
|
<button :class="{ active: favoriteThingMode === 'all' }" type="button" @click="favoriteThingMode = 'all'">
|
||||||
全部
|
{{ t('pages.pokemon.all') }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -104,7 +120,7 @@ watch(query, loadPokemon);
|
|||||||
</div>
|
</div>
|
||||||
</FilterPanel>
|
</FilterPanel>
|
||||||
|
|
||||||
<div v-if="loading" class="entity-grid" aria-busy="true" aria-label="正在加载 Pokemon 列表">
|
<div v-if="loading" class="entity-grid" aria-busy="true" :aria-label="t('pages.pokemon.loadingList')">
|
||||||
<article v-for="index in skeletonCardCount" :key="index" class="entity-card entity-card--skeleton">
|
<article v-for="index in skeletonCardCount" :key="index" class="entity-card entity-card--skeleton">
|
||||||
<Skeleton variant="box" width="42px" height="42px" class="skeleton-entity-mark" />
|
<Skeleton variant="box" width="42px" height="42px" class="skeleton-entity-mark" />
|
||||||
<div class="entity-card__content">
|
<div class="entity-card__content">
|
||||||
@@ -125,7 +141,7 @@ watch(query, loadPokemon);
|
|||||||
v-for="item in pokemon"
|
v-for="item in pokemon"
|
||||||
:key="item.id"
|
:key="item.id"
|
||||||
:title="`#${item.id} ${item.name}`"
|
:title="`#${item.id} ${item.name}`"
|
||||||
:subtitle="`喜欢的环境:${item.environment.name}`"
|
:subtitle="t('pages.pokemon.environmentPrefix', { name: item.environment.name })"
|
||||||
:to="`/pokemon/${item.id}`"
|
:to="`/pokemon/${item.id}`"
|
||||||
>
|
>
|
||||||
<EditMeta :entity="item" />
|
<EditMeta :entity="item" />
|
||||||
@@ -133,5 +149,7 @@ watch(query, loadPokemon);
|
|||||||
<EntityChips :items="item.favorite_things" />
|
<EntityChips :items="item.favorite_things" />
|
||||||
</EntityCard>
|
</EntityCard>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<PokemonEdit v-if="showEditor" />
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,23 +1,50 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, ref } from 'vue';
|
import { Icon } from '@iconify/vue';
|
||||||
|
import { computed, onMounted, ref, watch } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
import { useRoute } from 'vue-router';
|
import { useRoute } from 'vue-router';
|
||||||
import DetailSection from '../components/DetailSection.vue';
|
import DetailSection from '../components/DetailSection.vue';
|
||||||
import EditMeta from '../components/EditMeta.vue';
|
import EditHistoryPanel from '../components/EditHistoryPanel.vue';
|
||||||
import EntityChips from '../components/EntityChips.vue';
|
import EntityChips from '../components/EntityChips.vue';
|
||||||
import PageHeader from '../components/PageHeader.vue';
|
import PageHeader from '../components/PageHeader.vue';
|
||||||
import Skeleton from '../components/Skeleton.vue';
|
import Skeleton from '../components/Skeleton.vue';
|
||||||
|
import { iconBack, iconEdit } from '../icons';
|
||||||
import { api, type RecipeDetail } from '../services/api';
|
import { api, type RecipeDetail } from '../services/api';
|
||||||
|
import RecipeEdit from './RecipeEdit.vue';
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
|
const { t } = useI18n();
|
||||||
const recipe = ref<RecipeDetail | null>(null);
|
const recipe = ref<RecipeDetail | null>(null);
|
||||||
|
const showEditor = computed(() => route.name === 'recipe-edit');
|
||||||
|
|
||||||
|
async function loadRecipeDetail() {
|
||||||
|
recipe.value = await api.recipeDetail(String(route.params.id));
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
recipe.value = await api.recipeDetail(String(route.params.id));
|
await loadRecipeDetail();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => route.name,
|
||||||
|
(name, oldName) => {
|
||||||
|
if (oldName === 'recipe-edit' && name === 'recipe-detail') {
|
||||||
|
void loadRecipeDetail();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => route.params.id,
|
||||||
|
() => {
|
||||||
|
recipe.value = null;
|
||||||
|
void loadRecipeDetail();
|
||||||
|
}
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section v-if="!recipe" class="page-stack" aria-busy="true" aria-label="正在加载材料单详情">
|
<section v-if="!recipe" class="page-stack" aria-busy="true" :aria-label="t('pages.recipes.loadingDetail')">
|
||||||
<div class="page-header page-header--skeleton" aria-hidden="true">
|
<div class="page-header page-header--skeleton" aria-hidden="true">
|
||||||
<div class="page-header__copy">
|
<div class="page-header__copy">
|
||||||
<Skeleton width="112px" />
|
<Skeleton width="112px" />
|
||||||
@@ -44,25 +71,34 @@ onMounted(async () => {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section v-else class="page-stack">
|
<section v-else class="page-stack">
|
||||||
<PageHeader :title="recipe.name" subtitle="材料单详情">
|
<PageHeader :title="recipe.name" :subtitle="t('pages.recipes.detailSubtitle')">
|
||||||
<template #kicker>Recipe Detail</template>
|
<template #kicker>Recipe Detail</template>
|
||||||
<template #meta>
|
|
||||||
<EditMeta :entity="recipe" />
|
|
||||||
</template>
|
|
||||||
<template #actions>
|
<template #actions>
|
||||||
<RouterLink class="ui-button ui-button--primary ui-button--small" :to="`/recipes/${recipe.id}/edit`">编辑</RouterLink>
|
<RouterLink class="ui-button ui-button--primary ui-button--small" :to="`/recipes/${recipe.id}/edit`">
|
||||||
<RouterLink class="ui-button ui-button--blue ui-button--small" to="/recipes">返回列表</RouterLink>
|
<Icon :icon="iconEdit" class="ui-icon" aria-hidden="true" />
|
||||||
|
{{ t('common.edit') }}
|
||||||
|
</RouterLink>
|
||||||
|
<RouterLink class="ui-button ui-button--blue ui-button--small" to="/recipes">
|
||||||
|
<Icon :icon="iconBack" class="ui-icon" aria-hidden="true" />
|
||||||
|
{{ t('common.backToList') }}
|
||||||
|
</RouterLink>
|
||||||
</template>
|
</template>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
|
<div class="detail-with-sidebar">
|
||||||
<div class="detail-grid">
|
<div class="detail-grid">
|
||||||
<DetailSection title="入手方式">
|
<DetailSection :title="t('pages.items.acquisitionMethods')">
|
||||||
<EntityChips :items="recipe.acquisition_methods" />
|
<EntityChips :items="recipe.acquisition_methods" />
|
||||||
</DetailSection>
|
</DetailSection>
|
||||||
|
|
||||||
<DetailSection title="需要材料">
|
<DetailSection :title="t('pages.recipes.materials')">
|
||||||
<EntityChips :items="recipe.materials" />
|
<EntityChips :items="recipe.materials" />
|
||||||
</DetailSection>
|
</DetailSection>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<EditHistoryPanel :entity="recipe" :history="recipe.editHistory" />
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<RecipeEdit v-if="showEditor" />
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,14 +1,18 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { Icon } from '@iconify/vue';
|
||||||
import { computed, onMounted, ref } from 'vue';
|
import { computed, onMounted, ref } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
import { useRoute, useRouter } from 'vue-router';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
import PageHeader from '../components/PageHeader.vue';
|
import Modal from '../components/Modal.vue';
|
||||||
import Skeleton from '../components/Skeleton.vue';
|
import Skeleton from '../components/Skeleton.vue';
|
||||||
import StatusMessage from '../components/StatusMessage.vue';
|
import StatusMessage from '../components/StatusMessage.vue';
|
||||||
import TagsSelect from '../components/TagsSelect.vue';
|
import TagsSelect from '../components/TagsSelect.vue';
|
||||||
|
import { iconAdd, iconCancel, iconDelete, iconSave } from '../icons';
|
||||||
import { api, type ConfigType, type Item, type Options, type RecipePayload } from '../services/api';
|
import { api, type ConfigType, type Item, type Options, type RecipePayload } from '../services/api';
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { t } = useI18n();
|
||||||
const options = ref<Options | null>(null);
|
const options = ref<Options | null>(null);
|
||||||
const itemRows = ref<Item[]>([]);
|
const itemRows = ref<Item[]>([]);
|
||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
@@ -30,7 +34,11 @@ const resultItemOptions = computed(() =>
|
|||||||
.map((item) => ({ id: item.id, name: item.name }))
|
.map((item) => ({ id: item.id, name: item.name }))
|
||||||
);
|
);
|
||||||
const selectedItemName = computed(() => resultItemOptions.value.find((item) => String(item.id) === recipeForm.value.itemId)?.name ?? '');
|
const selectedItemName = computed(() => resultItemOptions.value.find((item) => String(item.id) === recipeForm.value.itemId)?.name ?? '');
|
||||||
const pageTitle = computed(() => (isEditing.value ? `编辑 ${selectedItemName.value || '材料单'}` : '新增材料单'));
|
const pageTitle = computed(() =>
|
||||||
|
isEditing.value
|
||||||
|
? t('pages.recipes.editTitle', { name: selectedItemName.value || t('pages.recipes.fallbackName') })
|
||||||
|
: t('pages.recipes.newTitle')
|
||||||
|
);
|
||||||
const cancelTo = computed(() => (isEditing.value ? `/recipes/${routeId.value}` : '/recipes'));
|
const cancelTo = computed(() => (isEditing.value ? `/recipes/${routeId.value}` : '/recipes'));
|
||||||
|
|
||||||
function toIds(values: string[]): number[] {
|
function toIds(values: string[]): number[] {
|
||||||
@@ -47,6 +55,10 @@ function errorText(error: unknown, fallback: string) {
|
|||||||
return error instanceof Error && error.message ? error.message : fallback;
|
return error instanceof Error && error.message ? error.message : fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function closeEditor() {
|
||||||
|
void router.push(cancelTo.value);
|
||||||
|
}
|
||||||
|
|
||||||
function preselectedItemId() {
|
function preselectedItemId() {
|
||||||
const itemId = route.query.itemId;
|
const itemId = route.query.itemId;
|
||||||
if (typeof itemId !== 'string') {
|
if (typeof itemId !== 'string') {
|
||||||
@@ -76,7 +88,7 @@ async function loadEditor() {
|
|||||||
recipeForm.value.itemId = preselectedItemId();
|
recipeForm.value.itemId = preselectedItemId();
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
message.value = errorText(error, '加载失败');
|
message.value = errorText(error, t('errors.loadFailed'));
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
@@ -104,7 +116,7 @@ async function createMultiOption(selectKey: string, type: ConfigType, name: stri
|
|||||||
values.push(value);
|
values.push(value);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
message.value = errorText(error, '添加失败');
|
message.value = errorText(error, t('errors.addFailed'));
|
||||||
} finally {
|
} finally {
|
||||||
creatingSelect.value = '';
|
creatingSelect.value = '';
|
||||||
}
|
}
|
||||||
@@ -123,7 +135,7 @@ async function saveRecipe() {
|
|||||||
const saved = isEditing.value ? await api.updateRecipe(routeId.value, payload) : await api.createRecipe(payload);
|
const saved = isEditing.value ? await api.updateRecipe(routeId.value, payload) : await api.createRecipe(payload);
|
||||||
await router.push(`/recipes/${saved.id}`);
|
await router.push(`/recipes/${saved.id}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
message.value = errorText(error, '保存失败');
|
message.value = errorText(error, t('errors.saveFailed'));
|
||||||
} finally {
|
} finally {
|
||||||
busy.value = false;
|
busy.value = false;
|
||||||
}
|
}
|
||||||
@@ -135,70 +147,75 @@ onMounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="page-stack">
|
<Modal :title="pageTitle" :subtitle="t('pages.recipes.editSubtitle')" :close-label="t('common.close')" size="wide" @close="closeEditor">
|
||||||
<PageHeader :title="pageTitle" subtitle="维护材料单结果物品、入手方式和需要材料。">
|
|
||||||
<template #kicker>Recipe Edit</template>
|
|
||||||
<template #actions>
|
|
||||||
<RouterLink class="ui-button ui-button--blue ui-button--small" :to="cancelTo">返回</RouterLink>
|
|
||||||
</template>
|
|
||||||
</PageHeader>
|
|
||||||
|
|
||||||
<StatusMessage v-if="message" variant="danger">{{ message }}</StatusMessage>
|
<StatusMessage v-if="message" variant="danger">{{ message }}</StatusMessage>
|
||||||
|
|
||||||
<form v-if="!loading && options" class="detail-section" @submit.prevent="saveRecipe">
|
<form v-if="!loading && options" id="recipe-edit-form" class="modal-edit-form" @submit.prevent="saveRecipe">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="recipe-item">物品</label>
|
<label for="recipe-item">{{ t('pages.recipes.item') }}</label>
|
||||||
<TagsSelect
|
<TagsSelect
|
||||||
id="recipe-item"
|
id="recipe-item"
|
||||||
v-model="recipeForm.itemId"
|
v-model="recipeForm.itemId"
|
||||||
:options="resultItemOptions"
|
:options="resultItemOptions"
|
||||||
:multiple="false"
|
:multiple="false"
|
||||||
placeholder="请选择"
|
:placeholder="t('common.select')"
|
||||||
search-placeholder="搜索物品"
|
:search-placeholder="t('pages.pokemon.searchItems')"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="recipe-methods">入手方式</label>
|
<label for="recipe-methods">{{ t('pages.items.acquisitionMethods') }}</label>
|
||||||
<TagsSelect
|
<TagsSelect
|
||||||
id="recipe-methods"
|
id="recipe-methods"
|
||||||
v-model="recipeForm.acquisitionMethodIds"
|
v-model="recipeForm.acquisitionMethodIds"
|
||||||
:options="options.acquisitionMethods"
|
:options="options.acquisitionMethods"
|
||||||
allow-create
|
allow-create
|
||||||
:creating="creatingSelect === 'recipe-methods'"
|
:creating="creatingSelect === 'recipe-methods'"
|
||||||
placeholder="搜索入手方式"
|
:placeholder="t('pages.items.searchMethods')"
|
||||||
@create="createMultiOption('recipe-methods', 'acquisition-methods', $event, recipeForm.acquisitionMethodIds)"
|
@create="createMultiOption('recipe-methods', 'acquisition-methods', $event, recipeForm.acquisitionMethodIds)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>需要材料</label>
|
<label>{{ t('pages.recipes.materials') }}</label>
|
||||||
<div v-for="(row, index) in recipeForm.materials" :key="index" class="inline-row">
|
<div v-for="(row, index) in recipeForm.materials" :key="index" class="inline-row">
|
||||||
<TagsSelect
|
<TagsSelect
|
||||||
:id="`recipe-material-${index}`"
|
:id="`recipe-material-${index}`"
|
||||||
v-model="row.itemId"
|
v-model="row.itemId"
|
||||||
:options="materialItemOptions"
|
:options="materialItemOptions"
|
||||||
:multiple="false"
|
:multiple="false"
|
||||||
placeholder="请选择"
|
:placeholder="t('common.select')"
|
||||||
search-placeholder="搜索物品"
|
:search-placeholder="t('pages.pokemon.searchItems')"
|
||||||
/>
|
/>
|
||||||
<input v-model.number="row.quantity" aria-label="数量" type="number" min="1" />
|
<input v-model.number="row.quantity" :aria-label="t('common.quantity')" type="number" min="1" />
|
||||||
<button type="button" @click="recipeForm.materials.splice(index, 1)">删除</button>
|
<button type="button" @click="recipeForm.materials.splice(index, 1)">
|
||||||
|
<Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" />
|
||||||
|
{{ t('common.delete') }}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" class="plain-button" @click="addRecipeMaterial">添加材料</button>
|
<button type="button" class="plain-button" @click="addRecipeMaterial">
|
||||||
</div>
|
<Icon :icon="iconAdd" class="ui-icon" aria-hidden="true" />
|
||||||
|
{{ t('pages.recipes.addMaterial') }}
|
||||||
<div class="form-actions">
|
</button>
|
||||||
<button type="submit" class="link-button" :disabled="busy">{{ busy ? '保存中' : '保存' }}</button>
|
|
||||||
<RouterLink class="plain-button" :to="cancelTo">取消</RouterLink>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<section v-else class="detail-section skeleton-detail-section" aria-busy="true" aria-label="正在加载材料单编辑内容">
|
<section v-else class="modal-edit-form skeleton-detail-section" aria-busy="true" :aria-label="t('pages.recipes.loadingEdit')">
|
||||||
<div v-for="index in 4" :key="index" class="field">
|
<div v-for="index in 4" :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>
|
||||||
</section>
|
</section>
|
||||||
</section>
|
|
||||||
|
<template v-if="!loading && options" #footer>
|
||||||
|
<button type="submit" form="recipe-edit-form" class="link-button" :disabled="busy">
|
||||||
|
<Icon :icon="iconSave" class="ui-icon" aria-hidden="true" />
|
||||||
|
{{ busy ? t('common.saving') : t('common.save') }}
|
||||||
|
</button>
|
||||||
|
<button type="button" class="plain-button" :disabled="busy" @click="closeEditor">
|
||||||
|
<Icon :icon="iconCancel" class="ui-icon" aria-hidden="true" />
|
||||||
|
{{ t('common.cancel') }}
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</Modal>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
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 { useRoute } from 'vue-router';
|
||||||
import EditMeta from '../components/EditMeta.vue';
|
import EditMeta from '../components/EditMeta.vue';
|
||||||
import EntityCard from '../components/EntityCard.vue';
|
import EntityCard from '../components/EntityCard.vue';
|
||||||
import FilterPanel from '../components/FilterPanel.vue';
|
import FilterPanel from '../components/FilterPanel.vue';
|
||||||
@@ -7,9 +10,13 @@ import PageHeader from '../components/PageHeader.vue';
|
|||||||
import Skeleton from '../components/Skeleton.vue';
|
import Skeleton from '../components/Skeleton.vue';
|
||||||
import Tabs, { type TabOption } from '../components/Tabs.vue';
|
import Tabs, { type TabOption } from '../components/Tabs.vue';
|
||||||
import TagsSelect from '../components/TagsSelect.vue';
|
import TagsSelect from '../components/TagsSelect.vue';
|
||||||
|
import { iconAdd, iconNoRecipe, iconRecipe } from '../icons';
|
||||||
import { api, type Item, type Options } from '../services/api';
|
import { api, type Item, type Options } from '../services/api';
|
||||||
|
import RecipeEdit from './RecipeEdit.vue';
|
||||||
|
|
||||||
const options = ref<Options | null>(null);
|
const options = ref<Options | null>(null);
|
||||||
|
const route = useRoute();
|
||||||
|
const { t } = useI18n();
|
||||||
const items = ref<Item[]>([]);
|
const items = ref<Item[]>([]);
|
||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
const search = ref('');
|
const search = ref('');
|
||||||
@@ -22,7 +29,7 @@ const filterSkeletonWidths = ['52px', '48px', '48px'];
|
|||||||
const skeletonCardCount = 6;
|
const skeletonCardCount = 6;
|
||||||
|
|
||||||
const categoryTabs = computed<TabOption[]>(() => [
|
const categoryTabs = computed<TabOption[]>(() => [
|
||||||
{ value: '', label: '全部' },
|
{ value: '', label: t('common.all') },
|
||||||
...(options.value?.itemCategories.map((item) => ({ value: String(item.id), label: item.name })) ?? [])
|
...(options.value?.itemCategories.map((item) => ({ value: String(item.id), label: item.name })) ?? [])
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -30,8 +37,10 @@ const itemQuery = computed(() => ({
|
|||||||
search: search.value,
|
search: search.value,
|
||||||
categoryId: categoryId.value,
|
categoryId: categoryId.value,
|
||||||
usageId: usageId.value,
|
usageId: usageId.value,
|
||||||
tagIds: tagIds.value.join(',')
|
tagIds: tagIds.value.join(','),
|
||||||
|
recipeOrder: 1
|
||||||
}));
|
}));
|
||||||
|
const showEditor = computed(() => route.name === 'recipe-new');
|
||||||
|
|
||||||
function recipeTarget(item: Item) {
|
function recipeTarget(item: Item) {
|
||||||
return item.recipe ? `/recipes/${item.recipe.id}` : undefined;
|
return item.recipe ? `/recipes/${item.recipe.id}` : undefined;
|
||||||
@@ -45,12 +54,12 @@ function createRecipeTarget(item: Item) {
|
|||||||
return `/recipes/new?itemId=${item.id}`;
|
return `/recipes/new?itemId=${item.id}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function itemMarker(item: Item) {
|
function itemIcon(item: Item) {
|
||||||
if (item.recipe) {
|
if (item.recipe) {
|
||||||
return '▦';
|
return iconRecipe;
|
||||||
}
|
}
|
||||||
|
|
||||||
return item.noRecipe ? '×' : '+';
|
return item.noRecipe ? iconNoRecipe : iconAdd;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadItems() {
|
async function loadItems() {
|
||||||
@@ -69,14 +78,17 @@ watch(itemQuery, loadItems);
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="page-stack">
|
<section class="page-stack">
|
||||||
<PageHeader title="材料单" subtitle="按分类、用途、标签查看材料单。">
|
<PageHeader :title="t('pages.recipes.title')" :subtitle="t('pages.recipes.subtitle')">
|
||||||
<template #kicker>Recipes</template>
|
<template #kicker>Recipes</template>
|
||||||
<template #actions>
|
<template #actions>
|
||||||
<RouterLink class="ui-button ui-button--primary ui-button--small" to="/recipes/new">新增</RouterLink>
|
<RouterLink class="ui-button ui-button--primary ui-button--small" to="/recipes/new">
|
||||||
|
<Icon :icon="iconAdd" class="ui-icon" aria-hidden="true" />
|
||||||
|
{{ t('common.add') }}
|
||||||
|
</RouterLink>
|
||||||
</template>
|
</template>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
<Tabs v-if="options" id="recipe-category" v-model="categoryId" :tabs="categoryTabs" label="分类" />
|
<Tabs v-if="options" id="recipe-category" v-model="categoryId" :tabs="categoryTabs" :label="t('pages.items.category')" />
|
||||||
<div v-else class="tabs tabs--component" aria-hidden="true">
|
<div v-else class="tabs tabs--component" aria-hidden="true">
|
||||||
<div class="tab-list tab-list--skeleton">
|
<div class="tab-list tab-list--skeleton">
|
||||||
<Skeleton
|
<Skeleton
|
||||||
@@ -92,25 +104,25 @@ watch(itemQuery, loadItems);
|
|||||||
|
|
||||||
<FilterPanel v-if="options">
|
<FilterPanel v-if="options">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="recipe-search">搜索</label>
|
<label for="recipe-search">{{ t('common.search') }}</label>
|
||||||
<input id="recipe-search" v-model="search" type="search" placeholder="名称" />
|
<input id="recipe-search" v-model="search" type="search" :placeholder="t('common.name')" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="recipe-usage">用途</label>
|
<label for="recipe-usage">{{ t('pages.items.usage') }}</label>
|
||||||
<TagsSelect
|
<TagsSelect
|
||||||
id="recipe-usage"
|
id="recipe-usage"
|
||||||
v-model="usageId"
|
v-model="usageId"
|
||||||
:options="options.itemUsages"
|
:options="options.itemUsages"
|
||||||
:multiple="false"
|
:multiple="false"
|
||||||
placeholder="全部"
|
:placeholder="t('common.all')"
|
||||||
search-placeholder="搜索用途"
|
:search-placeholder="t('pages.items.searchUsage')"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="recipe-tags">标签</label>
|
<label for="recipe-tags">{{ t('pages.items.tags') }}</label>
|
||||||
<TagsSelect id="recipe-tags" v-model="tagIds" :options="options.itemTags" placeholder="搜索标签" />
|
<TagsSelect id="recipe-tags" v-model="tagIds" :options="options.itemTags" :placeholder="t('pages.items.searchTags')" />
|
||||||
</div>
|
</div>
|
||||||
</FilterPanel>
|
</FilterPanel>
|
||||||
<FilterPanel v-else class="filter-panel--skeleton" aria-hidden="true">
|
<FilterPanel v-else class="filter-panel--skeleton" aria-hidden="true">
|
||||||
@@ -120,7 +132,7 @@ watch(itemQuery, loadItems);
|
|||||||
</div>
|
</div>
|
||||||
</FilterPanel>
|
</FilterPanel>
|
||||||
|
|
||||||
<div v-if="loading" class="entity-grid" aria-busy="true" aria-label="正在加载材料单列表">
|
<div v-if="loading" class="entity-grid" aria-busy="true" :aria-label="t('pages.recipes.loadingList')">
|
||||||
<article v-for="index in skeletonCardCount" :key="`recipe-skeleton-${index}`" class="entity-card entity-card--skeleton">
|
<article v-for="index in skeletonCardCount" :key="`recipe-skeleton-${index}`" class="entity-card entity-card--skeleton">
|
||||||
<Skeleton variant="box" width="42px" height="42px" class="skeleton-entity-mark" />
|
<Skeleton variant="box" width="42px" height="42px" class="skeleton-entity-mark" />
|
||||||
<div class="entity-card__content">
|
<div class="entity-card__content">
|
||||||
@@ -138,13 +150,16 @@ watch(itemQuery, loadItems);
|
|||||||
:title="item.name"
|
:title="item.name"
|
||||||
:subtitle="itemSubtitle(item)"
|
:subtitle="itemSubtitle(item)"
|
||||||
:to="recipeTarget(item)"
|
:to="recipeTarget(item)"
|
||||||
:marker="itemMarker(item)"
|
:icon="itemIcon(item)"
|
||||||
>
|
>
|
||||||
<EditMeta v-if="item.recipe" :entity="item.recipe" />
|
<EditMeta v-if="item.recipe" :entity="item.recipe" />
|
||||||
<RouterLink v-else-if="!item.noRecipe" class="ui-button ui-button--primary ui-button--small" :to="createRecipeTarget(item)">
|
<RouterLink v-else-if="!item.noRecipe" class="ui-button ui-button--primary ui-button--small" :to="createRecipeTarget(item)">
|
||||||
创建材料单
|
<Icon :icon="iconAdd" class="ui-icon" aria-hidden="true" />
|
||||||
|
{{ t('pages.items.createRecipe') }}
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
</EntityCard>
|
</EntityCard>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<RecipeEdit v-if="showEditor" />
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { Icon } from '@iconify/vue';
|
||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
import PageHeader from '../components/PageHeader.vue';
|
import PageHeader from '../components/PageHeader.vue';
|
||||||
import StatusMessage from '../components/StatusMessage.vue';
|
import StatusMessage from '../components/StatusMessage.vue';
|
||||||
|
import { iconMail } from '../icons';
|
||||||
import { api } from '../services/api';
|
import { api } from '../services/api';
|
||||||
|
|
||||||
const email = ref('');
|
const email = ref('');
|
||||||
@@ -10,6 +13,7 @@ const password = ref('');
|
|||||||
const busy = ref(false);
|
const busy = ref(false);
|
||||||
const message = ref('');
|
const message = ref('');
|
||||||
const errorMessage = ref('');
|
const errorMessage = ref('');
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
async function submitRegister() {
|
async function submitRegister() {
|
||||||
busy.value = true;
|
busy.value = true;
|
||||||
@@ -24,7 +28,7 @@ async function submitRegister() {
|
|||||||
});
|
});
|
||||||
message.value = response.message;
|
message.value = response.message;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
errorMessage.value = error instanceof Error && error.message ? error.message : '注册失败';
|
errorMessage.value = error instanceof Error && error.message ? error.message : t('auth.registerFailed');
|
||||||
} finally {
|
} finally {
|
||||||
busy.value = false;
|
busy.value = false;
|
||||||
}
|
}
|
||||||
@@ -34,23 +38,23 @@ async function submitRegister() {
|
|||||||
<template>
|
<template>
|
||||||
<section class="auth-page">
|
<section class="auth-page">
|
||||||
<div class="auth-panel">
|
<div class="auth-panel">
|
||||||
<PageHeader title="注册" subtitle="创建账号后需要完成邮箱验证">
|
<PageHeader :title="t('auth.registerTitle')" :subtitle="t('auth.registerSubtitle')">
|
||||||
<template #kicker>Trainer Pass</template>
|
<template #kicker>Trainer Pass</template>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
<form class="auth-form" @submit.prevent="submitRegister">
|
<form class="auth-form" @submit.prevent="submitRegister">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="register-email">邮箱</label>
|
<label for="register-email">{{ t('auth.email') }}</label>
|
||||||
<input id="register-email" v-model="email" autocomplete="email" required type="email" />
|
<input id="register-email" v-model="email" autocomplete="email" required type="email" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="register-display-name">显示名</label>
|
<label for="register-display-name">{{ t('auth.displayName') }}</label>
|
||||||
<input id="register-display-name" v-model="displayName" autocomplete="nickname" maxlength="40" required />
|
<input id="register-display-name" v-model="displayName" autocomplete="nickname" maxlength="40" required />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="register-password">密码</label>
|
<label for="register-password">{{ t('auth.password') }}</label>
|
||||||
<input
|
<input
|
||||||
id="register-password"
|
id="register-password"
|
||||||
v-model="password"
|
v-model="password"
|
||||||
@@ -65,13 +69,14 @@ async function submitRegister() {
|
|||||||
<StatusMessage v-if="errorMessage" variant="danger">{{ errorMessage }}</StatusMessage>
|
<StatusMessage v-if="errorMessage" variant="danger">{{ errorMessage }}</StatusMessage>
|
||||||
|
|
||||||
<button class="ui-button ui-button--primary" :disabled="busy" type="submit">
|
<button class="ui-button ui-button--primary" :disabled="busy" type="submit">
|
||||||
{{ busy ? '发送中' : '发送验证邮件' }}
|
<Icon :icon="iconMail" class="ui-icon" aria-hidden="true" />
|
||||||
|
{{ busy ? t('auth.sending') : t('auth.sendVerification') }}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<p class="auth-switch">
|
<p class="auth-switch">
|
||||||
已有账号?
|
{{ t('auth.hasAccount') }}
|
||||||
<RouterLink to="/login">登录</RouterLink>
|
<RouterLink to="/login">{{ t('nav.login') }}</RouterLink>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -1,22 +1,26 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { Icon } from '@iconify/vue';
|
||||||
import { onMounted, ref } from 'vue';
|
import { onMounted, ref } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
import { useRoute } from 'vue-router';
|
import { useRoute } from 'vue-router';
|
||||||
import PageHeader from '../components/PageHeader.vue';
|
import PageHeader from '../components/PageHeader.vue';
|
||||||
import Skeleton from '../components/Skeleton.vue';
|
import Skeleton from '../components/Skeleton.vue';
|
||||||
import StatusMessage from '../components/StatusMessage.vue';
|
import StatusMessage from '../components/StatusMessage.vue';
|
||||||
|
import { iconLogin } from '../icons';
|
||||||
import { api } from '../services/api';
|
import { api } from '../services/api';
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const busy = ref(true);
|
const busy = ref(true);
|
||||||
const message = ref('');
|
const message = ref('');
|
||||||
const errorMessage = ref('');
|
const errorMessage = ref('');
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
const token = typeof route.query.token === 'string' ? route.query.token : '';
|
const token = typeof route.query.token === 'string' ? route.query.token : '';
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
busy.value = false;
|
busy.value = false;
|
||||||
errorMessage.value = '验证链接无效或已过期';
|
errorMessage.value = t('auth.invalidVerification');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,7 +28,7 @@ onMounted(async () => {
|
|||||||
const response = await api.verifyEmail(token);
|
const response = await api.verifyEmail(token);
|
||||||
message.value = response.message;
|
message.value = response.message;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
errorMessage.value = error instanceof Error && error.message ? error.message : '邮箱验证失败';
|
errorMessage.value = error instanceof Error && error.message ? error.message : t('auth.verifyFailed');
|
||||||
} finally {
|
} finally {
|
||||||
busy.value = false;
|
busy.value = false;
|
||||||
}
|
}
|
||||||
@@ -34,11 +38,11 @@ onMounted(async () => {
|
|||||||
<template>
|
<template>
|
||||||
<section class="auth-page">
|
<section class="auth-page">
|
||||||
<div class="auth-panel">
|
<div class="auth-panel">
|
||||||
<PageHeader title="邮箱验证" subtitle="完成验证后即可登录">
|
<PageHeader :title="t('auth.verifyTitle')" :subtitle="t('auth.verifySubtitle')">
|
||||||
<template #kicker>Trainer Pass</template>
|
<template #kicker>Trainer Pass</template>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
<div v-if="busy" class="skeleton-auth-state" aria-busy="true" aria-label="正在验证邮箱">
|
<div v-if="busy" class="skeleton-auth-state" aria-busy="true" :aria-label="t('auth.verifyingEmail')">
|
||||||
<Skeleton width="62%" />
|
<Skeleton width="62%" />
|
||||||
<Skeleton width="84%" />
|
<Skeleton width="84%" />
|
||||||
<Skeleton variant="box" width="110px" height="44px" />
|
<Skeleton variant="box" width="110px" height="44px" />
|
||||||
@@ -46,7 +50,10 @@ onMounted(async () => {
|
|||||||
<StatusMessage v-else-if="message" variant="success">{{ message }}</StatusMessage>
|
<StatusMessage v-else-if="message" variant="success">{{ message }}</StatusMessage>
|
||||||
<StatusMessage v-else variant="danger">{{ errorMessage }}</StatusMessage>
|
<StatusMessage v-else variant="danger">{{ errorMessage }}</StatusMessage>
|
||||||
|
|
||||||
<RouterLink v-if="!busy" class="ui-button ui-button--primary" to="/login">去登录</RouterLink>
|
<RouterLink v-if="!busy" class="ui-button ui-button--primary" to="/login">
|
||||||
|
<Icon :icon="iconLogin" class="ui-icon" aria-hidden="true" />
|
||||||
|
{{ t('auth.goLogin') }}
|
||||||
|
</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
2198
pnpm-lock.yaml
generated
Normal file
2198
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user