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

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