Compare commits
3 Commits
f92e97b747
...
f26cfdc830
| Author | SHA1 | Date | |
|---|---|---|---|
| f26cfdc830 | |||
| 71b35b9cc6 | |||
| 70f7a73e6d |
@@ -20,6 +20,12 @@ RESEND_QUOTA_RESERVE=5
|
||||
RESEND_QUOTA_SNAPSHOT_TTL_MINUTES=10
|
||||
AI_MODERATION_API_KEY=
|
||||
|
||||
# Local Docker debug defaults:
|
||||
# docker compose -f docker-compose.debug.yml up --build
|
||||
# NUXT_PUBLIC_API_BASE_URL=http://localhost:20016
|
||||
# NUXT_SERVER_API_BASE_URL=http://backend:3001
|
||||
# NUXT_PUBLIC_SITE_URL=http://localhost:20015
|
||||
|
||||
# Cloudflared tunnel deployment example:
|
||||
# FRONTEND_ORIGIN=https://pokopiawiki.tootaio.com,http://localhost:20015
|
||||
# APP_ORIGIN=https://pokopiawiki.tootaio.com
|
||||
|
||||
@@ -1072,6 +1072,7 @@ API 暴露边界:
|
||||
- `frontend` 因 `docker compose up -d --build` 重建、启动中或临时不可达时,`frontend_gateway` 返回静态升级维护页并保持公开端口可访问;后端 `/health` 不可用时,前端网关也返回同一维护页,避免用户看到静态页面后遇到 API 不可用。
|
||||
- 升级维护页是基础设施级静态 fallback,不依赖 Vue、Vue I18n、后端 API 或数据库;页面只展示正式用户文案和品牌视觉,不展示构建日志、调试信息、内部字段或实现说明。
|
||||
- 升级维护页使用 `503`、`Retry-After: 300`、`Cache-Control: no-store` 和 `noindex`,提示用户 Pokopia Wiki 正在升级并将在约 5 分钟内恢复。
|
||||
- 本地 Docker 调试使用 `docker-compose.debug.yml`,通过 bind mount 运行 Nuxt dev server 与 backend `tsx watch`,支持前后端热重载;该调试入口不经过 `frontend_gateway` 维护页,不代表生产部署行为。
|
||||
|
||||
## API 概览
|
||||
|
||||
@@ -1198,3 +1199,4 @@ API 暴露边界:
|
||||
- `pnpm typecheck`
|
||||
- 不在 WSL 中运行测试作为完成任务的前置条件。
|
||||
- Docker 运行问题以用户提供的 `docker compose up --build` 输出为准进行后续修复。
|
||||
- 本地热重载调试可运行 `pnpm docker:debug` 或 `docker compose -f docker-compose.debug.yml up --build`;生产 SSR runtime 验证仍使用 `pnpm docker:prod` 或 `docker compose up --build`。
|
||||
|
||||
@@ -142,6 +142,7 @@ Keep this file aligned with implementation progress while the SSR migration is i
|
||||
- `frontend/Dockerfile` now runs `node .output/server/index.mjs` with `HOST=0.0.0.0` and `PORT=20015`; the obsolete lightweight static server file was removed.
|
||||
- `frontend_gateway` continues to proxy `frontend:20015` and keep the backend health-gated maintenance fallback independent from Nuxt.
|
||||
- `DESIGN.md` now documents the Nuxt server output deployment model and the existing browser API, server API, site URL, origin, and proxy environment variables.
|
||||
- `docker-compose.debug.yml` provides a separate local debug path that runs Nuxt dev server and backend `tsx watch` with bind-mounted source; default `docker compose up --build` remains the production SSR runtime validation path.
|
||||
- A local smoke check of `node frontend/.output/server/index.mjs` on port `20115` returned SSR HTML for `/` and `200` for `/robots.txt`; Docker compose runtime validation is still pending.
|
||||
|
||||
## Phase 8: Validation
|
||||
|
||||
110
docker-compose.debug.yml
Normal file
110
docker-compose.debug.yml
Normal file
@@ -0,0 +1,110 @@
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:18-alpine
|
||||
environment:
|
||||
POSTGRES_DB: pokopia
|
||||
POSTGRES_USER: pokopia
|
||||
POSTGRES_PASSWORD: pokopia
|
||||
volumes:
|
||||
- postgres18_data:/var/lib/postgresql
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U pokopia -d pokopia"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
|
||||
deps:
|
||||
image: node:22-alpine
|
||||
working_dir: /app
|
||||
environment:
|
||||
PNPM_HOME: /pnpm
|
||||
volumes:
|
||||
- .:/app
|
||||
- root_node_modules:/app/node_modules
|
||||
- backend_node_modules:/app/backend/node_modules
|
||||
- frontend_node_modules:/app/frontend/node_modules
|
||||
- pnpm_store:/pnpm/store
|
||||
command: >
|
||||
sh -lc "corepack enable &&
|
||||
corepack prepare pnpm@10.33.2 --activate &&
|
||||
pnpm config set store-dir /pnpm/store &&
|
||||
pnpm install --frozen-lockfile"
|
||||
|
||||
backend:
|
||||
image: node:22-alpine
|
||||
working_dir: /app
|
||||
environment:
|
||||
NODE_ENV: development
|
||||
PNPM_HOME: /pnpm
|
||||
DATABASE_URL: postgres://pokopia:pokopia@postgres:5432/pokopia
|
||||
BACKEND_PORT: 3001
|
||||
TRUST_PROXY: ${TRUST_PROXY:-false}
|
||||
FRONTEND_ORIGIN: ${FRONTEND_ORIGIN:-http://localhost:20015}
|
||||
APP_ORIGIN: ${APP_ORIGIN:-http://localhost:20015}
|
||||
UPLOAD_DIR: /app/uploads
|
||||
BACKEND_PUBLIC_ORIGIN: ${BACKEND_PUBLIC_ORIGIN:-http://localhost:20016}
|
||||
RESEND_API_KEY: ${RESEND_API_KEY:-}
|
||||
EMAIL_FROM: "${EMAIL_FROM:-Pokopia Wiki <onboarding@resend.dev>}"
|
||||
RESEND_DAILY_QUOTA_LIMIT: ${RESEND_DAILY_QUOTA_LIMIT:-100}
|
||||
RESEND_MONTHLY_QUOTA_LIMIT: ${RESEND_MONTHLY_QUOTA_LIMIT:-3000}
|
||||
RESEND_QUOTA_RESERVE: ${RESEND_QUOTA_RESERVE:-5}
|
||||
RESEND_QUOTA_SNAPSHOT_TTL_MINUTES: ${RESEND_QUOTA_SNAPSHOT_TTL_MINUTES:-10}
|
||||
AI_MODERATION_API_KEY: ${AI_MODERATION_API_KEY:-}
|
||||
ports:
|
||||
- "20016:3001"
|
||||
volumes:
|
||||
- .:/app
|
||||
- root_node_modules:/app/node_modules
|
||||
- backend_node_modules:/app/backend/node_modules
|
||||
- frontend_node_modules:/app/frontend/node_modules
|
||||
- pnpm_store:/pnpm/store
|
||||
- backend_uploads:/app/uploads
|
||||
command: >
|
||||
sh -lc "corepack enable &&
|
||||
corepack prepare pnpm@10.33.2 --activate &&
|
||||
pnpm --filter @pokopia/backend dev"
|
||||
depends_on:
|
||||
deps:
|
||||
condition: service_completed_successfully
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
|
||||
frontend:
|
||||
image: node:22-alpine
|
||||
working_dir: /app
|
||||
environment:
|
||||
NODE_ENV: development
|
||||
PNPM_HOME: /pnpm
|
||||
HOST: 0.0.0.0
|
||||
PORT: 20015
|
||||
CHOKIDAR_USEPOLLING: "true"
|
||||
NUXT_PUBLIC_API_BASE_URL: ${NUXT_PUBLIC_API_BASE_URL:-http://localhost:20016}
|
||||
NUXT_SERVER_API_BASE_URL: ${NUXT_SERVER_API_BASE_URL:-http://backend:3001}
|
||||
NUXT_PUBLIC_SITE_URL: ${NUXT_PUBLIC_SITE_URL:-http://localhost:20015}
|
||||
VITE_API_BASE_URL: ${VITE_API_BASE_URL:-http://localhost:20016}
|
||||
VITE_SITE_URL: ${VITE_SITE_URL:-http://localhost:20015}
|
||||
ports:
|
||||
- "20015:20015"
|
||||
volumes:
|
||||
- .:/app
|
||||
- root_node_modules:/app/node_modules
|
||||
- backend_node_modules:/app/backend/node_modules
|
||||
- frontend_node_modules:/app/frontend/node_modules
|
||||
- pnpm_store:/pnpm/store
|
||||
command: >
|
||||
sh -lc "corepack enable &&
|
||||
corepack prepare pnpm@10.33.2 --activate &&
|
||||
pnpm --filter @pokopia/frontend dev"
|
||||
depends_on:
|
||||
deps:
|
||||
condition: service_completed_successfully
|
||||
backend:
|
||||
condition: service_started
|
||||
|
||||
volumes:
|
||||
postgres18_data:
|
||||
backend_uploads:
|
||||
root_node_modules:
|
||||
backend_node_modules:
|
||||
frontend_node_modules:
|
||||
pnpm_store:
|
||||
@@ -48,7 +48,7 @@ type AncientArtifactListInitialData = {
|
||||
page: ListPage<AncientArtifact> | null;
|
||||
};
|
||||
|
||||
const { data: initialData } = await useAsyncData<AncientArtifactListInitialData>(
|
||||
const { data: initialData } = useAsyncData<AncientArtifactListInitialData>(
|
||||
`ancient-artifact-list-initial:${locale.value}`,
|
||||
async () => {
|
||||
const [optionsResult, artifactsResult] = await Promise.allSettled([
|
||||
@@ -68,13 +68,25 @@ const { data: initialData } = await useAsyncData<AncientArtifactListInitialData>
|
||||
{ default: () => ({ options: null, page: null }) }
|
||||
);
|
||||
|
||||
const initialPage = initialData.value?.page ?? null;
|
||||
options.value = initialData.value?.options ?? null;
|
||||
artifacts.value = initialPage?.items ?? [];
|
||||
const initialPageLoaded = ref(initialPage !== null);
|
||||
loading.value = !initialPageLoaded.value;
|
||||
nextCursor.value = initialPage?.nextCursor ?? null;
|
||||
hasMoreArtifacts.value = initialPage?.hasMore ?? false;
|
||||
const initialPageLoaded = ref(false);
|
||||
|
||||
function applyInitialData(data: AncientArtifactListInitialData | null | undefined) {
|
||||
if (!data) return;
|
||||
|
||||
if (!options.value && data.options) {
|
||||
options.value = data.options;
|
||||
}
|
||||
|
||||
if (initialPageLoaded.value || !data.page) {
|
||||
return;
|
||||
}
|
||||
|
||||
artifacts.value = data.page.items;
|
||||
nextCursor.value = data.page.nextCursor;
|
||||
hasMoreArtifacts.value = data.page.hasMore;
|
||||
initialPageLoaded.value = true;
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
const showEditor = computed(() => route.name === 'ancient-artifact-new');
|
||||
const canCreateArtifact = computed(() => currentUser.value?.permissions.includes('items.create') === true);
|
||||
@@ -160,6 +172,8 @@ onMounted(async () => {
|
||||
watch(artifactQuery, () => {
|
||||
void loadArtifacts();
|
||||
});
|
||||
|
||||
watch(initialData, applyInitialData, { immediate: true });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -13,7 +13,7 @@ import Skeleton from '../components/Skeleton.vue';
|
||||
import Tabs, { type TabOption } from '../components/Tabs.vue';
|
||||
import { iconBack, iconEdit, iconHabitat } from '../icons';
|
||||
import { applySeo, resolvedSeoHead, resolveSeo } from '../seo';
|
||||
import { api, getAuthToken, type AuthUser, type HabitatDetail } from '../services/api';
|
||||
import { api, type AuthUser, type HabitatDetail } from '../services/api';
|
||||
import HabitatEdit from './HabitatEdit.vue';
|
||||
|
||||
const route = useRoute();
|
||||
@@ -23,6 +23,7 @@ const currentUser = ref<AuthUser | null>(null);
|
||||
const detailTab = ref('details');
|
||||
const timeOfDays = ['早晨', '中午', '傍晚', '晚上'];
|
||||
const weathers = ['晴天', '阴天', '雨天'];
|
||||
const habitatDetailRouteNames = new Set(['habitat-detail', 'habitat-edit']);
|
||||
const showEditor = computed(() => route.name === 'habitat-edit');
|
||||
const canUpdateHabitat = computed(() => currentUser.value?.permissions.includes('habitats.update') === true);
|
||||
const listPath = computed(() => (habitat.value?.isEventItem ? '/event-habitats' : '/habitats'));
|
||||
@@ -33,11 +34,16 @@ const detailTabs = computed<TabOption[]>(() => [
|
||||
{ value: 'history', label: t('history.editHistory') }
|
||||
]);
|
||||
|
||||
const { data: initialHabitat } = await useAsyncData<HabitatDetail | null>(
|
||||
`habitat-detail:${String(route.params.id)}:${locale.value}`,
|
||||
const { data: initialHabitat } = useAsyncData<HabitatDetail | null>(
|
||||
`habitat-detail:${activeHabitatRouteId() ?? 'none'}:${locale.value}`,
|
||||
async () => {
|
||||
const routeId = activeHabitatRouteId();
|
||||
if (!routeId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return await api.habitatDetail(String(route.params.id));
|
||||
return await api.habitatDetail(routeId);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -45,8 +51,7 @@ const { data: initialHabitat } = await useAsyncData<HabitatDetail | null>(
|
||||
{ default: () => null }
|
||||
);
|
||||
|
||||
habitat.value = initialHabitat.value;
|
||||
const initialHabitatLoaded = ref(initialHabitat.value !== null);
|
||||
const initialHabitatLoaded = ref(false);
|
||||
const habitatSeo = computed(() =>
|
||||
habitat.value && route.meta.editorModal !== true
|
||||
? resolveSeo({
|
||||
@@ -60,6 +65,13 @@ const habitatSeo = computed(() =>
|
||||
|
||||
useHead(() => (habitatSeo.value ? resolvedSeoHead(habitatSeo.value) : {}));
|
||||
|
||||
function applyInitialHabitat(value: HabitatDetail | null | undefined) {
|
||||
if (!value || initialHabitatLoaded.value) return;
|
||||
|
||||
habitat.value = value;
|
||||
initialHabitatLoaded.value = true;
|
||||
}
|
||||
|
||||
type PokemonRow = {
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -100,6 +112,15 @@ function weatherLabel(value: string): string {
|
||||
return labels[value] ?? value;
|
||||
}
|
||||
|
||||
function activeHabitatRouteId(): string | null {
|
||||
return typeof route.name === 'string' &&
|
||||
habitatDetailRouteNames.has(route.name) &&
|
||||
typeof route.params.id === 'string' &&
|
||||
route.params.id.trim() !== ''
|
||||
? route.params.id
|
||||
: null;
|
||||
}
|
||||
|
||||
const pokemonRows = computed<PokemonRow[]>(() => {
|
||||
if (!habitat.value) return [];
|
||||
|
||||
@@ -146,8 +167,14 @@ const pokemonRows = computed<PokemonRow[]>(() => {
|
||||
});
|
||||
|
||||
async function loadHabitatDetail() {
|
||||
const routeId = activeHabitatRouteId();
|
||||
if (!routeId) {
|
||||
initialHabitatLoaded.value = true;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const nextHabitat = await api.habitatDetail(String(route.params.id));
|
||||
const nextHabitat = await api.habitatDetail(routeId);
|
||||
habitat.value = nextHabitat;
|
||||
initialHabitatLoaded.value = true;
|
||||
|
||||
@@ -166,13 +193,12 @@ async function loadHabitatDetail() {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (getAuthToken()) {
|
||||
try {
|
||||
currentUser.value = (await api.me()).user;
|
||||
} catch {
|
||||
currentUser.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!initialHabitatLoaded.value) {
|
||||
await loadHabitatDetail();
|
||||
}
|
||||
@@ -190,11 +216,17 @@ watch(
|
||||
watch(
|
||||
() => route.params.id,
|
||||
() => {
|
||||
if (!activeHabitatRouteId()) {
|
||||
return;
|
||||
}
|
||||
|
||||
habitat.value = null;
|
||||
detailTab.value = 'details';
|
||||
void loadHabitatDetail();
|
||||
}
|
||||
);
|
||||
|
||||
watch(initialHabitat, applyInitialHabitat, { immediate: true });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -13,7 +13,6 @@ import TranslationFields from '../components/TranslationFields.vue';
|
||||
import { iconAdd, iconCancel, iconDelete, iconPokemon, iconSave } from '../icons';
|
||||
import {
|
||||
api,
|
||||
getAuthToken,
|
||||
type AuthUser,
|
||||
type ConfigType,
|
||||
type EntityImage,
|
||||
@@ -156,11 +155,6 @@ function habitatNameForSave() {
|
||||
}
|
||||
|
||||
async function loadCurrentUser() {
|
||||
if (!getAuthToken()) {
|
||||
currentUser.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
currentUser.value = (await api.me()).user;
|
||||
} catch {
|
||||
|
||||
@@ -29,7 +29,7 @@ const query = computed(() => ({
|
||||
isEventItem: props.eventOnly ? 'true' : 'false'
|
||||
}));
|
||||
|
||||
const { data: initialData } = await useAsyncData<ListPage<Habitat> | null>(
|
||||
const { data: initialData } = useAsyncData<ListPage<Habitat> | null>(
|
||||
`${props.eventOnly ? 'event-habitat-list-initial' : 'habitat-list-initial'}:${locale.value}`,
|
||||
async () => {
|
||||
try {
|
||||
@@ -45,12 +45,18 @@ const { data: initialData } = await useAsyncData<ListPage<Habitat> | null>(
|
||||
{ default: () => null }
|
||||
);
|
||||
|
||||
const initialPage = initialData.value;
|
||||
habitats.value = initialPage?.items ?? [];
|
||||
const initialPageLoaded = ref(initialPage !== null);
|
||||
const loading = ref(!initialPageLoaded.value);
|
||||
nextCursor.value = initialPage?.nextCursor ?? null;
|
||||
hasMoreHabitats.value = initialPage?.hasMore ?? false;
|
||||
const initialPageLoaded = ref(false);
|
||||
const loading = ref(true);
|
||||
|
||||
function applyInitialData(page: ListPage<Habitat> | null | undefined) {
|
||||
if (!page || initialPageLoaded.value) return;
|
||||
|
||||
habitats.value = page.items;
|
||||
nextCursor.value = page.nextCursor;
|
||||
hasMoreHabitats.value = page.hasMore;
|
||||
initialPageLoaded.value = true;
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
const showEditor = computed(() => route.name === 'habitat-new' || route.name === 'event-habitat-new');
|
||||
const canCreateHabitat = computed(() => currentUser.value?.permissions.includes('habitats.create') === true);
|
||||
@@ -134,6 +140,8 @@ onMounted(async () => {
|
||||
watch(query, () => {
|
||||
void loadHabitats();
|
||||
});
|
||||
|
||||
watch(initialData, applyInitialData, { immediate: true });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -13,7 +13,7 @@ import Skeleton from '../components/Skeleton.vue';
|
||||
import Tabs, { type TabOption } from '../components/Tabs.vue';
|
||||
import { iconAdd, iconBack, iconEdit, iconHabitat, iconItem } from '../icons';
|
||||
import { applySeo, resolvedSeoHead, resolveSeo } from '../seo';
|
||||
import { api, getAuthToken, type AuthUser, type ItemDetail } from '../services/api';
|
||||
import { api, type AuthUser, type ItemDetail } from '../services/api';
|
||||
import ItemEdit from './ItemEdit.vue';
|
||||
|
||||
const route = useRoute();
|
||||
@@ -73,11 +73,16 @@ const possibleTagEvidenceSections = computed(() => [
|
||||
{ key: 'neutral', title: t('pages.pokemon.tradingNeutral'), rows: item.value?.possibleTags?.evidence.neutral ?? [] }
|
||||
]);
|
||||
|
||||
const { data: initialItem } = await useAsyncData<ItemDetail | null>(
|
||||
`item-detail:${String(route.name)}:${String(route.params.id)}:${locale.value}`,
|
||||
const { data: initialItem } = useAsyncData<ItemDetail | null>(
|
||||
`item-detail:${String(route.name)}:${activeItemRouteId() ?? 'none'}:${locale.value}`,
|
||||
async () => {
|
||||
const routeId = activeItemRouteId();
|
||||
if (!routeId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const nextItem = await api.itemDetail(String(route.params.id));
|
||||
const nextItem = await api.itemDetail(routeId);
|
||||
return isAncientArtifactRoute.value && !nextItem.ancientArtifactCategory ? null : nextItem;
|
||||
} catch {
|
||||
return null;
|
||||
@@ -86,8 +91,7 @@ const { data: initialItem } = await useAsyncData<ItemDetail | null>(
|
||||
{ default: () => null }
|
||||
);
|
||||
|
||||
item.value = initialItem.value;
|
||||
const initialItemLoaded = ref(initialItem.value !== null);
|
||||
const initialItemLoaded = ref(false);
|
||||
const itemSeo = computed(() =>
|
||||
item.value && route.meta.editorModal !== true
|
||||
? resolveSeo({
|
||||
@@ -101,6 +105,13 @@ const itemSeo = computed(() =>
|
||||
|
||||
useHead(() => (itemSeo.value ? resolvedSeoHead(itemSeo.value) : {}));
|
||||
|
||||
function applyInitialItem(value: ItemDetail | null | undefined) {
|
||||
if (!value || initialItemLoaded.value) return;
|
||||
|
||||
item.value = value;
|
||||
initialItemLoaded.value = true;
|
||||
}
|
||||
|
||||
const customization = computed(() => {
|
||||
if (!item.value) {
|
||||
return [];
|
||||
@@ -114,8 +125,14 @@ const customization = computed(() => {
|
||||
});
|
||||
|
||||
async function loadItemDetail() {
|
||||
const routeId = activeItemRouteId();
|
||||
if (!routeId) {
|
||||
initialItemLoaded.value = true;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const nextItem = await api.itemDetail(String(route.params.id));
|
||||
const nextItem = await api.itemDetail(routeId);
|
||||
|
||||
if (isAncientArtifactRoute.value && !nextItem.ancientArtifactCategory) {
|
||||
await router.replace(route.name === 'ancient-artifact-edit' ? `/items/${nextItem.id}/edit` : `/items/${nextItem.id}`);
|
||||
@@ -143,14 +160,19 @@ function isItemDetailRouteName(value: unknown) {
|
||||
return typeof value === 'string' && itemDetailRouteNames.has(value);
|
||||
}
|
||||
|
||||
function activeItemRouteId(): string | null {
|
||||
return isItemDetailRouteName(route.name) && typeof route.params.id === 'string' && route.params.id.trim() !== ''
|
||||
? route.params.id
|
||||
: null;
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (getAuthToken()) {
|
||||
try {
|
||||
currentUser.value = (await api.me()).user;
|
||||
} catch {
|
||||
currentUser.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!initialItemLoaded.value) {
|
||||
await loadItemDetail();
|
||||
}
|
||||
@@ -170,11 +192,17 @@ watch(
|
||||
watch(
|
||||
() => route.params.id,
|
||||
() => {
|
||||
if (!activeItemRouteId()) {
|
||||
return;
|
||||
}
|
||||
|
||||
item.value = null;
|
||||
detailTab.value = 'details';
|
||||
void loadItemDetail();
|
||||
}
|
||||
);
|
||||
|
||||
watch(initialItem, applyInitialItem, { immediate: true });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -12,7 +12,6 @@ import TranslationFields from '../components/TranslationFields.vue';
|
||||
import { iconCancel, iconSave } from '../icons';
|
||||
import {
|
||||
api,
|
||||
getAuthToken,
|
||||
type AuthUser,
|
||||
type ConfigType,
|
||||
type EntityImage,
|
||||
@@ -215,11 +214,6 @@ async function loadOptions() {
|
||||
}
|
||||
|
||||
async function loadCurrentUser() {
|
||||
if (!getAuthToken()) {
|
||||
currentUser.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
currentUser.value = (await api.me()).user;
|
||||
} catch {
|
||||
|
||||
@@ -110,7 +110,7 @@ type ItemListInitialData = {
|
||||
page: ListPage<Item> | null;
|
||||
};
|
||||
|
||||
const { data: initialData } = await useAsyncData<ItemListInitialData>(
|
||||
const { data: initialData } = useAsyncData<ItemListInitialData>(
|
||||
`${props.eventOnly ? 'event-item-list-initial' : 'item-list-initial'}:${locale.value}`,
|
||||
async () => {
|
||||
const [optionsResult, itemsResult] = await Promise.allSettled([
|
||||
@@ -130,13 +130,25 @@ const { data: initialData } = await useAsyncData<ItemListInitialData>(
|
||||
{ default: () => ({ options: null, page: null }) }
|
||||
);
|
||||
|
||||
const initialPage = initialData.value?.page ?? null;
|
||||
options.value = initialData.value?.options ?? null;
|
||||
items.value = initialPage?.items ?? [];
|
||||
const initialPageLoaded = ref(initialPage !== null);
|
||||
loading.value = !initialPageLoaded.value;
|
||||
nextCursor.value = initialPage?.nextCursor ?? null;
|
||||
hasMoreItems.value = initialPage?.hasMore ?? false;
|
||||
const initialPageLoaded = ref(false);
|
||||
|
||||
function applyInitialData(data: ItemListInitialData | null | undefined) {
|
||||
if (!data) return;
|
||||
|
||||
if (!options.value && data.options) {
|
||||
options.value = data.options;
|
||||
}
|
||||
|
||||
if (initialPageLoaded.value || !data.page) {
|
||||
return;
|
||||
}
|
||||
|
||||
items.value = data.page.items;
|
||||
nextCursor.value = data.page.nextCursor;
|
||||
hasMoreItems.value = data.page.hasMore;
|
||||
initialPageLoaded.value = true;
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
const showEditor = computed(() => route.name === 'item-new' || route.name === 'event-item-new');
|
||||
const canCreateItem = computed(() => currentUser.value?.permissions.includes('items.create') === true);
|
||||
@@ -543,6 +555,8 @@ onBeforeUnmount(() => {
|
||||
watch(itemQuery, () => {
|
||||
void loadItems();
|
||||
});
|
||||
|
||||
watch(initialData, applyInitialData, { immediate: true });
|
||||
watch(itemCreateDefaults, persistItemCreateDefaults, { deep: true });
|
||||
watch(showEditor, () => {
|
||||
closeCreateDefaultsMenu();
|
||||
|
||||
@@ -16,7 +16,7 @@ import StatusMessage from '../components/StatusMessage.vue';
|
||||
import Tabs, { type TabOption } from '../components/Tabs.vue';
|
||||
import { iconAdd, iconBack, iconCancel, iconCheck, iconEdit, iconHabitat, iconItem } from '../icons';
|
||||
import { applySeo, resolvedSeoHead, resolveSeo } from '../seo';
|
||||
import { api, getAuthToken, type AuthUser, type Item, type PokemonDetail, type PokemonPayload, type TradingPreference } from '../services/api';
|
||||
import { api, type AuthUser, type Item, type PokemonDetail, type PokemonPayload, type TradingPreference } from '../services/api';
|
||||
import PokemonEdit from './PokemonEdit.vue';
|
||||
|
||||
const route = useRoute();
|
||||
@@ -39,12 +39,18 @@ const tradingDraftItems = ref<Array<{ itemId: number; preference: TradingPrefere
|
||||
const timeOfDays = ['早晨', '中午', '傍晚', '晚上'];
|
||||
const weathers = ['晴天', '阴天', '雨天'];
|
||||
const relatedPokemonLimit = 6;
|
||||
const pokemonDetailRouteNames = new Set(['pokemon-detail', 'pokemon-edit']);
|
||||
|
||||
const { data: initialPokemon } = await useAsyncData<PokemonDetail | null>(
|
||||
`pokemon-detail:${String(route.params.id)}:${locale.value}`,
|
||||
const { data: initialPokemon } = useAsyncData<PokemonDetail | null>(
|
||||
`pokemon-detail:${activePokemonRouteId() ?? 'none'}:${locale.value}`,
|
||||
async () => {
|
||||
const routeId = activePokemonRouteId();
|
||||
if (!routeId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return await api.pokemonDetail(String(route.params.id));
|
||||
return await api.pokemonDetail(routeId);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -52,9 +58,7 @@ const { data: initialPokemon } = await useAsyncData<PokemonDetail | null>(
|
||||
{ default: () => null }
|
||||
);
|
||||
|
||||
pokemon.value = initialPokemon.value;
|
||||
relatedHabitatTab.value = initialPokemon.value ? habitatTabValue(initialPokemon.value.environment.id) : '';
|
||||
const initialPokemonLoaded = ref(initialPokemon.value !== null);
|
||||
const initialPokemonLoaded = ref(false);
|
||||
const pokemonSeo = computed(() =>
|
||||
pokemon.value && route.meta.editorModal !== true
|
||||
? resolveSeo({
|
||||
@@ -68,6 +72,14 @@ const pokemonSeo = computed(() =>
|
||||
|
||||
useHead(() => (pokemonSeo.value ? resolvedSeoHead(pokemonSeo.value) : {}));
|
||||
|
||||
function applyInitialPokemon(value: PokemonDetail | null | undefined) {
|
||||
if (!value || initialPokemonLoaded.value) return;
|
||||
|
||||
pokemon.value = value;
|
||||
relatedHabitatTab.value = habitatTabValue(value.environment.id);
|
||||
initialPokemonLoaded.value = true;
|
||||
}
|
||||
|
||||
type HabitatRow = {
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -93,6 +105,15 @@ function habitatTabValue(id: number): string {
|
||||
return `habitat-${id}`;
|
||||
}
|
||||
|
||||
function activePokemonRouteId(): string | null {
|
||||
return typeof route.name === 'string' &&
|
||||
pokemonDetailRouteNames.has(route.name) &&
|
||||
typeof route.params.id === 'string' &&
|
||||
route.params.id.trim() !== ''
|
||||
? route.params.id
|
||||
: null;
|
||||
}
|
||||
|
||||
function timeLabel(value: string): string {
|
||||
const labels: Record<string, string> = {
|
||||
早晨: t('appearance.morning'),
|
||||
@@ -439,8 +460,14 @@ async function saveTradingItems() {
|
||||
}
|
||||
|
||||
async function loadPokemonDetail() {
|
||||
const routeId = activePokemonRouteId();
|
||||
if (!routeId) {
|
||||
initialPokemonLoaded.value = true;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const nextPokemon = await api.pokemonDetail(String(route.params.id));
|
||||
const nextPokemon = await api.pokemonDetail(routeId);
|
||||
pokemon.value = nextPokemon;
|
||||
relatedHabitatTab.value = habitatTabValue(nextPokemon.environment.id);
|
||||
initialPokemonLoaded.value = true;
|
||||
@@ -461,13 +488,12 @@ async function loadPokemonDetail() {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (getAuthToken()) {
|
||||
try {
|
||||
currentUser.value = (await api.me()).user;
|
||||
} catch {
|
||||
currentUser.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!initialPokemonLoaded.value) {
|
||||
await loadPokemonDetail();
|
||||
}
|
||||
@@ -485,6 +511,10 @@ watch(
|
||||
watch(
|
||||
() => route.params.id,
|
||||
() => {
|
||||
if (!activePokemonRouteId()) {
|
||||
return;
|
||||
}
|
||||
|
||||
pokemon.value = null;
|
||||
relatedHabitatTab.value = '';
|
||||
detailTab.value = 'details';
|
||||
@@ -494,6 +524,8 @@ watch(
|
||||
void loadPokemonDetail();
|
||||
}
|
||||
);
|
||||
|
||||
watch(initialPokemon, applyInitialPokemon, { immediate: true });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -14,7 +14,6 @@ import TranslationFields from '../components/TranslationFields.vue';
|
||||
import { iconCancel, iconSave, iconSearch } from '../icons';
|
||||
import {
|
||||
api,
|
||||
getAuthToken,
|
||||
type AuthUser,
|
||||
type ConfigType,
|
||||
type EntityImage,
|
||||
@@ -195,11 +194,6 @@ async function loadOptions() {
|
||||
}
|
||||
|
||||
async function loadCurrentUser() {
|
||||
if (!getAuthToken()) {
|
||||
currentUser.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
currentUser.value = (await api.me()).user;
|
||||
} catch {
|
||||
|
||||
@@ -45,7 +45,7 @@ const query = computed(() => ({
|
||||
favoriteThingMode: favoriteThingMode.value
|
||||
}));
|
||||
|
||||
const { data: initialData } = await useAsyncData<PokemonListInitialData>(
|
||||
const { data: initialData } = useAsyncData<PokemonListInitialData>(
|
||||
`${props.eventOnly ? 'event-pokemon-list-initial' : 'pokemon-list-initial'}:${locale.value}`,
|
||||
async () => {
|
||||
const [optionsResult, pokemonResult] = await Promise.allSettled([
|
||||
@@ -65,15 +65,14 @@ const { data: initialData } = await useAsyncData<PokemonListInitialData>(
|
||||
{ default: () => ({ options: null, page: null }) }
|
||||
);
|
||||
|
||||
const initialPage = initialData.value?.page ?? null;
|
||||
const options = ref<Options | null>(initialData.value?.options ?? null);
|
||||
const pokemon = ref<Pokemon[]>(initialPage?.items ?? []);
|
||||
const options = ref<Options | null>(null);
|
||||
const pokemon = ref<Pokemon[]>([]);
|
||||
const currentUser = ref<AuthUser | null>(null);
|
||||
const initialPageLoaded = ref(initialPage !== null);
|
||||
const loading = ref(!initialPageLoaded.value);
|
||||
const initialPageLoaded = ref(false);
|
||||
const loading = ref(true);
|
||||
const loadingMore = ref(false);
|
||||
const nextCursor = ref<string | null>(initialPage?.nextCursor ?? null);
|
||||
const hasMorePokemon = ref(initialPage?.hasMore ?? false);
|
||||
const nextCursor = ref<string | null>(null);
|
||||
const hasMorePokemon = ref(false);
|
||||
const showEditor = computed(() => route.name === 'pokemon-new' || route.name === 'event-pokemon-new');
|
||||
const canCreatePokemon = computed(() => currentUser.value?.permissions.includes('pokemon.create') === true);
|
||||
const pageTitle = computed(() => t(props.eventOnly ? 'pages.eventPokemon.title' : 'pages.pokemon.title'));
|
||||
@@ -82,6 +81,24 @@ const pageKicker = computed(() => t(props.eventOnly ? 'pages.eventPokemon.kicker
|
||||
const newPokemonPath = computed(() => (props.eventOnly ? '/event-pokemon/new' : '/pokemon/new'));
|
||||
const loadingListLabel = computed(() => t(props.eventOnly ? 'pages.eventPokemon.loadingList' : 'pages.pokemon.loadingList'));
|
||||
|
||||
function applyInitialData(data: PokemonListInitialData | null | undefined) {
|
||||
if (!data) return;
|
||||
|
||||
if (!options.value && data.options) {
|
||||
options.value = data.options;
|
||||
}
|
||||
|
||||
if (initialPageLoaded.value || !data.page) {
|
||||
return;
|
||||
}
|
||||
|
||||
pokemon.value = data.page.items;
|
||||
nextCursor.value = data.page.nextCursor;
|
||||
hasMorePokemon.value = data.page.hasMore;
|
||||
initialPageLoaded.value = true;
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
async function loadPokemon(reset = true) {
|
||||
if (!reset && (loading.value || loadingMore.value || !hasMorePokemon.value)) {
|
||||
return;
|
||||
@@ -163,6 +180,8 @@ onMounted(async () => {
|
||||
watch(query, () => {
|
||||
void loadPokemon();
|
||||
});
|
||||
|
||||
watch(initialData, applyInitialData, { immediate: true });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -42,7 +42,7 @@ const recipeSubtitle = computed(() => {
|
||||
return categoryName ?? t('pages.recipes.detailSubtitle');
|
||||
});
|
||||
|
||||
const { data: initialRecipe } = await useAsyncData<RecipeDetail | null>(
|
||||
const { data: initialRecipe } = useAsyncData<RecipeDetail | null>(
|
||||
`recipe-detail:${String(route.params.id)}:${locale.value}`,
|
||||
async () => {
|
||||
try {
|
||||
@@ -54,8 +54,7 @@ const { data: initialRecipe } = await useAsyncData<RecipeDetail | null>(
|
||||
{ default: () => null }
|
||||
);
|
||||
|
||||
recipe.value = initialRecipe.value;
|
||||
const initialRecipeLoaded = ref(initialRecipe.value !== null);
|
||||
const initialRecipeLoaded = ref(false);
|
||||
const recipeSeo = computed(() =>
|
||||
recipe.value && route.meta.editorModal !== true
|
||||
? resolveSeo({
|
||||
@@ -69,6 +68,13 @@ const recipeSeo = computed(() =>
|
||||
|
||||
useHead(() => (recipeSeo.value ? resolvedSeoHead(recipeSeo.value) : {}));
|
||||
|
||||
function applyInitialRecipe(value: RecipeDetail | null | undefined) {
|
||||
if (!value || initialRecipeLoaded.value) return;
|
||||
|
||||
recipe.value = value;
|
||||
initialRecipeLoaded.value = true;
|
||||
}
|
||||
|
||||
async function loadRecipeDetail() {
|
||||
try {
|
||||
const nextRecipe = await api.recipeDetail(String(route.params.id));
|
||||
@@ -119,6 +125,8 @@ watch(
|
||||
void loadRecipeDetail();
|
||||
}
|
||||
);
|
||||
|
||||
watch(initialRecipe, applyInitialRecipe, { immediate: true });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -52,7 +52,7 @@ type RecipeListInitialData = {
|
||||
page: ListPage<Item> | null;
|
||||
};
|
||||
|
||||
const { data: initialData } = await useAsyncData<RecipeListInitialData>(
|
||||
const { data: initialData } = useAsyncData<RecipeListInitialData>(
|
||||
`recipe-list-initial:${locale.value}`,
|
||||
async () => {
|
||||
const [optionsResult, itemsResult] = await Promise.allSettled([
|
||||
@@ -72,13 +72,25 @@ const { data: initialData } = await useAsyncData<RecipeListInitialData>(
|
||||
{ default: () => ({ options: null, page: null }) }
|
||||
);
|
||||
|
||||
const initialPage = initialData.value?.page ?? null;
|
||||
options.value = initialData.value?.options ?? null;
|
||||
items.value = initialPage?.items ?? [];
|
||||
const initialPageLoaded = ref(initialPage !== null);
|
||||
loading.value = !initialPageLoaded.value;
|
||||
nextCursor.value = initialPage?.nextCursor ?? null;
|
||||
hasMoreItems.value = initialPage?.hasMore ?? false;
|
||||
const initialPageLoaded = ref(false);
|
||||
|
||||
function applyInitialData(data: RecipeListInitialData | null | undefined) {
|
||||
if (!data) return;
|
||||
|
||||
if (!options.value && data.options) {
|
||||
options.value = data.options;
|
||||
}
|
||||
|
||||
if (initialPageLoaded.value || !data.page) {
|
||||
return;
|
||||
}
|
||||
|
||||
items.value = data.page.items;
|
||||
nextCursor.value = data.page.nextCursor;
|
||||
hasMoreItems.value = data.page.hasMore;
|
||||
initialPageLoaded.value = true;
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
const showEditor = computed(() => route.name === 'recipe-new');
|
||||
const canCreateRecipe = computed(() => currentUser.value?.permissions.includes('recipes.create') === true);
|
||||
@@ -180,6 +192,8 @@ onMounted(async () => {
|
||||
watch(itemQuery, () => {
|
||||
void loadItems();
|
||||
});
|
||||
|
||||
watch(initialData, applyInitialData, { immediate: true });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -2,13 +2,15 @@
|
||||
"name": "pokopia",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@10.33.2",
|
||||
"packageManager": "pnpm@10.33.3+sha512.a19744364a7e248b92657a4ca5973f9354d21caf982579674b1c539f32c7420c47138ad8b1254df07aba9bc782d9b3029e3db34d5dbff974326eb74dac8ff489",
|
||||
"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"
|
||||
"build": "pnpm -r build",
|
||||
"docker:debug": "docker compose -f docker-compose.debug.yml up --build",
|
||||
"docker:prod": "docker compose up --build"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
packages:
|
||||
- backend
|
||||
- frontend
|
||||
allowBuilds:
|
||||
'@parcel/watcher': true
|
||||
esbuild: true
|
||||
|
||||
Reference in New Issue
Block a user