Compare commits

...

10 Commits

Author SHA1 Message Date
9fece8f54f feat(ui): add icons to navigation and UI components
Integrate @iconify/vue for consistent iconography across the app
Enhance buttons, entity cards, and status messages with visual indicators
2026-05-01 14:31:29 +08:00
ca3ca35dfc feat(i18n): display only active language in translation fields
Update TranslationFields to render a single input for the current locale
Ensure entity base names fallback to the active locale translation on save
2026-05-01 14:11:31 +08:00
62406bdc84 fix(i18n): prevent base name overwrites when editing in localized UI
Include base names in API responses to correctly populate edit forms.
Show base values as placeholders in translation fields for better UX.
Use default locale when fetching previous state for history diffs.
2026-05-01 14:07:07 +08:00
6812ddc428 feat(ui): use modal dialogs for entity creation and editing
Introduce reusable Modal component for forms
Update router to preserve scroll position when toggling modals
Refactor admin and entity views to render editors as overlays
2026-05-01 13:44:34 +08:00
bd068ce2f6 fix(ui): resolve transition key conflicts in reorderable lists
Add listKeyPrefix prop to ensure unique keys across list instances
Remove enter/leave transition styles to prevent animation glitches
2026-05-01 12:49:08 +08:00
239a2ec3b5 feat: add custom sorting for all major entities
Add sort_order column to pokemon, items, recipes, habitats, and configs
Implement drag-and-drop reordering in the admin interface
Update API endpoints and database queries to respect the new sort order
2026-05-01 12:30:46 +08:00
27100fbd22 feat(i18n): add full-stack internationalization support
Add languages and entity_translations tables to database schema
Implement localized queries and translation management in backend
Integrate frontend i18n and add translation UI components
2026-05-01 12:04:49 +08:00
91dd834413 feat(checklist): add daily checklist feature with admin management
Add daily checklist view for users to track daily tasks
Support creating, editing, deleting, and drag-and-drop reordering in admin panel
2026-05-01 09:40:00 +08:00
60cad3f5e8 feat(history): add detailed edit history tracking and display panel
Record field-level before/after changes in wiki_edit_logs
Replace EditMeta with EditHistoryPanel on entity detail pages
Update detail views to use a sidebar layout for history
2026-05-01 07:59:29 +08:00
14b13e479d refactor(ui): update pokemon detail grid layout and section title
Apply single-column stack layout to detail grids
Shorten the related items section title for conciseness
2026-05-01 06:55:20 +08:00
42 changed files with 7900 additions and 939 deletions

View File

@@ -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 拖曳排序
## 用户系统 ## 用户系统

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View 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
View 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
View 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';

View File

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

View File

@@ -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) => {

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff