fix(api): align frontend with REST contracts

This commit is contained in:
2026-07-30 20:38:52 +08:00
parent b57e58ff87
commit 782363cfb6
65 changed files with 99362 additions and 318 deletions
+3 -3
View File
@@ -78,7 +78,7 @@ const App = (props: React.PropsWithChildren<AppProps>) => {
let cancelled = false;
setAccessLoading(true);
apiFetch(`${config.BACKEND_URL}/api/v1/access/context`, {
apiFetch(`${config.BACKEND_URL}/api/v1/access-context`, {
projectHeaderMode: currentProjectId ? "include" : "omit",
skipAuthRedirect: true,
})
@@ -110,7 +110,7 @@ const App = (props: React.PropsWithChildren<AppProps>) => {
const auditKey = `tjwater-login-audit:${data.user.id}`;
if (sessionStorage.getItem(auditKey)) return;
apiFetch(`${config.BACKEND_URL}/api/v1/audit/session-events`, {
apiFetch(`${config.BACKEND_URL}/api/v1/audit-events`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ event: "login" }),
@@ -137,7 +137,7 @@ const App = (props: React.PropsWithChildren<AppProps>) => {
},
logout: async () => {
try {
await apiFetch(`${config.BACKEND_URL}/api/v1/audit/session-events`, {
await apiFetch(`${config.BACKEND_URL}/api/v1/audit-events`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ event: "logout" }),
+1 -1
View File
@@ -17,7 +17,7 @@ export default async function RootLayout({
const defaultMode = theme?.value === "dark" ? "dark" : "light";
return (
<html lang="en">
<html lang="zh-CN">
<body>
<Script src="/runtime-config.js" strategy="beforeInteractive" />
<Suspense>
+45
View File
@@ -0,0 +1,45 @@
import { act, fireEvent, render, screen } from "@testing-library/react";
import Login from "./page";
const mockLogin = jest.fn();
jest.mock("@refinedev/core", () => ({
useLogin: () => ({ mutate: mockLogin }),
}));
describe("Login", () => {
beforeEach(() => {
jest.useFakeTimers();
mockLogin.mockClear();
});
afterEach(() => {
jest.useRealTimers();
});
it("starts Keycloak login once when the page opens", () => {
const { rerender } = render(<Login />);
expect(mockLogin).toHaveBeenCalledTimes(1);
expect(mockLogin).toHaveBeenCalledWith({});
expect(screen.getByText("正在进入账号登录")).toBeInTheDocument();
rerender(<Login />);
expect(mockLogin).toHaveBeenCalledTimes(1);
});
it("shows a manual fallback when automatic redirect does not complete", () => {
render(<Login />);
expect(
screen.queryByRole("button", { name: "继续登录" }),
).not.toBeInTheDocument();
act(() => {
jest.advanceTimersByTime(4000);
});
fireEvent.click(screen.getByRole("button", { name: "继续登录" }));
expect(mockLogin).toHaveBeenCalledTimes(2);
});
});
+239 -34
View File
@@ -1,54 +1,259 @@
"use client";
import Image from "next/image";
import Box from "@mui/material/Box";
import Button from "@mui/material/Button";
import Container from "@mui/material/Container";
import CircularProgress from "@mui/material/CircularProgress";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import { useLogin } from "@refinedev/core";
import { Title } from "@components/title";
import { PROJECT_TITLE } from "@config/config";
import { useEffect, useRef, useState } from "react";
const FALLBACK_DELAY_MS = 4000;
export default function Login() {
const { mutate: login } = useLogin();
const loginStartedRef = useRef(false);
const [showFallback, setShowFallback] = useState(false);
useEffect(() => {
if (!loginStartedRef.current) {
loginStartedRef.current = true;
login({});
}
const fallbackTimer = window.setTimeout(
() => setShowFallback(true),
FALLBACK_DELAY_MS,
);
return () => window.clearTimeout(fallbackTimer);
}, [login]);
return (
<Container
style={{
height: "100vh",
display: "flex",
justifyContent: "center",
alignItems: "center",
<Box
component="main"
sx={{
minHeight: "100vh",
"@supports (height: 100svh)": {
minHeight: "100svh",
},
color: "oklch(0.3 0.055 215)",
bgcolor: "oklch(0.965 0.014 205)",
backgroundImage: `
linear-gradient(
90deg,
transparent 0%,
transparent 50%,
oklch(0.975 0.01 205 / 62%) 68%,
oklch(0.975 0.01 205 / 82%) 100%
),
url("/login-network-blueprint.svg")
`,
backgroundPosition: "center",
backgroundRepeat: "no-repeat",
backgroundSize: "cover",
"@keyframes tjwaterEnter": {
from: { opacity: 0, transform: "translateY(12px)" },
to: { opacity: 1, transform: "translateY(0)" },
},
}}
>
<Box
display="flex"
gap="36px"
justifyContent="center"
flexDirection="column"
sx={{
width: "100%",
maxWidth: 1720,
minHeight: "100vh",
"@supports (height: 100svh)": {
minHeight: "100svh",
},
mx: "auto",
px: {
xs: 1.75,
sm: 3,
md: "clamp(40px, 5vw, 88px)",
},
py: {
xs: "max(28px, env(safe-area-inset-top))",
md: "clamp(32px, 4.5vw, 76px)",
},
display: "grid",
gridTemplateColumns: {
xs: "minmax(0, 1fr)",
md: "minmax(360px, 1fr) minmax(400px, 460px)",
},
gridTemplateAreas: {
xs: '"brand" "status"',
md: '"brand status"',
},
alignContent: { xs: "center", md: "stretch" },
alignItems: "center",
gap: { xs: 2.25, md: "clamp(64px, 8vw, 152px)" },
}}
>
<Box display="flex" justifyContent="center">
<Title collapsed={false} />
</Box>
<Button
style={{ width: "240px" }}
size="large"
variant="contained"
onClick={() => login({})}
<Stack
spacing={{ xs: 0, md: 3 }}
direction={{ xs: "row", md: "column" }}
alignItems={{ xs: "center", md: "flex-start" }}
sx={{
position: "relative",
isolation: "isolate",
gridArea: "brand",
width: "fit-content",
maxWidth: "100%",
animation:
"tjwaterEnter 480ms cubic-bezier(0.16, 1, 0.3, 1) both",
"&::before": {
position: "absolute",
zIndex: -1,
inset: { xs: "-24px -20px", md: "-54px -72px" },
background: {
xs: `radial-gradient(
ellipse at center,
oklch(0.965 0.014 205 / 98%) 0%,
oklch(0.965 0.014 205 / 88%) 58%,
transparent 84%
)`,
md: `radial-gradient(
ellipse at center,
oklch(0.925 0.018 205 / 98%) 0%,
oklch(0.925 0.018 205 / 94%) 48%,
oklch(0.925 0.018 205 / 62%) 65%,
transparent 82%
)`,
},
pointerEvents: "none",
content: '""',
},
}}
>
Sign in
</Button>
<Typography align="center" color={"text.secondary"} fontSize="12px">
Powered by
<Image
style={{ padding: "0 5px" }}
alt="Keycloak"
src="https://refine.ams3.cdn.digitaloceanspaces.com/superplate-auth-icons%2Fkeycloak.svg"
width={18}
height={18}
<Box
component="img"
src="/login-logo-mark.svg"
alt=""
width={56}
height={56}
sx={{
width: { xs: 42, md: 56 },
height: { xs: 42, md: 56 },
flex: { xs: "0 0 42px", md: "0 0 auto" },
mr: { xs: 1.5, md: 0 },
}}
/>
Keycloak
</Typography>
<Typography
component="h1"
sx={{
maxWidth: 680,
m: 0,
fontSize: { xs: 21, sm: 28, md: "clamp(34px, 3vw, 46px)" },
fontWeight: 720,
lineHeight: { xs: 1.4, md: 1.28 },
textWrap: "balance",
}}
>
{PROJECT_TITLE}
</Typography>
<Box
aria-hidden="true"
sx={{
display: { xs: "none", md: "block" },
width: 64,
height: 3,
borderRadius: 999,
bgcolor: "oklch(0.58 0.12 180)",
}}
/>
</Stack>
<Box
aria-live="polite"
sx={{
gridArea: "status",
width: "100%",
maxWidth: 460,
justifySelf: "stretch",
p: { xs: 2.75, sm: 4.5 },
overflow: "hidden",
borderRadius: { xs: 4, sm: 4.5 },
bgcolor: "oklch(0.995 0.004 205 / 97%)",
boxShadow:
"0 32px 80px rgb(22 65 75 / 16%), 0 5px 18px rgb(22 65 75 / 9%)",
animation:
"tjwaterEnter 520ms 70ms cubic-bezier(0.16, 1, 0.3, 1) both",
}}
>
<Stack spacing={3}>
<Stack direction="row" spacing={2} alignItems="center">
<CircularProgress
size={30}
thickness={4.5}
aria-label="正在连接统一身份认证"
sx={{
flex: "0 0 auto",
color: "oklch(0.57 0.16 242)",
"@media (prefers-reduced-motion: reduce)": {
animation: "none",
"& .MuiCircularProgress-circle": {
animation: "none",
},
},
}}
/>
<Stack spacing={0.5}>
<Typography
component="h2"
sx={{ fontSize: { xs: 22, sm: 26 }, fontWeight: 720 }}
>
</Typography>
<Typography
sx={{
color: "oklch(0.52 0.035 215)",
fontSize: { xs: 14, sm: 15 },
lineHeight: 1.7,
}}
>
</Typography>
</Stack>
</Stack>
{showFallback ? (
<Button
fullWidth
size="large"
variant="contained"
onClick={() => login({})}
sx={{
minHeight: 48,
borderRadius: 3,
fontSize: 16,
fontWeight: 700,
textTransform: "none",
boxShadow:
"0 8px 18px oklch(0.48 0.15 242 / 20%)",
transitionProperty:
"transform, background-color, box-shadow",
"&:active": { transform: "scale(0.96)" },
}}
>
</Button>
) : null}
<Typography
sx={{
color: "oklch(0.52 0.035 215)",
fontSize: { xs: 12, sm: 13 },
lineHeight: 1.7,
textAlign: "center",
}}
>
访
</Typography>
</Stack>
</Box>
</Box>
</Container>
</Box>
);
}