更新
This commit is contained in:
@@ -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"
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { AppRouter } from "./router/AppRouter";
|
||||
import { AppRouter } from "@/router";
|
||||
|
||||
export default function App() {
|
||||
return <AppRouter />;
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
|
||||
2
frontend/src/api/index.ts
Normal file
2
frontend/src/api/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./auth";
|
||||
export * from "./upms";
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
18
frontend/src/components/AppCard/index.scss
Normal file
18
frontend/src/components/AppCard/index.scss
Normal 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;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { PropsWithChildren, ReactNode } from "react";
|
||||
import "./index.scss";
|
||||
|
||||
type AppCardProps = PropsWithChildren<{
|
||||
title: string;
|
||||
6
frontend/src/components/LoadingView/index.scss
Normal file
6
frontend/src/components/LoadingView/index.scss
Normal file
@@ -0,0 +1,6 @@
|
||||
.loading-view {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-height: 100vh;
|
||||
color: #5b6475;
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import "./index.scss";
|
||||
|
||||
export function LoadingView({ message = "Loading..." }: { message?: string }) {
|
||||
return <div className="loading-view">{message}</div>;
|
||||
}
|
||||
2
frontend/src/components/index.ts
Normal file
2
frontend/src/components/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./AppCard";
|
||||
export * from "./LoadingView";
|
||||
10
frontend/src/layouts/DefaultLayout/index.scss
Normal file
10
frontend/src/layouts/DefaultLayout/index.scss
Normal file
@@ -0,0 +1,10 @@
|
||||
.default-layout {
|
||||
min-height: 100vh;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.default-layout {
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Outlet } from "react-router-dom";
|
||||
import "./index.scss";
|
||||
|
||||
export function DefaultLayout() {
|
||||
return (
|
||||
44
frontend/src/layouts/SidebarLayout/index.scss
Normal file
44
frontend/src/layouts/SidebarLayout/index.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Outlet, useLocation } from "react-router-dom";
|
||||
import "./index.scss";
|
||||
|
||||
export function SidebarLayout() {
|
||||
const location = useLocation();
|
||||
2
frontend/src/layouts/index.ts
Normal file
2
frontend/src/layouts/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./DefaultLayout";
|
||||
export * from "./SidebarLayout";
|
||||
@@ -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>
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
import { AppCard } from "../components/AppCard";
|
||||
|
||||
export function DashboardPage() {
|
||||
return (
|
||||
<AppCard title="项目骨架已就绪">
|
||||
<p>当前页面用于承接首版后台管理端骨架,后续可继续补充机构端、教师端等业务模块。</p>
|
||||
</AppCard>
|
||||
);
|
||||
}
|
||||
32
frontend/src/pages/DashboardPage/index.scss
Normal file
32
frontend/src/pages/DashboardPage/index.scss
Normal 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;
|
||||
}
|
||||
113
frontend/src/pages/DashboardPage/index.tsx
Normal file
113
frontend/src/pages/DashboardPage/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
41
frontend/src/pages/LoginPage/index.scss
Normal file
41
frontend/src/pages/LoginPage/index.scss
Normal 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;
|
||||
}
|
||||
62
frontend/src/pages/LoginPage/index.tsx
Normal file
62
frontend/src/pages/LoginPage/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
6
frontend/src/pages/NotFoundPage/index.scss
Normal file
6
frontend/src/pages/NotFoundPage/index.scss
Normal file
@@ -0,0 +1,6 @@
|
||||
.not-found {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-height: 100vh;
|
||||
color: #5b6475;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import "./index.scss";
|
||||
|
||||
export function NotFoundPage() {
|
||||
return (
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
7
frontend/src/pages/RoutePlaceholderPage/index.scss
Normal file
7
frontend/src/pages/RoutePlaceholderPage/index.scss
Normal file
@@ -0,0 +1,7 @@
|
||||
.route-placeholder p {
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.route-placeholder p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
16
frontend/src/pages/RoutePlaceholderPage/index.tsx
Normal file
16
frontend/src/pages/RoutePlaceholderPage/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
4
frontend/src/pages/index.ts
Normal file
4
frontend/src/pages/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from "./DashboardPage";
|
||||
export * from "./LoginPage";
|
||||
export * from "./NotFoundPage";
|
||||
export * from "./RoutePlaceholderPage";
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
48
frontend/src/router/dynamic-router.ts
Normal file
48
frontend/src/router/dynamic-router.ts
Normal 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
|
||||
};
|
||||
}
|
||||
1
frontend/src/router/index.ts
Normal file
1
frontend/src/router/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./AppRouter";
|
||||
60
frontend/src/router/router-renderer.tsx
Normal file
60
frontend/src/router/router-renderer.tsx
Normal 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 /> }
|
||||
];
|
||||
}
|
||||
23
frontend/src/router/static-router.ts
Normal file
23
frontend/src/router/static-router.ts
Normal 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";
|
||||
}
|
||||
1
frontend/src/store/index.ts
Normal file
1
frontend/src/store/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./session";
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
17
frontend/src/styles/index.scss
Normal file
17
frontend/src/styles/index.scss
Normal 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;
|
||||
}
|
||||
3
frontend/src/types/index.ts
Normal file
3
frontend/src/types/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from "./api";
|
||||
export * from "./route";
|
||||
export * from "./upms";
|
||||
@@ -3,7 +3,7 @@ import type {
|
||||
UpmsLayoutType,
|
||||
UpmsRouteMeta,
|
||||
UpmsRouteNode
|
||||
} from "./upms";
|
||||
} from "@/types/upms";
|
||||
|
||||
export type LayoutType = UpmsLayoutType;
|
||||
export type RouteMeta = UpmsRouteMeta;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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" });
|
||||
|
||||
2
frontend/src/utils/index.ts
Normal file
2
frontend/src/utils/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./http";
|
||||
export * from "./storage";
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user