fix: normalize login and legacy menu routes
This commit is contained in:
@@ -119,7 +119,7 @@
|
||||
|
||||
- 登录页主视觉允许使用装饰性动效(如浮动背景与角色动画),但必须保持登录/注册接口调用链路与鉴权行为不变。
|
||||
- 首页怪兽交互基线:眼睛跟随鼠标,密码输入框聚焦时主动挪开视线(避免“盯着密码输入”观感)。
|
||||
- 当前实现位于 `web/src/app/page.tsx`;若后续继续扩展动效,优先抽离样式与展示组件,避免登录业务与视觉代码耦合过深。
|
||||
- 当前实现位于 `web/src/app/login/page.tsx`;若后续继续扩展动效,优先抽离样式与展示组件,避免登录业务与视觉代码耦合过深。
|
||||
|
||||
## AI 聊天口径(2026-04-13)
|
||||
|
||||
@@ -456,7 +456,7 @@
|
||||
|
||||
## 登录页双角色视觉口径(2026-04-23)
|
||||
|
||||
- 登录页主视觉已从单怪兽升级为“双角色怪兽”(毛怪 + 大眼仔)构图,实现在 `web/src/app/page.tsx`。
|
||||
- 登录页主视觉已从单怪兽升级为“双角色怪兽”(毛怪 + 大眼仔)构图,当前登录页实现位于 `web/src/app/login/page.tsx`。
|
||||
- 交互基线保持:眼睛跟随鼠标;密码输入时执行避视动作(毛怪转头,大眼仔轻微眯眼)。
|
||||
- 视觉实现采用纯前端结构与 CSS 动效,不引入外部图片资源,不影响登录/注册链路。
|
||||
|
||||
@@ -557,14 +557,15 @@
|
||||
- 安全边界:
|
||||
- 前端仅负责显隐与交互;最终权限判定以后端依赖校验为准。
|
||||
|
||||
## 首页与登录口径(2026-04-23)
|
||||
## 首页与登录口径(2026-05-16)
|
||||
|
||||
- `/` 默认作为登录入口页,不再承载“已登录后停留的首页面板”。
|
||||
- 登录态(含刷新会话恢复)进入 `/` 时,前端立即跳转 `/users`,实现“登录后直达后台”。
|
||||
- `/login` 作为规范登录入口;在当前部署 `NEXT_PUBLIC_APP_BASE_PATH=/fl` 下,对外地址为 `/fl/login`。
|
||||
- `/` 不再直接承载登录表单,仅用于重定向到 `/login`。
|
||||
- 登录态(含刷新会话恢复)进入 `/login` 或 `/` 时,前端立即跳转 `/users`,实现“登录后直达后台”。
|
||||
- 后台壳层文案对齐:
|
||||
- 未登录访问后台时提示“前往登录”(`/`);
|
||||
- 未登录访问后台时提示“前往登录”(`/login`);
|
||||
- 账号菜单提供“用户管理”(`/users`)作为默认后台入口。
|
||||
- 退出登录口径:统一跳转到登录页 `/`(不保留在当前后台路由)。
|
||||
- 退出登录口径:统一跳转到登录页 `/login`(不保留在当前后台路由)。
|
||||
|
||||
## 站点标题口径(2026-04-24)
|
||||
|
||||
@@ -577,7 +578,7 @@
|
||||
- 左侧为品牌与机器人主题视觉区;
|
||||
- 右侧为白色登录卡片(品牌头、表单、主操作按钮、辅助链接)。
|
||||
- 交互口径保持:
|
||||
- 登录态进入 `/` 仍自动跳转 `/users`;
|
||||
- 登录态进入 `/login` 或 `/` 仍自动跳转 `/users`;
|
||||
- 登录/注册逻辑不变,视觉改造不改变鉴权接口契约。
|
||||
|
||||
## 后台账号入口口径(2026-04-23)
|
||||
@@ -615,9 +616,9 @@
|
||||
- 兼容保留:`fquiz:theme:mode`(legacy 四态映射)
|
||||
- `AI 生成主题` 当前为交互与文案对齐态,未内置站内 AI 主题生成流程;“主题编辑器”默认跳转官方编辑器页。
|
||||
|
||||
## 登录页文案口径(2026-04-24)
|
||||
## 登录页文案口径(2026-05-16)
|
||||
|
||||
- 登录页(`web/src/app/page.tsx`)默认展示文案统一为中文,不再保留英文提示文案。
|
||||
- 登录页(`web/src/app/login/page.tsx`)默认展示文案统一为中文,不再保留英文提示文案。
|
||||
|
||||
## 前端编译口径(2026-04-24)
|
||||
|
||||
@@ -660,10 +661,16 @@
|
||||
- 后台“仪表盘”页面已下线,不再作为可访问菜单和默认首页。
|
||||
- 前端路由口径:
|
||||
- `/admin`、`/dashboard` 统一重定向到 `/users`;
|
||||
- 登录态进入 `/` 默认跳转到 `/users`;
|
||||
- 登录态进入 `/login` 或 `/` 默认跳转到 `/users`;
|
||||
- `web/src/app/admin/page.tsx` 改为重定向页,不再渲染卡片工作台。
|
||||
- 后端菜单口径:
|
||||
- `seed_service.DEFAULT_MENUS` 删除 `dashboard`;
|
||||
|
||||
## 前端菜单路由兼容口径(2026-05-16)
|
||||
|
||||
- 前端公开菜单路由的规范地址统一使用真实页面路径,例如 `/users`、`/roles`、`/menus`、`/system-params`、`/power-lines`。
|
||||
- 历史别名路径(如 `/user`、`/role`、`/menu`、`/system-param`、`/power-line`、`/worker`、`/tower-model`、`/file`)由前端路由层自动规范到对应正式地址,避免旧菜单数据或手输地址直接落到 404。
|
||||
- 后台菜单渲染与菜单管理页默认展示规范化后的 path,减少“菜单能点但高亮错位”或“列表里还是旧地址”的前后不一致。
|
||||
- `ROLE_MENU_BINDINGS` 删除 admin/user 对 `dashboard` 的绑定;
|
||||
- `legacy_authz_service`、`legacy_admin_rbac_service`、`admin_service` 对 `dashboard` 统一加入下线过滤集合,屏蔽历史库残留菜单记录。
|
||||
|
||||
|
||||
@@ -72,3 +72,59 @@
|
||||
|
||||
- 风险与关注点:
|
||||
- 当前在线 `REFRESH_COOKIE_SECURE=true`;若继续通过纯 HTTP IP 访问,浏览器可能仍不会持久化 refresh cookie。若部署后出现“登录后刷新即掉线”,需将当前环境的 `REFRESH_COOKIE_SECURE` 调整为 `false` 或切到 HTTPS 入口。
|
||||
|
||||
## Work Log - 登录入口迁移到 `/fl/login`(2026-05-16)
|
||||
|
||||
- 背景:
|
||||
- 当前前端登录页挂在应用根路径;在生产 basePath=`/fl` 下,对外登录地址表现为 `/fl`。
|
||||
- 需求要求将登录路由显式调整为 `/fl/login`,同时保留旧入口兼容跳转。
|
||||
|
||||
- 本次改动:
|
||||
- `web/src/app/login/page.tsx`
|
||||
- 新增独立登录页,沿用现有登录表单、记住密码和登录态自动跳转逻辑。
|
||||
- `web/src/app/page.tsx`
|
||||
- 根页改为重定向到 `/login`,不再直接承载登录表单。
|
||||
- `web/src/middleware.ts`
|
||||
- 将 `/login` 标记为公开直达路由,避免被后台 rewrite 到 `/admin/login`。
|
||||
- `web/src/components/auth-provider.tsx`
|
||||
- 退出登录后统一跳转到 `/login`。
|
||||
- `web/src/app/admin/layout.tsx`
|
||||
- 未登录态提示中的“前往登录”入口改为 `/login`。
|
||||
- `MEMORY.md`
|
||||
- 同步更新长期路由口径:`/login` 为规范登录入口,`/` 仅做跳转。
|
||||
|
||||
- 验证:
|
||||
- `npm --prefix web run lint -- src/app/page.tsx src/app/login/page.tsx src/middleware.ts src/components/auth-provider.tsx src/app/admin/layout.tsx`
|
||||
通过;仅保留既有 `<img>` 使用警告,无新增 lint error。
|
||||
- 已本地确认 Next.js 16 安装包的 app redirect 渲染链会按 basePath 处理重定向,当前 `redirect("/login")` 口径可用于 `/fl/login` 场景。
|
||||
- `npm --prefix web run build` 已启动并进入 `next build --webpack`,但在本次会话等待窗口内未返回最终退出码;期间未出现与本次改动相关的编译报错输出。
|
||||
|
||||
- 风险与关注点:
|
||||
- 仓库内仍有多处“返回首页”按钮保留指向 `/`;当前会经过根页跳转到 `/login`,功能不受影响,但若后续希望所有入口都直接落到规范地址,可再统一替换为 `/login`。
|
||||
|
||||
## Work Log - 菜单旧路由兼容修复(2026-05-16)
|
||||
|
||||
- 背景:
|
||||
- 用户反馈 `/fl/user` 按预期应进入用户页面,但实际返回 404。
|
||||
- 当前前端真实页面路径为 `/users`,而部分历史菜单 path/手输地址仍可能使用旧别名(如 `/user`、`/role`、`/menu`)。
|
||||
|
||||
- 本次改动:
|
||||
- `web/src/lib/app-route-path.ts`
|
||||
- 新增统一路由规范化 helper,集中维护旧菜单别名到正式公开路由的映射。
|
||||
- `web/src/middleware.ts`
|
||||
- 对 `/user`、`/role`、`/menu` 等旧地址,以及 `/admin/*` 旧入口,统一先重定向到规范公开路由,再走后台 rewrite。
|
||||
- `web/src/app/admin/layout.tsx`
|
||||
- 后台菜单树 path 统一先做规范化,避免菜单点击后高亮丢失或 path 仍指向旧地址。
|
||||
- `web/src/app/admin/menus/page.tsx`
|
||||
- 菜单管理页读取菜单列表时展示规范化 path;提交菜单编辑时也会把旧别名收敛到正式地址。
|
||||
- `MEMORY.md`
|
||||
- 补充“前端菜单路由兼容口径”,记录规范地址与旧别名自动收敛规则。
|
||||
|
||||
- 验证:
|
||||
- `npm --prefix web run lint -- src/lib/app-route-path.ts src/middleware.ts src/app/admin/layout.tsx src/app/admin/menus/page.tsx`
|
||||
通过;仅保留既有 `<img>` warning,无新增 lint error。
|
||||
- `git diff --check -- web/src/lib/app-route-path.ts web/src/middleware.ts web/src/app/admin/layout.tsx web/src/app/admin/menus/page.tsx`
|
||||
通过。
|
||||
|
||||
- 风险与关注点:
|
||||
- 当前别名映射基于已知历史菜单路径补齐;若库里还存在其他非常规旧 path,仍需按实际反馈继续补别名表。
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
import { useAuth } from "@/components/auth-provider";
|
||||
import { useTopicSubscription } from "@/hooks/use-topic-subscription";
|
||||
import { readApiError } from "@/lib/api";
|
||||
import { normalizeAppRoutePath } from "@/lib/app-route-path";
|
||||
import type { MenuTreeItem } from "@/types/auth";
|
||||
import { useThemeAppearance } from "@/components/ui-antd";
|
||||
import { withBasePath } from "@/lib/base-path";
|
||||
@@ -60,16 +61,7 @@ function ThemeIcon() {
|
||||
}
|
||||
|
||||
function normalizeAdminPath(path: string | null): string | null {
|
||||
if (!path) {
|
||||
return path;
|
||||
}
|
||||
if (path === "/admin" || path === "/admin/") {
|
||||
return "/users";
|
||||
}
|
||||
if (path.startsWith("/admin/")) {
|
||||
return path.slice("/admin".length);
|
||||
}
|
||||
return path;
|
||||
return normalizeAppRoutePath(path);
|
||||
}
|
||||
|
||||
function normalizeMenuTreePaths(items: MenuTreeItem[]): MenuTreeItem[] {
|
||||
@@ -152,7 +144,8 @@ function AdminCenteredState({ children }: { children: ReactNode }) {
|
||||
}
|
||||
|
||||
export default function AdminLayout({ children }: { children: ReactNode }) {
|
||||
const pathname = usePathname();
|
||||
const rawPathname = usePathname();
|
||||
const pathname = normalizeAppRoutePath(rawPathname) ?? rawPathname;
|
||||
const screens = Grid.useBreakpoint();
|
||||
const isDesktop = screens.md === true;
|
||||
const { user, initializing, fetchWithAuth, logout } = useAuth();
|
||||
@@ -340,7 +333,7 @@ export default function AdminLayout({ children }: { children: ReactNode }) {
|
||||
subTitle="登录后才能访问后台工作台。"
|
||||
extra={(
|
||||
<Button type="primary">
|
||||
<Link href="/">前往登录</Link>
|
||||
<Link href="/login">前往登录</Link>
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -28,6 +28,7 @@ import type { CSSProperties, ComponentType } from "react";
|
||||
import { useAuth } from "@/components/auth-provider";
|
||||
import { useTopicSubscription } from "@/hooks/use-topic-subscription";
|
||||
import { readApiError } from "@/lib/api";
|
||||
import { normalizeAppRoutePath } from "@/lib/app-route-path";
|
||||
import type { MenuItem, MenuListResponse } from "@/types/auth";
|
||||
|
||||
const AntCard = Card as unknown as ComponentType<CardProps>;
|
||||
@@ -82,6 +83,13 @@ function compareMenuIds(a: string, b: string): number {
|
||||
return a.localeCompare(b, "zh-CN");
|
||||
}
|
||||
|
||||
function normalizeMenuItemPath(menu: MenuItem): MenuItem {
|
||||
return {
|
||||
...menu,
|
||||
path: normalizeAppRoutePath(menu.path),
|
||||
};
|
||||
}
|
||||
|
||||
export default function AdminMenusPage() {
|
||||
const { user, initializing, fetchWithAuth, hasPermission } = useAuth();
|
||||
const { message: messageApi, modal } = App.useApp();
|
||||
@@ -166,7 +174,7 @@ export default function AdminMenusPage() {
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as MenuListResponse;
|
||||
setMenus(payload.items);
|
||||
setMenus(payload.items.map(normalizeMenuItemPath));
|
||||
setLoading(false);
|
||||
}, [canRead, fetchWithAuth]);
|
||||
|
||||
@@ -227,7 +235,7 @@ export default function AdminMenusPage() {
|
||||
const payload = {
|
||||
code: values.code.trim(),
|
||||
name: values.name.trim(),
|
||||
path: values.path?.trim() ? values.path.trim() : null,
|
||||
path: normalizeAppRoutePath(values.path?.trim() ? values.path.trim() : null),
|
||||
icon: values.icon?.trim() ? values.icon.trim() : null,
|
||||
parent_id: values.parent_id?.trim() ? values.parent_id.trim() : null,
|
||||
type: values.type,
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
"use client";
|
||||
|
||||
import { IdcardOutlined, LockOutlined } from "@ant-design/icons";
|
||||
import { ChangeEvent, FormEvent, useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Alert, Button, Checkbox, Input, Space, Typography } from "antd";
|
||||
|
||||
import { useAuth } from "@/components/auth-provider";
|
||||
import { Card } from "@/components/ui-antd";
|
||||
import { withBasePath } from "@/lib/base-path";
|
||||
|
||||
const LOGIN_REMEMBER_KEY = "login.remember";
|
||||
const LOGIN_USER_ID_KEY = "login.user_id";
|
||||
const LOGIN_PASSWORD_KEY = "login.password";
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
const { user, initializing, login } = useAuth();
|
||||
|
||||
const [userId, setUserId] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [rememberPassword, setRememberPassword] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!initializing && user) {
|
||||
router.replace("/users");
|
||||
}
|
||||
}, [initializing, router, user]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
const remembered = window.localStorage.getItem(LOGIN_REMEMBER_KEY) === "1";
|
||||
if (!remembered) {
|
||||
return;
|
||||
}
|
||||
|
||||
setRememberPassword(true);
|
||||
setUserId(window.localStorage.getItem(LOGIN_USER_ID_KEY) ?? "");
|
||||
setPassword(window.localStorage.getItem(LOGIN_PASSWORD_KEY) ?? "");
|
||||
}, []);
|
||||
|
||||
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
setError("");
|
||||
setBusy(true);
|
||||
|
||||
try {
|
||||
const normalizedUserId = userId.trim();
|
||||
await login(normalizedUserId, password);
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
if (rememberPassword) {
|
||||
window.localStorage.setItem(LOGIN_REMEMBER_KEY, "1");
|
||||
window.localStorage.setItem(LOGIN_USER_ID_KEY, normalizedUserId);
|
||||
window.localStorage.setItem(LOGIN_PASSWORD_KEY, password);
|
||||
} else {
|
||||
window.localStorage.removeItem(LOGIN_REMEMBER_KEY);
|
||||
window.localStorage.removeItem(LOGIN_USER_ID_KEY);
|
||||
window.localStorage.removeItem(LOGIN_PASSWORD_KEY);
|
||||
}
|
||||
}
|
||||
|
||||
setPassword("");
|
||||
} catch (submitError) {
|
||||
const message = submitError instanceof Error ? submitError.message : "未知错误";
|
||||
setError(message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (initializing) {
|
||||
return (
|
||||
<main className="mx-auto flex min-h-screen w-full max-w-3xl items-center justify-center px-6 py-20">
|
||||
<Typography.Text type="secondary">正在初始化会话...</Typography.Text>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
if (user) {
|
||||
return (
|
||||
<main className="mx-auto flex min-h-screen w-full max-w-3xl items-center justify-center px-6 py-20">
|
||||
<Typography.Text type="secondary">正在跳转到控制台...</Typography.Text>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="flex min-h-screen items-center justify-center bg-[var(--fquiz-theme-bg-layout)] px-4 py-8">
|
||||
<Card className="w-full max-w-[360px]">
|
||||
<Space direction="vertical" size={20} className="w-full">
|
||||
<div className="flex justify-center">
|
||||
<img
|
||||
src={withBasePath("/favicon.ico")}
|
||||
alt="高压电塔图标"
|
||||
width={72}
|
||||
height={72}
|
||||
className="h-[72px] w-[72px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Typography.Title level={3} className="!mb-0 !text-center">
|
||||
防雷计算
|
||||
</Typography.Title>
|
||||
|
||||
<form className="space-y-4" onSubmit={handleSubmit}>
|
||||
<Input
|
||||
size="large"
|
||||
value={userId}
|
||||
prefix={<IdcardOutlined />}
|
||||
placeholder="用户 ID"
|
||||
onChange={(event: ChangeEvent<HTMLInputElement>) => setUserId(event.currentTarget.value)}
|
||||
autoComplete="username"
|
||||
required
|
||||
/>
|
||||
|
||||
<Input.Password
|
||||
size="large"
|
||||
value={password}
|
||||
prefix={<LockOutlined />}
|
||||
placeholder="密码"
|
||||
onChange={(event: ChangeEvent<HTMLInputElement>) => setPassword(event.currentTarget.value)}
|
||||
autoComplete="current-password"
|
||||
minLength={1}
|
||||
maxLength={128}
|
||||
required
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Checkbox
|
||||
checked={rememberPassword}
|
||||
onChange={(event) => setRememberPassword(event.target.checked)}
|
||||
>
|
||||
记住密码
|
||||
</Checkbox>
|
||||
</div>
|
||||
|
||||
<Button block size="large" type="primary" htmlType="submit" loading={busy}>
|
||||
登录
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{error && <Alert showIcon type="error" message={error} />}
|
||||
</Space>
|
||||
</Card>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
+2
-150
@@ -1,153 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { IdcardOutlined, LockOutlined } from "@ant-design/icons";
|
||||
import { ChangeEvent, FormEvent, useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Alert, Button, Checkbox, Input, Space, Typography } from "antd";
|
||||
|
||||
import { useAuth } from "@/components/auth-provider";
|
||||
import { Card } from "@/components/ui-antd";
|
||||
import { withBasePath } from "@/lib/base-path";
|
||||
|
||||
const LOGIN_REMEMBER_KEY = "login.remember";
|
||||
const LOGIN_USER_ID_KEY = "login.user_id";
|
||||
const LOGIN_PASSWORD_KEY = "login.password";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function Home() {
|
||||
const router = useRouter();
|
||||
const { user, initializing, login } = useAuth();
|
||||
|
||||
const [userId, setUserId] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [rememberPassword, setRememberPassword] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!initializing && user) {
|
||||
router.replace("/users");
|
||||
}
|
||||
}, [initializing, router, user]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
const remembered = window.localStorage.getItem(LOGIN_REMEMBER_KEY) === "1";
|
||||
if (!remembered) {
|
||||
return;
|
||||
}
|
||||
|
||||
setRememberPassword(true);
|
||||
setUserId(window.localStorage.getItem(LOGIN_USER_ID_KEY) ?? "");
|
||||
setPassword(window.localStorage.getItem(LOGIN_PASSWORD_KEY) ?? "");
|
||||
}, []);
|
||||
|
||||
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
setError("");
|
||||
setBusy(true);
|
||||
|
||||
try {
|
||||
const normalizedUserId = userId.trim();
|
||||
await login(normalizedUserId, password);
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
if (rememberPassword) {
|
||||
window.localStorage.setItem(LOGIN_REMEMBER_KEY, "1");
|
||||
window.localStorage.setItem(LOGIN_USER_ID_KEY, normalizedUserId);
|
||||
window.localStorage.setItem(LOGIN_PASSWORD_KEY, password);
|
||||
} else {
|
||||
window.localStorage.removeItem(LOGIN_REMEMBER_KEY);
|
||||
window.localStorage.removeItem(LOGIN_USER_ID_KEY);
|
||||
window.localStorage.removeItem(LOGIN_PASSWORD_KEY);
|
||||
}
|
||||
}
|
||||
|
||||
setPassword("");
|
||||
} catch (submitError) {
|
||||
const message = submitError instanceof Error ? submitError.message : "未知错误";
|
||||
setError(message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (initializing) {
|
||||
return (
|
||||
<main className="mx-auto flex min-h-screen w-full max-w-3xl items-center justify-center px-6 py-20">
|
||||
<Typography.Text type="secondary">正在初始化会话...</Typography.Text>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
if (user) {
|
||||
return (
|
||||
<main className="mx-auto flex min-h-screen w-full max-w-3xl items-center justify-center px-6 py-20">
|
||||
<Typography.Text type="secondary">正在跳转到控制台...</Typography.Text>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="flex min-h-screen items-center justify-center bg-[var(--fquiz-theme-bg-layout)] px-4 py-8">
|
||||
<Card className="w-full max-w-[360px]">
|
||||
<Space direction="vertical" size={20} className="w-full">
|
||||
<div className="flex justify-center">
|
||||
<img
|
||||
src={withBasePath("/favicon.ico")}
|
||||
alt="高压电塔图标"
|
||||
width={72}
|
||||
height={72}
|
||||
className="h-[72px] w-[72px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Typography.Title level={3} className="!mb-0 !text-center">
|
||||
防雷计算
|
||||
</Typography.Title>
|
||||
|
||||
<form className="space-y-4" onSubmit={handleSubmit}>
|
||||
<Input
|
||||
size="large"
|
||||
value={userId}
|
||||
prefix={<IdcardOutlined />}
|
||||
placeholder="用户 ID"
|
||||
onChange={(event: ChangeEvent<HTMLInputElement>) => setUserId(event.currentTarget.value)}
|
||||
autoComplete="username"
|
||||
required
|
||||
/>
|
||||
|
||||
<Input.Password
|
||||
size="large"
|
||||
value={password}
|
||||
prefix={<LockOutlined />}
|
||||
placeholder="密码"
|
||||
onChange={(event: ChangeEvent<HTMLInputElement>) => setPassword(event.currentTarget.value)}
|
||||
autoComplete="current-password"
|
||||
minLength={1}
|
||||
maxLength={128}
|
||||
required
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Checkbox
|
||||
checked={rememberPassword}
|
||||
onChange={(event) => setRememberPassword(event.target.checked)}
|
||||
>
|
||||
记住密码
|
||||
</Checkbox>
|
||||
</div>
|
||||
|
||||
<Button block size="large" type="primary" htmlType="submit" loading={busy}>
|
||||
登录
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{error && <Alert showIcon type="error" message={error} />}
|
||||
</Space>
|
||||
</Card>
|
||||
</main>
|
||||
);
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@ import { getApiBaseUrl, readApiError } from "@/lib/api";
|
||||
import { withBasePath } from "@/lib/base-path";
|
||||
import type { AuthTokenResponse, UserPublic } from "@/types/auth";
|
||||
|
||||
const AUTH_SESSION_HINT_KEY = "auth.session_hint";
|
||||
|
||||
type AuthContextValue = {
|
||||
user: UserPublic | null;
|
||||
accessToken: string | null;
|
||||
@@ -41,6 +43,27 @@ function withApiPath(path: string): string {
|
||||
return `${getApiBaseUrl()}${path}`;
|
||||
}
|
||||
|
||||
function hasSessionHint(): boolean {
|
||||
if (typeof window === "undefined") {
|
||||
return false;
|
||||
}
|
||||
return window.localStorage.getItem(AUTH_SESSION_HINT_KEY) === "1";
|
||||
}
|
||||
|
||||
function persistSessionHint(): void {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
window.localStorage.setItem(AUTH_SESSION_HINT_KEY, "1");
|
||||
}
|
||||
|
||||
function clearSessionHint(): void {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
window.localStorage.removeItem(AUTH_SESSION_HINT_KEY);
|
||||
}
|
||||
|
||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const [user, setUser] = useState<UserPublic | null>(null);
|
||||
const [accessToken, setAccessToken] = useState<string | null>(null);
|
||||
@@ -57,12 +80,14 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const clearAuth = useCallback(() => {
|
||||
setUser(null);
|
||||
applyToken(null);
|
||||
clearSessionHint();
|
||||
}, [applyToken]);
|
||||
|
||||
const applyAuthPayload = useCallback(
|
||||
(payload: AuthTokenResponse) => {
|
||||
setUser(payload.user);
|
||||
applyToken(payload.access_token);
|
||||
persistSessionHint();
|
||||
},
|
||||
[applyToken],
|
||||
);
|
||||
@@ -183,7 +208,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
});
|
||||
} finally {
|
||||
if (typeof window !== "undefined") {
|
||||
window.location.replace(withBasePath("/"));
|
||||
window.location.replace(withBasePath("/login"));
|
||||
return;
|
||||
}
|
||||
clearAuth();
|
||||
@@ -192,6 +217,11 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
|
||||
useEffect(() => {
|
||||
const bootstrap = async () => {
|
||||
if (!hasSessionHint()) {
|
||||
setInitializing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await refreshAccessToken();
|
||||
} finally {
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
const APP_ROUTE_ALIASES: Record<string, string> = {
|
||||
"/admin": "/users",
|
||||
"/dashboard": "/users",
|
||||
"/user": "/users",
|
||||
"/role": "/roles",
|
||||
"/menu": "/menus",
|
||||
"/system-param": "/system-params",
|
||||
"/power-line": "/power-lines",
|
||||
"/lightning-current": "/lightning-currents",
|
||||
"/worker": "/workers",
|
||||
"/tower-model": "/tower-models",
|
||||
"/file": "/files",
|
||||
};
|
||||
|
||||
function trimTrailingSlash(pathname: string): string {
|
||||
if (pathname === "/") {
|
||||
return pathname;
|
||||
}
|
||||
return pathname.replace(/\/+$/, "") || "/";
|
||||
}
|
||||
|
||||
export function normalizeAppRoutePath(path: string | null): string | null {
|
||||
if (!path) {
|
||||
return path;
|
||||
}
|
||||
|
||||
if (path.startsWith("http://") || path.startsWith("https://") || path.startsWith("//")) {
|
||||
return path;
|
||||
}
|
||||
|
||||
const normalizedPath = trimTrailingSlash(path.startsWith("/") ? path : `/${path}`);
|
||||
const publicPath = normalizedPath.startsWith("/admin/")
|
||||
? trimTrailingSlash(normalizedPath.slice("/admin".length) || "/")
|
||||
: normalizedPath;
|
||||
|
||||
return APP_ROUTE_ALIASES[publicPath] ?? publicPath;
|
||||
}
|
||||
+13
-14
@@ -1,5 +1,7 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
import { normalizeAppRoutePath } from "./lib/app-route-path";
|
||||
|
||||
function normalizeBasePath(value: string | undefined): string {
|
||||
const trimmed = value?.trim() ?? "";
|
||||
if (!trimmed || trimmed === "/") {
|
||||
@@ -50,7 +52,7 @@ function withBasePath(pathname: string): string {
|
||||
}
|
||||
|
||||
function isBypassedPath(pathname: string): boolean {
|
||||
if (pathname === "/") {
|
||||
if (pathname === "/" || pathname === "/login" || pathname === "/login/") {
|
||||
return true;
|
||||
}
|
||||
if (PUBLIC_FILE.test(pathname)) {
|
||||
@@ -68,26 +70,23 @@ export function middleware(request: NextRequest) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
// Keep backward compatibility for existing /admin links.
|
||||
if (pathname === "/admin" || pathname === "/admin/") {
|
||||
const url = request.nextUrl.clone();
|
||||
url.pathname = withBasePath("/users");
|
||||
return NextResponse.redirect(url);
|
||||
}
|
||||
if (pathname === "/dashboard" || pathname === "/dashboard/") {
|
||||
const url = request.nextUrl.clone();
|
||||
url.pathname = withBasePath("/users");
|
||||
return NextResponse.redirect(url);
|
||||
}
|
||||
const canonicalPath = normalizeAppRoutePath(pathname) ?? pathname;
|
||||
|
||||
// Keep backward compatibility for existing /admin links and legacy menu aliases.
|
||||
if (pathname.startsWith("/admin/")) {
|
||||
const url = request.nextUrl.clone();
|
||||
url.pathname = withBasePath(pathname.slice("/admin".length) || "/users");
|
||||
url.pathname = withBasePath(canonicalPath);
|
||||
return NextResponse.redirect(url);
|
||||
}
|
||||
if (canonicalPath !== pathname) {
|
||||
const url = request.nextUrl.clone();
|
||||
url.pathname = withBasePath(canonicalPath);
|
||||
return NextResponse.redirect(url);
|
||||
}
|
||||
|
||||
// New public URLs without /admin are internally rewritten to existing routes.
|
||||
const url = request.nextUrl.clone();
|
||||
url.pathname = withBasePath(`/admin${pathname}`);
|
||||
url.pathname = withBasePath(`/admin${canonicalPath}`);
|
||||
return NextResponse.rewrite(url);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user