This commit is contained in:
2026-04-17 16:31:32 +08:00
parent adadb3bf1d
commit 2476655b28
116 changed files with 3875 additions and 583 deletions

View File

@@ -15,9 +15,11 @@
"react-router-dom": "^6.30.1"
},
"devDependencies": {
"@types/node": "^25.6.0",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react-swc": "^3.8.0",
"sass-embedded": "^1.99.0",
"typescript": "^5.7.3",
"vite": "^6.2.0"
}

View File

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

View File

@@ -1,20 +1,29 @@
import type { ApiResponse } from "../types/api";
import { http } from "../utils/http";
interface LoginInput {
import type { ApiResponse } from "@/types";
import { http } from "@/utils";
export interface LoginInput {
username: string;
password: string;
mobile?: string;
smsCode?: string;
provinceCode: string;
areaCode: string;
tenantId: string;
clientType?: "WEB" | "MINI";
}
export interface TokenPayload {
accessToken: string;
refreshToken: string;
tokenType: string;
expiresIn: number;
}
export async function login(input: LoginInput) {
return http.post<ApiResponse<{ accessToken: string; refreshToken: string }>>("/auth/tokens", input);
return http.post<ApiResponse<TokenPayload>>("/auth/tokens", input);
}
export async function refreshToken(refreshToken: string) {
return http.post<ApiResponse<{ accessToken: string; refreshToken: string }>>("/auth/tokens/refresh", {
return http.post<ApiResponse<TokenPayload>>("/auth/tokens/refresh", {
refreshToken
});
}

View File

@@ -0,0 +1,2 @@
export * from "./auth";
export * from "./upms";

View File

@@ -1,12 +1,17 @@
import {
getUpmsAreasRemote,
getUpmsCurrentUserRemote,
getUpmsDepartmentsRemote,
getUpmsRoutesRemote,
getUpmsTenantsRemote
} from "../remote/upmsRemote";
import type { CurrentRouteUser, RouteNode } from "../types/route";
import type { UpmsAreaNode, UpmsDeptNode, UpmsTenantNode } from "../types/upms";
import type { ApiResponse, CurrentRouteUser, RouteNode } from "@/types";
import type {
UpmsAreaNode,
UpmsClass,
UpmsClassCourse,
UpmsClassMember,
UpmsDeptNode,
UpmsFileMetadata,
UpmsFileUploadRequest,
UpmsInboxMessage,
UpmsMessageReadResult,
UpmsTenantNode
} from "@/types/upms";
import { http } from "@/utils";
function normalizeAreaNodes(nodes: UpmsAreaNode[]): UpmsAreaNode[] {
return nodes.map((node) => ({
@@ -30,26 +35,67 @@ function normalizeDeptNodes(nodes: UpmsDeptNode[]): UpmsDeptNode[] {
}
export async function fetchDynamicRoutes(): Promise<RouteNode[]> {
const response = await getUpmsRoutesRemote();
const response = await http.get<ApiResponse<RouteNode[]>>("/upms/routes");
return response.data as RouteNode[];
}
export async function fetchCurrentUser(): Promise<CurrentRouteUser> {
const response = await getUpmsCurrentUserRemote();
const response = await http.get<ApiResponse<CurrentRouteUser>>("/upms/users/current");
return response.data as CurrentRouteUser;
}
export async function fetchAreas(): Promise<UpmsAreaNode[]> {
const response = await getUpmsAreasRemote();
const response = await http.get<ApiResponse<UpmsAreaNode[]>>("/upms/areas");
return normalizeAreaNodes(response.data as UpmsAreaNode[]);
}
export async function fetchTenants(): Promise<UpmsTenantNode[]> {
const response = await getUpmsTenantsRemote();
const response = await http.get<ApiResponse<UpmsTenantNode[]>>("/upms/tenants");
return normalizeTenantNodes(response.data as UpmsTenantNode[]);
}
export async function fetchDepartments(): Promise<UpmsDeptNode[]> {
const response = await getUpmsDepartmentsRemote();
const response = await http.get<ApiResponse<UpmsDeptNode[]>>("/upms/departments");
return normalizeDeptNodes(response.data as UpmsDeptNode[]);
}
export async function fetchClasses(): Promise<UpmsClass[]> {
const response = await http.get<ApiResponse<UpmsClass[]>>("/upms/classes");
return response.data as UpmsClass[];
}
export async function fetchClassMembers(classId: string): Promise<UpmsClassMember[]> {
const response = await http.get<ApiResponse<UpmsClassMember[]>>(
`/upms/classes/${encodeURIComponent(classId)}/members`
);
return response.data as UpmsClassMember[];
}
export async function fetchClassCourses(classId: string): Promise<UpmsClassCourse[]> {
const response = await http.get<ApiResponse<UpmsClassCourse[]>>(
`/upms/classes/${encodeURIComponent(classId)}/courses`
);
return response.data as UpmsClassCourse[];
}
export async function uploadFileMetadata(request: UpmsFileUploadRequest): Promise<UpmsFileMetadata> {
const response = await http.post<ApiResponse<UpmsFileMetadata>>("/upms/files/upload", request);
return response.data as UpmsFileMetadata;
}
export async function fetchFileMetadata(fileId: string): Promise<UpmsFileMetadata> {
const response = await http.get<ApiResponse<UpmsFileMetadata>>(`/upms/files/${encodeURIComponent(fileId)}`);
return response.data as UpmsFileMetadata;
}
export async function fetchInboxMessages(): Promise<UpmsInboxMessage[]> {
const response = await http.get<ApiResponse<UpmsInboxMessage[]>>("/upms/messages/inbox");
return response.data as UpmsInboxMessage[];
}
export async function markInboxMessageRead(messageId: string): Promise<UpmsMessageReadResult> {
const response = await http.post<ApiResponse<UpmsMessageReadResult>>(
`/upms/messages/${encodeURIComponent(messageId)}/read`
);
return response.data as UpmsMessageReadResult;
}

View File

@@ -0,0 +1,18 @@
.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;
}

View File

@@ -1,4 +1,5 @@
import type { PropsWithChildren, ReactNode } from "react";
import "./index.scss";
type AppCardProps = PropsWithChildren<{
title: string;

View File

@@ -0,0 +1,6 @@
.loading-view {
display: grid;
place-items: center;
min-height: 100vh;
color: #5b6475;
}

View File

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

View File

@@ -0,0 +1,2 @@
export * from "./AppCard";
export * from "./LoadingView";

View File

@@ -0,0 +1,10 @@
.default-layout {
min-height: 100vh;
padding: 24px;
}
@media (max-width: 768px) {
.default-layout {
padding: 16px;
}
}

View File

@@ -1,4 +1,5 @@
import { Outlet } from "react-router-dom";
import "./index.scss";
export function DefaultLayout() {
return (

View File

@@ -0,0 +1,44 @@
.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;
}
@media (max-width: 768px) {
.shell {
grid-template-columns: 1fr;
}
.shell__sidebar {
padding: 16px;
}
.shell__content {
padding: 16px;
}
}

View File

@@ -1,4 +1,5 @@
import { Outlet, useLocation } from "react-router-dom";
import "./index.scss";
export function SidebarLayout() {
const location = useLocation();

View File

@@ -0,0 +1,2 @@
export * from "./DefaultLayout";
export * from "./SidebarLayout";

View File

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

View File

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

View File

@@ -0,0 +1,32 @@
.dashboard-grid {
display: grid;
gap: 16px;
}
.dashboard-list {
margin: 0;
padding-left: 16px;
display: grid;
gap: 8px;
}
.dashboard-message-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.dashboard-message-item button {
border: 1px solid #cbd5e1;
border-radius: 8px;
background: #fff;
padding: 4px 10px;
cursor: pointer;
}
.dashboard-error {
margin: 0;
color: #dc2626;
font-size: 14px;
}

View File

@@ -0,0 +1,113 @@
import { useEffect, useMemo, useState } from "react";
import { fetchClasses, fetchCurrentUser, fetchInboxMessages, markInboxMessageRead } from "@/api";
import { AppCard } from "@/components";
import type { CurrentRouteUser, UpmsClass, UpmsInboxMessage } from "@/types";
import "./index.scss";
export function DashboardPage() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [currentUser, setCurrentUser] = useState<CurrentRouteUser | null>(null);
const [classes, setClasses] = useState<UpmsClass[]>([]);
const [messages, setMessages] = useState<UpmsInboxMessage[]>([]);
useEffect(() => {
let active = true;
Promise.all([fetchCurrentUser(), fetchClasses(), fetchInboxMessages()])
.then(([user, classList, inbox]) => {
if (!active) {
return;
}
setCurrentUser(user);
setClasses(classList);
setMessages(inbox);
})
.catch((exception) => {
if (!active) {
return;
}
setError(exception instanceof Error ? exception.message : "加载控制台数据失败");
})
.finally(() => {
if (active) {
setLoading(false);
}
});
return () => {
active = false;
};
}, []);
const unreadCount = useMemo(
() => messages.filter((message) => message.readStatus === "UNREAD").length,
[messages]
);
async function handleMarkRead(messageId: string) {
try {
const result = await markInboxMessageRead(messageId);
setMessages((previous) =>
previous.map((message) =>
message.messageId === messageId
? { ...message, readStatus: result.readStatus, readAt: result.readAt }
: message
)
);
} catch (exception) {
setError(exception instanceof Error ? exception.message : "站内信已读操作失败");
}
}
return (
<div className="dashboard-grid">
<AppCard title="当前登录用户">
{loading ? <p>...</p> : null}
{currentUser ? (
<ul className="dashboard-list">
<li>{currentUser.displayName}{currentUser.username}</li>
<li>{currentUser.tenantId}</li>
<li>{currentUser.deptId}</li>
<li>{currentUser.permissionCodes.length}</li>
</ul>
) : null}
</AppCard>
<AppCard title={`班级概览(${classes.length}`}>
{loading ? <p>...</p> : null}
{classes.length > 0 ? (
<ul className="dashboard-list">
{classes.slice(0, 5).map((item) => (
<li key={item.classId}>
{item.className}{item.classCode || "未编码"}
</li>
))}
</ul>
) : !loading ? (
<p></p>
) : null}
</AppCard>
<AppCard title={`站内信(未读 ${unreadCount}`}>
{loading ? <p>...</p> : null}
{messages.length > 0 ? (
<ul className="dashboard-list">
{messages.slice(0, 5).map((message) => (
<li key={message.messageId} className="dashboard-message-item">
<span>
[{message.readStatus}] {message.title}
</span>
{message.readStatus === "UNREAD" ? (
<button type="button" onClick={() => handleMarkRead(message.messageId)}>
</button>
) : null}
</li>
))}
</ul>
) : !loading ? (
<p></p>
) : null}
{error ? <p className="dashboard-error">{error}</p> : null}
</AppCard>
</div>
);
}

View File

@@ -1,42 +0,0 @@
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,41 @@
.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;
}
.login-form button:disabled {
opacity: 0.75;
cursor: not-allowed;
}
.login-form__error {
margin: 0;
color: #dc2626;
font-size: 14px;
}

View File

@@ -0,0 +1,62 @@
/**
* @description Web 端登录页;提交表单调用 /auth/tokens,成功后 window.location.replace 触发路由重建以加载动态路由
* @filename index.tsx
* @author wangys
* @copyright xyzh
* @since 2026-04-17
*/
import { type FormEvent, useState } from "react";
import { login } from "@/api";
import { setTokens } from "@/utils";
import "./index.scss";
export function LoginPage() {
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [form, setForm] = useState({
username: "admin",
password: "admin123",
provinceCode: "330000",
areaCode: "330100",
tenantId: "SCH-HQ",
clientType: "WEB" as const
});
async function handleSubmit(event: FormEvent) {
event.preventDefault();
setSubmitting(true);
setError(null);
try {
const response = await login(form);
setTokens(response.data.accessToken, response.data.refreshToken);
window.location.replace("/");
} catch (exception) {
setError(exception instanceof Error ? exception.message : "登录失败,请稍后重试");
} finally {
setSubmitting(false);
}
}
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="密码"
/>
{error ? <p className="login-form__error">{error}</p> : null}
<button type="submit" disabled={submitting}>
{submitting ? "登录中..." : "登录"}
</button>
</form>
</div>
);
}

View File

@@ -0,0 +1,6 @@
.not-found {
display: grid;
place-items: center;
min-height: 100vh;
color: #5b6475;
}

View File

@@ -1,4 +1,5 @@
import { Link } from "react-router-dom";
import "./index.scss";
export function NotFoundPage() {
return (

View File

@@ -1,13 +0,0 @@
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,7 @@
.route-placeholder p {
margin: 0 0 8px;
}
.route-placeholder p:last-child {
margin-bottom: 0;
}

View File

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

View File

@@ -0,0 +1,4 @@
export * from "./DashboardPage";
export * from "./LoginPage";
export * from "./NotFoundPage";
export * from "./RoutePlaceholderPage";

View File

@@ -1,23 +0,0 @@
import type { ApiResponse } from "../types/api";
import type { UpmsAreaNode, UpmsCurrentUser, UpmsDeptNode, UpmsRouteNode, UpmsTenantNode } from "../types/upms";
import { http } from "../utils/http";
export function getUpmsRoutesRemote() {
return http.get<ApiResponse<UpmsRouteNode[]>>("/upms/routes");
}
export function getUpmsCurrentUserRemote() {
return http.get<ApiResponse<UpmsCurrentUser>>("/upms/users/current");
}
export function getUpmsAreasRemote() {
return http.get<ApiResponse<UpmsAreaNode[]>>("/upms/areas");
}
export function getUpmsTenantsRemote() {
return http.get<ApiResponse<UpmsTenantNode[]>>("/upms/tenants");
}
export function getUpmsDepartmentsRemote() {
return http.get<ApiResponse<UpmsDeptNode[]>>("/upms/departments");
}

View File

@@ -1,90 +1,14 @@
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 /> }
];
}
import { useMemo } from "react";
import { useRoutes } from "react-router-dom";
import { useDynamicRouterData } from "./dynamic-router";
import { renderAppRoutes } from "./router-renderer";
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]);
const routerData = useDynamicRouterData();
const routes = useMemo(
() => renderAppRoutes(routerData),
[routerData.authed, routerData.loading, routerData.loadError, routerData.dynamicRoutes]
);
return useRoutes(routes);
}

View File

@@ -0,0 +1,48 @@
import { fetchDynamicRoutes } from "@/api";
import { isAuthenticated, signOut } from "@/store";
import type { RouteNode } from "@/types";
import { useEffect, useState } from "react";
export interface DynamicRouterData {
authed: boolean;
loading: boolean;
loadError: boolean;
dynamicRoutes: RouteNode[];
}
export function useDynamicRouterData(): DynamicRouterData {
const authed = isAuthenticated();
const [dynamicRoutes, setDynamicRoutes] = useState<RouteNode[]>([]);
const [loading, setLoading] = useState(authed);
const [loadError, setLoadError] = useState(false);
useEffect(() => {
if (!authed) {
setDynamicRoutes([]);
setLoading(false);
setLoadError(false);
return;
}
setLoading(true);
setLoadError(false);
fetchDynamicRoutes()
.then((routes) => {
setDynamicRoutes(routes);
if (!routes || routes.length === 0) {
setLoadError(true);
}
})
.catch(() => {
setLoadError(true);
signOut();
})
.finally(() => setLoading(false));
}, [authed]);
return {
authed,
loading,
loadError,
dynamicRoutes
};
}

View File

@@ -0,0 +1 @@
export * from "./AppRouter";

View File

@@ -0,0 +1,60 @@
import { LoadingView } from "@/components";
import { DefaultLayout, SidebarLayout } from "@/layouts";
import { DashboardPage, LoginPage, NotFoundPage, RoutePlaceholderPage } from "@/pages";
import type { RouteNode } from "@/types";
import { Navigate, type RouteObject } from "react-router-dom";
import { resolveRouterScene, type RouterStateInput } from "./static-router";
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 buildLayoutRoutes(dynamicRoutes: RouteNode[]): RouteObject[] {
const grouped = dynamicRoutes.reduce<Record<string, RouteNode[]>>((acc, route) => {
acc[route.layout] ??= [];
acc[route.layout].push(route);
return acc;
}, {});
return 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;
});
}
export function renderAppRoutes(state: RouterStateInput): RouteObject[] {
const scene = resolveRouterScene(state);
if (scene === "UNAUTHED") {
return [
{ path: "/login", element: <LoginPage /> },
{ path: "*", element: <Navigate to="/login" replace /> }
];
}
if (scene === "LOADING") {
return [{ path: "*", element: <LoadingView message="正在加载路由..." /> }];
}
if (scene === "LOAD_ERROR") {
return [{ path: "*", element: <Navigate to="/login" replace /> }];
}
return [
{ path: "/login", element: <Navigate to="/" replace /> },
...buildLayoutRoutes(state.dynamicRoutes),
{ path: "*", element: <NotFoundPage /> }
];
}

View File

@@ -0,0 +1,23 @@
import type { RouteNode } from "@/types";
export type RouterScene = "UNAUTHED" | "LOADING" | "LOAD_ERROR" | "READY";
export interface RouterStateInput {
authed: boolean;
loading: boolean;
loadError: boolean;
dynamicRoutes: RouteNode[];
}
export function resolveRouterScene(state: RouterStateInput): RouterScene {
if (!state.authed) {
return "UNAUTHED";
}
if (state.loading) {
return "LOADING";
}
if (state.loadError || state.dynamicRoutes.length === 0) {
return "LOAD_ERROR";
}
return "READY";
}

View File

@@ -0,0 +1 @@
export * from "./session";

View File

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

View File

@@ -1,120 +0,0 @@
: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,17 @@
: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;
}

View File

@@ -0,0 +1,3 @@
export * from "./api";
export * from "./route";
export * from "./upms";

View File

@@ -3,7 +3,7 @@ import type {
UpmsLayoutType,
UpmsRouteMeta,
UpmsRouteNode
} from "./upms";
} from "@/types/upms";
export type LayoutType = UpmsLayoutType;
export type RouteMeta = UpmsRouteMeta;

View File

@@ -58,3 +58,73 @@ export interface UpmsDeptNode {
deptPath: string;
children: UpmsDeptNode[];
}
export interface UpmsClass {
classId: string;
classCode: string;
className: string;
gradeCode: string;
status: string;
tenantId: string;
deptId: string;
}
export interface UpmsClassMember {
classId: string;
userId: string;
username: string;
displayName: string;
memberRole: string;
memberStatus: string;
joinedAt: string;
leftAt: string | null;
}
export interface UpmsClassCourse {
classId: string;
courseId: string;
relationStatus: string;
}
export interface UpmsFileUploadRequest {
mediaType: string;
objectKey: string;
fileName?: string;
mimeType?: string;
fileSize?: number;
fileHash?: string;
durationMs?: number;
}
export interface UpmsFileMetadata {
fileId: string;
mediaType: string;
objectKey: string;
fileName: string | null;
mimeType: string | null;
fileSize: number | null;
fileHash: string | null;
durationMs: number | null;
uploadedBy: string | null;
tenantId: string;
tenantPath: string | null;
createdAt: string;
}
export interface UpmsInboxMessage {
messageId: string;
messageType: string;
bizType: string;
title: string;
content: string;
webJumpUrl: string;
readStatus: "UNREAD" | "READ" | "ARCHIVED";
readAt: string | null;
sendAt: string;
}
export interface UpmsMessageReadResult {
messageId: string;
readStatus: "READ" | "UNREAD" | "ARCHIVED";
readAt: string | null;
}

View File

@@ -1,5 +1,12 @@
import type { ApiResponse } from "../types/api";
import { getAccessToken } from "./storage";
/**
* @description Web 端统一 HTTP 客户端;自动注入 Authorization、401 触发 refresh token 轮换、并发刷新合并、刷新失败跳登录
* @filename http.ts
* @author wangys
* @copyright xyzh
* @since 2026-04-17
*/
import type { ApiResponse } from "@/types";
import { clearTokens, getAccessToken, getRefreshToken, setTokens } from "@/utils/storage";
const BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "/api";
const DEFAULT_TIMEOUT = 10000;
@@ -11,8 +18,11 @@ interface RequestOptions {
body?: unknown;
headers?: Record<string, string>;
timeout?: number;
skipAuthRefresh?: boolean;
}
let refreshPromise: Promise<string | null> | null = null;
function isApiResponse(payload: unknown): payload is ApiResponse<unknown> {
if (typeof payload !== "object" || payload === null) {
return false;
@@ -28,6 +38,10 @@ function buildUrl(path: string) {
return `${BASE_URL}${path.startsWith("/") ? path : `/${path}`}`;
}
function isAuthTokenPath(path: string) {
return path.startsWith("/auth/tokens");
}
async function parseResponse(response: Response) {
if (response.status === 204) {
return null;
@@ -59,6 +73,17 @@ async function request<T>(path: string, options: RequestOptions = {}): Promise<T
});
const payload = await parseResponse(response);
if (response.status === 401 && !options.skipAuthRefresh && !isAuthTokenPath(path)) {
const nextAccessToken = await tryRefreshAccessToken();
if (nextAccessToken) {
return request<T>(path, { ...options, skipAuthRefresh: true });
}
clearTokens();
if (typeof window !== "undefined" && window.location.pathname !== "/login") {
window.location.replace("/login");
}
throw new Error("登录已过期,请重新登录");
}
if (!response.ok) {
const message =
typeof payload === "object" && payload !== null && "message" in payload
@@ -76,6 +101,48 @@ async function request<T>(path: string, options: RequestOptions = {}): Promise<T
}
}
async function tryRefreshAccessToken(): Promise<string | null> {
if (refreshPromise) {
return refreshPromise;
}
const refreshToken = getRefreshToken();
if (!refreshToken) {
return null;
}
refreshPromise = (async () => {
try {
const response = await fetch(buildUrl("/auth/tokens/refresh"), {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({ refreshToken })
});
const payload = await parseResponse(response);
if (!response.ok) {
return null;
}
if (!isApiResponse(payload) || payload.code !== 0 || !payload.data) {
return null;
}
const tokenData = payload.data as { accessToken?: string; refreshToken?: string };
if (!tokenData.accessToken || !tokenData.refreshToken) {
return null;
}
setTokens(tokenData.accessToken, tokenData.refreshToken);
return tokenData.accessToken;
} catch (error) {
return null;
} finally {
refreshPromise = null;
}
})();
return refreshPromise;
}
export const http = {
get<T>(path: string, options?: Omit<RequestOptions, "method" | "body">) {
return request<T>(path, { ...options, method: "GET" });

View File

@@ -0,0 +1,2 @@
export * from "./http";
export * from "./storage";

View File

@@ -1,13 +1,34 @@
const ACCESS_TOKEN_KEY = "k12study.access-token";
const REFRESH_TOKEN_KEY = "k12study.refresh-token";
export function getAccessToken() {
return localStorage.getItem(ACCESS_TOKEN_KEY);
}
export function getRefreshToken() {
return localStorage.getItem(REFRESH_TOKEN_KEY);
}
export function setAccessToken(token: string) {
localStorage.setItem(ACCESS_TOKEN_KEY, token);
}
export function setRefreshToken(token: string) {
localStorage.setItem(REFRESH_TOKEN_KEY, token);
}
export function setTokens(accessToken: string, refreshToken: string) {
setAccessToken(accessToken);
setRefreshToken(refreshToken);
}
export function clearAccessToken() {
localStorage.removeItem(ACCESS_TOKEN_KEY);
}
export function clearRefreshToken() {
localStorage.removeItem(REFRESH_TOKEN_KEY);
}
export function clearTokens() {
clearAccessToken();
clearRefreshToken();
}

View File

@@ -15,8 +15,10 @@
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"baseUrl": ".",
"types": ["vite/client"]
"paths": {
"@/*": ["./src/*"]
},
"types": ["vite/client", "node"]
},
"include": ["src", "vite.config.ts"]
}

View File

@@ -1,8 +1,14 @@
import path from "node:path";
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react-swc";
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
"@": path.resolve(__dirname, "src")
}
},
server: {
port: 5173,
proxy: {