initial commit
This commit is contained in:
6
.env.example
Normal file
6
.env.example
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
POSTGRES_DB=pokopia
|
||||||
|
POSTGRES_USER=pokopia
|
||||||
|
POSTGRES_PASSWORD=pokopia
|
||||||
|
DATABASE_URL=postgres://pokopia:pokopia@localhost:5432/pokopia
|
||||||
|
BACKEND_PORT=3001
|
||||||
|
VITE_API_BASE_URL=http://localhost:3001
|
||||||
9
.gitignore
vendored
Normal file
9
.gitignore
vendored
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
node_modules/
|
||||||
|
.pnpm-store/
|
||||||
|
dist/
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
coverage/
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
155
AGENTS.md
Normal file
155
AGENTS.md
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
# AGENTS.md
|
||||||
|
|
||||||
|
## Core Principles
|
||||||
|
|
||||||
|
* Always read `DESIGN.md` before making any change.
|
||||||
|
* Follow the existing structure and conventions strictly.
|
||||||
|
* Make **minimal, targeted changes only**. Do not refactor unrelated code.
|
||||||
|
* Prefer clarity over cleverness. Avoid unnecessary abstraction.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Mandatory Workflow (MUST FOLLOW)
|
||||||
|
|
||||||
|
For any non-trivial task:
|
||||||
|
|
||||||
|
1. **Read `DESIGN.md`**
|
||||||
|
2. **Produce a short plan (no code)**
|
||||||
|
3. Wait for approval
|
||||||
|
4. Implement in small steps
|
||||||
|
5. Run lightweight validation when practical (lint/typecheck). Do not run tests in WSL.
|
||||||
|
|
||||||
|
Do NOT skip planning.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scope Control (Prevent Code Bloat)
|
||||||
|
|
||||||
|
* Only modify files directly related to the task.
|
||||||
|
* Do NOT:
|
||||||
|
|
||||||
|
* Introduce new layers (services, utils, hooks, etc.) unless clearly required
|
||||||
|
* Split files unnecessarily
|
||||||
|
* Rewrite existing modules without explicit instruction
|
||||||
|
* Prefer editing existing files over creating new ones.
|
||||||
|
* Keep functions and components small and readable.
|
||||||
|
|
||||||
|
If a task grows beyond scope, STOP and ask.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## UI Safety Rules (CRITICAL)
|
||||||
|
|
||||||
|
User-facing UI must NEVER contain:
|
||||||
|
|
||||||
|
* prompts
|
||||||
|
* remarks
|
||||||
|
* planning notes
|
||||||
|
* debug messages
|
||||||
|
* explanations of what was changed
|
||||||
|
* internal field names like `debug`, `meta`, `internal`
|
||||||
|
|
||||||
|
### Strict Rules
|
||||||
|
|
||||||
|
* Only render **business data** and intended UI text
|
||||||
|
* Never display:
|
||||||
|
|
||||||
|
* "Updated successfully because..."
|
||||||
|
* "Changed X to Y"
|
||||||
|
* "TODO", "NOTE", "DEBUG"
|
||||||
|
* Debug information must go to logs, not UI
|
||||||
|
* Separate internal data from API responses
|
||||||
|
|
||||||
|
Violations are considered critical errors.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Data & API Design Rules
|
||||||
|
|
||||||
|
* Follow `DESIGN.md` as the **single source of truth**
|
||||||
|
* PostgreSQL:
|
||||||
|
|
||||||
|
* use `snake_case`
|
||||||
|
* define proper primary/foreign keys
|
||||||
|
* avoid premature optimization
|
||||||
|
* APIs:
|
||||||
|
|
||||||
|
* return only necessary fields
|
||||||
|
* do not expose internal metadata
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Code Style & Structure
|
||||||
|
|
||||||
|
* Vue:
|
||||||
|
|
||||||
|
* Components: `PascalCase`
|
||||||
|
* Composables: `useXxx`
|
||||||
|
* General:
|
||||||
|
|
||||||
|
* variables/functions: `camelCase`
|
||||||
|
* Keep files focused and under reasonable length
|
||||||
|
* Avoid duplication
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing & Validation
|
||||||
|
|
||||||
|
This project is developed from WSL, but runtime validation is done through Docker.
|
||||||
|
|
||||||
|
Agent workflow:
|
||||||
|
|
||||||
|
* Run:
|
||||||
|
|
||||||
|
* lint
|
||||||
|
* typecheck
|
||||||
|
|
||||||
|
* Do NOT run tests in WSL.
|
||||||
|
* Do NOT require local test execution before finishing a task.
|
||||||
|
* The user will run `docker compose up --build`.
|
||||||
|
* If Docker reports errors, the user will paste the error output and the agent will fix those errors in a follow-up pass.
|
||||||
|
|
||||||
|
When adding tests is clearly useful, keep them focused and minimal, but do not execute them locally unless explicitly requested.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Definition of Done
|
||||||
|
|
||||||
|
A task is complete ONLY IF:
|
||||||
|
|
||||||
|
* Matches `DESIGN.md`
|
||||||
|
* Minimal diff (no unrelated changes)
|
||||||
|
* No UI leaks of internal info
|
||||||
|
* Code is readable and concise
|
||||||
|
* Passes lint/typecheck when practical
|
||||||
|
* Docker runtime issues are handled from user-provided `docker compose up --build` output
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Anti-Patterns (STRICTLY FORBIDDEN)
|
||||||
|
|
||||||
|
* Adding UI remarks like "I updated this"
|
||||||
|
* Over-engineering simple features
|
||||||
|
* Creating unused files or abstractions
|
||||||
|
* Mixing internal/debug data into UI
|
||||||
|
* Large, unfocused commits
|
||||||
|
* Silent behavior changes outside scope
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## When Unsure
|
||||||
|
|
||||||
|
* Ask for clarification
|
||||||
|
* Do not guess requirements
|
||||||
|
* Do not invent features not in `DESIGN.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Project Context
|
||||||
|
|
||||||
|
* Goal: Pokopia Wiki
|
||||||
|
* Stack:
|
||||||
|
|
||||||
|
* Frontend: Vue
|
||||||
|
* Backend: Node + PostgreSQL
|
||||||
|
* Infra: Docker
|
||||||
116
DESIGN.md
Normal file
116
DESIGN.md
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
# Pokopia Wiki
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
- 后端:Postgresql
|
||||||
|
- 前端:Vue
|
||||||
|
- 运维:Docker
|
||||||
|
都要用最新的框架
|
||||||
|
|
||||||
|
# 功能描述
|
||||||
|
|
||||||
|
- 一个具有社区功能的 Pokopia 游戏 Wiki
|
||||||
|
|
||||||
|
## 数据
|
||||||
|
|
||||||
|
Pokemon 可配置:
|
||||||
|
- ID
|
||||||
|
- 名字
|
||||||
|
- 特长(可多选,最多 2 个)
|
||||||
|
- 喜欢的环境(单选)
|
||||||
|
- 喜欢的东西(可多选,最多 6 个)
|
||||||
|
- 出现的栖息地(可多选)
|
||||||
|
|
||||||
|
特长 可配置:
|
||||||
|
- 名称
|
||||||
|
- 二级分类(可空,用于给乱撒这类特长做二级分类)
|
||||||
|
Eg: 名称:乱撒,二级分类:棉花
|
||||||
|
|
||||||
|
喜欢的环境 可配置:
|
||||||
|
- 名称
|
||||||
|
|
||||||
|
喜欢的东西(标签) 可配置:
|
||||||
|
- 名称
|
||||||
|
|
||||||
|
物品 可配置:
|
||||||
|
- 名称
|
||||||
|
- 分类
|
||||||
|
- 用途
|
||||||
|
- 入手方式(可多选)
|
||||||
|
- 客制化:
|
||||||
|
- 可染色
|
||||||
|
- 可双区染色
|
||||||
|
- 可改花纹
|
||||||
|
- 标签(多选)
|
||||||
|
|
||||||
|
材料单 可配置:
|
||||||
|
- 名称
|
||||||
|
- 入手方式(可多选)
|
||||||
|
- 需要材料(可多样,多数量)
|
||||||
|
|
||||||
|
物品 / 材料单分类:
|
||||||
|
- 名称
|
||||||
|
|
||||||
|
物品 / 材料单用途:
|
||||||
|
- 名称
|
||||||
|
|
||||||
|
入手方式 可配置:
|
||||||
|
- 名称
|
||||||
|
|
||||||
|
地图:
|
||||||
|
- 名称
|
||||||
|
|
||||||
|
栖息地:
|
||||||
|
- 名称
|
||||||
|
- 配方(物品,数量)
|
||||||
|
- 可出现的宝可梦(可多选)
|
||||||
|
|
||||||
|
出现契机
|
||||||
|
- 时间:早晨 / 中午 / 傍晚 / 晚上
|
||||||
|
- 天气:晴天 / 阴天 / 雨天
|
||||||
|
- 稀有度:1 ~ 3 星
|
||||||
|
- 地图关联
|
||||||
|
|
||||||
|
## 功能
|
||||||
|
|
||||||
|
- Pokemon 列表
|
||||||
|
- 搜索
|
||||||
|
- 筛选
|
||||||
|
- 特长(可多选,满足任意条件 / 满足全部条件)
|
||||||
|
- 喜欢的环境
|
||||||
|
- 喜欢的东西(可多选,满足任意条件 / 满足全部条件)
|
||||||
|
- Pokemon 详情页
|
||||||
|
- 特长
|
||||||
|
- 喜欢的环境
|
||||||
|
- 喜欢的东西
|
||||||
|
- 栖息地
|
||||||
|
- 栖息地列表
|
||||||
|
- 栖息地详情页
|
||||||
|
- 配方列表
|
||||||
|
- 可能出现的宝可梦列表
|
||||||
|
- 出现时间
|
||||||
|
- 出现天气
|
||||||
|
- 稀有度
|
||||||
|
- 出现的地图列表
|
||||||
|
- 物品 / 材料单列表
|
||||||
|
- 根据分类显示(标签页)
|
||||||
|
- 筛选
|
||||||
|
- 用途
|
||||||
|
- 标签
|
||||||
|
- 物品详情页
|
||||||
|
- 基本信息
|
||||||
|
- 用途
|
||||||
|
- 入手方式
|
||||||
|
- 自定义
|
||||||
|
- 可染色
|
||||||
|
- 可双区染色
|
||||||
|
- 可改花纹
|
||||||
|
- 材料单信息
|
||||||
|
- 入手方式
|
||||||
|
- 需要材料列表
|
||||||
|
- 标签
|
||||||
|
- 相关栖息地
|
||||||
|
- 材料单详情页
|
||||||
|
- 基本信息
|
||||||
|
- 入手方式
|
||||||
|
- 需要材料列表
|
||||||
8
backend/Dockerfile
Normal file
8
backend/Dockerfile
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
FROM node:22-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json ./
|
||||||
|
RUN npm install
|
||||||
|
COPY . .
|
||||||
|
EXPOSE 3001
|
||||||
|
CMD ["npm", "run", "start"]
|
||||||
122
backend/db/schema.sql
Normal file
122
backend/db/schema.sql
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS environments (
|
||||||
|
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
|
name text NOT NULL UNIQUE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS skills (
|
||||||
|
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
|
name text NOT NULL,
|
||||||
|
subcategory text,
|
||||||
|
UNIQUE (name, subcategory)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS favorite_things (
|
||||||
|
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
|
name text NOT NULL UNIQUE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS pokemon (
|
||||||
|
id integer PRIMARY KEY,
|
||||||
|
name text NOT NULL UNIQUE,
|
||||||
|
environment_id integer NOT NULL REFERENCES environments(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS pokemon_skills (
|
||||||
|
pokemon_id integer NOT NULL REFERENCES pokemon(id) ON DELETE CASCADE,
|
||||||
|
skill_id integer NOT NULL REFERENCES skills(id),
|
||||||
|
PRIMARY KEY (pokemon_id, skill_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS pokemon_favorite_things (
|
||||||
|
pokemon_id integer NOT NULL REFERENCES pokemon(id) ON DELETE CASCADE,
|
||||||
|
favorite_thing_id integer NOT NULL REFERENCES favorite_things(id),
|
||||||
|
PRIMARY KEY (pokemon_id, favorite_thing_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS item_categories (
|
||||||
|
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
|
name text NOT NULL UNIQUE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS item_usages (
|
||||||
|
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
|
name text NOT NULL UNIQUE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS acquisition_methods (
|
||||||
|
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
|
name text NOT NULL UNIQUE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS item_tags (
|
||||||
|
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
|
name text NOT NULL UNIQUE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS recipes (
|
||||||
|
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
|
name text NOT NULL UNIQUE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS recipe_acquisition_methods (
|
||||||
|
recipe_id integer NOT NULL REFERENCES recipes(id) ON DELETE CASCADE,
|
||||||
|
acquisition_method_id integer NOT NULL REFERENCES acquisition_methods(id),
|
||||||
|
PRIMARY KEY (recipe_id, acquisition_method_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS items (
|
||||||
|
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
|
name text NOT NULL UNIQUE,
|
||||||
|
category_id integer NOT NULL REFERENCES item_categories(id),
|
||||||
|
usage_id integer NOT NULL REFERENCES item_usages(id),
|
||||||
|
recipe_id integer REFERENCES recipes(id),
|
||||||
|
dyeable boolean NOT NULL DEFAULT false,
|
||||||
|
dual_dyeable boolean NOT NULL DEFAULT false,
|
||||||
|
pattern_editable boolean NOT NULL DEFAULT false
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS item_acquisition_methods (
|
||||||
|
item_id integer NOT NULL REFERENCES items(id) ON DELETE CASCADE,
|
||||||
|
acquisition_method_id integer NOT NULL REFERENCES acquisition_methods(id),
|
||||||
|
PRIMARY KEY (item_id, acquisition_method_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS item_item_tags (
|
||||||
|
item_id integer NOT NULL REFERENCES items(id) ON DELETE CASCADE,
|
||||||
|
item_tag_id integer NOT NULL REFERENCES item_tags(id),
|
||||||
|
PRIMARY KEY (item_id, item_tag_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS recipe_materials (
|
||||||
|
recipe_id integer NOT NULL REFERENCES recipes(id) ON DELETE CASCADE,
|
||||||
|
item_id integer NOT NULL REFERENCES items(id),
|
||||||
|
quantity integer NOT NULL CHECK (quantity > 0),
|
||||||
|
PRIMARY KEY (recipe_id, item_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS maps (
|
||||||
|
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
|
name text NOT NULL UNIQUE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS habitats (
|
||||||
|
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
|
name text NOT NULL UNIQUE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS habitat_recipe_items (
|
||||||
|
habitat_id integer NOT NULL REFERENCES habitats(id) ON DELETE CASCADE,
|
||||||
|
item_id integer NOT NULL REFERENCES items(id),
|
||||||
|
quantity integer NOT NULL CHECK (quantity > 0),
|
||||||
|
PRIMARY KEY (habitat_id, item_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS habitat_pokemon (
|
||||||
|
habitat_id integer NOT NULL REFERENCES habitats(id) ON DELETE CASCADE,
|
||||||
|
pokemon_id integer NOT NULL REFERENCES pokemon(id) ON DELETE CASCADE,
|
||||||
|
map_id integer NOT NULL REFERENCES maps(id),
|
||||||
|
time_of_day text NOT NULL CHECK (time_of_day IN ('早晨', '中午', '傍晚', '晚上')),
|
||||||
|
weather text NOT NULL CHECK (weather IN ('晴天', '阴天', '雨天')),
|
||||||
|
rarity integer NOT NULL CHECK (rarity BETWEEN 1 AND 3),
|
||||||
|
PRIMARY KEY (habitat_id, pokemon_id, map_id, time_of_day, weather)
|
||||||
|
);
|
||||||
147
backend/db/seed.sql
Normal file
147
backend/db/seed.sql
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
INSERT INTO environments (id, name) VALUES
|
||||||
|
(1, '森林'),
|
||||||
|
(2, '水边'),
|
||||||
|
(3, '草原')
|
||||||
|
ON CONFLICT (id) DO NOTHING;
|
||||||
|
|
||||||
|
INSERT INTO skills (id, name, subcategory) VALUES
|
||||||
|
(1, '采集', NULL),
|
||||||
|
(2, '乱撒', '棉花'),
|
||||||
|
(3, '浇水', NULL),
|
||||||
|
(4, '搬运', NULL)
|
||||||
|
ON CONFLICT (id) DO NOTHING;
|
||||||
|
|
||||||
|
INSERT INTO favorite_things (id, name) VALUES
|
||||||
|
(1, '莓果'),
|
||||||
|
(2, '花朵'),
|
||||||
|
(3, '木材'),
|
||||||
|
(4, '清水'),
|
||||||
|
(5, '棉花')
|
||||||
|
ON CONFLICT (id) DO NOTHING;
|
||||||
|
|
||||||
|
INSERT INTO pokemon (id, name, environment_id) VALUES
|
||||||
|
(1, '妙蛙种子', 1),
|
||||||
|
(4, '小火龙', 3),
|
||||||
|
(7, '杰尼龟', 2),
|
||||||
|
(25, '皮卡丘', 3)
|
||||||
|
ON CONFLICT (id) DO NOTHING;
|
||||||
|
|
||||||
|
INSERT INTO pokemon_skills (pokemon_id, skill_id) VALUES
|
||||||
|
(1, 1),
|
||||||
|
(1, 3),
|
||||||
|
(4, 4),
|
||||||
|
(7, 3),
|
||||||
|
(25, 1),
|
||||||
|
(25, 2)
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
INSERT INTO pokemon_favorite_things (pokemon_id, favorite_thing_id) VALUES
|
||||||
|
(1, 1),
|
||||||
|
(1, 2),
|
||||||
|
(4, 3),
|
||||||
|
(7, 4),
|
||||||
|
(25, 1),
|
||||||
|
(25, 5)
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
INSERT INTO item_categories (id, name) VALUES
|
||||||
|
(1, '家具'),
|
||||||
|
(2, '材料'),
|
||||||
|
(3, '装饰')
|
||||||
|
ON CONFLICT (id) DO NOTHING;
|
||||||
|
|
||||||
|
INSERT INTO item_usages (id, name) VALUES
|
||||||
|
(1, '栖息地配方'),
|
||||||
|
(2, '建造'),
|
||||||
|
(3, '装饰')
|
||||||
|
ON CONFLICT (id) DO NOTHING;
|
||||||
|
|
||||||
|
INSERT INTO acquisition_methods (id, name) VALUES
|
||||||
|
(1, '采集'),
|
||||||
|
(2, '制作'),
|
||||||
|
(3, '探索')
|
||||||
|
ON CONFLICT (id) DO NOTHING;
|
||||||
|
|
||||||
|
INSERT INTO item_tags (id, name) VALUES
|
||||||
|
(1, '自然'),
|
||||||
|
(2, '木质'),
|
||||||
|
(3, '柔软'),
|
||||||
|
(4, '水域')
|
||||||
|
ON CONFLICT (id) DO NOTHING;
|
||||||
|
|
||||||
|
INSERT INTO recipes (id, name) VALUES
|
||||||
|
(1, '木质长椅材料单'),
|
||||||
|
(2, '棉花垫材料单')
|
||||||
|
ON CONFLICT (id) DO NOTHING;
|
||||||
|
|
||||||
|
INSERT INTO items (id, name, category_id, usage_id, recipe_id, dyeable, dual_dyeable, pattern_editable) VALUES
|
||||||
|
(1, '原木', 2, 2, NULL, false, false, false),
|
||||||
|
(2, '棉花', 2, 2, NULL, true, false, false),
|
||||||
|
(3, '木质长椅', 1, 3, 1, true, true, false),
|
||||||
|
(4, '清水瓶', 3, 1, NULL, false, false, true),
|
||||||
|
(5, '棉花垫', 1, 3, 2, true, false, true)
|
||||||
|
ON CONFLICT (id) DO NOTHING;
|
||||||
|
|
||||||
|
INSERT INTO item_acquisition_methods (item_id, acquisition_method_id) VALUES
|
||||||
|
(1, 1),
|
||||||
|
(2, 1),
|
||||||
|
(3, 2),
|
||||||
|
(4, 3),
|
||||||
|
(5, 2)
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
INSERT INTO item_item_tags (item_id, item_tag_id) VALUES
|
||||||
|
(1, 1),
|
||||||
|
(1, 2),
|
||||||
|
(2, 3),
|
||||||
|
(3, 2),
|
||||||
|
(4, 4),
|
||||||
|
(5, 3)
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
INSERT INTO recipe_acquisition_methods (recipe_id, acquisition_method_id) VALUES
|
||||||
|
(1, 2),
|
||||||
|
(2, 2)
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
INSERT INTO recipe_materials (recipe_id, item_id, quantity) VALUES
|
||||||
|
(1, 1, 6),
|
||||||
|
(2, 2, 4)
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
INSERT INTO maps (id, name) VALUES
|
||||||
|
(1, '起始平原'),
|
||||||
|
(2, '微风森林'),
|
||||||
|
(3, '湖畔小径')
|
||||||
|
ON CONFLICT (id) DO NOTHING;
|
||||||
|
|
||||||
|
INSERT INTO habitats (id, name) VALUES
|
||||||
|
(1, '绿荫营地'),
|
||||||
|
(2, '湖边小窝')
|
||||||
|
ON CONFLICT (id) DO NOTHING;
|
||||||
|
|
||||||
|
INSERT INTO habitat_recipe_items (habitat_id, item_id, quantity) VALUES
|
||||||
|
(1, 1, 8),
|
||||||
|
(1, 3, 1),
|
||||||
|
(2, 2, 3),
|
||||||
|
(2, 4, 2)
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
INSERT INTO habitat_pokemon (habitat_id, pokemon_id, map_id, time_of_day, weather, rarity) VALUES
|
||||||
|
(1, 1, 2, '早晨', '晴天', 1),
|
||||||
|
(1, 25, 1, '傍晚', '阴天', 2),
|
||||||
|
(2, 7, 3, '中午', '雨天', 1),
|
||||||
|
(2, 1, 3, '早晨', '雨天', 2)
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
SELECT setval(pg_get_serial_sequence('environments', 'id'), (SELECT max(id) FROM environments));
|
||||||
|
SELECT setval(pg_get_serial_sequence('skills', 'id'), (SELECT max(id) FROM skills));
|
||||||
|
SELECT setval(pg_get_serial_sequence('favorite_things', 'id'), (SELECT max(id) FROM favorite_things));
|
||||||
|
SELECT setval(pg_get_serial_sequence('item_categories', 'id'), (SELECT max(id) FROM item_categories));
|
||||||
|
SELECT setval(pg_get_serial_sequence('item_usages', 'id'), (SELECT max(id) FROM item_usages));
|
||||||
|
SELECT setval(pg_get_serial_sequence('acquisition_methods', 'id'), (SELECT max(id) FROM acquisition_methods));
|
||||||
|
SELECT setval(pg_get_serial_sequence('item_tags', 'id'), (SELECT max(id) FROM item_tags));
|
||||||
|
SELECT setval(pg_get_serial_sequence('recipes', 'id'), (SELECT max(id) FROM recipes));
|
||||||
|
SELECT setval(pg_get_serial_sequence('items', 'id'), (SELECT max(id) FROM items));
|
||||||
|
SELECT setval(pg_get_serial_sequence('maps', 'id'), (SELECT max(id) FROM maps));
|
||||||
|
SELECT setval(pg_get_serial_sequence('habitats', 'id'), (SELECT max(id) FROM habitats));
|
||||||
25
backend/package.json
Normal file
25
backend/package.json
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"name": "@pokopia/backend",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "tsx watch src/server.ts",
|
||||||
|
"start": "tsx src/server.ts",
|
||||||
|
"build": "tsc --noEmit",
|
||||||
|
"lint": "tsc --noEmit",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"test": "node --test --import tsx tests/*.test.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@fastify/cors": "latest",
|
||||||
|
"fastify": "latest",
|
||||||
|
"pg": "latest"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "latest",
|
||||||
|
"@types/pg": "latest",
|
||||||
|
"tsx": "latest",
|
||||||
|
"typescript": "latest"
|
||||||
|
}
|
||||||
|
}
|
||||||
31
backend/src/db.ts
Normal file
31
backend/src/db.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
import pg from 'pg';
|
||||||
|
|
||||||
|
const { Pool } = pg;
|
||||||
|
type QueryResultRow = pg.QueryResultRow;
|
||||||
|
|
||||||
|
const databaseUrl = process.env.DATABASE_URL ?? 'postgres://pokopia:pokopia@localhost:5432/pokopia';
|
||||||
|
|
||||||
|
export const pool = new Pool({
|
||||||
|
connectionString: databaseUrl
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function query<T extends QueryResultRow>(sql: string, params: unknown[] = []): Promise<T[]> {
|
||||||
|
const result = await pool.query<T>(sql, params);
|
||||||
|
return result.rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function queryOne<T extends QueryResultRow>(sql: string, params: unknown[] = []): Promise<T | null> {
|
||||||
|
const rows = await query<T>(sql, params);
|
||||||
|
return rows[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function initializeDatabase(): Promise<void> {
|
||||||
|
const dbDir = path.join(process.cwd(), 'db');
|
||||||
|
const schema = await readFile(path.join(dbDir, 'schema.sql'), 'utf8');
|
||||||
|
const seed = await readFile(path.join(dbDir, 'seed.sql'), 'utf8');
|
||||||
|
|
||||||
|
await pool.query(schema);
|
||||||
|
await pool.query(seed);
|
||||||
|
}
|
||||||
49
backend/src/filter.ts
Normal file
49
backend/src/filter.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
export type MatchMode = 'any' | 'all';
|
||||||
|
|
||||||
|
export function parseIdList(value: unknown): number[] {
|
||||||
|
if (typeof value !== 'string' || value.trim() === '') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return value
|
||||||
|
.split(',')
|
||||||
|
.map((item) => Number(item.trim()))
|
||||||
|
.filter((item) => Number.isInteger(item) && item > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseMatchMode(value: unknown): MatchMode {
|
||||||
|
return value === 'all' ? 'all' : 'any';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sqlForRelationFilter(
|
||||||
|
ids: number[],
|
||||||
|
mode: MatchMode,
|
||||||
|
relationTable: string,
|
||||||
|
ownerColumn: string,
|
||||||
|
targetColumn: string,
|
||||||
|
ownerExpression: string,
|
||||||
|
params: unknown[]
|
||||||
|
): string {
|
||||||
|
if (ids.length === 0) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
params.push(ids);
|
||||||
|
const paramIndex = params.length;
|
||||||
|
|
||||||
|
if (mode === 'all') {
|
||||||
|
return `(
|
||||||
|
SELECT count(DISTINCT ${targetColumn})
|
||||||
|
FROM ${relationTable}
|
||||||
|
WHERE ${ownerColumn} = ${ownerExpression}
|
||||||
|
AND ${targetColumn} = ANY($${paramIndex}::int[])
|
||||||
|
) = ${ids.length}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM ${relationTable}
|
||||||
|
WHERE ${ownerColumn} = ${ownerExpression}
|
||||||
|
AND ${targetColumn} = ANY($${paramIndex}::int[])
|
||||||
|
)`;
|
||||||
|
}
|
||||||
355
backend/src/queries.ts
Normal file
355
backend/src/queries.ts
Normal file
@@ -0,0 +1,355 @@
|
|||||||
|
import { parseIdList, parseMatchMode, sqlForRelationFilter } from './filter.ts';
|
||||||
|
import { query, queryOne } from './db.ts';
|
||||||
|
|
||||||
|
type QueryValue = string | string[] | undefined;
|
||||||
|
|
||||||
|
type QueryParams = Record<string, QueryValue>;
|
||||||
|
|
||||||
|
function asString(value: QueryValue): string | undefined {
|
||||||
|
return Array.isArray(value) ? value[0] : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function optionSelect(tableName: string): Promise<Array<{ id: number; name: string }>> {
|
||||||
|
return query(`SELECT id, name FROM ${tableName} ORDER BY name`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pokemonProjection = `
|
||||||
|
SELECT
|
||||||
|
p.id,
|
||||||
|
p.name,
|
||||||
|
json_build_object('id', e.id, 'name', e.name) AS environment,
|
||||||
|
COALESCE((
|
||||||
|
SELECT json_agg(json_build_object('id', s.id, 'name', s.name, 'subcategory', s.subcategory) ORDER BY s.name, s.subcategory)
|
||||||
|
FROM pokemon_skills ps
|
||||||
|
JOIN skills s ON s.id = ps.skill_id
|
||||||
|
WHERE ps.pokemon_id = p.id
|
||||||
|
), '[]'::json) AS skills,
|
||||||
|
COALESCE((
|
||||||
|
SELECT json_agg(json_build_object('id', ft.id, 'name', ft.name) ORDER BY ft.name)
|
||||||
|
FROM pokemon_favorite_things pft
|
||||||
|
JOIN favorite_things ft ON ft.id = pft.favorite_thing_id
|
||||||
|
WHERE pft.pokemon_id = p.id
|
||||||
|
), '[]'::json) AS favorite_things
|
||||||
|
FROM pokemon p
|
||||||
|
JOIN environments e ON e.id = p.environment_id
|
||||||
|
`;
|
||||||
|
|
||||||
|
export async function getOptions() {
|
||||||
|
const [skills, environments, favoriteThings, itemCategories, itemUsages, itemTags] = await Promise.all([
|
||||||
|
query<{ id: number; name: string; subcategory: string | null }>(
|
||||||
|
'SELECT id, name, subcategory FROM skills ORDER BY name, subcategory'
|
||||||
|
),
|
||||||
|
optionSelect('environments'),
|
||||||
|
optionSelect('favorite_things'),
|
||||||
|
optionSelect('item_categories'),
|
||||||
|
optionSelect('item_usages'),
|
||||||
|
optionSelect('item_tags')
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
skills,
|
||||||
|
environments,
|
||||||
|
favoriteThings,
|
||||||
|
itemCategories,
|
||||||
|
itemUsages,
|
||||||
|
itemTags
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listPokemon(paramsQuery: QueryParams) {
|
||||||
|
const params: unknown[] = [];
|
||||||
|
const conditions: string[] = [];
|
||||||
|
const search = asString(paramsQuery.search)?.trim();
|
||||||
|
const environmentId = Number(asString(paramsQuery.environmentId));
|
||||||
|
const skillIds = parseIdList(asString(paramsQuery.skillIds));
|
||||||
|
const favoriteThingIds = parseIdList(asString(paramsQuery.favoriteThingIds));
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
params.push(`%${search}%`);
|
||||||
|
conditions.push(`p.name ILIKE $${params.length}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Number.isInteger(environmentId) && environmentId > 0) {
|
||||||
|
params.push(environmentId);
|
||||||
|
conditions.push(`p.environment_id = $${params.length}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const skillFilter = sqlForRelationFilter(
|
||||||
|
skillIds,
|
||||||
|
parseMatchMode(asString(paramsQuery.skillMode)),
|
||||||
|
'pokemon_skills',
|
||||||
|
'pokemon_id',
|
||||||
|
'skill_id',
|
||||||
|
'p.id',
|
||||||
|
params
|
||||||
|
);
|
||||||
|
if (skillFilter) {
|
||||||
|
conditions.push(skillFilter);
|
||||||
|
}
|
||||||
|
|
||||||
|
const favoriteThingFilter = sqlForRelationFilter(
|
||||||
|
favoriteThingIds,
|
||||||
|
parseMatchMode(asString(paramsQuery.favoriteThingMode)),
|
||||||
|
'pokemon_favorite_things',
|
||||||
|
'pokemon_id',
|
||||||
|
'favorite_thing_id',
|
||||||
|
'p.id',
|
||||||
|
params
|
||||||
|
);
|
||||||
|
if (favoriteThingFilter) {
|
||||||
|
conditions.push(favoriteThingFilter);
|
||||||
|
}
|
||||||
|
|
||||||
|
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||||
|
return query(`${pokemonProjection} ${whereClause} ORDER BY p.id`, params);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPokemon(id: number) {
|
||||||
|
const pokemon = await queryOne(`${pokemonProjection} WHERE p.id = $1`, [id]);
|
||||||
|
if (!pokemon) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const habitats = await query(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
h.id,
|
||||||
|
h.name,
|
||||||
|
hp.time_of_day,
|
||||||
|
hp.weather,
|
||||||
|
hp.rarity,
|
||||||
|
json_build_object('id', m.id, 'name', m.name) AS map
|
||||||
|
FROM habitat_pokemon hp
|
||||||
|
JOIN habitats h ON h.id = hp.habitat_id
|
||||||
|
JOIN maps m ON m.id = hp.map_id
|
||||||
|
WHERE hp.pokemon_id = $1
|
||||||
|
ORDER BY h.name, hp.rarity, m.name
|
||||||
|
`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
return { ...pokemon, habitats };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listHabitats() {
|
||||||
|
return query(`
|
||||||
|
SELECT
|
||||||
|
h.id,
|
||||||
|
h.name,
|
||||||
|
COALESCE((
|
||||||
|
SELECT json_agg(json_build_object('id', i.id, 'name', i.name, 'quantity', hri.quantity) ORDER BY i.name)
|
||||||
|
FROM habitat_recipe_items hri
|
||||||
|
JOIN items i ON i.id = hri.item_id
|
||||||
|
WHERE hri.habitat_id = h.id
|
||||||
|
), '[]'::json) AS recipe,
|
||||||
|
COALESCE((
|
||||||
|
SELECT json_agg(DISTINCT jsonb_build_object('id', p.id, 'name', p.name))
|
||||||
|
FROM habitat_pokemon hp
|
||||||
|
JOIN pokemon p ON p.id = hp.pokemon_id
|
||||||
|
WHERE hp.habitat_id = h.id
|
||||||
|
), '[]'::json) AS pokemon
|
||||||
|
FROM habitats h
|
||||||
|
ORDER BY h.name
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getHabitat(id: number) {
|
||||||
|
const habitat = await queryOne(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
h.id,
|
||||||
|
h.name,
|
||||||
|
COALESCE((
|
||||||
|
SELECT json_agg(json_build_object('id', i.id, 'name', i.name, 'quantity', hri.quantity) ORDER BY i.name)
|
||||||
|
FROM habitat_recipe_items hri
|
||||||
|
JOIN items i ON i.id = hri.item_id
|
||||||
|
WHERE hri.habitat_id = h.id
|
||||||
|
), '[]'::json) AS recipe
|
||||||
|
FROM habitats h
|
||||||
|
WHERE h.id = $1
|
||||||
|
`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!habitat) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pokemon = await query(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
p.id,
|
||||||
|
p.name,
|
||||||
|
hp.time_of_day,
|
||||||
|
hp.weather,
|
||||||
|
hp.rarity,
|
||||||
|
json_build_object('id', m.id, 'name', m.name) AS map
|
||||||
|
FROM habitat_pokemon hp
|
||||||
|
JOIN pokemon p ON p.id = hp.pokemon_id
|
||||||
|
JOIN maps m ON m.id = hp.map_id
|
||||||
|
WHERE hp.habitat_id = $1
|
||||||
|
ORDER BY hp.rarity, p.id, m.name
|
||||||
|
`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
return { ...habitat, pokemon };
|
||||||
|
}
|
||||||
|
|
||||||
|
const itemProjection = `
|
||||||
|
SELECT
|
||||||
|
i.id,
|
||||||
|
i.name,
|
||||||
|
json_build_object('id', c.id, 'name', c.name) AS category,
|
||||||
|
json_build_object('id', u.id, 'name', u.name) AS usage,
|
||||||
|
json_build_object(
|
||||||
|
'dyeable', i.dyeable,
|
||||||
|
'dualDyeable', i.dual_dyeable,
|
||||||
|
'patternEditable', i.pattern_editable
|
||||||
|
) AS customization,
|
||||||
|
COALESCE((
|
||||||
|
SELECT json_agg(json_build_object('id', t.id, 'name', t.name) ORDER BY t.name)
|
||||||
|
FROM item_item_tags iit
|
||||||
|
JOIN item_tags t ON t.id = iit.item_tag_id
|
||||||
|
WHERE iit.item_id = i.id
|
||||||
|
), '[]'::json) AS tags
|
||||||
|
FROM items i
|
||||||
|
JOIN item_categories c ON c.id = i.category_id
|
||||||
|
JOIN item_usages u ON u.id = i.usage_id
|
||||||
|
`;
|
||||||
|
|
||||||
|
export async function listItems(paramsQuery: QueryParams) {
|
||||||
|
const params: unknown[] = [];
|
||||||
|
const conditions: string[] = [];
|
||||||
|
const categoryId = Number(asString(paramsQuery.categoryId));
|
||||||
|
const usageId = Number(asString(paramsQuery.usageId));
|
||||||
|
const tagIds = parseIdList(asString(paramsQuery.tagIds));
|
||||||
|
const search = asString(paramsQuery.search)?.trim();
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
params.push(`%${search}%`);
|
||||||
|
conditions.push(`i.name ILIKE $${params.length}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Number.isInteger(categoryId) && categoryId > 0) {
|
||||||
|
params.push(categoryId);
|
||||||
|
conditions.push(`i.category_id = $${params.length}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Number.isInteger(usageId) && usageId > 0) {
|
||||||
|
params.push(usageId);
|
||||||
|
conditions.push(`i.usage_id = $${params.length}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tagFilter = sqlForRelationFilter(
|
||||||
|
tagIds,
|
||||||
|
'any',
|
||||||
|
'item_item_tags',
|
||||||
|
'item_id',
|
||||||
|
'item_tag_id',
|
||||||
|
'i.id',
|
||||||
|
params
|
||||||
|
);
|
||||||
|
if (tagFilter) {
|
||||||
|
conditions.push(tagFilter);
|
||||||
|
}
|
||||||
|
|
||||||
|
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||||
|
return query(`${itemProjection} ${whereClause} ORDER BY c.name, i.name`, params);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getItem(id: number) {
|
||||||
|
const item = await queryOne(`${itemProjection} WHERE i.id = $1`, [id]);
|
||||||
|
if (!item) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [acquisitionMethods, recipe, relatedHabitats] = await Promise.all([
|
||||||
|
query(
|
||||||
|
`
|
||||||
|
SELECT am.id, am.name
|
||||||
|
FROM item_acquisition_methods iam
|
||||||
|
JOIN acquisition_methods am ON am.id = iam.acquisition_method_id
|
||||||
|
WHERE iam.item_id = $1
|
||||||
|
ORDER BY am.name
|
||||||
|
`,
|
||||||
|
[id]
|
||||||
|
),
|
||||||
|
queryOne(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
r.id,
|
||||||
|
r.name,
|
||||||
|
COALESCE((
|
||||||
|
SELECT json_agg(json_build_object('id', am.id, 'name', am.name) ORDER BY am.name)
|
||||||
|
FROM recipe_acquisition_methods ram
|
||||||
|
JOIN acquisition_methods am ON am.id = ram.acquisition_method_id
|
||||||
|
WHERE ram.recipe_id = r.id
|
||||||
|
), '[]'::json) AS acquisition_methods,
|
||||||
|
COALESCE((
|
||||||
|
SELECT json_agg(json_build_object('id', mi.id, 'name', mi.name, 'quantity', rm.quantity) ORDER BY mi.name)
|
||||||
|
FROM recipe_materials rm
|
||||||
|
JOIN items mi ON mi.id = rm.item_id
|
||||||
|
WHERE rm.recipe_id = r.id
|
||||||
|
), '[]'::json) AS materials
|
||||||
|
FROM items i
|
||||||
|
JOIN recipes r ON r.id = i.recipe_id
|
||||||
|
WHERE i.id = $1
|
||||||
|
`,
|
||||||
|
[id]
|
||||||
|
),
|
||||||
|
query(
|
||||||
|
`
|
||||||
|
SELECT h.id, h.name, hri.quantity
|
||||||
|
FROM habitat_recipe_items hri
|
||||||
|
JOIN habitats h ON h.id = hri.habitat_id
|
||||||
|
WHERE hri.item_id = $1
|
||||||
|
ORDER BY h.name
|
||||||
|
`,
|
||||||
|
[id]
|
||||||
|
)
|
||||||
|
]);
|
||||||
|
|
||||||
|
return { ...item, acquisitionMethods, recipe, relatedHabitats };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listRecipes() {
|
||||||
|
return query(`
|
||||||
|
SELECT
|
||||||
|
r.id,
|
||||||
|
r.name,
|
||||||
|
COALESCE((
|
||||||
|
SELECT json_agg(json_build_object('id', i.id, 'name', i.name, 'quantity', rm.quantity) ORDER BY i.name)
|
||||||
|
FROM recipe_materials rm
|
||||||
|
JOIN items i ON i.id = rm.item_id
|
||||||
|
WHERE rm.recipe_id = r.id
|
||||||
|
), '[]'::json) AS materials
|
||||||
|
FROM recipes r
|
||||||
|
ORDER BY r.name
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getRecipe(id: number) {
|
||||||
|
return queryOne(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
r.id,
|
||||||
|
r.name,
|
||||||
|
COALESCE((
|
||||||
|
SELECT json_agg(json_build_object('id', am.id, 'name', am.name) ORDER BY am.name)
|
||||||
|
FROM recipe_acquisition_methods ram
|
||||||
|
JOIN acquisition_methods am ON am.id = ram.acquisition_method_id
|
||||||
|
WHERE ram.recipe_id = r.id
|
||||||
|
), '[]'::json) AS acquisition_methods,
|
||||||
|
COALESCE((
|
||||||
|
SELECT json_agg(json_build_object('id', i.id, 'name', i.name, 'quantity', rm.quantity) ORDER BY i.name)
|
||||||
|
FROM recipe_materials rm
|
||||||
|
JOIN items i ON i.id = rm.item_id
|
||||||
|
WHERE rm.recipe_id = r.id
|
||||||
|
), '[]'::json) AS materials
|
||||||
|
FROM recipes r
|
||||||
|
WHERE r.id = $1
|
||||||
|
`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
}
|
||||||
89
backend/src/server.ts
Normal file
89
backend/src/server.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import cors from '@fastify/cors';
|
||||||
|
import Fastify from 'fastify';
|
||||||
|
import { initializeDatabase, pool } from './db.ts';
|
||||||
|
import {
|
||||||
|
getHabitat,
|
||||||
|
getItem,
|
||||||
|
getOptions,
|
||||||
|
getPokemon,
|
||||||
|
getRecipe,
|
||||||
|
listHabitats,
|
||||||
|
listItems,
|
||||||
|
listPokemon,
|
||||||
|
listRecipes
|
||||||
|
} from './queries.ts';
|
||||||
|
|
||||||
|
const app = Fastify({
|
||||||
|
logger: true
|
||||||
|
});
|
||||||
|
|
||||||
|
await app.register(cors, {
|
||||||
|
origin: process.env.FRONTEND_ORIGIN ?? true
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/health', async () => ({ ok: true }));
|
||||||
|
|
||||||
|
app.get('/api/options', async () => getOptions());
|
||||||
|
|
||||||
|
app.get('/api/pokemon', async (request) => listPokemon(request.query as Record<string, string | string[] | undefined>));
|
||||||
|
|
||||||
|
app.get('/api/pokemon/:id', async (request, reply) => {
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
const pokemon = await getPokemon(Number(id));
|
||||||
|
|
||||||
|
if (!pokemon) {
|
||||||
|
return reply.code(404).send({ message: 'Not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return pokemon;
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/habitats', async () => listHabitats());
|
||||||
|
|
||||||
|
app.get('/api/habitats/:id', async (request, reply) => {
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
const habitat = await getHabitat(Number(id));
|
||||||
|
|
||||||
|
if (!habitat) {
|
||||||
|
return reply.code(404).send({ message: 'Not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return habitat;
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/items', async (request) => listItems(request.query as Record<string, string | string[] | undefined>));
|
||||||
|
|
||||||
|
app.get('/api/items/:id', async (request, reply) => {
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
const item = await getItem(Number(id));
|
||||||
|
|
||||||
|
if (!item) {
|
||||||
|
return reply.code(404).send({ message: 'Not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return item;
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/recipes', async () => listRecipes());
|
||||||
|
|
||||||
|
app.get('/api/recipes/:id', async (request, reply) => {
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
const recipe = await getRecipe(Number(id));
|
||||||
|
|
||||||
|
if (!recipe) {
|
||||||
|
return reply.code(404).send({ message: 'Not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return recipe;
|
||||||
|
});
|
||||||
|
|
||||||
|
const port = Number(process.env.BACKEND_PORT ?? 3001);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await initializeDatabase();
|
||||||
|
await app.listen({ host: '0.0.0.0', port });
|
||||||
|
} catch (error) {
|
||||||
|
app.log.error(error);
|
||||||
|
await pool.end();
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
33
backend/tests/filter.test.ts
Normal file
33
backend/tests/filter.test.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { describe, it } from 'node:test';
|
||||||
|
import { parseIdList, parseMatchMode, sqlForRelationFilter } from '../src/filter.ts';
|
||||||
|
|
||||||
|
describe('filter helpers', () => {
|
||||||
|
it('parses comma separated ids and drops invalid values', () => {
|
||||||
|
assert.deepEqual(parseIdList('1, 2, x, -1, 3'), [1, 2, 3]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults match mode to any', () => {
|
||||||
|
assert.equal(parseMatchMode('all'), 'all');
|
||||||
|
assert.equal(parseMatchMode('anything'), 'any');
|
||||||
|
assert.equal(parseMatchMode(undefined), 'any');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds relation filters with bound array parameters', () => {
|
||||||
|
const params: unknown[] = [];
|
||||||
|
const sql = sqlForRelationFilter(
|
||||||
|
[1, 2],
|
||||||
|
'all',
|
||||||
|
'pokemon_skills',
|
||||||
|
'pokemon_id',
|
||||||
|
'skill_id',
|
||||||
|
'p.id',
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(params.length, 1);
|
||||||
|
assert.deepEqual(params[0], [1, 2]);
|
||||||
|
assert.match(sql, /count\(DISTINCT skill_id\)/);
|
||||||
|
assert.match(sql, /= 2/);
|
||||||
|
});
|
||||||
|
});
|
||||||
14
backend/tsconfig.json
Normal file
14
backend/tsconfig.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"types": ["node"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "tests/**/*.ts"]
|
||||||
|
}
|
||||||
42
docker-compose.yml
Normal file
42
docker-compose.yml
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:18-alpine
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: pokopia
|
||||||
|
POSTGRES_USER: pokopia
|
||||||
|
POSTGRES_PASSWORD: pokopia
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
volumes:
|
||||||
|
- postgres18_data:/var/lib/postgresql
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U pokopia -d pokopia"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 10
|
||||||
|
|
||||||
|
backend:
|
||||||
|
build:
|
||||||
|
context: ./backend
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgres://pokopia:pokopia@postgres:5432/pokopia
|
||||||
|
BACKEND_PORT: 3001
|
||||||
|
FRONTEND_ORIGIN: http://localhost:3000
|
||||||
|
ports:
|
||||||
|
- "3001:3001"
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: ./frontend
|
||||||
|
environment:
|
||||||
|
VITE_API_BASE_URL: http://localhost:3001
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres18_data:
|
||||||
8
frontend/Dockerfile
Normal file
8
frontend/Dockerfile
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
FROM node:22-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json ./
|
||||||
|
RUN npm install
|
||||||
|
COPY . .
|
||||||
|
EXPOSE 3000
|
||||||
|
CMD ["npm", "run", "dev"]
|
||||||
12
frontend/index.html
Normal file
12
frontend/index.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Pokopia Wiki</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
26
frontend/package.json
Normal file
26
frontend/package.json
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"name": "@pokopia/frontend",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite --host 0.0.0.0 --port 3000",
|
||||||
|
"build": "vue-tsc --noEmit && vite build",
|
||||||
|
"lint": "vue-tsc --noEmit",
|
||||||
|
"typecheck": "vue-tsc --noEmit",
|
||||||
|
"test": "vitest run"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@vitejs/plugin-vue": "latest",
|
||||||
|
"vite": "latest",
|
||||||
|
"vue": "latest",
|
||||||
|
"vue-router": "latest"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "latest",
|
||||||
|
"@vue/tsconfig": "latest",
|
||||||
|
"typescript": "latest",
|
||||||
|
"vitest": "latest",
|
||||||
|
"vue-tsc": "latest"
|
||||||
|
}
|
||||||
|
}
|
||||||
24
frontend/src/App.vue
Normal file
24
frontend/src/App.vue
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
const navItems = [
|
||||||
|
{ label: 'Pokemon', to: '/pokemon' },
|
||||||
|
{ label: '栖息地', to: '/habitats' },
|
||||||
|
{ label: '物品 / 材料单', to: '/items' }
|
||||||
|
];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="app-shell">
|
||||||
|
<header class="topbar">
|
||||||
|
<RouterLink class="brand" to="/pokemon">Pokopia Wiki</RouterLink>
|
||||||
|
<nav class="nav-tabs" aria-label="主导航">
|
||||||
|
<RouterLink v-for="item in navItems" :key="item.to" :to="item.to">
|
||||||
|
{{ item.label }}
|
||||||
|
</RouterLink>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="page">
|
||||||
|
<RouterView />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
16
frontend/src/components/EntityChips.vue
Normal file
16
frontend/src/components/EntityChips.vue
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { NamedEntity } from '../services/api';
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
items: Array<NamedEntity & { subcategory?: string | null; quantity?: number }>;
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="chips">
|
||||||
|
<span v-for="item in items" :key="`${item.id}-${item.name}`" class="chip">
|
||||||
|
{{ item.name }}<span v-if="item.subcategory"> · {{ item.subcategory }}</span
|
||||||
|
><span v-if="item.quantity"> × {{ item.quantity }}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
6
frontend/src/main.ts
Normal file
6
frontend/src/main.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { createApp } from 'vue';
|
||||||
|
import App from './App.vue';
|
||||||
|
import { router } from './router';
|
||||||
|
import './styles/main.css';
|
||||||
|
|
||||||
|
createApp(App).use(router).mount('#app');
|
||||||
23
frontend/src/router/index.ts
Normal file
23
frontend/src/router/index.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { createRouter, createWebHistory } from 'vue-router';
|
||||||
|
import PokemonList from '../views/PokemonList.vue';
|
||||||
|
import PokemonDetail from '../views/PokemonDetail.vue';
|
||||||
|
import HabitatList from '../views/HabitatList.vue';
|
||||||
|
import HabitatDetail from '../views/HabitatDetail.vue';
|
||||||
|
import ItemsList from '../views/ItemsList.vue';
|
||||||
|
import ItemDetail from '../views/ItemDetail.vue';
|
||||||
|
import RecipeDetail from '../views/RecipeDetail.vue';
|
||||||
|
|
||||||
|
export const router = createRouter({
|
||||||
|
history: createWebHistory(),
|
||||||
|
routes: [
|
||||||
|
{ path: '/', redirect: '/pokemon' },
|
||||||
|
{ path: '/pokemon', component: PokemonList },
|
||||||
|
{ path: '/pokemon/:id', component: PokemonDetail },
|
||||||
|
{ path: '/habitats', component: HabitatList },
|
||||||
|
{ path: '/habitats/:id', component: HabitatDetail },
|
||||||
|
{ path: '/items', component: ItemsList },
|
||||||
|
{ path: '/items/:id', component: ItemDetail },
|
||||||
|
{ path: '/recipes/:id', component: RecipeDetail }
|
||||||
|
],
|
||||||
|
scrollBehavior: () => ({ top: 0 })
|
||||||
|
});
|
||||||
10
frontend/src/services/api.test.ts
Normal file
10
frontend/src/services/api.test.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { buildQuery } from './api';
|
||||||
|
|
||||||
|
describe('buildQuery', () => {
|
||||||
|
it('keeps business filters and drops empty values', () => {
|
||||||
|
expect(buildQuery({ search: '妙蛙', environmentId: 1, skillIds: '', usageId: undefined })).toBe(
|
||||||
|
'?search=%E5%A6%99%E8%9B%99&environmentId=1'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
120
frontend/src/services/api.ts
Normal file
120
frontend/src/services/api.ts
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:3001';
|
||||||
|
|
||||||
|
export interface NamedEntity {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Skill extends NamedEntity {
|
||||||
|
subcategory: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Pokemon {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
environment: NamedEntity;
|
||||||
|
skills: Skill[];
|
||||||
|
favorite_things: NamedEntity[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PokemonDetail extends Pokemon {
|
||||||
|
habitats: Array<{
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
time_of_day: string;
|
||||||
|
weather: string;
|
||||||
|
rarity: number;
|
||||||
|
map: NamedEntity;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Habitat {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
recipe: Array<NamedEntity & { quantity: number }>;
|
||||||
|
pokemon?: NamedEntity[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HabitatDetail extends Habitat {
|
||||||
|
pokemon: Array<NamedEntity & {
|
||||||
|
time_of_day: string;
|
||||||
|
weather: string;
|
||||||
|
rarity: number;
|
||||||
|
map: NamedEntity;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Item {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
category: NamedEntity;
|
||||||
|
usage: NamedEntity;
|
||||||
|
customization: {
|
||||||
|
dyeable: boolean;
|
||||||
|
dualDyeable: boolean;
|
||||||
|
patternEditable: boolean;
|
||||||
|
};
|
||||||
|
tags: NamedEntity[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ItemDetail extends Item {
|
||||||
|
acquisitionMethods: NamedEntity[];
|
||||||
|
recipe: RecipeDetail | null;
|
||||||
|
relatedHabitats: Array<NamedEntity & { quantity: number }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Recipe {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
materials: Array<NamedEntity & { quantity: number }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecipeDetail extends Recipe {
|
||||||
|
acquisition_methods: NamedEntity[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Options {
|
||||||
|
skills: Skill[];
|
||||||
|
environments: NamedEntity[];
|
||||||
|
favoriteThings: NamedEntity[];
|
||||||
|
itemCategories: NamedEntity[];
|
||||||
|
itemUsages: NamedEntity[];
|
||||||
|
itemTags: NamedEntity[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildQuery(params: Record<string, string | number | undefined>): string {
|
||||||
|
const search = new URLSearchParams();
|
||||||
|
|
||||||
|
Object.entries(params).forEach(([key, value]) => {
|
||||||
|
if (value !== undefined && value !== '') {
|
||||||
|
search.set(key, String(value));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const query = search.toString();
|
||||||
|
return query ? `?${query}` : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getJson<T>(path: string): Promise<T> {
|
||||||
|
const response = await fetch(`${apiBaseUrl}${path}`);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Request failed with ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json() as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
options: () => getJson<Options>('/api/options'),
|
||||||
|
pokemon: (params: Record<string, string | number | undefined>) =>
|
||||||
|
getJson<Pokemon[]>(`/api/pokemon${buildQuery(params)}`),
|
||||||
|
pokemonDetail: (id: string | number) => getJson<PokemonDetail>(`/api/pokemon/${id}`),
|
||||||
|
habitats: () => getJson<Habitat[]>('/api/habitats'),
|
||||||
|
habitatDetail: (id: string | number) => getJson<HabitatDetail>(`/api/habitats/${id}`),
|
||||||
|
items: (params: Record<string, string | number | undefined>) =>
|
||||||
|
getJson<Item[]>(`/api/items${buildQuery(params)}`),
|
||||||
|
itemDetail: (id: string | number) => getJson<ItemDetail>(`/api/items/${id}`),
|
||||||
|
recipes: () => getJson<Recipe[]>('/api/recipes'),
|
||||||
|
recipeDetail: (id: string | number) => getJson<RecipeDetail>(`/api/recipes/${id}`)
|
||||||
|
};
|
||||||
301
frontend/src/styles/main.css
Normal file
301
frontend/src/styles/main.css
Normal file
@@ -0,0 +1,301 @@
|
|||||||
|
:root {
|
||||||
|
color: #17211b;
|
||||||
|
background: #f6f4ee;
|
||||||
|
font-family:
|
||||||
|
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
|
font-synthesis: none;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
min-width: 320px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
input,
|
||||||
|
select {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell {
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 10;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 24px;
|
||||||
|
padding: 16px clamp(16px, 4vw, 48px);
|
||||||
|
border-bottom: 1px solid #d7d2c4;
|
||||||
|
background: rgba(246, 244, 238, 0.94);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #1d3b2b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-tabs a {
|
||||||
|
min-width: max-content;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #4e5c52;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-tabs a.router-link-active {
|
||||||
|
background: #1f6f50;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page {
|
||||||
|
width: min(1180px, calc(100% - 32px));
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 28px 0 56px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: end;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: clamp(28px, 4vw, 40px);
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-subtitle {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
color: #657067;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(150px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
padding: 16px;
|
||||||
|
border: 1px solid #d7d2c4;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field label {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #566156;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field input,
|
||||||
|
.field select {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 40px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 1px solid #c7c0b2;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fffdfa;
|
||||||
|
color: #17211b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.segmented {
|
||||||
|
display: inline-flex;
|
||||||
|
width: fit-content;
|
||||||
|
padding: 3px;
|
||||||
|
border: 1px solid #c7c0b2;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #f1eee5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.segmented button {
|
||||||
|
min-width: 52px;
|
||||||
|
min-height: 32px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: transparent;
|
||||||
|
color: #566156;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.segmented button.active {
|
||||||
|
background: #ffffff;
|
||||||
|
color: #1f6f50;
|
||||||
|
font-weight: 800;
|
||||||
|
box-shadow: 0 1px 4px rgba(31, 111, 80, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entity-card,
|
||||||
|
.detail-section {
|
||||||
|
border: 1px solid #d7d2c4;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entity-card {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entity-card h2,
|
||||||
|
.entity-card h3,
|
||||||
|
.detail-section h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-line {
|
||||||
|
margin: 0;
|
||||||
|
color: #657067;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chips {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 28px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border: 1px solid #cddfce;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #edf7ef;
|
||||||
|
color: #1f5c40;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-section {
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-section h2 {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-list li {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px 0;
|
||||||
|
border-bottom: 1px solid #ebe6da;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-list li:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 36px;
|
||||||
|
width: fit-content;
|
||||||
|
padding: 7px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #a83f39;
|
||||||
|
color: #ffffff;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
padding: 18px;
|
||||||
|
border: 1px solid #d7d2c4;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #ffffff;
|
||||||
|
color: #657067;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs button {
|
||||||
|
min-height: 40px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
border: 1px solid #c7c0b2;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fffdfa;
|
||||||
|
color: #566156;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs button.active {
|
||||||
|
border-color: #1f6f50;
|
||||||
|
background: #1f6f50;
|
||||||
|
color: #ffffff;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.topbar {
|
||||||
|
align-items: start;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
align-items: start;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar,
|
||||||
|
.detail-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
43
frontend/src/views/HabitatDetail.vue
Normal file
43
frontend/src/views/HabitatDetail.vue
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, ref } from 'vue';
|
||||||
|
import { useRoute } from 'vue-router';
|
||||||
|
import EntityChips from '../components/EntityChips.vue';
|
||||||
|
import { api, type HabitatDetail } from '../services/api';
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const habitat = ref<HabitatDetail | null>(null);
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
habitat.value = await api.habitatDetail(String(route.params.id));
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<p v-if="!habitat" class="status">加载中</p>
|
||||||
|
<section v-else>
|
||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
|
<h1 class="page-title">{{ habitat.name }}</h1>
|
||||||
|
<p class="page-subtitle">栖息地详情</p>
|
||||||
|
</div>
|
||||||
|
<RouterLink class="link-button" to="/habitats">返回列表</RouterLink>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="detail-grid">
|
||||||
|
<section class="detail-section">
|
||||||
|
<h2>配方列表</h2>
|
||||||
|
<EntityChips :items="habitat.recipe" />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="detail-section">
|
||||||
|
<h2>可能出现的宝可梦</h2>
|
||||||
|
<ul class="row-list">
|
||||||
|
<li v-for="item in habitat.pokemon" :key="`${item.id}-${item.map.id}-${item.time_of_day}`">
|
||||||
|
<RouterLink :to="`/pokemon/${item.id}`">{{ item.name }}</RouterLink>
|
||||||
|
<span>{{ item.time_of_day }} · {{ item.weather }} · {{ item.rarity }} 星 · {{ item.map.name }}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
33
frontend/src/views/HabitatList.vue
Normal file
33
frontend/src/views/HabitatList.vue
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, ref } from 'vue';
|
||||||
|
import EntityChips from '../components/EntityChips.vue';
|
||||||
|
import { api, type Habitat } from '../services/api';
|
||||||
|
|
||||||
|
const habitats = ref<Habitat[]>([]);
|
||||||
|
const loading = ref(true);
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
habitats.value = await api.habitats();
|
||||||
|
loading.value = false;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section>
|
||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
|
<h1 class="page-title">栖息地</h1>
|
||||||
|
<p class="page-subtitle">查看配方和可能出现的宝可梦。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="loading" class="status">加载中</p>
|
||||||
|
<div v-else class="grid">
|
||||||
|
<RouterLink v-for="item in habitats" :key="item.id" class="entity-card" :to="`/habitats/${item.id}`">
|
||||||
|
<h2>{{ item.name }}</h2>
|
||||||
|
<EntityChips :items="item.recipe" />
|
||||||
|
<EntityChips :items="item.pokemon ?? []" />
|
||||||
|
</RouterLink>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
77
frontend/src/views/ItemDetail.vue
Normal file
77
frontend/src/views/ItemDetail.vue
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref } from 'vue';
|
||||||
|
import { useRoute } from 'vue-router';
|
||||||
|
import EntityChips from '../components/EntityChips.vue';
|
||||||
|
import { api, type ItemDetail } from '../services/api';
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const item = ref<ItemDetail | null>(null);
|
||||||
|
|
||||||
|
const customization = computed(() => {
|
||||||
|
if (!item.value) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
item.value.customization.dyeable ? '可染色' : '',
|
||||||
|
item.value.customization.dualDyeable ? '可双区染色' : '',
|
||||||
|
item.value.customization.patternEditable ? '可改花纹' : ''
|
||||||
|
].filter(Boolean);
|
||||||
|
});
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
item.value = await api.itemDetail(String(route.params.id));
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<p v-if="!item" class="status">加载中</p>
|
||||||
|
<section v-else>
|
||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
|
<h1 class="page-title">{{ item.name }}</h1>
|
||||||
|
<p class="page-subtitle">{{ item.category.name }} · {{ item.usage.name }}</p>
|
||||||
|
</div>
|
||||||
|
<RouterLink class="link-button" to="/items">返回列表</RouterLink>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="detail-grid">
|
||||||
|
<section class="detail-section">
|
||||||
|
<h2>入手方式</h2>
|
||||||
|
<EntityChips :items="item.acquisitionMethods" />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="detail-section">
|
||||||
|
<h2>自定义</h2>
|
||||||
|
<div v-if="customization.length" class="chips">
|
||||||
|
<span v-for="entry in customization" :key="entry" class="chip">{{ entry }}</span>
|
||||||
|
</div>
|
||||||
|
<p v-else class="meta-line">无</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="detail-section">
|
||||||
|
<h2>标签</h2>
|
||||||
|
<EntityChips :items="item.tags" />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="detail-section">
|
||||||
|
<h2>材料单信息</h2>
|
||||||
|
<template v-if="item.recipe">
|
||||||
|
<RouterLink :to="`/recipes/${item.recipe.id}`">{{ item.recipe.name }}</RouterLink>
|
||||||
|
<EntityChips :items="item.recipe.materials" />
|
||||||
|
</template>
|
||||||
|
<p v-else class="meta-line">无</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="detail-section">
|
||||||
|
<h2>相关栖息地</h2>
|
||||||
|
<ul class="row-list">
|
||||||
|
<li v-for="habitat in item.relatedHabitats" :key="habitat.id">
|
||||||
|
<RouterLink :to="`/habitats/${habitat.id}`">{{ habitat.name }}</RouterLink>
|
||||||
|
<span>× {{ habitat.quantity }}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
101
frontend/src/views/ItemsList.vue
Normal file
101
frontend/src/views/ItemsList.vue
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref, watch } from 'vue';
|
||||||
|
import EntityChips from '../components/EntityChips.vue';
|
||||||
|
import { api, type Item, type Options, type Recipe } from '../services/api';
|
||||||
|
|
||||||
|
const tab = ref<'items' | 'recipes'>('items');
|
||||||
|
const options = ref<Options | null>(null);
|
||||||
|
const items = ref<Item[]>([]);
|
||||||
|
const recipes = ref<Recipe[]>([]);
|
||||||
|
const loading = ref(true);
|
||||||
|
const search = ref('');
|
||||||
|
const categoryId = ref('');
|
||||||
|
const usageId = ref('');
|
||||||
|
const tagIds = ref<string[]>([]);
|
||||||
|
|
||||||
|
const itemQuery = computed(() => ({
|
||||||
|
search: search.value,
|
||||||
|
categoryId: categoryId.value,
|
||||||
|
usageId: usageId.value,
|
||||||
|
tagIds: tagIds.value.join(',')
|
||||||
|
}));
|
||||||
|
|
||||||
|
async function loadItems() {
|
||||||
|
loading.value = true;
|
||||||
|
if (tab.value === 'items') {
|
||||||
|
items.value = await api.items(itemQuery.value);
|
||||||
|
} else {
|
||||||
|
recipes.value = await api.recipes();
|
||||||
|
}
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
options.value = await api.options();
|
||||||
|
await loadItems();
|
||||||
|
});
|
||||||
|
|
||||||
|
watch([tab, itemQuery], loadItems);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section>
|
||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
|
<h1 class="page-title">物品 / 材料单</h1>
|
||||||
|
<p class="page-subtitle">按分类、用途、标签查看物品,并浏览材料单。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tabs" role="tablist" aria-label="物品和材料单">
|
||||||
|
<button :class="{ active: tab === 'items' }" type="button" @click="tab = 'items'">物品</button>
|
||||||
|
<button :class="{ active: tab === 'recipes' }" type="button" @click="tab = 'recipes'">材料单</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="tab === 'items' && options" class="toolbar">
|
||||||
|
<div class="field">
|
||||||
|
<label for="item-search">搜索</label>
|
||||||
|
<input id="item-search" v-model="search" type="search" placeholder="名称" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="category">分类</label>
|
||||||
|
<select id="category" v-model="categoryId">
|
||||||
|
<option value="">全部</option>
|
||||||
|
<option v-for="item in options.itemCategories" :key="item.id" :value="item.id">{{ item.name }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="usage">用途</label>
|
||||||
|
<select id="usage" v-model="usageId">
|
||||||
|
<option value="">全部</option>
|
||||||
|
<option v-for="item in options.itemUsages" :key="item.id" :value="item.id">{{ item.name }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="tags">标签</label>
|
||||||
|
<select id="tags" v-model="tagIds" multiple>
|
||||||
|
<option v-for="item in options.itemTags" :key="item.id" :value="String(item.id)">{{ item.name }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="loading" class="status">加载中</p>
|
||||||
|
<div v-else-if="tab === 'items'" class="grid">
|
||||||
|
<RouterLink v-for="item in items" :key="item.id" class="entity-card" :to="`/items/${item.id}`">
|
||||||
|
<h2>{{ item.name }}</h2>
|
||||||
|
<p class="meta-line">{{ item.category.name }} · {{ item.usage.name }}</p>
|
||||||
|
<EntityChips :items="item.tags" />
|
||||||
|
</RouterLink>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="grid">
|
||||||
|
<RouterLink v-for="item in recipes" :key="item.id" class="entity-card" :to="`/recipes/${item.id}`">
|
||||||
|
<h2>{{ item.name }}</h2>
|
||||||
|
<EntityChips :items="item.materials" />
|
||||||
|
</RouterLink>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
48
frontend/src/views/PokemonDetail.vue
Normal file
48
frontend/src/views/PokemonDetail.vue
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, ref } from 'vue';
|
||||||
|
import { useRoute } from 'vue-router';
|
||||||
|
import EntityChips from '../components/EntityChips.vue';
|
||||||
|
import { api, type PokemonDetail } from '../services/api';
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const pokemon = ref<PokemonDetail | null>(null);
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
pokemon.value = await api.pokemonDetail(String(route.params.id));
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<p v-if="!pokemon" class="status">加载中</p>
|
||||||
|
<section v-else>
|
||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
|
<h1 class="page-title">#{{ pokemon.id }} {{ pokemon.name }}</h1>
|
||||||
|
<p class="page-subtitle">喜欢的环境:{{ pokemon.environment.name }}</p>
|
||||||
|
</div>
|
||||||
|
<RouterLink class="link-button" to="/pokemon">返回列表</RouterLink>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="detail-grid">
|
||||||
|
<section class="detail-section">
|
||||||
|
<h2>特长</h2>
|
||||||
|
<EntityChips :items="pokemon.skills" />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="detail-section">
|
||||||
|
<h2>喜欢的东西</h2>
|
||||||
|
<EntityChips :items="pokemon.favorite_things" />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="detail-section">
|
||||||
|
<h2>栖息地</h2>
|
||||||
|
<ul class="row-list">
|
||||||
|
<li v-for="habitat in pokemon.habitats" :key="`${habitat.id}-${habitat.map.id}-${habitat.time_of_day}`">
|
||||||
|
<RouterLink :to="`/habitats/${habitat.id}`">{{ habitat.name }}</RouterLink>
|
||||||
|
<span>{{ habitat.time_of_day }} · {{ habitat.weather }} · {{ habitat.rarity }} 星 · {{ habitat.map.name }}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
105
frontend/src/views/PokemonList.vue
Normal file
105
frontend/src/views/PokemonList.vue
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref, watch } from 'vue';
|
||||||
|
import EntityChips from '../components/EntityChips.vue';
|
||||||
|
import { api, type Options, type Pokemon } from '../services/api';
|
||||||
|
|
||||||
|
const options = ref<Options | null>(null);
|
||||||
|
const pokemon = ref<Pokemon[]>([]);
|
||||||
|
const loading = ref(true);
|
||||||
|
const search = ref('');
|
||||||
|
const environmentId = ref('');
|
||||||
|
const skillIds = ref<string[]>([]);
|
||||||
|
const skillMode = ref<'any' | 'all'>('any');
|
||||||
|
const favoriteThingIds = ref<string[]>([]);
|
||||||
|
const favoriteThingMode = ref<'any' | 'all'>('any');
|
||||||
|
|
||||||
|
const query = computed(() => ({
|
||||||
|
search: search.value,
|
||||||
|
environmentId: environmentId.value,
|
||||||
|
skillIds: skillIds.value.join(','),
|
||||||
|
skillMode: skillMode.value,
|
||||||
|
favoriteThingIds: favoriteThingIds.value.join(','),
|
||||||
|
favoriteThingMode: favoriteThingMode.value
|
||||||
|
}));
|
||||||
|
|
||||||
|
async function loadPokemon() {
|
||||||
|
loading.value = true;
|
||||||
|
pokemon.value = await api.pokemon(query.value);
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
options.value = await api.options();
|
||||||
|
await loadPokemon();
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(query, loadPokemon);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section>
|
||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
|
<h1 class="page-title">Pokemon</h1>
|
||||||
|
<p class="page-subtitle">搜索宝可梦,并按特长、环境、喜欢的东西筛选。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="options" class="toolbar">
|
||||||
|
<div class="field">
|
||||||
|
<label for="pokemon-search">搜索</label>
|
||||||
|
<input id="pokemon-search" v-model="search" type="search" placeholder="名字" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="environment">喜欢的环境</label>
|
||||||
|
<select id="environment" v-model="environmentId">
|
||||||
|
<option value="">全部</option>
|
||||||
|
<option v-for="item in options.environments" :key="item.id" :value="item.id">
|
||||||
|
{{ item.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="skills">特长</label>
|
||||||
|
<select id="skills" v-model="skillIds" multiple>
|
||||||
|
<option v-for="item in options.skills" :key="item.id" :value="String(item.id)">
|
||||||
|
{{ item.name }}{{ item.subcategory ? ` · ${item.subcategory}` : '' }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<div class="segmented" aria-label="特长匹配方式">
|
||||||
|
<button :class="{ active: skillMode === 'any' }" type="button" @click="skillMode = 'any'">任意</button>
|
||||||
|
<button :class="{ active: skillMode === 'all' }" type="button" @click="skillMode = 'all'">全部</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="favorite-things">喜欢的东西</label>
|
||||||
|
<select id="favorite-things" v-model="favoriteThingIds" multiple>
|
||||||
|
<option v-for="item in options.favoriteThings" :key="item.id" :value="String(item.id)">
|
||||||
|
{{ item.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<div class="segmented" aria-label="喜欢的东西匹配方式">
|
||||||
|
<button :class="{ active: favoriteThingMode === 'any' }" type="button" @click="favoriteThingMode = 'any'">
|
||||||
|
任意
|
||||||
|
</button>
|
||||||
|
<button :class="{ active: favoriteThingMode === 'all' }" type="button" @click="favoriteThingMode = 'all'">
|
||||||
|
全部
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="loading" class="status">加载中</p>
|
||||||
|
<div v-else class="grid">
|
||||||
|
<RouterLink v-for="item in pokemon" :key="item.id" class="entity-card" :to="`/pokemon/${item.id}`">
|
||||||
|
<h2>#{{ item.id }} {{ item.name }}</h2>
|
||||||
|
<p class="meta-line">喜欢的环境:{{ item.environment.name }}</p>
|
||||||
|
<EntityChips :items="item.skills" />
|
||||||
|
<EntityChips :items="item.favorite_things" />
|
||||||
|
</RouterLink>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
38
frontend/src/views/RecipeDetail.vue
Normal file
38
frontend/src/views/RecipeDetail.vue
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, ref } from 'vue';
|
||||||
|
import { useRoute } from 'vue-router';
|
||||||
|
import EntityChips from '../components/EntityChips.vue';
|
||||||
|
import { api, type RecipeDetail } from '../services/api';
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const recipe = ref<RecipeDetail | null>(null);
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
recipe.value = await api.recipeDetail(String(route.params.id));
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<p v-if="!recipe" class="status">加载中</p>
|
||||||
|
<section v-else>
|
||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
|
<h1 class="page-title">{{ recipe.name }}</h1>
|
||||||
|
<p class="page-subtitle">材料单详情</p>
|
||||||
|
</div>
|
||||||
|
<RouterLink class="link-button" to="/items">返回列表</RouterLink>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="detail-grid">
|
||||||
|
<section class="detail-section">
|
||||||
|
<h2>入手方式</h2>
|
||||||
|
<EntityChips :items="recipe.acquisition_methods" />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="detail-section">
|
||||||
|
<h2>需要材料</h2>
|
||||||
|
<EntityChips :items="recipe.materials" />
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
8
frontend/tsconfig.json
Normal file
8
frontend/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"strict": true,
|
||||||
|
"types": ["vite/client", "vitest/globals"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.vue", "vite.config.ts"]
|
||||||
|
}
|
||||||
9
frontend/vite.config.ts
Normal file
9
frontend/vite.config.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import vue from '@vitejs/plugin-vue';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue()],
|
||||||
|
server: {
|
||||||
|
port: 3000
|
||||||
|
}
|
||||||
|
});
|
||||||
15
package.json
Normal file
15
package.json
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"name": "pokopia",
|
||||||
|
"private": true,
|
||||||
|
"packageManager": "pnpm@10.33.2",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "pnpm --parallel --filter @pokopia/backend --filter @pokopia/frontend dev",
|
||||||
|
"lint": "pnpm -r lint",
|
||||||
|
"typecheck": "pnpm -r typecheck",
|
||||||
|
"test": "pnpm -r test",
|
||||||
|
"build": "pnpm -r build"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22"
|
||||||
|
}
|
||||||
|
}
|
||||||
3
pnpm-workspace.yaml
Normal file
3
pnpm-workspace.yaml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
packages:
|
||||||
|
- backend
|
||||||
|
- frontend
|
||||||
Reference in New Issue
Block a user