feat: initialize zhixing-web with React, React Router, and Tailwind CSS
- Set up the main application structure with App component and routing. - Implemented global state management for UI theme using Zustand. - Created API utilities for fetching system status. - Developed system status page with responsive design and theme toggle. - Added testing setup with Vitest and initial tests for system status page. - Configured TypeScript with appropriate settings for the application. - Integrated Tailwind CSS for styling and responsive design. - Established Vite configuration for development and testing environments.
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
import { RouterProvider } from "@tanstack/react-router"
|
||||
|
||||
import { AppProviders } from "@/app/providers"
|
||||
import { router } from "@/app/router"
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<AppProviders>
|
||||
<RouterProvider router={router} />
|
||||
</AppProviders>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { QueryClientProvider } from "@tanstack/react-query"
|
||||
import { type PropsWithChildren, useEffect } from "react"
|
||||
|
||||
import { queryClient } from "@/app/query-client"
|
||||
import { useUiStore } from "@/shared/config/ui-store"
|
||||
|
||||
function ThemeEffect() {
|
||||
const theme = useUiStore((state) => state.theme)
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.classList.toggle("dark", theme === "dark")
|
||||
}, [theme])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function AppProviders({ children }: PropsWithChildren) {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeEffect />
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { QueryClient } from "@tanstack/react-query"
|
||||
|
||||
export const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
refetchOnWindowFocus: false,
|
||||
retry: 1,
|
||||
staleTime: 30_000,
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,11 @@
|
||||
import { createRouter } from "@tanstack/react-router"
|
||||
|
||||
import { routeTree } from "@/routes/route-tree"
|
||||
|
||||
export const router = createRouter({ routeTree })
|
||||
|
||||
declare module "@tanstack/react-router" {
|
||||
interface Register {
|
||||
router: typeof router
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { requestJson } from "@/shared/api/request-json"
|
||||
|
||||
import type { SystemStatus } from "./system.types"
|
||||
|
||||
export function getSystemStatus(signal?: AbortSignal) {
|
||||
return requestJson<SystemStatus>("/api/v1/system/status", { signal })
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
|
||||
import { getSystemStatus } from "./system.api"
|
||||
|
||||
export const systemStatusQueryKey = ["system", "status"] as const
|
||||
|
||||
export function useSystemStatus() {
|
||||
return useQuery({
|
||||
queryFn: ({ signal }) => getSystemStatus(signal),
|
||||
queryKey: systemStatusQueryKey,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export interface SystemStatus {
|
||||
environment: string
|
||||
service: string
|
||||
status: "ok"
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { render, screen } from "@testing-library/react"
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||
|
||||
import { SystemStatusPage } from "./system-status-page"
|
||||
|
||||
const useSystemStatus = vi.fn()
|
||||
|
||||
vi.mock("@/features/system/api/system.query", () => ({
|
||||
useSystemStatus: () => useSystemStatus(),
|
||||
}))
|
||||
|
||||
describe("SystemStatusPage", () => {
|
||||
beforeEach(() => {
|
||||
useSystemStatus.mockReturnValue({
|
||||
data: {
|
||||
environment: "test",
|
||||
service: "Zhixing Server",
|
||||
status: "ok",
|
||||
},
|
||||
isError: false,
|
||||
isPending: false,
|
||||
})
|
||||
})
|
||||
|
||||
it("renders the connected backend state", () => {
|
||||
render(<SystemStatusPage />)
|
||||
|
||||
expect(screen.getByText("运行正常")).toBeInTheDocument()
|
||||
expect(screen.getByText("test")).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,76 @@
|
||||
import { Activity, Moon, Sun } from "lucide-react"
|
||||
|
||||
import { useSystemStatus } from "@/features/system/api/system.query"
|
||||
import { useUiStore } from "@/shared/config/ui-store"
|
||||
import { Badge } from "@/shared/ui/badge"
|
||||
import { Button } from "@/shared/ui/button"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/shared/ui/card"
|
||||
|
||||
export function SystemStatusPage() {
|
||||
const status = useSystemStatus()
|
||||
const theme = useUiStore((state) => state.theme)
|
||||
const toggleTheme = useUiStore((state) => state.toggleTheme)
|
||||
|
||||
const statusLabel = status.isPending
|
||||
? "正在连接"
|
||||
: status.isError
|
||||
? "连接异常"
|
||||
: "运行正常"
|
||||
|
||||
return (
|
||||
<main className="flex min-h-screen items-center justify-center bg-background p-6 text-foreground">
|
||||
<Card className="w-full max-w-xl">
|
||||
<CardHeader className="flex-row items-start justify-between gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<CardTitle className="flex items-center gap-2 text-2xl">
|
||||
<Activity className="size-6 text-primary" aria-hidden="true" />
|
||||
Zhixing System
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
量化系统开发环境已完成最小链路连接。
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button
|
||||
aria-label="切换主题"
|
||||
onClick={toggleTheme}
|
||||
size="icon"
|
||||
variant="outline"
|
||||
>
|
||||
{theme === "dark" ? (
|
||||
<Sun aria-hidden="true" />
|
||||
) : (
|
||||
<Moon aria-hidden="true" />
|
||||
)}
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between rounded-lg border bg-muted/40 p-4">
|
||||
<div>
|
||||
<p className="font-medium">后端服务</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{status.data?.service ?? "Zhixing Server"}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant={status.isError ? "destructive" : "default"}>
|
||||
{statusLabel}
|
||||
</Badge>
|
||||
</div>
|
||||
{status.data ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
当前环境:
|
||||
<span className="font-medium text-foreground">
|
||||
{status.data.environment}
|
||||
</span>
|
||||
</p>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { StrictMode } from "react"
|
||||
import { createRoot } from "react-dom/client"
|
||||
|
||||
import { App } from "@/app/app"
|
||||
import "@/styles/globals.css"
|
||||
|
||||
const rootElement = document.getElementById("root")
|
||||
|
||||
if (rootElement === null) {
|
||||
throw new Error("Missing #root element")
|
||||
}
|
||||
|
||||
createRoot(rootElement).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,15 @@
|
||||
import { createRootRoute, createRoute, Outlet } from "@tanstack/react-router"
|
||||
|
||||
import { SystemStatusPage } from "@/features/system/pages/system-status-page"
|
||||
|
||||
const rootRoute = createRootRoute({
|
||||
component: () => <Outlet />,
|
||||
})
|
||||
|
||||
const indexRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "/",
|
||||
component: SystemStatusPage,
|
||||
})
|
||||
|
||||
export const routeTree = rootRoute.addChildren([indexRoute])
|
||||
@@ -0,0 +1,36 @@
|
||||
export class ApiError extends Error {
|
||||
readonly status: number
|
||||
|
||||
constructor(status: number, message: string) {
|
||||
super(message)
|
||||
this.name = "ApiError"
|
||||
this.status = status
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a JSON request through the application's same-origin transport boundary.
|
||||
*
|
||||
* @param input - Same-origin API path or URL.
|
||||
* @param init - Native fetch options such as AbortSignal and method.
|
||||
* @returns The decoded response body typed by the feature adapter.
|
||||
* @throws ApiError when the server returns a non-success response.
|
||||
*/
|
||||
export async function requestJson<T>(
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit,
|
||||
): Promise<T> {
|
||||
const headers = new Headers(init?.headers)
|
||||
headers.set("Accept", "application/json")
|
||||
|
||||
const response = await fetch(input, { ...init, headers })
|
||||
|
||||
if (!response.ok) {
|
||||
throw new ApiError(
|
||||
response.status,
|
||||
`API request failed with status ${response.status}`,
|
||||
)
|
||||
}
|
||||
|
||||
return (await response.json()) as T
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { create } from "zustand"
|
||||
import { persist } from "zustand/middleware"
|
||||
|
||||
type Theme = "dark" | "light"
|
||||
|
||||
interface UiState {
|
||||
theme: Theme
|
||||
toggleTheme: () => void
|
||||
}
|
||||
|
||||
export const useUiStore = create<UiState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
theme: "light",
|
||||
toggleTheme: () =>
|
||||
set((state) => ({ theme: state.theme === "light" ? "dark" : "light" })),
|
||||
}),
|
||||
{ name: "zhixing-ui" },
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,28 @@
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import type { HTMLAttributes } from "react"
|
||||
|
||||
import { cn } from "@/shared/ui/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border-transparent bg-primary text-primary-foreground",
|
||||
destructive: "border-transparent bg-destructive text-white",
|
||||
outline: "text-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: { variant: "default" },
|
||||
},
|
||||
)
|
||||
|
||||
export function Badge({
|
||||
className,
|
||||
variant,
|
||||
...props
|
||||
}: HTMLAttributes<HTMLSpanElement> & VariantProps<typeof badgeVariants>) {
|
||||
return (
|
||||
<span className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import type { ButtonHTMLAttributes } from "react"
|
||||
|
||||
import { cn } from "@/shared/ui/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium transition-colors disabled:pointer-events-none disabled:opacity-50 [&_svg]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
outline:
|
||||
"border bg-background hover:bg-accent hover:text-accent-foreground",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2",
|
||||
icon: "size-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: { size: "default", variant: "default" },
|
||||
},
|
||||
)
|
||||
|
||||
export function Button({
|
||||
className,
|
||||
size,
|
||||
type = "button",
|
||||
variant,
|
||||
...props
|
||||
}: ButtonHTMLAttributes<HTMLButtonElement> &
|
||||
VariantProps<typeof buttonVariants>) {
|
||||
return (
|
||||
<button
|
||||
className={cn(buttonVariants({ size, variant }), className)}
|
||||
type={type}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { HTMLAttributes } from "react"
|
||||
|
||||
import { cn } from "@/shared/ui/utils"
|
||||
|
||||
export function Card({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-xl border bg-card text-card-foreground shadow",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function CardHeader({
|
||||
className,
|
||||
...props
|
||||
}: HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function CardTitle({
|
||||
className,
|
||||
...props
|
||||
}: HTMLAttributes<HTMLHeadingElement>) {
|
||||
return (
|
||||
<h1
|
||||
className={cn("font-semibold leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function CardDescription({
|
||||
className,
|
||||
...props
|
||||
}: HTMLAttributes<HTMLParagraphElement>) {
|
||||
return (
|
||||
<p className={cn("text-sm text-muted-foreground", className)} {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
export function CardContent({
|
||||
className,
|
||||
...props
|
||||
}: HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn("p-6 pt-0", className)} {...props} />
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
}
|
||||
|
||||
:root {
|
||||
--background: oklch(0.985 0.002 247.839);
|
||||
--foreground: oklch(0.145 0.015 252.417);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0.015 252.417);
|
||||
--primary: oklch(0.488 0.243 264.376);
|
||||
--primary-foreground: oklch(0.97 0.014 254.604);
|
||||
--muted: oklch(0.968 0.007 247.896);
|
||||
--muted-foreground: oklch(0.554 0.046 257.417);
|
||||
--accent: oklch(0.932 0.032 255.585);
|
||||
--accent-foreground: oklch(0.279 0.041 260.031);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--radius: 0.75rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.129 0.042 264.695);
|
||||
--foreground: oklch(0.984 0.003 247.858);
|
||||
--card: oklch(0.208 0.042 265.755);
|
||||
--card-foreground: oklch(0.984 0.003 247.858);
|
||||
--primary: oklch(0.707 0.165 254.624);
|
||||
--primary-foreground: oklch(0.208 0.042 265.755);
|
||||
--muted: oklch(0.279 0.041 260.031);
|
||||
--muted-foreground: oklch(0.704 0.04 256.788);
|
||||
--accent: oklch(0.279 0.041 260.031);
|
||||
--accent-foreground: oklch(0.984 0.003 247.858);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-slate-200 dark:border-slate-800;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply m-0 bg-background text-foreground antialiased;
|
||||
font-family:
|
||||
Inter,
|
||||
ui-sans-serif,
|
||||
system-ui,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
"Segoe UI",
|
||||
sans-serif;
|
||||
}
|
||||
|
||||
button {
|
||||
font: inherit;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
import "@testing-library/jest-dom/vitest"
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user