initial commit

This commit is contained in:
2026-04-29 17:46:58 +08:00
commit b428595769
38 changed files with 2229 additions and 0 deletions

0
.codex Normal file
View File

6
.env.example Normal file
View 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
View File

@@ -0,0 +1,9 @@
node_modules/
.pnpm-store/
dist/
.env
.env.*
!.env.example
coverage/
*.log
.DS_Store

2
.npmrc Normal file
View File

@@ -0,0 +1,2 @@
store-dir=.pnpm-store
package-import-method=copy

155
AGENTS.md Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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);
}

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

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

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

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

View 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}`)
};

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

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

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

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

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

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

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

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

@@ -0,0 +1,3 @@
packages:
- backend
- frontend