initial commit
This commit is contained in:
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
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user