This commit is contained in:
2026-04-14 16:27:47 +08:00
commit 4b38a4c952
134 changed files with 7478 additions and 0 deletions

12
frontend/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>K12Study Admin</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

24
frontend/package.json Normal file
View File

@@ -0,0 +1,24 @@
{
"name": "k12study-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"packageManager": "pnpm@10.33.0",
"scripts": {
"dev": "vite --host",
"build": "tsc -p tsconfig.json && vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.30.1"
},
"devDependencies": {
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react-swc": "^3.8.0",
"typescript": "^5.7.3",
"vite": "^6.2.0"
}
}

5
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,5 @@
import { AppRouter } from "./router/AppRouter";
export default function App() {
return <AppRouter />;
}

14
frontend/src/api/auth.ts Normal file
View File

@@ -0,0 +1,14 @@
import type { ApiResponse } from "../types/api";
import { http } from "../utils/http";
interface LoginInput {
username: string;
password: string;
provinceCode: string;
areaCode: string;
tenantId: string;
}
export async function login(input: LoginInput) {
return http.post<ApiResponse<{ accessToken: string; refreshToken: string }>>("/auth/login", input);
}

13
frontend/src/api/upms.ts Normal file
View File

@@ -0,0 +1,13 @@
import type { ApiResponse } from "../types/api";
import type { CurrentRouteUser, RouteNode } from "../types/route";
import { http } from "../utils/http";
export async function fetchDynamicRoutes(): Promise<RouteNode[]> {
const response = await http.get<ApiResponse<RouteNode[]>>("/upms/routes");
return response.data as RouteNode[];
}
export async function fetchCurrentUser(): Promise<CurrentRouteUser> {
const response = await http.get<ApiResponse<CurrentRouteUser>>("/upms/current-user");
return response.data as CurrentRouteUser;
}

View File

@@ -0,0 +1,18 @@
import type { PropsWithChildren, ReactNode } from "react";
type AppCardProps = PropsWithChildren<{
title: string;
extra?: ReactNode;
}>;
export function AppCard({ title, extra, children }: AppCardProps) {
return (
<section className="app-card">
<header className="app-card__header">
<h3>{title}</h3>
{extra}
</header>
<div className="app-card__body">{children}</div>
</section>
);
}

View File

@@ -0,0 +1,3 @@
export function LoadingView({ message = "Loading..." }: { message?: string }) {
return <div className="loading-view">{message}</div>;
}

View File

@@ -0,0 +1,9 @@
import { Outlet } from "react-router-dom";
export function DefaultLayout() {
return (
<div className="default-layout">
<Outlet />
</div>
);
}

View File

@@ -0,0 +1,18 @@
import { Outlet, useLocation } from "react-router-dom";
export function SidebarLayout() {
const location = useLocation();
return (
<div className="shell">
<aside className="shell__sidebar">
<div className="shell__brand">K12Study</div>
<div className="shell__hint">React </div>
</aside>
<main className="shell__content">
<header className="shell__header">{location.pathname}</header>
<Outlet />
</main>
</div>
);
}

13
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,13 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import App from "./App";
import "./styles/app.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>
);

View File

@@ -0,0 +1,9 @@
import { AppCard } from "../components/AppCard";
export function DashboardPage() {
return (
<AppCard title="项目骨架已就绪">
<p></p>
</AppCard>
);
}

View File

@@ -0,0 +1,42 @@
import { type FormEvent, useState } from "react";
import { useNavigate } from "react-router-dom";
import { login } from "../api/auth";
import { setAccessToken } from "../utils/storage";
export function LoginPage() {
const navigate = useNavigate();
const [form, setForm] = useState({
username: "admin",
password: "admin123",
provinceCode: "330000",
areaCode: "330100",
tenantId: "SCH-HQ"
});
async function handleSubmit(event: FormEvent) {
event.preventDefault();
const response = await login(form);
setAccessToken(response.data.accessToken);
navigate("/");
}
return (
<div className="login-page">
<form className="login-form" onSubmit={handleSubmit}>
<h1>K12Study Admin</h1>
<input
value={form.username}
onChange={(event) => setForm({ ...form, username: event.target.value })}
placeholder="用户名"
/>
<input
type="password"
value={form.password}
onChange={(event) => setForm({ ...form, password: event.target.value })}
placeholder="密码"
/>
<button type="submit"></button>
</form>
</div>
);
}

View File

@@ -0,0 +1,10 @@
import { Link } from "react-router-dom";
export function NotFoundPage() {
return (
<div className="not-found">
<h1>404</h1>
<Link to="/"></Link>
</div>
);
}

View File

@@ -0,0 +1,13 @@
import { useLocation } from "react-router-dom";
import { AppCard } from "../components/AppCard";
export function RoutePlaceholderPage() {
const location = useLocation();
return (
<AppCard title="动态路由占位页">
<p>{location.pathname}</p>
<p></p>
</AppCard>
);
}

View File

@@ -0,0 +1,90 @@
import { useEffect, useMemo, useState } from "react";
import { Navigate, type RouteObject, useRoutes } from "react-router-dom";
import { fetchDynamicRoutes } from "../api/upms";
import { LoadingView } from "../components/LoadingView";
import { DefaultLayout } from "../layouts/DefaultLayout";
import { SidebarLayout } from "../layouts/SidebarLayout";
import { DashboardPage } from "../pages/DashboardPage";
import { LoginPage } from "../pages/LoginPage";
import { NotFoundPage } from "../pages/NotFoundPage";
import { RoutePlaceholderPage } from "../pages/RoutePlaceholderPage";
import type { RouteNode } from "../types/route";
const layoutRegistry = {
DEFAULT: DefaultLayout,
SIDEBAR: SidebarLayout
};
function toChildRoute(route: RouteNode): RouteObject {
const Component = route.component === "dashboard" ? DashboardPage : RoutePlaceholderPage;
return {
path: route.path === "/" ? "" : route.path.replace(/^\//, ""),
element: <Component />
};
}
function buildRoutes(dynamicRoutes: RouteNode[]): RouteObject[] {
const grouped = dynamicRoutes.reduce<Record<string, RouteNode[]>>((acc, route) => {
acc[route.layout] ??= [];
acc[route.layout].push(route);
return acc;
}, {});
const layoutRoutes = Object.entries(grouped).map(([layout, routes]) => {
const Layout = layoutRegistry[layout as keyof typeof layoutRegistry] ?? DefaultLayout;
return {
path: "/",
element: <Layout />,
children: routes.map(toChildRoute)
} satisfies RouteObject;
});
return [
{ path: "/login", element: <LoginPage /> },
...layoutRoutes,
{ path: "*", element: <NotFoundPage /> }
];
}
export function AppRouter() {
const [dynamicRoutes, setDynamicRoutes] = useState<RouteNode[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchDynamicRoutes()
.then((routes) => setDynamicRoutes(routes))
.catch(() =>
setDynamicRoutes([
{
id: "dashboard",
path: "/",
name: "dashboard",
component: "dashboard",
layout: "SIDEBAR",
meta: {
title: "控制台",
hidden: false,
permissionCodes: ["dashboard:view"]
},
children: []
}
])
)
.finally(() => setLoading(false));
}, []);
const routes = useMemo(() => {
if (loading) {
return [{ path: "*", element: <LoadingView message="正在加载路由..." /> }];
}
const nextRoutes = buildRoutes(dynamicRoutes);
if (dynamicRoutes.length === 0) {
nextRoutes.unshift({ path: "/", element: <Navigate to="/login" replace /> });
}
return nextRoutes;
}, [dynamicRoutes, loading]);
return useRoutes(routes);
}

View File

@@ -0,0 +1,9 @@
import { clearAccessToken, getAccessToken } from "../utils/storage";
export function isAuthenticated() {
return Boolean(getAccessToken());
}
export function signOut() {
clearAccessToken();
}

120
frontend/src/styles/app.css Normal file
View File

@@ -0,0 +1,120 @@
:root {
color: #1a1f36;
background: linear-gradient(180deg, #eef2ff 0%, #f8fafc 100%);
font-family: "Segoe UI", "PingFang SC", sans-serif;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
}
a {
color: #2563eb;
}
.shell {
display: grid;
grid-template-columns: 240px 1fr;
min-height: 100vh;
}
.shell__sidebar {
padding: 24px;
background: #111827;
color: #f9fafb;
}
.shell__brand {
font-size: 24px;
font-weight: 700;
}
.shell__hint {
margin-top: 12px;
color: #94a3b8;
}
.shell__content {
padding: 24px;
}
.shell__header {
margin-bottom: 16px;
color: #475569;
}
.app-card {
border-radius: 20px;
background: #ffffff;
padding: 24px;
box-shadow: 0 20px 50px rgba(15, 23, 42, 0.08);
}
.app-card__header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.app-card__body {
color: #334155;
line-height: 1.6;
}
.login-page {
display: grid;
place-items: center;
min-height: 100vh;
}
.login-form {
display: grid;
gap: 12px;
width: min(360px, calc(100vw - 32px));
padding: 32px;
border-radius: 24px;
background: #ffffff;
box-shadow: 0 24px 60px rgba(15, 23, 42, 0.12);
}
.login-form input,
.login-form button {
height: 44px;
padding: 0 14px;
border-radius: 12px;
border: 1px solid #cbd5e1;
}
.login-form button {
border: none;
background: #2563eb;
color: #ffffff;
cursor: pointer;
}
.loading-view,
.not-found {
display: grid;
place-items: center;
min-height: 100vh;
color: #5b6475;
}
@media (max-width: 768px) {
.shell {
grid-template-columns: 1fr;
}
.shell__sidebar {
padding: 16px;
}
.shell__content {
padding: 16px;
}
}

View File

@@ -0,0 +1,6 @@
export interface ApiResponse<T> {
code: number;
message: string;
data: T;
traceId: string;
}

View File

@@ -0,0 +1,27 @@
export type LayoutType = "DEFAULT" | "SIDEBAR";
export interface RouteMeta {
title: string;
icon?: string;
permissionCodes?: string[];
hidden?: boolean;
}
export interface RouteNode {
id: string;
path: string;
name: string;
component: string;
layout: LayoutType;
meta: RouteMeta;
children: RouteNode[];
}
export interface CurrentRouteUser {
userId: string;
username: string;
displayName: string;
tenantId: string;
deptId: string;
permissionCodes: string[];
}

View File

@@ -0,0 +1,83 @@
import { getAccessToken } from "./storage";
const BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "/api";
const DEFAULT_TIMEOUT = 10000;
type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
interface RequestOptions {
method?: HttpMethod;
body?: unknown;
headers?: Record<string, string>;
timeout?: number;
}
function buildUrl(path: string) {
if (/^https?:\/\//.test(path)) {
return path;
}
return `${BASE_URL}${path.startsWith("/") ? path : `/${path}`}`;
}
async function parseResponse(response: Response) {
if (response.status === 204) {
return null;
}
const contentType = response.headers.get("content-type") ?? "";
if (contentType.includes("application/json")) {
return response.json();
}
return response.text();
}
async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
const controller = new AbortController();
const timeoutId = window.setTimeout(() => controller.abort(), options.timeout ?? DEFAULT_TIMEOUT);
const token = getAccessToken();
try {
const response = await fetch(buildUrl(path), {
method: options.method ?? "GET",
headers: {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
...options.headers
},
body: options.body === undefined ? undefined : JSON.stringify(options.body),
signal: controller.signal
});
const payload = await parseResponse(response);
if (!response.ok) {
const message =
typeof payload === "object" && payload !== null && "message" in payload
? String(payload.message)
: `Request failed with status ${response.status}`;
throw new Error(message);
}
return payload as T;
} finally {
window.clearTimeout(timeoutId);
}
}
export const http = {
get<T>(path: string, options?: Omit<RequestOptions, "method" | "body">) {
return request<T>(path, { ...options, method: "GET" });
},
post<T>(path: string, body?: unknown, options?: Omit<RequestOptions, "method" | "body">) {
return request<T>(path, { ...options, method: "POST", body });
},
put<T>(path: string, body?: unknown, options?: Omit<RequestOptions, "method" | "body">) {
return request<T>(path, { ...options, method: "PUT", body });
},
patch<T>(path: string, body?: unknown, options?: Omit<RequestOptions, "method" | "body">) {
return request<T>(path, { ...options, method: "PATCH", body });
},
delete<T>(path: string, options?: Omit<RequestOptions, "method" | "body">) {
return request<T>(path, { ...options, method: "DELETE" });
}
};

View File

@@ -0,0 +1,13 @@
const ACCESS_TOKEN_KEY = "k12study.access-token";
export function getAccessToken() {
return localStorage.getItem(ACCESS_TOKEN_KEY);
}
export function setAccessToken(token: string) {
localStorage.setItem(ACCESS_TOKEN_KEY, token);
}
export function clearAccessToken() {
localStorage.removeItem(ACCESS_TOKEN_KEY);
}

22
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"baseUrl": ".",
"types": ["vite/client"]
},
"include": ["src", "vite.config.ts"]
}

15
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,15 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react-swc";
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
"/api": {
target: "http://localhost:8088",
changeOrigin: true
}
}
}
});