任务三

This commit is contained in:
Yvan
2026-01-07 19:37:45 +08:00
parent be3b6641a6
commit 66e28bad92
12 changed files with 372 additions and 286 deletions
+34 -3
View File
@@ -1,11 +1,42 @@
/**
* 权限配置 - 参考GVA的权限验证逻辑
* @see https://umijs.org/docs/max/access#access
* */
*/
export default function access(
initialState: { currentUser?: API.CurrentUser } | undefined,
initialState: { currentUser?: API.UserInfo } | undefined,
) {
const { currentUser } = initialState ?? {};
// 获取当前用户的角色ID
const authorityId = currentUser?.authorityId || currentUser?.authority?.authorityId;
// 超级管理员角色ID(与GVA保持一致,888为超级管理员)
const isSuperAdmin = authorityId === 888;
// 普通管理员
const isAdmin = !!currentUser && !!authorityId;
return {
canAdmin: currentUser && currentUser.access === 'admin',
// 是否为超级管理员
canSuperAdmin: isSuperAdmin,
// 是否为管理员(已登录且有角色)
canAdmin: isAdmin,
// 是否已登录
isLogin: !!currentUser,
// 按钮权限检查函数 - 参考GVA的btnAuth
canAccess: (btnKey: string) => {
// 超级管理员拥有所有权限
if (isSuperAdmin) return true;
// 检查当前路由的按钮权限
const btns = (currentUser as any)?.btns || {};
return !!btns[btnKey];
},
};
}
/**
* 按钮权限Hook - 参考GVA的useBtnAuth
* 在组件中使用: const { canAccess } = useAccess();
* 然后: canAccess('add') 检查是否有添加权限
*/
+28 -120
View File
@@ -2,7 +2,6 @@ import {
AvatarDropdown,
AvatarName,
Footer,
Question,
SelectLang,
} from "@/components";
import { getUserInfo } from "@/services/system/user";
@@ -18,38 +17,42 @@ const isDev = process.env.NODE_ENV === "development";
const isDevOrTest = isDev || process.env.CI;
const loginPath = "/user/login";
/**
* 获取用户信息 - 参考GVA的GetUserInfo
*/
const fetchUserInfo = async (): Promise<API.UserInfo | undefined> => {
try {
const token = localStorage.getItem('token');
if (!token) {
return undefined;
}
const res = await getUserInfo();
// 与GVA一致,code === 0 表示成功
if (res.code === 0 && res.data?.userInfo) {
// 同步更新localStorage中的userInfo - 与GVA一致
localStorage.setItem('userInfo', JSON.stringify(res.data.userInfo));
return res.data.userInfo;
}
return undefined;
} catch (error) {
// 错误已在requestErrorConfig中处理,这里不需要额外处理
// 401错误会弹出Modal,用户点击确定后跳转登录页
return undefined;
}
};
/**
* @see https://umijs.org/docs/api/runtime-config#getinitialstate
* */
*/
export async function getInitialState(): Promise<{
settings?: Partial<LayoutSettings>;
settings?: Partial<LayoutSettings> & { showWatermark?: boolean };
currentUser?: API.UserInfo;
loading?: boolean;
fetchUserInfo?: () => Promise<API.UserInfo | undefined>;
}> {
const fetchUserInfo = async () => {
try {
const token = localStorage.getItem('token');
if (!token) {
return undefined;
}
const res = await getUserInfo();
if (res.code === 0 && res.data?.userInfo) {
return res.data.userInfo;
}
return undefined;
} catch (error) {
history.push(loginPath);
}
return undefined;
};
// 如果不是登录页面,执行
// 如果不是登录页面,获取用户信息 - 与GVA的permission.js逻辑一致
const { location } = history;
if (
![loginPath, "/user/register", "/user/register-result"].includes(
location.pathname
)
) {
if (![loginPath, "/user/register", "/user/register-result"].includes(location.pathname)) {
const currentUser = await fetchUserInfo();
return {
fetchUserInfo,
@@ -62,98 +65,3 @@ export async function getInitialState(): Promise<{
settings: defaultSettings as Partial<LayoutSettings>,
};
}
// ProLayout 支持的api https://procomponents.ant.design/components/layout
export const layout: RunTimeLayoutConfig = ({
initialState,
setInitialState,
}) => {
return {
actionsRender: () => [
<Question key="doc" />,
<SelectLang key="SelectLang" />,
],
avatarProps: {
src: initialState?.currentUser?.avatar,
title: <AvatarName />,
render: (_, avatarChildren) => (
<AvatarDropdown>{avatarChildren}</AvatarDropdown>
),
},
waterMarkProps: {
content: initialState?.currentUser?.name,
},
footerRender: () => <Footer />,
onPageChange: () => {
const { location } = history;
// 如果没有登录,重定向到 login
if (!initialState?.currentUser && location.pathname !== loginPath) {
history.push(loginPath);
}
},
bgLayoutImgList: [
{
src: "https://mdn.alipayobjects.com/yuyan_qk0oxh/afts/img/D2LWSqNny4sAAAAAAAAAAAAAFl94AQBr",
left: 85,
bottom: 100,
height: "303px",
},
{
src: "https://mdn.alipayobjects.com/yuyan_qk0oxh/afts/img/C2TWRpJpiC0AAAAAAAAAAAAAFl94AQBr",
bottom: -68,
right: -45,
height: "303px",
},
{
src: "https://mdn.alipayobjects.com/yuyan_qk0oxh/afts/img/F6vSTbj8KpYAAAAAAAAAAAAAFl94AQBr",
bottom: 0,
left: 0,
width: "331px",
},
],
links: isDevOrTest
? [
<Link key="openapi" to="/umi/plugin/openapi" target="_blank">
<LinkOutlined />
<span>OpenAPI 文档</span>
</Link>,
]
: [],
menuHeaderRender: undefined,
// 自定义 403 页面
// unAccessible: <div>unAccessible</div>,
// 增加一个 loading 的状态
childrenRender: (children) => {
// if (initialState?.loading) return <PageLoading />;
return (
<>
{children}
{isDevOrTest && (
<SettingDrawer
disableUrlParams
enableDarkTheme
settings={initialState?.settings}
onSettingChange={(settings) => {
setInitialState((preInitialState) => ({
...preInitialState,
settings,
}));
}}
/>
)}
</>
);
},
...initialState?.settings,
};
};
/**
* @name request 配置,可以配置错误处理
* 它基于 axios 和 ahooks 的 useRequest 提供了一套统一的网络请求和错误处理方案。
* @doc https://umijs.org/docs/max/request#配置
*/
export const request: RequestConfig = {
baseURL: isDev ? "" : "https://proapi.azurewebsites.net",
...errorConfig,
};
+10 -8
View File
@@ -3,29 +3,31 @@ import { DefaultFooter } from '@ant-design/pro-components';
import React from 'react';
const Footer: React.FC = () => {
const currentYear = new Date().getFullYear();
return (
<DefaultFooter
style={{
background: 'none',
}}
copyright="Powered by Ant Desgin"
copyright={`${currentYear} KRA Admin - Kratos React Admin`}
links={[
{
key: 'Ant Design Pro',
title: 'Ant Design Pro',
href: 'https://pro.ant.design',
key: 'KRA Admin',
title: 'KRA Admin',
href: 'https://github.com/your-org/kra',
blankTarget: true,
},
{
key: 'github',
title: <GithubOutlined />,
href: 'https://github.com/ant-design/ant-design-pro',
href: 'https://github.com/your-org/kra',
blankTarget: true,
},
{
key: 'Ant Design',
title: 'Ant Design',
href: 'https://ant.design',
key: 'Kratos',
title: 'Powered by Kratos',
href: 'https://go-kratos.dev',
blankTarget: true,
},
]}
@@ -1,15 +1,16 @@
import {
LogoutOutlined,
SettingOutlined,
SwapOutlined,
UserOutlined,
} from "@ant-design/icons";
import { history, useModel } from "@umijs/max";
import type { MenuProps } from "antd";
import { Spin } from "antd";
import { message, Spin } from "antd";
import { createStyles } from "antd-style";
import React from "react";
import React, { useMemo } from "react";
import { flushSync } from "react-dom";
import HeaderDropdown from "../HeaderDropdown";
import { setUserAuthority } from "@/services/system/user";
export type GlobalHeaderRightProps = {
menu?: boolean;
@@ -41,14 +42,13 @@ const useStyles = createStyles(({ token }) => {
});
export const AvatarDropdown: React.FC<GlobalHeaderRightProps> = ({
menu,
children,
}) => {
/**
* 退出登录,并且将当前的 url 保存
*/
const { styles } = useStyles();
const { initialState, setInitialState } = useModel("@@initialState");
// 退出登录
const loginOut = async () => {
// 清除本地存储的token和用户信息
localStorage.removeItem('token');
localStorage.removeItem('userInfo');
const { search, pathname } = window.location;
@@ -56,9 +56,7 @@ export const AvatarDropdown: React.FC<GlobalHeaderRightProps> = ({
const searchParams = new URLSearchParams({
redirect: pathname + search,
});
/** 此方法会跳转到 redirect 参数所在的位置 */
const redirect = urlParams.get("redirect");
// Note: There may be security issues, please note
if (window.location.pathname !== "/user/login" && !redirect) {
history.replace({
pathname: "/user/login",
@@ -66,9 +64,21 @@ export const AvatarDropdown: React.FC<GlobalHeaderRightProps> = ({
});
}
};
const { styles } = useStyles();
const { initialState, setInitialState } = useModel("@@initialState");
// 切换角色
const changeUserAuth = async (authorityId: number) => {
try {
const res = await setUserAuthority({ authorityId });
if (res.code === 0) {
message.success('角色切换成功');
window.sessionStorage.setItem('needCloseAll', 'true');
window.sessionStorage.setItem('needToHome', 'true');
window.location.reload();
}
} catch (error) {
message.error('角色切换失败');
}
};
const onMenuClick: MenuProps["onClick"] = (event) => {
const { key } = event;
@@ -83,18 +93,17 @@ export const AvatarDropdown: React.FC<GlobalHeaderRightProps> = ({
history.push('/person');
return;
}
history.push(`/account/${key}`);
// 处理角色切换
if (key.startsWith('authority_')) {
const authorityId = parseInt(key.replace('authority_', ''), 10);
changeUserAuth(authorityId);
return;
}
};
const loading = (
<span className={styles.action}>
<Spin
size="small"
style={{
marginLeft: 8,
marginRight: 8,
}}
/>
<Spin size="small" style={{ marginLeft: 8, marginRight: 8 }} />
</span>
);
@@ -108,30 +117,57 @@ export const AvatarDropdown: React.FC<GlobalHeaderRightProps> = ({
return loading;
}
const menuItems = [
...(menu
? [
{
key: "center",
icon: <UserOutlined />,
label: "个人中心",
},
{
key: "settings",
icon: <SettingOutlined />,
label: "个人设置",
},
{
type: "divider" as const,
},
]
: []),
{
key: "logout",
// 构建菜单项 - 参考GVA的角色切换功能
const menuItems = useMemo(() => {
const items: MenuProps['items'] = [];
// 当前角色显示
if (currentUser.authority?.authorityName) {
items.push({
key: 'currentRole',
label: <span style={{ fontWeight: 'bold' }}>当前角色:{currentUser.authority.authorityName}</span>,
disabled: true,
});
}
// 可切换的其他角色
if (currentUser.authorities && currentUser.authorities.length > 1) {
const otherAuthorities = currentUser.authorities.filter(
(auth: any) => auth.authorityId !== currentUser.authorityId
);
if (otherAuthorities.length > 0) {
items.push({ type: 'divider' });
otherAuthorities.forEach((auth: any) => {
items.push({
key: `authority_${auth.authorityId}`,
icon: <SwapOutlined />,
label: `切换为:${auth.authorityName}`,
});
});
}
}
items.push({ type: 'divider' });
// 个人信息
items.push({
key: 'center',
icon: <UserOutlined />,
label: '个人信息',
});
items.push({ type: 'divider' });
// 退出登录
items.push({
key: 'logout',
icon: <LogoutOutlined />,
label: "退出登录",
},
];
label: '退出登录',
});
return items;
}, [currentUser]);
return (
<HeaderDropdown
+2 -1
View File
@@ -6,7 +6,8 @@
* 布局组件
*/
import Footer from './Footer';
import Logo from './Logo';
import { Question, SelectLang } from './RightContent';
import { AvatarDropdown, AvatarName } from './RightContent/AvatarDropdown';
export { AvatarDropdown, AvatarName, Footer, Question, SelectLang };
export { AvatarDropdown, AvatarName, Footer, Logo, Question, SelectLang };
+136 -43
View File
@@ -1,72 +1,165 @@
import type { RequestOptions } from "@@/plugin-request/request";
/**
* 请求配置 - 完全参考GVA的request.js实现
* 包含请求拦截器、响应拦截器、错误处理
*/
import type { RequestOptions } from "@@/plugin-request/request";
import type { RequestConfig } from "@umijs/max";
import { message as toast } from "antd";
import { message, Modal } from "antd";
import { history } from "@umijs/max";
// Define the structure of the expected response.
interface ResponseStructure {
code: number;
reason?: string;
message?: string;
msg?: string;
metadata?: Map<string, number>;
}
const loginPath = "/user/login";
// Request configuration with error handling and interceptors.
// 获取错误消息 - 与GVA的getErrorMessage一致
function getErrorMessage(error: any): string {
return error.response?.data?.msg || error.response?.statusText || '请求失败';
}
// 清除存储并跳转登录 - 与GVA的ClearStorage一致
function clearStorageAndRedirect() {
localStorage.removeItem('token');
localStorage.removeItem('userInfo');
localStorage.removeItem('originSetting');
sessionStorage.clear();
history.push({ pathname: loginPath });
}
// 预设错误信息 - 与GVA的presetErrors一致
const presetErrors: Record<string | number, { title: string; tips: string }> = {
500: {
title: '服务器发生内部错误',
tips: '此类错误内容常见于后台panic,请先查看后台日志',
},
404: {
title: '资源未找到',
tips: '此类错误多为接口未注册或请求路径与api路径不符',
},
401: {
title: '身份认证失败',
tips: '您的身份认证已过期或无效,请重新登录',
},
network: {
title: '网络错误',
tips: '无法连接到服务器,请检查您的网络连接',
},
};
// 显示错误弹窗 - 与GVA的ErrorPreview组件逻辑一致
function showErrorModal(code: string | number, errorMessage: string, onConfirm?: () => void) {
const preset = presetErrors[code] || { title: '请求错误', tips: '请检查控制台获取更多信息' };
Modal.error({
title: preset.title,
content: (
<div>
<p style={{ marginBottom: 8 }}>{errorMessage}</p>
<p style={{ color: '#666', fontSize: 12 }}>{preset.tips}</p>
</div>
),
okText: '确定',
onOk: () => {
onConfirm?.();
},
});
}
// 请求配置
export const errorConfig: RequestConfig = {
timeout: 99999, // 超时时间 - 与GVA保持一致
errorConfig: {
// 错误抛出器 - 与GVA一致,code !== 0 时抛出错误
errorThrower: (res) => {
const { code, reason, message, msg } = res as unknown as ResponseStructure;
if (code !== 0 && code !== 200) {
const error: any = new Error(message || msg);
error.info = { reason, message: message || msg };
const { code, msg, message: errorMsg } = res as any;
if (code !== 0) {
const error: any = new Error(msg || errorMsg || '请求失败');
error.info = { code, message: msg || errorMsg };
throw error;
}
},
// 错误处理器 - 与GVA的response error拦截器一致
errorHandler: (error: any, opts: any) => {
if (opts?.skipErrorHandler) throw error;
if (error.response?.status === 401) {
localStorage.removeItem('token');
localStorage.removeItem('userInfo');
history.push(loginPath);
// 网络错误 - 与GVA一致
if (!error.response) {
showErrorModal('network', getErrorMessage(error));
return;
}
if (error.response?.data) {
const errorInfo: ResponseStructure | undefined = error.response?.data;
if (errorInfo) {
const { message, msg, reason } = errorInfo;
toast.error((reason ? reason + ": " : "") + (message || msg || "请求失败"));
}
} else if (error.response) {
toast.error(`Response status: ${error.response.status}`);
} else if (error.request) {
toast.error("None response! Please retry.");
} else {
toast.error("Request error, please retry.");
const status = error.response?.status;
const errorMsg = getErrorMessage(error);
// 401 身份认证失败 - 与GVA一致,点击确定后清除存储并跳转
if (status === 401) {
showErrorModal(401, errorMsg, () => {
clearStorageAndRedirect();
});
return;
}
// 其他HTTP错误 - 与GVA一致
showErrorModal(status, errorMsg);
},
},
// 请求拦截器 - 与GVA的request拦截器一致
requestInterceptors: [
(config: RequestOptions) => {
// 添加token到请求头
const token = localStorage.getItem('token');
if (token) {
config.headers = {
...config.headers,
'x-token': token,
};
const token = localStorage.getItem('token') || '';
let userId = '';
try {
const userInfo = localStorage.getItem('userInfo');
if (userInfo) {
const user = JSON.parse(userInfo);
userId = String(user.ID || user.id || '');
}
} catch (e) {
// ignore parse error
}
// 设置请求头 - 与GVA完全一致
config.headers = {
'Content-Type': 'application/json',
'x-token': token,
'x-user-id': userId,
...config.headers,
};
return config;
},
],
// 响应拦截器 - 与GVA的response拦截器一致
responseInterceptors: [
(response) => {
if (response.status !== 200) {
toast.error(`Request error: ${response.status}`);
(response: any) => {
// 处理新token - 与GVA一致
const newToken = response.headers?.['new-token'];
if (newToken) {
localStorage.setItem('token', newToken);
}
return response;
const { data } = response;
// 如果没有code字段,直接返回 - 与GVA一致
if (typeof data?.code === 'undefined') {
return response;
}
// code === 0 或 headers.success === 'true' 表示成功 - 与GVA完全一致
if (data.code === 0 || response.headers?.success === 'true') {
// 处理header中的msg - 与GVA一致
if (response.headers?.msg) {
data.msg = decodeURI(response.headers.msg);
}
// 返回 response.data 而不是整个 response - 与GVA一致
return { ...response, data };
}
// 业务错误,显示错误消息 - 与GVA一致
message.error(data.msg || decodeURI(response.headers?.msg || '') || '请求失败');
return { ...response, data };
},
],
};
+1 -1
View File
@@ -44,7 +44,7 @@ export async function getUserList(data: API.PageParams) {
}
/** 设置用户权限 POST /user/setUserAuthority */
export async function setUserAuthority(data: { uuid: string; authorityId: number }) {
export async function setUserAuthority(data: { authorityId: number }) {
return request<API.BaseResult>('/api/user/setUserAuthority', {
method: 'POST',
data,