chore: initialize frontend project

This commit is contained in:
kever
2026-06-07 12:57:41 +08:00
commit 46796b5918
25 changed files with 4596 additions and 0 deletions

3
.env.example Normal file
View File

@@ -0,0 +1,3 @@
NODE_ENV=development
PORT=3000
GO_BACKEND_URL=http://127.0.0.1:8080

18
.gitignore vendored Normal file
View File

@@ -0,0 +1,18 @@
node_modules
.DS_Store
dist
dist-ssr
*.local
.env
.nitro
.tanstack
.wrangler
.output
.vinxi
__unconfig*
todos.json
.idea
.vscode
*.log
.cta.json
.codex-dev-job-id

82
AGENTS.md Normal file
View File

@@ -0,0 +1,82 @@
<!-- intent-skills:start -->
# Skill mappings - load `use` with `pnpm dlx @tanstack/intent@latest load <use>`.
skills:
- when: "Install TanStack Devtools, pick framework adapter (React/Vue/Solid/Preact), register plugins via plugins prop, configure shell (position, hotkeys, theme, hideUntilHover, requireUrlFlag, eventBusConfig). TanStackDevtools component, defaultOpen, localStorage persistence."
use: "@tanstack/devtools#devtools-app-setup"
- when: "Publish plugin to npm and submit to TanStack Devtools Marketplace. PluginMetadata registry format, plugin-registry.ts, pluginImport (importName, type), requires (packageName, minVersion), framework tagging, multi-framework submissions, featured plugins."
use: "@tanstack/devtools#devtools-marketplace"
- when: "Build devtools panel components that display emitted event data. Listen via EventClient.on(), handle theme (light/dark), use @tanstack/devtools-ui components. Plugin registration (name, render, id, defaultOpen), lifecycle (mount, activate, destroy), max 3 active plugins. Two paths: Solid.js core with devtools-ui for multi-framework support, or framework-specific panels."
use: "@tanstack/devtools#devtools-plugin-panel"
- when: "Handle devtools in production vs development. removeDevtoolsOnBuild, devDependency vs regular dependency, conditional imports, NoOp plugin variants for tree-shaking, non-Vite production exclusion patterns."
use: "@tanstack/devtools#devtools-production"
- when: "Two-way event patterns between devtools panel and application. App-to-devtools observation, devtools-to-app commands, time-travel debugging with snapshots and revert. structuredClone for snapshot safety, distinct event suffixes for observation vs commands, serializable payloads only."
use: "@tanstack/devtools-event-client#devtools-bidirectional"
- when: "Create typed EventClient for a library. Define event maps with typed payloads, pluginId auto-prepend namespacing, emit()/on()/onAll()/onAllPluginEvents() API. Connection lifecycle (5 retries, 300ms), event queuing, enabled/disabled state, SSR fallbacks, singleton pattern. Unique pluginId requirement to avoid event collisions."
use: "@tanstack/devtools-event-client#devtools-event-client"
- when: "Analyze library codebase for critical architecture and debugging points, add strategic event emissions. Identify middleware boundaries, state transitions, lifecycle hooks. Consolidate events (1 not 15), debounce high-frequency updates, DRY shared payload fields, guard emit() for production. Transparent server/client event bridging."
use: "@tanstack/devtools-event-client#devtools-instrumentation"
- when: "Configure @tanstack/devtools-vite for source inspection (data-tsd-source, inspectHotkey, ignore patterns), console piping (client-to-server, server-to-client, levels), enhanced logging, server event bus (port, host, HTTPS), production stripping (removeDevtoolsOnBuild), editor integration (launch-editor, custom editor.open). Must be FIRST plugin in Vite config. Vite ^6 || ^7 only."
use: "@tanstack/devtools-vite#devtools-vite-plugin"
- when: "Step-by-step migration from Next.js App Router to TanStack Start: route definition conversion, API mapping, server function conversion from Server Actions, middleware conversion, data fetching pattern changes."
use: "@tanstack/react-start#lifecycle/migrate-from-nextjs"
- when: "React bindings for TanStack Start: createStart, StartClient, StartServer, React-specific imports, re-exports from @tanstack/react-router, full project setup with React, useServerFn hook."
use: "@tanstack/react-start#react-start"
- when: "Implement, review, debug, and refactor TanStack Start React Server Components in React 19 apps. Use when tasks mention @tanstack/react-start/rsc, renderServerComponent, createCompositeComponent, CompositeComponent, renderToReadableStream, createFromReadableStream, createFromFetch, Composite Components, React Flight streams, loader or query owned RSC caching, router.invalidate, structuralSharing: false, selective SSR, stale names like renderRsc or .validator, or migration from Next App Router RSC patterns. Do not use for generic SSR or non-TanStack RSC frameworks except brief comparison."
use: "@tanstack/react-start#react-start/server-components"
- when: "Framework-agnostic core concepts for TanStack Router: route trees, createRouter, createRoute, createRootRoute, createRootRouteWithContext, addChildren, Register type declaration, route matching, route sorting, file naming conventions. Entry point for all router skills."
use: "@tanstack/router-core#router-core"
- when: "Route protection with beforeLoad, redirect()/throw redirect(), isRedirect helper, authenticated layout routes (_authenticated), non-redirect auth (inline login), RBAC with roles and permissions, auth provider integration (Auth0, Clerk, Supabase), router context for auth state."
use: "@tanstack/router-core#router-core/auth-and-guards"
- when: "Automatic code splitting (autoCodeSplitting), .lazy.tsx convention, createLazyFileRoute, createLazyRoute, lazyRouteComponent, getRouteApi for typed hooks in split files, codeSplitGroupings per-route override, splitBehavior programmatic config, critical vs non-critical properties."
use: "@tanstack/router-core#router-core/code-splitting"
- when: "Route loader option, loaderDeps for cache keys, staleTime/gcTime/ defaultPreloadStaleTime SWR caching, pendingComponent/pendingMs/ pendingMinMs, errorComponent/onError/onCatch, beforeLoad, router context and createRootRouteWithContext DI pattern, router.invalidate, Await component, deferred data loading with unawaited promises."
use: "@tanstack/router-core#router-core/data-loading"
- when: "Link component, useNavigate, Navigate component, router.navigate, ToOptions/NavigateOptions/LinkOptions, from/to relative navigation, activeOptions/activeProps, preloading (intent/viewport/render), preloadDelay, navigation blocking (useBlocker, Block), createLink, linkOptions helper, scroll restoration, MatchRoute."
use: "@tanstack/router-core#router-core/navigation"
- when: "notFound() function, notFoundComponent, defaultNotFoundComponent, notFoundMode (fuzzy/root), errorComponent, CatchBoundary, CatchNotFound, isNotFound, NotFoundRoute (deprecated), route masking (mask option, createRouteMask, unmaskOnReload)."
use: "@tanstack/router-core#router-core/not-found-and-errors"
- when: "Dynamic path segments ($paramName), splat routes ($ / _splat), optional params ({-$paramName}), prefix/suffix patterns ({$param}.ext), useParams, params.parse/stringify, pathParamsAllowedCharacters, i18n locale patterns."
use: "@tanstack/router-core#router-core/path-params"
- when: "validateSearch, search param validation with Zod/Valibot/ArkType adapters, fallback(), search middlewares (retainSearchParams, stripSearchParams), custom serialization (parseSearch, stringifySearch), search param inheritance, loaderDeps for cache keys, reading and writing search params."
use: "@tanstack/router-core#router-core/search-params"
- when: "Non-streaming and streaming SSR, RouterClient/RouterServer, renderRouterToString/renderRouterToStream, createRequestHandler, defaultRenderHandler/defaultStreamHandler, HeadContent/Scripts components, head route option (meta/links/styles/scripts), ScriptOnce, automatic loader dehydration/hydration, memory history on server, data serialization, document head management."
use: "@tanstack/router-core#router-core/ssr"
- when: "Full type inference philosophy (never cast, never annotate inferred values), Register module declaration, from narrowing on hooks and Link, strict:false for shared components, getRouteApi for code-split typed access, addChildren with object syntax for TS perf, LinkProps and ValidateLinkOptions type utilities, as const satisfies pattern."
use: "@tanstack/router-core#router-core/type-safety"
- when: "TanStack Router bundler plugin for route generation and automatic code splitting. Supports Vite, Webpack, Rspack, and esbuild. Configures autoCodeSplitting, routesDirectory, target framework, and code split groupings."
use: "@tanstack/router-plugin#router-plugin"
- when: "Programmatic route tree building as an alternative to filesystem conventions: rootRoute, index, route, layout, physical, defineVirtualSubtreeConfig. Use with TanStack Router plugin's virtualRouteConfig option."
use: "@tanstack/virtual-file-routes#virtual-file-routes"
<!-- intent-skills:end -->
# 项目上下文my-tanstack-app (BFF + Go 后端版本)
## 项目初始化信息
* **脚手架命令:** `npx @tanstack/cli@latest create my-tanstack-app --agent --tailwind --add-ons tanstack-query`
* **模板类型:** Blank 纯净模板。
* **技术栈方案:** TanStack Start (React 19), TanStack Query, Tailwind CSS, pnpm.
* **部署目标:** 自建云服务器 (VPS),运行于标准的 Node.js 环境。
## 核心架构决策
1. **BFF 架构分层:** 本项目作为无状态的 Web 渲染与数据转发层BFF。不包含任何数据库MySQL驱动与凭证不直接操作底层数据。
2. **异构系统数据消费:** 前端或服务端渲染所需的一切核心业务数据均通过网络请求HTTP/RESTful异步调用自主部署的 **Go 语言后端服务**
3. **数据流控制:** 路由级的数据预取优先在 `loader` 中通过 TanStack Query 执行,利用 `dehydrate` / `hydrate` 机制确保 SSR 数据无缝传递至客户端。
## 环境变量要求 (.env)
在 VPS 生产环境及本地开发环境中配置:
* `NODE_ENV`: `production``development`
* `PORT`: `3000` (前端服务运行端口)
* `GO_BACKEND_URL`: `http://127.0.0.1:8080` (指向你自建的 Go 后端服务的内部/外部基准地址)
## 部署与运维笔记
* **打包模式:** 修改 `app.config.ts` 中的 target 预设为 `node`(基于 Nitro 引擎进行 Node 兼容构建)。
* **运行机制:** 产物通过 `node``pm2` 在 VPS 上独立拉起进程。建议在前端挂载 Nginx 负责 443 端口 SSL 证书卸载与静态资源(`.js`, `.css`)的高效分发。
## 踩坑与注意点 (Known Gotchas)
* **跨域与内网穿透:** 在 SSR 阶段Node.js 发起请求的源是服务器本身(需通过 `process.env.GO_BACKEND_URL` 访问内网);在客户端阶段,浏览器发起请求需要能够跨域或通过反向代理正确路由到 Go 服务上。推荐在 Node 侧统一使用 `createServerFn` 做一层 API Proxy 转发。
## 后续推进计划
- [ ] 确保 `app.config.ts` 已切换为 `node` 独立运行预设。
- [ ] 运行 `pnpm install` 冻结当前依赖树。
- [ ] 封装 `src/utils/api.ts` 统一劫持 `GO_BACKEND_URL`
- [ ]`src/routes/index.tsx``loader` 中,编写第一个通过 TanStack Query 获取 Go 后端商品或任务列表的接口。

207
README.md Normal file
View File

@@ -0,0 +1,207 @@
Welcome to your new TanStack Start app!
# Getting Started
To run this application:
```bash
pnpm install
pnpm dev
```
# Building For Production
To build this application for production:
```bash
pnpm build
```
## Testing
This project uses [Vitest](https://vitest.dev/) for testing. You can run the tests with:
```bash
pnpm test
```
## Styling
This project uses [Tailwind CSS](https://tailwindcss.com/) for styling.
### Removing Tailwind CSS
If you prefer not to use Tailwind CSS:
1. Remove the demo pages in `src/routes/demo/`
2. Replace the Tailwind import in `src/styles.css` with your own styles
3. Remove `tailwindcss()` from the plugins array in `vite.config.ts`
4. Uninstall the packages: `pnpm add @tailwindcss/vite tailwindcss --dev`
## Deploy with Nitro
This project uses Nitro as a generic server adapter, so it can run on any Node-compatible host.
```bash
npm run build
node dist/server/index.mjs
```
The build output is a self-contained Node server. To deploy, push the `dist/` directory to your host (Render, Fly.io, your own VPS, etc.) and run the server command above.
For host-specific presets (Vercel, Netlify, Cloudflare, AWS Lambda, etc.) and tuning, see https://v3.nitro.build/deploy.
## Routing
This project uses [TanStack Router](https://tanstack.com/router) with file-based routing. Routes are managed as files in `src/routes`.
### Adding A Route
To add a new route to your application just add a new file in the `./src/routes` directory.
TanStack will automatically generate the content of the route file for you.
Now that you have two routes you can use a `Link` component to navigate between them.
### Adding Links
To use SPA (Single Page Application) navigation you will need to import the `Link` component from `@tanstack/react-router`.
```tsx
import { Link } from "@tanstack/react-router";
```
Then anywhere in your JSX you can use it like so:
```tsx
<Link to="/about">About</Link>
```
This will create a link that will navigate to the `/about` route.
More information on the `Link` component can be found in the [Link documentation](https://tanstack.com/router/v1/docs/framework/react/api/router/linkComponent).
### Using A Layout
In the File Based Routing setup the layout is located in `src/routes/__root.tsx`. Anything you add to the root route will appear in all the routes. The route content will appear in the JSX where you render `{children}` in the `shellComponent`.
Here is an example layout that includes a header:
```tsx
import { HeadContent, Scripts, createRootRoute } from '@tanstack/react-router'
export const Route = createRootRoute({
head: () => ({
meta: [
{ charSet: 'utf-8' },
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
{ title: 'My App' },
],
}),
shellComponent: ({ children }) => (
<html lang="en">
<head>
<HeadContent />
</head>
<body>
<header>
<nav>
<Link to="/">Home</Link>
<Link to="/about">About</Link>
</nav>
</header>
{children}
<Scripts />
</body>
</html>
),
})
```
More information on layouts can be found in the [Layouts documentation](https://tanstack.com/router/latest/docs/framework/react/guide/routing-concepts#layouts).
## Server Functions
TanStack Start provides server functions that allow you to write server-side code that seamlessly integrates with your client components.
```tsx
import { createServerFn } from '@tanstack/react-start'
const getServerTime = createServerFn({
method: 'GET',
}).handler(async () => {
return new Date().toISOString()
})
// Use in a component
function MyComponent() {
const [time, setTime] = useState('')
useEffect(() => {
getServerTime().then(setTime)
}, [])
return <div>Server time: {time}</div>
}
```
## API Routes
You can create API routes by using the `server` property in your route definitions:
```tsx
import { createFileRoute } from '@tanstack/react-router'
import { json } from '@tanstack/react-start'
export const Route = createFileRoute('/api/hello')({
server: {
handlers: {
GET: () => json({ message: 'Hello, World!' }),
},
},
})
```
## Data Fetching
There are multiple ways to fetch data in your application. You can use TanStack Query to fetch data from a server. But you can also use the `loader` functionality built into TanStack Router to load the data for a route before it's rendered.
For example:
```tsx
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/people')({
loader: async () => {
const response = await fetch('https://swapi.dev/api/people')
return response.json()
},
component: PeopleComponent,
})
function PeopleComponent() {
const data = Route.useLoaderData()
return (
<ul>
{data.results.map((person) => (
<li key={person.name}>{person.name}</li>
))}
</ul>
)
}
```
Loaders simplify your data fetching logic dramatically. Check out more information in the [Loader documentation](https://tanstack.com/router/latest/docs/framework/react/guide/data-loading#loader-parameters).
# Demo files
Files prefixed with `demo` can be safely deleted. They are there to provide a starting point for you to play around with the features you've installed.
# Learn More
You can learn more about all of the offerings from TanStack in the [TanStack documentation](https://tanstack.com).
For TanStack Start specific documentation, visit [TanStack Start](https://tanstack.com/start).

53
package.json Normal file
View File

@@ -0,0 +1,53 @@
{
"name": "my-tanstack-app",
"private": true,
"type": "module",
"imports": {
"#/*": "./src/*"
},
"scripts": {
"dev": "vite dev --port 3000",
"generate-routes": "tsr generate",
"build": "vite build",
"start": "node .output/server/index.mjs",
"preview": "vite preview",
"test": "vitest run"
},
"dependencies": {
"@tailwindcss/vite": "^4.1.18",
"@tanstack/react-devtools": "latest",
"@tanstack/react-query": "latest",
"@tanstack/react-query-devtools": "latest",
"@tanstack/react-router": "latest",
"@tanstack/react-router-devtools": "latest",
"@tanstack/react-router-ssr-query": "latest",
"@tanstack/react-start": "latest",
"@tanstack/router-plugin": "^1.132.0",
"lucide-react": "^0.545.0",
"nitro": "npm:nitro-nightly@latest",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"tailwindcss": "^4.1.18"
},
"devDependencies": {
"@tailwindcss/typography": "^0.5.16",
"@tanstack/devtools-vite": "latest",
"@tanstack/router-cli": "^1.132.0",
"@testing-library/dom": "^10.4.1",
"@testing-library/react": "^16.3.0",
"@types/node": "^22.10.2",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"@vitejs/plugin-react": "^6.0.1",
"jsdom": "^28.1.0",
"typescript": "^6.0.2",
"vite": "^8.0.0",
"vitest": "^4.1.5"
},
"pnpm": {
"onlyBuiltDependencies": [
"esbuild",
"lightningcss"
]
}
}

3606
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

BIN
public/logo192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

BIN
public/logo512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

25
public/manifest.json Normal file
View File

@@ -0,0 +1,25 @@
{
"short_name": "TanStack App",
"name": "Create TanStack App Sample",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "logo192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "logo512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}

3
public/robots.txt Normal file
View File

@@ -0,0 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:

View File

@@ -0,0 +1,65 @@
import { queryOptions } from '@tanstack/react-query'
import { createServerFn } from '@tanstack/react-start'
import { requestGoBackendData } from '#/utils/api'
export interface HeroDetail {
id: number
heroName: string
heroCode: string
heroAttrLv60: string
creator: string
createTime: string | null
updater: string
updateTime: string | null
deleted: boolean
nickName: string
rarity: string
role: string
zodiac: string
headImgUrl: string
attribute: string
remark: string
rawJson: string
contentJsonSet: string
updateTimeSet: string | null
}
export const DEFAULT_HERO_ID = 1
export interface HeroQueryResult {
hero: HeroDetail | null
heroId: number
source: 'backend' | 'fallback'
message?: string
}
const getHero = createServerFn({ method: 'GET' }).handler(async () => {
try {
const hero = await requestGoBackendData<HeroDetail | null>(
`/heroes/${DEFAULT_HERO_ID}`,
)
return {
hero,
heroId: DEFAULT_HERO_ID,
source: 'backend',
} satisfies HeroQueryResult
} catch (error) {
return {
hero: null,
heroId: DEFAULT_HERO_ID,
source: 'fallback',
message:
error instanceof Error
? error.message
: 'Go backend is unavailable right now.',
} satisfies HeroQueryResult
}
})
export function heroQueryOptions() {
return queryOptions({
queryKey: ['hero', DEFAULT_HERO_ID],
queryFn: () => getHero(),
})
}

View File

@@ -0,0 +1,6 @@
import { ReactQueryDevtoolsPanel } from '@tanstack/react-query-devtools'
export default {
name: 'Tanstack Query',
render: <ReactQueryDevtoolsPanel />,
}

View File

@@ -0,0 +1,10 @@
import { QueryClient } from '@tanstack/react-query'
export function getContext() {
const queryClient = new QueryClient()
return {
queryClient,
}
}
export default function TanstackQueryProvider() {}

68
src/routeTree.gen.ts Normal file
View File

@@ -0,0 +1,68 @@
/* eslint-disable */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// This file was automatically generated by TanStack Router.
// You should NOT make any changes in this file as it will be overwritten.
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from './routes/__root'
import { Route as IndexRouteImport } from './routes/index'
const IndexRoute = IndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => rootRouteImport,
} as any)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/'
fileRoutesByTo: FileRoutesByTo
to: '/'
id: '__root__' | '/'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
}
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/': {
id: '/'
path: '/'
fullPath: '/'
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
}
}
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
._addFileTypes<FileRouteTypes>()
import type { getRouter } from './router.tsx'
import type { createStart } from '@tanstack/react-start'
declare module '@tanstack/react-start' {
interface Register {
ssr: true
router: Awaited<ReturnType<typeof getRouter>>
}
}

31
src/router.tsx Normal file
View File

@@ -0,0 +1,31 @@
import { createRouter as createTanStackRouter } from '@tanstack/react-router'
import { routeTree } from './routeTree.gen'
import type { ReactNode } from 'react'
import { QueryClient } from '@tanstack/react-query'
import { setupRouterSsrQueryIntegration } from '@tanstack/react-router-ssr-query'
import TanstackQueryProvider, {
getContext,
} from './integrations/tanstack-query/root-provider'
export function getRouter() {
const context = getContext()
const router = createTanStackRouter({
routeTree,
context,
scrollRestoration: true,
defaultPreload: 'intent',
defaultPreloadStaleTime: 0,
})
setupRouterSsrQueryIntegration({ router, queryClient: context.queryClient })
return router
}
declare module '@tanstack/react-router' {
interface Register {
router: ReturnType<typeof getRouter>
}
}

67
src/routes/__root.tsx Normal file
View File

@@ -0,0 +1,67 @@
import {
HeadContent,
Scripts,
createRootRouteWithContext,
} from '@tanstack/react-router'
import { TanStackRouterDevtoolsPanel } from '@tanstack/react-router-devtools'
import { TanStackDevtools } from '@tanstack/react-devtools'
import TanStackQueryDevtools from '../integrations/tanstack-query/devtools'
import appCss from '../styles.css?url'
import type { QueryClient } from '@tanstack/react-query'
interface MyRouterContext {
queryClient: QueryClient
}
export const Route = createRootRouteWithContext<MyRouterContext>()({
head: () => ({
meta: [
{
charSet: 'utf-8',
},
{
name: 'viewport',
content: 'width=device-width, initial-scale=1',
},
{
title: 'TanStack Start Starter',
},
],
links: [
{
rel: 'stylesheet',
href: appCss,
},
],
}),
shellComponent: RootDocument,
})
function RootDocument({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<head>
<HeadContent />
</head>
<body>
{children}
<TanStackDevtools
config={{
position: 'bottom-right',
}}
plugins={[
{
name: 'Tanstack Router',
render: <TanStackRouterDevtoolsPanel />,
},
TanStackQueryDevtools,
]}
/>
<Scripts />
</body>
</html>
)
}

138
src/routes/index.tsx Normal file
View File

@@ -0,0 +1,138 @@
import { useSuspenseQuery } from '@tanstack/react-query'
import { createFileRoute } from '@tanstack/react-router'
import {
DEFAULT_HERO_ID,
heroQueryOptions,
} from '#/features/tasks/queries'
export const Route = createFileRoute('/')({
loader: ({ context }) =>
context.queryClient.ensureQueryData(heroQueryOptions()),
component: Home,
})
function Home() {
const { data } = useSuspenseQuery(heroQueryOptions())
const isFallback = data.source === 'fallback'
const hero = data.hero
const endpoint = `http://127.0.0.1:8080/heroes/${data.heroId}`
return (
<main className="min-h-screen bg-stone-950 px-6 py-16 text-stone-50">
<div className="mx-auto flex max-w-5xl flex-col gap-10">
<section className="grid gap-6 rounded-3xl border border-stone-800 bg-[radial-gradient(circle_at_top_left,_rgba(245,158,11,0.25),_transparent_30%),linear-gradient(135deg,_rgba(28,25,23,0.98),_rgba(12,10,9,0.92))] p-8 shadow-2xl shadow-amber-950/20">
<span className="w-fit rounded-full border border-amber-400/30 bg-amber-300/10 px-3 py-1 text-sm text-amber-200">
TanStack Start BFF
</span>
<div className="space-y-4">
<h1 className="max-w-3xl text-4xl font-semibold tracking-tight text-balance sm:text-5xl">
Frontend now renders against the backend hero contract
</h1>
<p className="max-w-2xl text-base leading-7 text-stone-300 sm:text-lg">
The UI no longer calls the non-existent `/tasks` endpoint. It now
reads `GET /heroes/:id` through a server function and unwraps the
backend response envelope consistently.
</p>
</div>
</section>
<section className="grid gap-4">
<div className="flex items-center justify-between">
<h2 className="text-2xl font-semibold text-stone-100">Hero Detail</h2>
<span className="text-sm text-stone-400">Hero ID: {data.heroId}</span>
</div>
{isFallback ? (
<div className="grid gap-4 rounded-3xl border border-amber-500/30 bg-amber-500/10 p-6 text-stone-100">
<div className="space-y-2">
<h3 className="text-lg font-semibold text-amber-200">
Go backend is temporarily unavailable
</h3>
<p className="text-sm leading-6 text-stone-300">
The frontend is running, but the API pointed to by
`GO_BACKEND_URL` is not returning hero data right now.
</p>
</div>
<div className="rounded-2xl border border-stone-700/80 bg-stone-950/60 p-4 text-sm text-stone-300">
<p className="font-medium text-stone-100">Fallback Details</p>
<p className="mt-2 break-words text-stone-400">
{data.message ?? 'No additional error details were returned.'}
</p>
</div>
<div className="grid gap-3 text-sm text-stone-300 sm:grid-cols-3">
<div className="rounded-2xl border border-stone-800 bg-stone-900/70 p-4">
<p className="font-medium text-stone-100">Frontend</p>
<p className="mt-2 text-stone-400">TanStack Start BFF is up</p>
</div>
<div className="rounded-2xl border border-stone-800 bg-stone-900/70 p-4">
<p className="font-medium text-stone-100">Backend Endpoint</p>
<p className="mt-2 text-stone-400">{endpoint}</p>
</div>
<div className="rounded-2xl border border-stone-800 bg-stone-900/70 p-4">
<p className="font-medium text-stone-100">Recovery</p>
<p className="mt-2 text-stone-400">Restart Go service and refresh</p>
</div>
</div>
</div>
) : hero ? (
<div className="grid gap-4 rounded-3xl border border-stone-800 bg-stone-900/80 p-6">
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="space-y-2">
<h3 className="text-2xl font-semibold text-stone-100">
{hero.heroName || 'Unnamed Hero'}
</h3>
<p className="text-sm text-stone-400">
Code: {hero.heroCode || '-'} | Nickname: {hero.nickName || '-'}
</p>
</div>
<span className="rounded-full bg-emerald-400/15 px-3 py-1 text-xs font-medium text-emerald-300">
Backend Connected
</span>
</div>
<dl className="grid gap-4 text-sm text-stone-300 sm:grid-cols-2 lg:grid-cols-3">
<div className="rounded-2xl border border-stone-800 bg-stone-950/60 p-4">
<dt className="text-stone-500">Attribute</dt>
<dd className="mt-2 text-stone-100">{hero.attribute || '-'}</dd>
</div>
<div className="rounded-2xl border border-stone-800 bg-stone-950/60 p-4">
<dt className="text-stone-500">Role</dt>
<dd className="mt-2 text-stone-100">{hero.role || '-'}</dd>
</div>
<div className="rounded-2xl border border-stone-800 bg-stone-950/60 p-4">
<dt className="text-stone-500">Rarity</dt>
<dd className="mt-2 text-stone-100">{hero.rarity || '-'}</dd>
</div>
<div className="rounded-2xl border border-stone-800 bg-stone-950/60 p-4">
<dt className="text-stone-500">Zodiac</dt>
<dd className="mt-2 text-stone-100">{hero.zodiac || '-'}</dd>
</div>
<div className="rounded-2xl border border-stone-800 bg-stone-950/60 p-4">
<dt className="text-stone-500">Lv60 Stats</dt>
<dd className="mt-2 text-stone-100">{hero.heroAttrLv60 || '-'}</dd>
</div>
<div className="rounded-2xl border border-stone-800 bg-stone-950/60 p-4">
<dt className="text-stone-500">Updated At</dt>
<dd className="mt-2 text-stone-100">{hero.updateTime || '-'}</dd>
</div>
</dl>
<div className="rounded-2xl border border-stone-800 bg-stone-950/60 p-4 text-sm text-stone-300">
<p className="font-medium text-stone-100">Remark</p>
<p className="mt-2 leading-6 text-stone-400">{hero.remark || 'No remark provided.'}</p>
</div>
</div>
) : (
<div className="rounded-2xl border border-dashed border-stone-700 bg-stone-900/50 p-8 text-sm text-stone-400">
The backend responded, but `GET /heroes/{DEFAULT_HERO_ID}` did not
return a hero record.
</div>
)}
</section>
</div>
</main>
)
}

17
src/styles.css Normal file
View File

@@ -0,0 +1,17 @@
@import "tailwindcss";
* {
box-sizing: border-box;
}
html,
body,
#app {
min-height: 100%;
}
body {
margin: 0;
}

64
src/utils/api.test.ts Normal file
View File

@@ -0,0 +1,64 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { BackendRequestError, requestGoBackendData } from './api'
const originalBackendUrl = process.env.GO_BACKEND_URL
afterEach(() => {
process.env.GO_BACKEND_URL = originalBackendUrl
vi.restoreAllMocks()
})
describe('BackendRequestError', () => {
it('should preserve status and details', () => {
const error = new BackendRequestError('request failed', 502, 'bad gateway')
expect(error.name).toBe('BackendRequestError')
expect(error.message).toBe('request failed')
expect(error.status).toBe(502)
expect(error.details).toBe('bad gateway')
})
})
describe('requestGoBackendData', () => {
it('should unwrap backend response data', async () => {
process.env.GO_BACKEND_URL = 'http://127.0.0.1:8080'
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
code: 'OK',
message: 'success',
data: { id: 1, heroName: 'Ras' },
}),
}),
)
await expect(
requestGoBackendData<{ id: number; heroName: string }>('/heroes/1'),
).resolves.toEqual({
id: 1,
heroName: 'Ras',
})
})
it('should throw for backend business errors', async () => {
process.env.GO_BACKEND_URL = 'http://127.0.0.1:8080'
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
code: 'ERROR',
message: 'hero not found',
data: null,
}),
}),
)
await expect(requestGoBackendData('/heroes/999')).rejects.toMatchObject({
name: 'BackendRequestError',
status: 200,
})
})
})

72
src/utils/api.ts Normal file
View File

@@ -0,0 +1,72 @@
export class BackendRequestError extends Error {
constructor(
message: string,
readonly status: number,
readonly details?: string,
) {
super(message)
this.name = 'BackendRequestError'
}
}
export interface BackendResponse<T> {
code: string
message: string
data: T
}
function getRequiredEnv(name: 'GO_BACKEND_URL') {
const value = process.env[name]
if (!value) {
throw new Error(`Missing required environment variable: ${name}`)
}
return value
}
function buildBackendUrl(path: string) {
return new URL(path, getRequiredEnv('GO_BACKEND_URL')).toString()
}
export async function requestGoBackend<T>(
path: string,
init?: RequestInit,
): Promise<BackendResponse<T>> {
const response = await fetch(buildBackendUrl(path), {
...init,
headers: {
Accept: 'application/json',
...init?.headers,
},
})
if (!response.ok) {
const details = await response.text()
throw new BackendRequestError(
`Go backend request failed: ${response.status} ${response.statusText}`,
response.status,
details,
)
}
return response.json() as Promise<BackendResponse<T>>
}
export async function requestGoBackendData<T>(
path: string,
init?: RequestInit,
): Promise<T> {
const response = await requestGoBackend<T>(path, init)
if (response.code !== 'OK') {
throw new BackendRequestError(
`Go backend business error: ${response.message}`,
200,
JSON.stringify(response),
)
}
return response.data
}

28
tsconfig.json Normal file
View File

@@ -0,0 +1,28 @@
{
"include": ["**/*.ts", "**/*.tsx"],
"compilerOptions": {
"target": "ES2022",
"jsx": "react-jsx",
"module": "ESNext",
"paths": {
"#/*": ["./src/*"],
"@/*": ["./src/*"]
},
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"types": ["vite/client"],
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
/* Linting */
"skipLibCheck": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
}
}

3
tsr.config.json Normal file
View File

@@ -0,0 +1,3 @@
{
"target": "react"
}

21
vite.config.ts Normal file
View File

@@ -0,0 +1,21 @@
import { defineConfig } from 'vite'
import { devtools } from '@tanstack/devtools-vite'
import { tanstackStart } from '@tanstack/react-start/plugin/vite'
import viteReact from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import { nitro } from 'nitro/vite'
const config = defineConfig({
resolve: { tsconfigPaths: true },
plugins: [
devtools(),
nitro({ rollupConfig: { external: [/^@sentry\//] } }),
tailwindcss(),
tanstackStart(),
viteReact(),
],
})
export default config

9
vitest.config.ts Normal file
View File

@@ -0,0 +1,9 @@
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
environment: 'node',
globals: true,
include: ['src/**/*.test.ts'],
},
})