chore: initialize frontend project
This commit is contained in:
3
.env.example
Normal file
3
.env.example
Normal file
@@ -0,0 +1,3 @@
|
||||
NODE_ENV=development
|
||||
PORT=3000
|
||||
GO_BACKEND_URL=http://127.0.0.1:8080
|
||||
18
.gitignore
vendored
Normal file
18
.gitignore
vendored
Normal 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
82
AGENTS.md
Normal 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
207
README.md
Normal 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
53
package.json
Normal 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
3606
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
BIN
public/favicon.ico
Normal file
BIN
public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.8 KiB |
BIN
public/logo192.png
Normal file
BIN
public/logo192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.2 KiB |
BIN
public/logo512.png
Normal file
BIN
public/logo512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.4 KiB |
25
public/manifest.json
Normal file
25
public/manifest.json
Normal 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
3
public/robots.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
# https://www.robotstxt.org/robotstxt.html
|
||||
User-agent: *
|
||||
Disallow:
|
||||
65
src/features/tasks/queries.ts
Normal file
65
src/features/tasks/queries.ts
Normal 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(),
|
||||
})
|
||||
}
|
||||
6
src/integrations/tanstack-query/devtools.tsx
Normal file
6
src/integrations/tanstack-query/devtools.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import { ReactQueryDevtoolsPanel } from '@tanstack/react-query-devtools'
|
||||
|
||||
export default {
|
||||
name: 'Tanstack Query',
|
||||
render: <ReactQueryDevtoolsPanel />,
|
||||
}
|
||||
10
src/integrations/tanstack-query/root-provider.tsx
Normal file
10
src/integrations/tanstack-query/root-provider.tsx
Normal 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
68
src/routeTree.gen.ts
Normal 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
31
src/router.tsx
Normal 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
67
src/routes/__root.tsx
Normal 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
138
src/routes/index.tsx
Normal 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
17
src/styles.css
Normal 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
64
src/utils/api.test.ts
Normal 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
72
src/utils/api.ts
Normal 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
28
tsconfig.json
Normal 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
3
tsr.config.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"target": "react"
|
||||
}
|
||||
21
vite.config.ts
Normal file
21
vite.config.ts
Normal 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
9
vitest.config.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'node',
|
||||
globals: true,
|
||||
include: ['src/**/*.test.ts'],
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user