前端重构

This commit is contained in:
Yvan 2026-01-08 11:20:29 +08:00
parent d76655a30a
commit f12f2e0f00
201 changed files with 27517 additions and 537 deletions

View File

@ -168,7 +168,7 @@ export default defineConfig({
mock: {
include: ["mock/**/*", "src/pages/**/_mock.ts"],
},
utoopack: {},
// utoopack: {},
requestRecord: {},
exportStatic: {},
define: {

View File

@ -1,28 +1,50 @@
import type { ProLayoutProps } from '@ant-design/pro-components';
/**
* @name
* KRA - Default Settings
*
*/
const Settings: ProLayoutProps & {
pwa?: boolean;
logo?: string;
} = {
navTheme: 'light',
// 拂晓蓝
// 主题色 - 拂晓蓝
colorPrimary: '#1890ff',
// 布局模式: side | top | mix
layout: 'mix',
// 内容宽度: Fluid | Fixed
contentWidth: 'Fluid',
fixedHeader: false,
// 固定头部
fixedHeader: true,
// 固定侧边栏
fixSiderbar: true,
// 色弱模式
colorWeak: false,
title: 'Ant Design Pro',
pwa: true,
logo: 'https://gw.alipayobjects.com/zos/rmsportal/KDpgvguMpGfqaHPjicRK.svg',
// 标题
title: 'Kratos Admin',
// PWA
pwa: false,
// Logo
logo: '/logo.svg',
// 图标字体
iconfontUrl: '',
// Token 配置
token: {
// 参见ts声明demo 见文档通过token 修改样式
//https://procomponents.ant.design/components/layout#%E9%80%9A%E8%BF%87-token-%E4%BF%AE%E6%94%B9%E6%A0%B7%E5%BC%8F
// 侧边栏背景色
sider: {
colorMenuBackground: '#fff',
colorTextMenu: 'rgba(0, 0, 0, 0.65)',
colorTextMenuSelected: '#1890ff',
colorBgMenuItemSelected: '#e6f7ff',
},
},
// 分割菜单
splitMenus: false,
// 页脚渲染
footerRender: true,
// 菜单头部渲染
menuHeaderRender: true,
};
export default Settings;

View File

@ -1,47 +1,255 @@
/**
* KRA -
* GVA
* @name umi
* @description path,component,routes,redirect,wrappers,name,icon
* @param path path 第一种是动态参数 :id *
* @param component location path React src/pages
* @param routes layout 使
* @param redirect
* @param wrappers
* @param name menu.ts menu.xxxx name login menu.ts menu.login
* @param icon https://ant.design/components/icon-cn 注意去除风格后缀和大小写,如想要配置图标为 <StepBackwardOutlined /> 则取值应为 stepBackward 或 StepBackward如想要配置图标为 <UserOutlined /> 则取值应为 user 或者 User
* @doc https://umijs.org/docs/guides/routes
*/
export default [
// 用户相关(无布局)
{
path: "/user",
path: '/user',
layout: false,
routes: [
{
name: "login",
path: "/user/login",
component: "./user/login",
name: 'login',
path: '/user/login',
component: './user/login',
},
],
},
// 数据库初始化(无布局)
{
path: "/welcome",
name: "welcome",
icon: "smile",
component: "./Welcome",
},
{
name: "admin",
icon: "crown",
path: "/admins",
access: "canAdmin",
component: "./admins",
},
{
path: "/",
redirect: "/welcome",
},
{
component: "404",
path: '/init',
layout: false,
path: "./*",
component: './init',
},
// 错误页面(无布局)
{
path: '/error',
layout: false,
routes: [
{ path: '/error/403', component: './error/403' },
{ path: '/error/404', component: './error/404' },
{ path: '/error/500', component: './error/500' },
{ path: '/error/reload', component: './error/reload' },
],
},
// 仪表盘
{
path: '/dashboard',
name: 'dashboard',
icon: 'dashboard',
component: './dashboard',
},
// 超级管理员 - 用户管理
{
path: '/admin',
name: 'superAdmin',
icon: 'crown',
access: 'isAdmin',
routes: [
{
path: '/admin/user',
name: 'user',
icon: 'user',
component: './system/user',
},
{
path: '/admin/authority',
name: 'authority',
icon: 'team',
component: './system/authority',
},
{
path: '/admin/menu',
name: 'menu',
icon: 'menu',
component: './system/menu',
},
{
path: '/admin/api',
name: 'api',
icon: 'api',
component: './system/api',
},
{
path: '/admin/operation',
name: 'operation',
icon: 'fileSearch',
component: './system/operation',
},
{
path: '/admin/dictionary',
name: 'dictionary',
icon: 'book',
component: './system/dictionary',
},
{
path: '/admin/dictionary/detail/:id',
name: 'dictionaryDetail',
component: './system/dictionary/detail',
hideInMenu: true,
},
{
path: '/admin/params',
name: 'params',
icon: 'setting',
component: './system/params',
},
],
},
// 系统工具
{
path: '/systemTools',
name: 'systemTools',
icon: 'tool',
access: 'isAdmin',
routes: [
{
path: '/systemTools/system',
name: 'system',
icon: 'setting',
component: './systemTools/system',
},
{
path: '/systemTools/version',
name: 'version',
icon: 'info-circle',
component: './systemTools/version',
},
{
path: '/systemTools/sysError',
name: 'sysError',
icon: 'warning',
component: './systemTools/sysError',
},
{
path: '/systemTools/autoCode',
name: 'autoCode',
icon: 'code',
component: './systemTools/autoCode',
},
{
path: '/systemTools/autoCodeAdmin',
name: 'autoCodeAdmin',
icon: 'database',
component: './systemTools/autoCodeAdmin',
},
{
path: '/systemTools/exportTemplate',
name: 'exportTemplate',
icon: 'export',
component: './systemTools/exportTemplate',
},
{
path: '/systemTools/formCreate',
name: 'formCreate',
icon: 'form',
component: './systemTools/formCreate',
},
{
path: '/systemTools/installPlugin',
name: 'installPlugin',
icon: 'appstore-add',
component: './systemTools/installPlugin',
},
{
path: '/systemTools/pubPlug',
name: 'pubPlug',
icon: 'cloud-upload',
component: './systemTools/pubPlug',
},
],
},
// 插件
{
path: '/plugin',
name: 'plugin',
icon: 'appstore',
routes: [
{
path: '/plugin/announcement',
name: 'announcement',
icon: 'notification',
component: './plugin/announcement',
},
{
path: '/plugin/email',
name: 'email',
icon: 'mail',
component: './plugin/email',
},
],
},
// 示例
{
path: '/example',
name: 'example',
icon: 'experiment',
routes: [
{
path: '/example/customer',
name: 'customer',
icon: 'contacts',
component: './example/customer',
},
{
path: '/example/upload',
name: 'upload',
icon: 'upload',
component: './example/upload',
},
{
path: '/example/upload/scan',
name: 'scanUpload',
icon: 'scan',
component: './example/upload/scanUpload',
hideInMenu: true,
},
{
path: '/example/breakpoint',
name: 'breakpoint',
icon: 'cloud-upload',
component: './example/breakpoint',
},
],
},
// 个人中心
{
path: '/person',
name: 'person',
icon: 'user',
component: './person',
hideInMenu: true,
},
// 关于
{
path: '/about',
name: 'about',
icon: 'info-circle',
component: './about',
},
// 默认重定向
{
path: '/',
redirect: '/dashboard',
},
// 404
{
path: '*',
layout: false,
component: './error/404',
},
];

View File

@ -31,11 +31,13 @@
"test:update": "npm run jest -- -u",
"tsc": "tsc --noEmit"
},
"browserslist": ["defaults"],
"browserslist": [
"defaults"
],
"dependencies": {
"@ant-design/charts": "^2.2.1",
"@ant-design/icons": "^6.1.0",
"@ant-design/pro-components": "^2.8.9",
"@ant-design/charts": "^2.2.1",
"antd": "^6.0.0",
"antd-style": "^3.7.0",
"axios": "^1.8.2",
@ -44,6 +46,7 @@
"echarts": "^5.5.1",
"echarts-for-react": "^3.0.2",
"lodash": "^4.17.21",
"mitt": "^3.0.1",
"nprogress": "^0.2.0",
"qs": "^6.13.0",
"react": "^19.1.0",
@ -56,6 +59,7 @@
},
"devDependencies": {
"@ant-design/pro-cli": "^3.3.0",
"@biomejs/biome": "^2.1.1",
"@commitlint/cli": "^20.1.0",
"@commitlint/config-conventional": "^20.0.0",
"@testing-library/dom": "^10.4.0",
@ -63,6 +67,7 @@
"@types/express": "^5.0.3",
"@types/jest": "^30.0.0",
"@types/lodash": "^4.17.13",
"@types/node": "^24.0.13",
"@types/nprogress": "^0.2.3",
"@types/qs": "^6.9.17",
"@types/react": "^19.1.8",
@ -82,16 +87,22 @@
"ts-node": "^10.9.2",
"typescript": "^5.6.3",
"umi-presets-pro": "^2.0.3",
"umi-serve": "^1.9.11",
"@biomejs/biome": "^2.1.1",
"@types/node": "^24.0.13"
"umi-serve": "^1.9.11"
},
"engines": {
"node": ">=20.0.0"
},
"create-umi": {
"ignoreScript": ["docker*", "functions*", "site", "generateMock"],
"ignoreDependencies": ["netlify*", "serverless"],
"ignoreScript": [
"docker*",
"functions*",
"site",
"generateMock"
],
"ignoreDependencies": [
"netlify*",
"serverless"
],
"ignore": [
".dockerignore",
".git",

View File

@ -1,11 +1,129 @@
/**
* KRA - Access Control
*
* @see https://umijs.org/docs/max/access#access
* */
export default function access(
initialState: { currentUser?: API.CurrentUser } | undefined,
) {
*/
export interface UserAuthority {
authorityId: string | number;
authorityName: string;
defaultRouter?: string;
}
export interface CurrentUser {
uuid?: string;
userName?: string;
nickName?: string;
headerImg?: string;
authority?: UserAuthority;
authorities?: UserAuthority[];
access?: string;
// 按钮权限列表
buttons?: string[];
// 菜单权限列表
menus?: string[];
}
export interface InitialState {
currentUser?: CurrentUser;
settings?: any;
loading?: boolean;
}
export default function access(initialState: InitialState | undefined) {
const { currentUser } = initialState ?? {};
const authority = currentUser?.authority;
const authorities = currentUser?.authorities || [];
const buttons = currentUser?.buttons || [];
const menus = currentUser?.menus || [];
// 获取所有角色ID
const authorityIds = authorities.map((a) =>
typeof a.authorityId === 'string' ? parseInt(a.authorityId, 10) : a.authorityId
);
// 当前角色ID
const currentAuthorityId = authority?.authorityId
? (typeof authority.authorityId === 'string'
? parseInt(authority.authorityId, 10)
: authority.authorityId)
: 0;
// 是否为超级管理员 (authorityId = 888)
const isSuperAdmin = currentAuthorityId === 888 || authorityIds.includes(888);
// 是否为管理员 (authorityId <= 1000 通常为管理员角色)
const isAdmin = isSuperAdmin || currentAuthorityId <= 1000;
return {
canAdmin: currentUser && currentUser.access === 'admin',
// 是否已登录
isLoggedIn: !!currentUser,
// 是否为超级管理员
isSuperAdmin,
// 是否为管理员
isAdmin,
// 兼容旧版 canAdmin
canAdmin: isAdmin,
// 当前角色ID
currentAuthorityId,
// 所有角色ID列表
authorityIds,
// 按钮权限列表
buttons,
// 菜单权限列表
menus,
// 检查是否有指定角色
hasAuthority: (authorityId: number) => {
if (isSuperAdmin) return true;
return authorityIds.includes(authorityId) || currentAuthorityId === authorityId;
},
// 检查是否有指定按钮权限
hasButton: (buttonKey: string) => {
if (isSuperAdmin) return true;
return buttons.includes(buttonKey);
},
// 检查是否有指定菜单权限
hasMenu: (menuPath: string) => {
if (isSuperAdmin) return true;
return menus.includes(menuPath);
},
// 检查路由访问权限
canAccessRoute: (path: string) => {
if (isSuperAdmin) return true;
// 公开路由
const publicRoutes = ['/user/login', '/user/register', '/init', '/404', '/403', '/500'];
if (publicRoutes.some((route) => path.startsWith(route))) {
return true;
}
// 检查菜单权限
if (menus.length > 0) {
return menus.some((menu) => path.startsWith(menu));
}
// 默认允许访问(如果没有配置菜单权限)
return true;
},
// 检查是否有任一指定权限
hasAnyAuthority: (ids: number[]) => {
if (isSuperAdmin) return true;
return ids.some((id) => authorityIds.includes(id) || currentAuthorityId === id);
},
// 检查是否有所有指定权限
hasAllAuthorities: (ids: number[]) => {
if (isSuperAdmin) return true;
return ids.every((id) => authorityIds.includes(id) || currentAuthorityId === id);
},
};
}

View File

@ -1,129 +1,144 @@
import {
AvatarDropdown,
AvatarName,
Footer,
Question,
SelectLang,
} from "@/components";
import { createAdminService } from "@/services/index";
import { Admin } from "@/services/kratos/admin/v1/index";
import { LinkOutlined } from "@ant-design/icons";
import type { Settings as LayoutSettings } from "@ant-design/pro-components";
import { SettingDrawer } from "@ant-design/pro-components";
import type { RequestConfig, RunTimeLayoutConfig } from "@umijs/max";
import { history, Link } from "@umijs/max";
import defaultSettings from "../config/defaultSettings";
import { errorConfig } from "./requestErrorConfig";
/**
* KRA - App Configuration
*
*/
import { AvatarDropdown, AvatarName, Footer, Question } from '@/components';
import { getUserInfo } from '@/services/kratos/user';
import { getToken, removeToken } from '@/utils/auth';
import { UserOutlined } from '@ant-design/icons';
import type { Settings as LayoutSettings } from '@ant-design/pro-components';
import { SettingDrawer } from '@ant-design/pro-components';
import type { RequestConfig, RunTimeLayoutConfig } from '@umijs/max';
import { history } from '@umijs/max';
import { message } from 'antd';
import defaultSettings from '../config/defaultSettings';
import { errorConfig } from './requestErrorConfig';
const isDev = process.env.NODE_ENV === "development";
const isDevOrTest = isDev || process.env.CI;
const loginPath = "/user/login";
const adminService = createAdminService();
const isDev = process.env.NODE_ENV === 'development';
const loginPath = '/user/login';
const initPath = '/init';
const whiteList = [loginPath, initPath, '/error/403', '/error/404', '/error/500'];
/**
*
* @see https://umijs.org/docs/api/runtime-config#getinitialstate
* */
*/
export async function getInitialState(): Promise<{
settings?: Partial<LayoutSettings>;
currentUser?: API.CurrentUser;
loading?: boolean;
fetchUserInfo?: () => Promise<Admin | undefined>;
fetchUserInfo?: () => Promise<API.CurrentUser | undefined>;
}> {
const fetchUserInfo = async () => {
const fetchUserInfo = async (): Promise<API.CurrentUser | undefined> => {
try {
return await adminService.Current({});
const token = getToken();
if (!token) {
return undefined;
}
const res = await getUserInfo();
if (res.data?.code === 0 && res.data?.data?.userInfo) {
const userInfo = res.data.data.userInfo;
return {
uuid: userInfo.uuid,
userName: userInfo.userName,
nickName: userInfo.nickName,
name: userInfo.nickName || userInfo.userName,
avatar: userInfo.headerImg,
headerImg: userInfo.headerImg,
authority: userInfo.authority,
authorities: userInfo.authorities,
// 按钮权限(如果后端返回)
buttons: res.data.data.buttons || [],
// 菜单权限(如果后端返回)
menus: res.data.data.menus || [],
};
}
return undefined;
} catch (error) {
history.push(loginPath);
console.error('获取用户信息失败:', error);
return undefined;
}
return undefined;
};
// 如果不是登录页面,执行
// 如果是白名单页面,不获取用户信息
const { location } = history;
if (
![loginPath, "/user/register", "/user/register-result"].includes(
location.pathname
)
) {
const currentUser = await fetchUserInfo();
if (whiteList.some((path) => location.pathname.startsWith(path))) {
return {
fetchUserInfo,
currentUser,
settings: defaultSettings as Partial<LayoutSettings>,
};
}
// 获取用户信息
const currentUser = await fetchUserInfo();
if (!currentUser) {
// 未登录,跳转到登录页
history.push(loginPath);
}
return {
fetchUserInfo,
currentUser,
settings: defaultSettings as Partial<LayoutSettings>,
};
}
// ProLayout 支持的api https://procomponents.ant.design/components/layout
export const layout: RunTimeLayoutConfig = ({
initialState,
setInitialState,
}) => {
/**
* ProLayout
* @see https://procomponents.ant.design/components/layout
*/
export const layout: RunTimeLayoutConfig = ({ initialState, setInitialState }) => {
return {
actionsRender: () => [
<Question key="doc" />,
<SelectLang key="SelectLang" />,
],
// 右上角操作区
actionsRender: () => [<Question key="doc" />],
// 头像配置
avatarProps: {
src: initialState?.currentUser?.avatar,
src: initialState?.currentUser?.avatar || initialState?.currentUser?.headerImg,
title: <AvatarName />,
render: (_, avatarChildren) => (
<AvatarDropdown>{avatarChildren}</AvatarDropdown>
),
icon: <UserOutlined />,
render: (_, avatarChildren) => <AvatarDropdown>{avatarChildren}</AvatarDropdown>,
},
// 水印
waterMarkProps: {
content: initialState?.currentUser?.name,
content: initialState?.currentUser?.nickName || initialState?.currentUser?.userName,
},
// 页脚
footerRender: () => <Footer />,
// 页面切换时的回调
onPageChange: () => {
const { location } = history;
// 如果没有登录,重定向到 login
if (!initialState?.currentUser && location.pathname !== loginPath) {
// 白名单页面不检查登录状态
if (whiteList.some((path) => location.pathname.startsWith(path))) {
return;
}
// 如果没有登录,重定向到登录页
if (!initialState?.currentUser) {
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 的状态
unAccessible: (
<div style={{ textAlign: 'center', padding: '100px 0' }}>
<h1>403</h1>
<p>访</p>
</div>
),
// 子元素渲染
childrenRender: (children) => {
// if (initialState?.loading) return <PageLoading />;
return (
<>
{children}
{isDevOrTest && (
{isDev && (
<SettingDrawer
disableUrlParams
enableDarkTheme
@ -139,16 +154,17 @@ export const layout: RunTimeLayoutConfig = ({
</>
);
},
...initialState?.settings,
};
};
/**
* @name request
* axios ahooks useRequest
* @doc https://umijs.org/docs/max/request#配置
*
* @see https://umijs.org/docs/max/request#配置
*/
export const request: RequestConfig = {
baseURL: isDev ? "" : "https://proapi.azurewebsites.net",
baseURL: isDev ? '/api' : '/api',
timeout: 30000,
...errorConfig,
};

54
web/src/assets/index.ts Normal file
View File

@ -0,0 +1,54 @@
/**
* KRA - Assets Index
*
*
*
* - 404.png (404)
* - background.svg ()
* - banner.jpg ()
* - dashboard.png ()
* - login_background.jpg ()
* - login_background.svg (SVG)
* - login_left.svg ()
* - noBody.png ()
* - notFound.png ()
*
* 使KRA品牌
* - logo.png (KRA Logo)
* - logo.jpg (KRA Logo JPG)
* - logo_login.png (Logo)
* - nav_logo.png (Logo)
*/
// 默认头像
export const defaultAvatar = '/logo.svg';
// Logo
export const logo = '/logo.svg';
export const logoLogin = '/logo.svg';
export const navLogo = '/logo.svg';
// 背景图
export const loginBackground = '/login_background.jpg';
export const background = '/background.svg';
// 占位图
export const notFoundImage = '/404.png';
export const noDataImage = '/noBody.png';
// 仪表盘
export const dashboardImage = '/dashboard.png';
export const bannerImage = '/banner.jpg';
export default {
defaultAvatar,
logo,
logoLogin,
navLogo,
loginBackground,
background,
notFoundImage,
noDataImage,
dashboardImage,
bannerImage,
};

View File

@ -0,0 +1,74 @@
/**
* KRA - Array Control Component
* Dynamic array form control for adding/removing items
*/
import React from 'react';
import { Button, Space } from 'antd';
import { PlusOutlined, MinusCircleOutlined } from '@ant-design/icons';
interface ArrayCtrlProps<T = any> {
value?: T[];
onChange?: (value: T[]) => void;
defaultItem?: T;
renderItem: (item: T, index: number, onChange: (item: T) => void) => React.ReactNode;
max?: number;
min?: number;
addText?: string;
className?: string;
style?: React.CSSProperties;
}
function ArrayCtrl<T = any>({
value = [],
onChange,
defaultItem = {} as T,
renderItem,
max,
min = 0,
addText = '添加',
className,
style,
}: ArrayCtrlProps<T>) {
const handleAdd = () => {
if (max && value.length >= max) return;
onChange?.([...value, { ...defaultItem }]);
};
const handleRemove = (index: number) => {
if (value.length <= min) return;
const newValue = [...value];
newValue.splice(index, 1);
onChange?.(newValue);
};
const handleItemChange = (index: number, item: T) => {
const newValue = [...value];
newValue[index] = item;
onChange?.(newValue);
};
return (
<div className={className} style={style}>
{value.map((item, index) => (
<div key={index} style={{ display: 'flex', alignItems: 'flex-start', marginBottom: 8 }}>
<div style={{ flex: 1 }}>
{renderItem(item, index, (newItem) => handleItemChange(index, newItem))}
</div>
{value.length > min && (
<MinusCircleOutlined
style={{ marginLeft: 8, marginTop: 8, color: '#ff4d4f', cursor: 'pointer' }}
onClick={() => handleRemove(index)}
/>
)}
</div>
))}
{(!max || value.length < max) && (
<Button type="dashed" onClick={handleAdd} icon={<PlusOutlined />} style={{ width: '100%' }}>
{addText}
</Button>
)}
</div>
);
}
export default ArrayCtrl;

View File

@ -0,0 +1,2 @@
export { default as ArrayCtrl } from './ArrayCtrl';
export { default } from './ArrayCtrl';

View File

@ -0,0 +1,47 @@
/**
* KRA - Bottom Info Component
* Footer information display
*/
import React from 'react';
interface BottomInfoProps {
className?: string;
style?: React.CSSProperties;
}
const BottomInfo: React.FC<BottomInfoProps> = ({ className, style }) => {
return (
<div
className={className}
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
padding: '16px 0',
fontSize: 14,
color: '#666',
...style,
}}
>
<div>
<span style={{ marginRight: 4 }}>Powered by</span>
<a
href="https://github.com/go-kratos/kratos"
target="_blank"
rel="noopener noreferrer"
style={{ fontWeight: 600, color: '#1890ff' }}
>
Kratos Admin
</a>
</div>
<div>
<span style={{ marginRight: 4 }}>Copyright © {new Date().getFullYear()}</span>
<span>KRA Team</span>
</div>
</div>
);
};
export default BottomInfo;

View File

@ -0,0 +1,2 @@
export { default as BottomInfo } from './BottomInfo';
export { default } from './BottomInfo';

View File

@ -0,0 +1,85 @@
/**
* KRA - Base Chart Component
* ECharts wrapper with responsive sizing
*/
import React, { useRef, useEffect } from 'react';
import * as echarts from 'echarts';
import type { EChartsOption } from 'echarts';
interface BaseChartProps {
option: EChartsOption;
width?: string | number;
height?: string | number;
autoResize?: boolean;
theme?: string | object;
loading?: boolean;
className?: string;
style?: React.CSSProperties;
}
const BaseChart: React.FC<BaseChartProps> = ({
option,
width = '100%',
height = 300,
autoResize = true,
theme,
loading = false,
className,
style,
}) => {
const chartRef = useRef<HTMLDivElement>(null);
const chartInstance = useRef<echarts.ECharts | null>(null);
// Initialize chart
useEffect(() => {
if (!chartRef.current) return;
chartInstance.current = echarts.init(chartRef.current, theme);
return () => {
chartInstance.current?.dispose();
};
}, [theme]);
// Update option
useEffect(() => {
if (!chartInstance.current) return;
chartInstance.current.setOption(option, true);
}, [option]);
// Handle loading state
useEffect(() => {
if (!chartInstance.current) return;
if (loading) {
chartInstance.current.showLoading();
} else {
chartInstance.current.hideLoading();
}
}, [loading]);
// Handle resize
useEffect(() => {
if (!autoResize || !chartInstance.current) return;
const handleResize = () => {
chartInstance.current?.resize();
};
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, [autoResize]);
return (
<div
ref={chartRef}
className={className}
style={{
width: typeof width === 'number' ? `${width}px` : width,
height: typeof height === 'number' ? `${height}px` : height,
...style,
}}
/>
);
};
export default BaseChart;

View File

@ -0,0 +1,61 @@
/**
* KRA - Line Chart Component
*/
import React, { useMemo } from 'react';
import BaseChart from './BaseChart';
import type { EChartsOption } from 'echarts';
interface LineChartProps {
data: { name: string; value: number }[];
xAxisData?: string[];
title?: string;
smooth?: boolean;
areaStyle?: boolean;
width?: string | number;
height?: string | number;
loading?: boolean;
}
const LineChart: React.FC<LineChartProps> = ({
data,
xAxisData,
title,
smooth = true,
areaStyle = false,
width,
height = 300,
loading,
}) => {
const option: EChartsOption = useMemo(() => ({
title: title ? { text: title, left: 'center' } : undefined,
tooltip: {
trigger: 'axis',
},
xAxis: {
type: 'category',
data: xAxisData || data.map((item) => item.name),
boundaryGap: false,
},
yAxis: {
type: 'value',
},
series: [
{
type: 'line',
data: data.map((item) => item.value),
smooth,
areaStyle: areaStyle ? {} : undefined,
},
],
grid: {
left: '3%',
right: '4%',
bottom: '3%',
containLabel: true,
},
}), [data, xAxisData, title, smooth, areaStyle]);
return <BaseChart option={option} width={width} height={height} loading={loading} />;
};
export default LineChart;

View File

@ -0,0 +1,58 @@
/**
* KRA - Pie Chart Component
*/
import React, { useMemo } from 'react';
import BaseChart from './BaseChart';
import type { EChartsOption } from 'echarts';
interface PieChartProps {
data: { name: string; value: number }[];
title?: string;
radius?: string | [string, string];
roseType?: 'radius' | 'area' | false;
width?: string | number;
height?: string | number;
loading?: boolean;
}
const PieChart: React.FC<PieChartProps> = ({
data,
title,
radius = '50%',
roseType = false,
width,
height = 300,
loading,
}) => {
const option: EChartsOption = useMemo(() => ({
title: title ? { text: title, left: 'center' } : undefined,
tooltip: {
trigger: 'item',
formatter: '{a} <br/>{b}: {c} ({d}%)',
},
legend: {
orient: 'vertical',
left: 'left',
},
series: [
{
name: title || '数据',
type: 'pie',
radius,
roseType: roseType || undefined,
data,
emphasis: {
itemStyle: {
shadowBlur: 10,
shadowOffsetX: 0,
shadowColor: 'rgba(0, 0, 0, 0.5)',
},
},
},
],
}), [data, title, radius, roseType]);
return <BaseChart option={option} width={width} height={height} loading={loading} />;
};
export default PieChart;

View File

@ -0,0 +1,6 @@
/**
* KRA - Chart Components Export
*/
export { default as BaseChart } from './BaseChart';
export { default as LineChart } from './LineChart';
export { default as PieChart } from './PieChart';

View File

@ -0,0 +1,71 @@
/**
* KRA - Error Preview Component
* Display error messages with stack trace
*/
import React from 'react';
import { Alert, Typography, Collapse } from 'antd';
const { Text, Paragraph } = Typography;
const { Panel } = Collapse;
interface ErrorPreviewProps {
error?: string;
stack?: string;
method?: string;
path?: string;
showStack?: boolean;
className?: string;
style?: React.CSSProperties;
}
const ErrorPreview: React.FC<ErrorPreviewProps> = ({
error,
stack,
method,
path,
showStack = true,
className,
style,
}) => {
if (!error) return null;
return (
<div className={className} style={style}>
<Alert
type="error"
message={
<div>
{method && path && (
<Text type="secondary" style={{ marginRight: 8 }}>
[{method}] {path}
</Text>
)}
<Text strong>{error}</Text>
</div>
}
description={
showStack && stack ? (
<Collapse ghost size="small">
<Panel header="查看堆栈信息" key="stack">
<Paragraph>
<pre style={{
margin: 0,
fontSize: 12,
whiteSpace: 'pre-wrap',
wordBreak: 'break-all',
maxHeight: 300,
overflow: 'auto',
}}>
{stack}
</pre>
</Paragraph>
</Panel>
</Collapse>
) : undefined
}
/>
</div>
);
};
export default ErrorPreview;

View File

@ -0,0 +1,2 @@
export { default as ErrorPreview } from './ErrorPreview';
export { default } from './ErrorPreview';

View File

@ -0,0 +1,62 @@
/**
* KRA - Export Excel Component
* Export data to Excel file
*/
import React, { useState } from 'react';
import { Button, message } from 'antd';
import { DownloadOutlined } from '@ant-design/icons';
import { exportExcel } from '@/services/kratos/exportTemplate';
interface ExportExcelProps {
templateId: string;
params?: Record<string, any>;
fileName?: string;
buttonText?: string;
buttonType?: 'primary' | 'default' | 'dashed' | 'link' | 'text';
className?: string;
style?: React.CSSProperties;
}
const ExportExcel: React.FC<ExportExcelProps> = ({
templateId,
params = {},
fileName,
buttonText = '导出Excel',
buttonType = 'primary',
className,
style,
}) => {
const [loading, setLoading] = useState(false);
const handleExport = async () => {
try {
setLoading(true);
const res = await exportExcel({ templateID: templateId, ...params });
if (res.code === 0) {
// Handle download URL or blob
message.success('导出成功');
} else {
message.error(res.msg || '导出失败');
}
} catch (error) {
message.error('导出失败');
} finally {
setLoading(false);
}
};
return (
<Button
type={buttonType}
icon={<DownloadOutlined />}
loading={loading}
onClick={handleExport}
className={className}
style={style}
>
{buttonText}
</Button>
);
};
export default ExportExcel;

View File

@ -0,0 +1,77 @@
/**
* KRA - Import Excel Component
* Import data from Excel file
*/
import React, { useState } from 'react';
import { Upload, Button, message } from 'antd';
import { UploadOutlined } from '@ant-design/icons';
import type { UploadProps } from 'antd';
import { getToken } from '@/utils/auth';
interface ImportExcelProps {
action: string;
onSuccess?: (data: any) => void;
onError?: (error: Error) => void;
accept?: string;
buttonText?: string;
buttonType?: 'primary' | 'default' | 'dashed' | 'link' | 'text';
className?: string;
style?: React.CSSProperties;
}
const ImportExcel: React.FC<ImportExcelProps> = ({
action,
onSuccess,
onError,
accept = '.xlsx,.xls',
buttonText = '导入Excel',
buttonType = 'primary',
className,
style,
}) => {
const [loading, setLoading] = useState(false);
const uploadProps: UploadProps = {
name: 'file',
action,
accept,
headers: { 'x-token': getToken() || '' },
showUploadList: false,
beforeUpload: () => {
setLoading(true);
return true;
},
onChange: (info) => {
if (info.file.status === 'done') {
setLoading(false);
const { response } = info.file;
if (response?.code === 0) {
message.success('导入成功');
onSuccess?.(response.data);
} else {
message.error(response?.msg || '导入失败');
}
} else if (info.file.status === 'error') {
setLoading(false);
message.error('导入失败');
onError?.(new Error('Import failed'));
}
},
};
return (
<Upload {...uploadProps}>
<Button
type={buttonType}
icon={<UploadOutlined />}
loading={loading}
className={className}
style={style}
>
{buttonText}
</Button>
</Upload>
);
};
export default ImportExcel;

View File

@ -0,0 +1,2 @@
export { default as ExportExcel } from './ExportExcel';
export { default as ImportExcel } from './ImportExcel';

View File

@ -1,31 +1,37 @@
/**
* KRA - Footer Component
*
*/
import { GithubOutlined } from '@ant-design/icons';
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} Kratos Admin`}
links={[
{
key: 'Ant Design Pro',
title: 'Ant Design Pro',
href: 'https://pro.ant.design',
key: 'Kratos Admin',
title: 'Kratos Admin',
href: 'https://github.com/go-kratos/kratos',
blankTarget: true,
},
{
key: 'github',
title: <GithubOutlined />,
href: 'https://github.com/ant-design/ant-design-pro',
href: 'https://github.com/go-kratos/kratos',
blankTarget: true,
},
{
key: 'Ant Design',
title: 'Ant Design',
href: 'https://ant.design',
key: 'Kratos',
title: 'Kratos Framework',
href: 'https://go-kratos.dev',
blankTarget: true,
},
]}

View File

@ -0,0 +1,66 @@
/**
* KRA - Logo Component
* Display KRA logo with optional title
*/
import React from 'react';
interface LogoProps {
collapsed?: boolean;
showTitle?: boolean;
size?: 'small' | 'default' | 'large';
className?: string;
style?: React.CSSProperties;
}
const sizeMap = {
small: { logo: 24, fontSize: 14 },
default: { logo: 32, fontSize: 18 },
large: { logo: 48, fontSize: 24 },
};
const Logo: React.FC<LogoProps> = ({
collapsed = false,
showTitle = true,
size = 'default',
className,
style,
}) => {
const { logo: logoSize, fontSize } = sizeMap[size];
return (
<div
className={className}
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
padding: '0 16px',
height: 64,
...style,
}}
>
<img
src="/logo.svg"
alt="KRA Logo"
style={{
width: logoSize,
height: logoSize,
}}
/>
{showTitle && !collapsed && (
<span
style={{
fontSize,
fontWeight: 600,
color: 'inherit',
whiteSpace: 'nowrap',
}}
>
Kratos Admin
</span>
)}
</div>
);
};
export default Logo;

View File

@ -0,0 +1,2 @@
export { default as Logo } from './Logo';
export { default } from './Logo';

View File

@ -0,0 +1,53 @@
/**
* KRA - Word Document Viewer Component
* Display Word documents using Microsoft Office Online or Google Docs
*/
import React from 'react';
import { Empty, Spin } from 'antd';
interface DocxViewerProps {
url?: string;
width?: string | number;
height?: string | number;
loading?: boolean;
provider?: 'microsoft' | 'google';
className?: string;
style?: React.CSSProperties;
}
const DocxViewer: React.FC<DocxViewerProps> = ({
url,
width = '100%',
height = 600,
loading = false,
provider = 'microsoft',
className,
style,
}) => {
if (!url) {
return <Empty description="暂无Word文档" />;
}
// Use Microsoft Office Online or Google Docs viewer
const viewerUrl = provider === 'microsoft'
? `https://view.officeapps.live.com/op/embed.aspx?src=${encodeURIComponent(url)}`
: `https://docs.google.com/viewer?url=${encodeURIComponent(url)}&embedded=true`;
return (
<Spin spinning={loading}>
<iframe
src={viewerUrl}
className={className}
style={{
width: typeof width === 'number' ? `${width}px` : width,
height: typeof height === 'number' ? `${height}px` : height,
border: 'none',
...style,
}}
title="Word Viewer"
/>
</Spin>
);
};
export default DocxViewer;

View File

@ -0,0 +1,53 @@
/**
* KRA - Excel Viewer Component
* Display Excel files using Microsoft Office Online or Google Docs
*/
import React from 'react';
import { Empty, Spin } from 'antd';
interface ExcelViewerProps {
url?: string;
width?: string | number;
height?: string | number;
loading?: boolean;
provider?: 'microsoft' | 'google';
className?: string;
style?: React.CSSProperties;
}
const ExcelViewer: React.FC<ExcelViewerProps> = ({
url,
width = '100%',
height = 600,
loading = false,
provider = 'microsoft',
className,
style,
}) => {
if (!url) {
return <Empty description="暂无Excel文件" />;
}
// Use Microsoft Office Online or Google Docs viewer
const viewerUrl = provider === 'microsoft'
? `https://view.officeapps.live.com/op/embed.aspx?src=${encodeURIComponent(url)}`
: `https://docs.google.com/viewer?url=${encodeURIComponent(url)}&embedded=true`;
return (
<Spin spinning={loading}>
<iframe
src={viewerUrl}
className={className}
style={{
width: typeof width === 'number' ? `${width}px` : width,
height: typeof height === 'number' ? `${height}px` : height,
border: 'none',
...style,
}}
title="Excel Viewer"
/>
</Spin>
);
};
export default ExcelViewer;

View File

@ -0,0 +1,46 @@
/**
* KRA - PDF Viewer Component
* Display PDF files using iframe or embed
*/
import React from 'react';
import { Empty, Spin } from 'antd';
interface PDFViewerProps {
url?: string;
width?: string | number;
height?: string | number;
loading?: boolean;
className?: string;
style?: React.CSSProperties;
}
const PDFViewer: React.FC<PDFViewerProps> = ({
url,
width = '100%',
height = 600,
loading = false,
className,
style,
}) => {
if (!url) {
return <Empty description="暂无PDF文件" />;
}
return (
<Spin spinning={loading}>
<iframe
src={url}
className={className}
style={{
width: typeof width === 'number' ? `${width}px` : width,
height: typeof height === 'number' ? `${height}px` : height,
border: 'none',
...style,
}}
title="PDF Viewer"
/>
</Spin>
);
};
export default PDFViewer;

View File

@ -0,0 +1,3 @@
export { default as PDFViewer } from './PDFViewer';
export { default as ExcelViewer } from './ExcelViewer';
export { default as DocxViewer } from './DocxViewer';

View File

@ -0,0 +1,47 @@
/**
* KRA - Rich Text Editor Component
* Rich text editing with toolbar
*/
import React, { useRef, useEffect } from 'react';
import { Input } from 'antd';
const { TextArea } = Input;
interface RichTextEditorProps {
value?: string;
onChange?: (value: string) => void;
placeholder?: string;
height?: number;
disabled?: boolean;
className?: string;
style?: React.CSSProperties;
}
/**
* Simple rich text editor using textarea
* For full rich text support, integrate with a library like TinyMCE, Quill, or Draft.js
*/
const RichTextEditor: React.FC<RichTextEditorProps> = ({
value,
onChange,
placeholder = '请输入内容...',
height = 300,
disabled = false,
className,
style,
}) => {
return (
<div className={className} style={style}>
<TextArea
value={value}
onChange={(e) => onChange?.(e.target.value)}
placeholder={placeholder}
disabled={disabled}
style={{ height, resize: 'vertical' }}
showCount
/>
</div>
);
};
export default RichTextEditor;

View File

@ -0,0 +1,44 @@
/**
* KRA - Rich Text Viewer Component
* Display rich text content
*/
import React from 'react';
import { Typography } from 'antd';
const { Paragraph } = Typography;
interface RichTextViewerProps {
content?: string;
className?: string;
style?: React.CSSProperties;
}
const RichTextViewer: React.FC<RichTextViewerProps> = ({
content,
className,
style,
}) => {
if (!content) {
return <div className={className} style={{ color: '#999', ...style }}></div>;
}
// If content is HTML, render it safely
if (content.includes('<') && content.includes('>')) {
return (
<div
className={className}
style={style}
dangerouslySetInnerHTML={{ __html: content }}
/>
);
}
// Plain text with line breaks
return (
<Paragraph className={className} style={{ whiteSpace: 'pre-wrap', ...style }}>
{content}
</Paragraph>
);
};
export default RichTextViewer;

View File

@ -0,0 +1,2 @@
export { default as RichTextEditor } from './RichTextEditor';
export { default as RichTextViewer } from './RichTextViewer';

View File

@ -0,0 +1,143 @@
/**
* KRA - Select File Component
* Select file from uploaded files
*/
import React, { useState, useEffect } from 'react';
import { Modal, Table, Spin, Input, Tag } from 'antd';
import { FileOutlined, FileImageOutlined, FileTextOutlined, FilePdfOutlined } from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import { getFileList } from '@/services/kratos/fileUpload';
import { formatFileSize } from '@/utils/format';
const { Search } = Input;
interface SelectFileProps {
visible: boolean;
onCancel: () => void;
onSelect: (url: string, file: FileItem) => void;
accept?: string;
}
interface FileItem {
ID: number;
url: string;
name: string;
tag: string;
key: string;
}
const getFileIcon = (name: string) => {
const ext = name.split('.').pop()?.toLowerCase();
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'].includes(ext || '')) {
return <FileImageOutlined style={{ color: '#52c41a' }} />;
}
if (['pdf'].includes(ext || '')) {
return <FilePdfOutlined style={{ color: '#ff4d4f' }} />;
}
if (['doc', 'docx', 'txt', 'md'].includes(ext || '')) {
return <FileTextOutlined style={{ color: '#1890ff' }} />;
}
return <FileOutlined />;
};
const SelectFile: React.FC<SelectFileProps> = ({
visible,
onCancel,
onSelect,
accept,
}) => {
const [loading, setLoading] = useState(false);
const [files, setFiles] = useState<FileItem[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [pageSize] = useState(10);
const [keyword, setKeyword] = useState('');
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
const fetchFiles = async () => {
setLoading(true);
try {
const res = await getFileList({ page, pageSize, keyword });
if (res.code === 0) {
setFiles(res.data?.list || []);
setTotal(res.data?.total || 0);
}
} finally {
setLoading(false);
}
};
useEffect(() => {
if (visible) {
fetchFiles();
setSelectedRowKeys([]);
}
}, [visible, page, keyword]);
const columns: ColumnsType<FileItem> = [
{
title: '文件名',
dataIndex: 'name',
render: (name: string) => (
<span>
{getFileIcon(name)} {name}
</span>
),
},
{
title: '标签',
dataIndex: 'tag',
width: 100,
render: (tag: string) => tag ? <Tag>{tag}</Tag> : '-',
},
];
const handleOk = () => {
const selectedFile = files.find((f) => f.ID === selectedRowKeys[0]);
if (selectedFile) {
onSelect(selectedFile.url, selectedFile);
}
onCancel();
};
return (
<Modal
title="选择文件"
open={visible}
onCancel={onCancel}
onOk={handleOk}
width={700}
okButtonProps={{ disabled: selectedRowKeys.length === 0 }}
>
<Search
placeholder="搜索文件名"
onSearch={(value) => {
setKeyword(value);
setPage(1);
}}
style={{ marginBottom: 16 }}
/>
<Table
rowKey="ID"
columns={columns}
dataSource={files}
loading={loading}
rowSelection={{
type: 'radio',
selectedRowKeys,
onChange: setSelectedRowKeys,
}}
pagination={{
current: page,
total,
pageSize,
onChange: setPage,
showSizeChanger: false,
}}
size="small"
/>
</Modal>
);
};
export default SelectFile;

View File

@ -0,0 +1,2 @@
export { default as SelectFile } from './SelectFile';
export { default } from './SelectFile';

View File

@ -0,0 +1,137 @@
/**
* KRA - Select Image Component
* Select image from uploaded files
*/
import React, { useState, useEffect } from 'react';
import { Modal, Image, Spin, Empty, Pagination, Input } from 'antd';
import { getFileList } from '@/services/kratos/fileUpload';
const { Search } = Input;
interface SelectImageProps {
visible: boolean;
onCancel: () => void;
onSelect: (url: string) => void;
multiple?: boolean;
}
interface FileItem {
ID: number;
url: string;
name: string;
tag: string;
}
const SelectImage: React.FC<SelectImageProps> = ({
visible,
onCancel,
onSelect,
multiple = false,
}) => {
const [loading, setLoading] = useState(false);
const [files, setFiles] = useState<FileItem[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [pageSize] = useState(12);
const [keyword, setKeyword] = useState('');
const [selected, setSelected] = useState<string[]>([]);
const fetchFiles = async () => {
setLoading(true);
try {
const res = await getFileList({ page, pageSize, keyword });
if (res.code === 0) {
setFiles(res.data?.list || []);
setTotal(res.data?.total || 0);
}
} finally {
setLoading(false);
}
};
useEffect(() => {
if (visible) {
fetchFiles();
}
}, [visible, page, keyword]);
const handleSelect = (url: string) => {
if (multiple) {
setSelected((prev) =>
prev.includes(url) ? prev.filter((u) => u !== url) : [...prev, url]
);
} else {
onSelect(url);
}
};
const handleOk = () => {
if (multiple && selected.length > 0) {
onSelect(selected.join(','));
}
onCancel();
};
return (
<Modal
title="选择图片"
open={visible}
onCancel={onCancel}
onOk={handleOk}
width={800}
okButtonProps={{ disabled: multiple && selected.length === 0 }}
>
<Search
placeholder="搜索文件名"
onSearch={(value) => {
setKeyword(value);
setPage(1);
}}
style={{ marginBottom: 16 }}
/>
<Spin spinning={loading}>
{files.length === 0 ? (
<Empty description="暂无图片" />
) : (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{files.map((file) => (
<div
key={file.ID}
onClick={() => handleSelect(file.url)}
style={{
width: 120,
height: 120,
border: selected.includes(file.url)
? '2px solid #1890ff'
: '1px solid #d9d9d9',
borderRadius: 4,
cursor: 'pointer',
overflow: 'hidden',
}}
>
<Image
src={file.url}
alt={file.name}
width={118}
height={118}
style={{ objectFit: 'cover' }}
preview={false}
/>
</div>
))}
</div>
)}
</Spin>
<Pagination
current={page}
total={total}
pageSize={pageSize}
onChange={setPage}
style={{ marginTop: 16, textAlign: 'right' }}
showSizeChanger={false}
/>
</Modal>
);
};
export default SelectImage;

View File

@ -0,0 +1,2 @@
export { default as SelectImage } from './SelectImage';
export { default } from './SelectImage';

View File

@ -0,0 +1,67 @@
/**
* KRA - SVG Icon Component
* Render SVG icons from local or iconify
*/
import React from 'react';
import * as AntdIcons from '@ant-design/icons';
interface SvgIconProps {
icon?: string;
localIcon?: string;
className?: string;
style?: React.CSSProperties;
size?: number | string;
color?: string;
onClick?: () => void;
}
const SvgIcon: React.FC<SvgIconProps> = ({
icon,
localIcon,
className,
style,
size = '1em',
color,
onClick,
}) => {
// Try to use Ant Design icon
if (icon) {
const IconComponent = (AntdIcons as any)[icon] || (AntdIcons as any)[`${icon}Outlined`];
if (IconComponent) {
return (
<IconComponent
className={className}
style={{
fontSize: typeof size === 'number' ? `${size}px` : size,
color,
...style,
}}
onClick={onClick}
/>
);
}
}
// Use local SVG symbol
if (localIcon) {
return (
<svg
aria-hidden="true"
className={className}
style={{
width: typeof size === 'number' ? `${size}px` : size,
height: typeof size === 'number' ? `${size}px` : size,
fill: color || 'currentColor',
...style,
}}
onClick={onClick}
>
<use xlinkHref={`#${localIcon}`} />
</svg>
);
}
return null;
};
export default SvgIcon;

View File

@ -0,0 +1,2 @@
export { default as SvgIcon } from './SvgIcon';
export { default } from './SvgIcon';

View File

@ -0,0 +1,103 @@
/**
* KRA - Common Upload Component
* Basic file upload with progress and drag-drop support
*/
import React, { useState } from 'react';
import { Upload, Button, message } from 'antd';
import { UploadOutlined, InboxOutlined } from '@ant-design/icons';
import type { UploadProps, UploadFile } from 'antd';
import { getToken } from '@/utils/auth';
interface CommonUploadProps {
action?: string;
classId?: number;
accept?: string;
maxSize?: number; // MB
multiple?: boolean;
drag?: boolean;
showFileList?: boolean;
onSuccess?: (url: string, file: UploadFile) => void;
onError?: (error: Error) => void;
children?: React.ReactNode;
}
const CommonUpload: React.FC<CommonUploadProps> = ({
action = '/api/fileUploadAndDownload/upload',
classId = 0,
accept,
maxSize = 5,
multiple = true,
drag = false,
showFileList = false,
onSuccess,
onError,
children,
}) => {
const [loading, setLoading] = useState(false);
const beforeUpload: UploadProps['beforeUpload'] = (file) => {
const isLtMaxSize = file.size / 1024 / 1024 < maxSize;
if (!isLtMaxSize) {
message.error(`文件大小不能超过 ${maxSize}MB`);
return false;
}
setLoading(true);
return true;
};
const handleChange: UploadProps['onChange'] = (info) => {
if (info.file.status === 'done') {
setLoading(false);
const { response } = info.file;
if (response?.code === 0 && response?.data?.file) {
message.success('上传成功');
onSuccess?.(response.data.file.url, info.file);
} else {
message.error(response?.msg || '上传失败');
}
} else if (info.file.status === 'error') {
setLoading(false);
message.error('上传失败');
onError?.(new Error('Upload failed'));
}
};
const uploadProps: UploadProps = {
action,
headers: { 'x-token': getToken() || '' },
data: { classId },
accept,
multiple,
showUploadList: showFileList,
beforeUpload,
onChange: handleChange,
};
if (drag) {
return (
<Upload.Dragger {...uploadProps}>
{children || (
<>
<p className="ant-upload-drag-icon">
<InboxOutlined />
</p>
<p className="ant-upload-text"></p>
<p className="ant-upload-hint"></p>
</>
)}
</Upload.Dragger>
);
}
return (
<Upload {...uploadProps}>
{children || (
<Button icon={<UploadOutlined />} loading={loading}>
</Button>
)}
</Upload>
);
};
export default CommonUpload;

View File

@ -0,0 +1,333 @@
/**
* KRA - Image Cropper Component
* Provides image cropping functionality with preview
*/
import React, { useState, useRef, useCallback } from 'react';
import { Modal, Button, Upload, Select, Space, Tooltip, message, Slider } from 'antd';
import {
RotateLeftOutlined,
RotateRightOutlined,
ZoomInOutlined,
ZoomOutOutlined,
UploadOutlined,
} from '@ant-design/icons';
import Cropper from 'react-cropper';
import 'cropperjs/dist/cropper.css';
import { uploadFile } from '@/services/kratos/fileUpload';
import { useToken } from '@/models';
import type { UploadFile, RcFile } from 'antd/es/upload/interface';
interface CropperImageProps {
/** Class ID for file categorization */
classId?: number;
/** Callback when upload succeeds */
onSuccess?: (url: string) => void;
/** Button text */
buttonText?: string;
/** Max file size in MB */
maxSize?: number;
}
interface RatioOption {
label: string;
value: number[];
}
const ratioOptions: RatioOption[] = [
{ label: '1:1', value: [1, 1] },
{ label: '16:9', value: [16, 9] },
{ label: '9:16', value: [9, 16] },
{ label: '4:3', value: [4, 3] },
{ label: '自由比例', value: [] },
];
const CropperImage: React.FC<CropperImageProps> = ({
classId = 0,
onSuccess,
buttonText = '裁剪上传',
maxSize = 8,
}) => {
const token = useToken();
const cropperRef = useRef<Cropper>(null);
const [visible, setVisible] = useState(false);
const [imgSrc, setImgSrc] = useState<string>('');
const [uploading, setUploading] = useState(false);
const [currentRatio, setCurrentRatio] = useState(4); // Default to free ratio
const [previewUrl, setPreviewUrl] = useState<string>('');
const [zoom, setZoom] = useState(1);
// Get aspect ratio based on selection
const getAspectRatio = useCallback(() => {
const ratio = ratioOptions[currentRatio].value;
if (ratio.length === 0) return NaN; // Free ratio
return ratio[0] / ratio[1];
}, [currentRatio]);
// Handle file selection
const handleBeforeUpload = (file: RcFile) => {
const isImage = file.type.startsWith('image/');
if (!isImage) {
message.error('请选择图片文件');
return false;
}
if (file.size / 1024 / 1024 > maxSize) {
message.error(`文件大小不能超过${maxSize}MB!`);
return false;
}
const reader = new FileReader();
reader.onload = (e) => {
setImgSrc(e.target?.result as string);
setVisible(true);
};
reader.readAsDataURL(file);
return false; // Prevent auto upload
};
// Rotate image
const handleRotate = (degree: number) => {
const cropper = cropperRef.current?.cropper;
if (cropper) {
cropper.rotate(degree);
}
};
// Zoom image
const handleZoom = (ratio: number) => {
const cropper = cropperRef.current?.cropper;
if (cropper) {
cropper.zoom(ratio);
}
};
// Handle ratio change
const handleRatioChange = (value: number) => {
setCurrentRatio(value);
const cropper = cropperRef.current?.cropper;
if (cropper) {
const ratio = ratioOptions[value].value;
if (ratio.length === 0) {
cropper.setAspectRatio(NaN);
} else {
cropper.setAspectRatio(ratio[0] / ratio[1]);
}
}
};
// Handle crop and upload
const handleUpload = async () => {
const cropper = cropperRef.current?.cropper;
if (!cropper) return;
setUploading(true);
try {
const canvas = cropper.getCroppedCanvas({
maxWidth: 1200,
maxHeight: 1200,
imageSmoothingEnabled: true,
imageSmoothingQuality: 'high',
});
canvas.toBlob(async (blob) => {
if (!blob) {
message.error('裁剪失败');
setUploading(false);
return;
}
const file = new File([blob], `${Date.now()}.jpg`, { type: 'image/jpeg' });
const formData = new FormData();
formData.append('file', file);
if (classId) {
formData.append('classId', String(classId));
}
try {
const res = await uploadFile(formData);
if (res.code === 0 && res.data?.file?.url) {
message.success('上传成功');
setVisible(false);
setImgSrc('');
onSuccess?.(res.data.file.url);
} else {
message.error(res.msg || '上传失败');
}
} catch (error: any) {
message.error('上传失败: ' + (error.message || '未知错误'));
} finally {
setUploading(false);
}
}, 'image/jpeg', 0.9);
} catch (error: any) {
setUploading(false);
message.error('裁剪失败: ' + (error.message || '未知错误'));
}
};
// Handle crop event for preview
const handleCrop = () => {
const cropper = cropperRef.current?.cropper;
if (cropper) {
const canvas = cropper.getCroppedCanvas({
maxWidth: 300,
maxHeight: 300,
});
setPreviewUrl(canvas.toDataURL());
}
};
// Handle modal close
const handleClose = () => {
setVisible(false);
setImgSrc('');
setPreviewUrl('');
setZoom(1);
};
return (
<>
<Upload
accept="image/*"
showUploadList={false}
beforeUpload={handleBeforeUpload}
>
<Button type="primary" icon={<UploadOutlined />}>
{buttonText}
</Button>
</Upload>
<Modal
title="图片裁剪"
open={visible}
width={1200}
onCancel={handleClose}
footer={[
<Button key="cancel" onClick={handleClose}>
</Button>,
<Button
key="upload"
type="primary"
loading={uploading}
onClick={handleUpload}
>
{uploading ? '上传中...' : '上传'}
</Button>,
]}
destroyOnClose
>
<div style={{ display: 'flex', gap: 30, height: 600 }}>
{/* Left: Cropper Area */}
<div style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
<div
style={{
flex: 1,
backgroundColor: '#f8f8f8',
borderRadius: 8,
overflow: 'hidden',
}}
>
{imgSrc && (
<Cropper
ref={cropperRef}
src={imgSrc}
style={{ height: '100%', width: '100%' }}
aspectRatio={getAspectRatio()}
guides={true}
viewMode={1}
minCropBoxHeight={10}
minCropBoxWidth={10}
background={false}
responsive={true}
autoCropArea={0.8}
checkOrientation={false}
crop={handleCrop}
/>
)}
</div>
{/* Toolbar */}
<div
style={{
marginTop: 20,
padding: 10,
backgroundColor: '#fff',
borderRadius: 8,
boxShadow: '0 2px 12px rgba(0,0,0,0.1)',
display: 'flex',
alignItems: 'center',
gap: 16,
}}
>
<Space.Compact>
<Tooltip title="向左旋转">
<Button icon={<RotateLeftOutlined />} onClick={() => handleRotate(-90)} />
</Tooltip>
<Tooltip title="向右旋转">
<Button icon={<RotateRightOutlined />} onClick={() => handleRotate(90)} />
</Tooltip>
<Tooltip title="放大">
<Button icon={<ZoomInOutlined />} onClick={() => handleZoom(0.1)} />
</Tooltip>
<Tooltip title="缩小">
<Button icon={<ZoomOutOutlined />} onClick={() => handleZoom(-0.1)} />
</Tooltip>
</Space.Compact>
<Select
value={currentRatio}
onChange={handleRatioChange}
style={{ width: 120 }}
options={ratioOptions.map((item, index) => ({
label: item.label,
value: index,
}))}
/>
</div>
</div>
{/* Right: Preview Area */}
<div style={{ width: 340 }}>
<div
style={{
backgroundColor: '#fff',
padding: 20,
borderRadius: 8,
boxShadow: '0 2px 12px rgba(0,0,0,0.1)',
}}
>
<div style={{ marginBottom: 15, color: '#666' }}></div>
<div
style={{
width: 300,
height: 300,
backgroundColor: '#f5f5f5',
borderRadius: 8,
overflow: 'hidden',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
{previewUrl ? (
<img
src={previewUrl}
alt="preview"
style={{ maxWidth: '100%', maxHeight: '100%' }}
/>
) : (
<span style={{ color: '#999' }}></span>
)}
</div>
</div>
</div>
</div>
</Modal>
</>
);
};
export default CropperImage;

View File

@ -0,0 +1,111 @@
/**
* KRA - Image Upload Component
* Image upload with preview and compression support
*/
import React, { useState } from 'react';
import { Upload, message, Image } from 'antd';
import { PlusOutlined, LoadingOutlined } from '@ant-design/icons';
import type { UploadProps, UploadFile } from 'antd';
import { getToken } from '@/utils/auth';
interface ImageUploadProps {
value?: string;
onChange?: (url: string) => void;
action?: string;
classId?: number;
maxSize?: number; // KB
accept?: string;
width?: number;
height?: number;
}
const ImageUpload: React.FC<ImageUploadProps> = ({
value,
onChange,
action = '/api/fileUploadAndDownload/upload',
classId = 0,
maxSize = 2048, // 2MB
accept = 'image/jpeg,image/png,image/gif,image/webp',
width = 104,
height = 104,
}) => {
const [loading, setLoading] = useState(false);
const [previewOpen, setPreviewOpen] = useState(false);
const beforeUpload: UploadProps['beforeUpload'] = (file) => {
const isImage = file.type.startsWith('image/');
if (!isImage) {
message.error('只能上传图片文件');
return false;
}
const isLtMaxSize = file.size / 1024 < maxSize;
if (!isLtMaxSize) {
message.error(`图片大小不能超过 ${maxSize}KB`);
return false;
}
setLoading(true);
return true;
};
const handleChange: UploadProps['onChange'] = (info) => {
if (info.file.status === 'done') {
setLoading(false);
const { response } = info.file;
if (response?.code === 0 && response?.data?.file) {
onChange?.(response.data.file.url);
} else {
message.error(response?.msg || '上传失败');
}
} else if (info.file.status === 'error') {
setLoading(false);
message.error('上传失败');
}
};
const uploadButton = (
<div>
{loading ? <LoadingOutlined /> : <PlusOutlined />}
<div style={{ marginTop: 8 }}></div>
</div>
);
return (
<>
<Upload
name="file"
listType="picture-card"
showUploadList={false}
action={action}
headers={{ 'x-token': getToken() || '' }}
data={{ classId }}
accept={accept}
beforeUpload={beforeUpload}
onChange={handleChange}
>
{value ? (
<img
src={value}
alt="avatar"
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
onClick={(e) => {
e.stopPropagation();
setPreviewOpen(true);
}}
/>
) : (
uploadButton
)}
</Upload>
<Image
style={{ display: 'none' }}
preview={{
visible: previewOpen,
onVisibleChange: setPreviewOpen,
}}
src={value}
/>
</>
);
};
export default ImageUpload;

View File

@ -0,0 +1,117 @@
/**
* KRA - QR Code Upload Component
* Generates QR code for mobile scan upload
*/
import React, { useState, useCallback } from 'react';
import { Modal, Button } from 'antd';
import { MobileOutlined } from '@ant-design/icons';
import QRCode from 'react-qr-code';
import { useToken } from '@/models';
interface QRCodeUploadProps {
/** Class ID for file categorization */
classId?: number;
/** Callback when upload finishes */
onSuccess?: (url?: string) => void;
/** Button text */
buttonText?: string;
/** Logo URL to display in QR code center */
logoUrl?: string;
}
const QRCodeUpload: React.FC<QRCodeUploadProps> = ({
classId = 0,
onSuccess,
buttonText = '扫码上传',
logoUrl,
}) => {
const token = useToken();
const [visible, setVisible] = useState(false);
const [codeUrl, setCodeUrl] = useState('');
// Generate QR code URL
const createQrCode = useCallback(() => {
const { protocol, host } = window.location;
const url = `${protocol}//${host}/#/scanUpload?id=${classId}&token=${token}&t=${Date.now()}`;
setCodeUrl(url);
setVisible(true);
}, [classId, token]);
// Handle finish
const handleFinish = () => {
setVisible(false);
setCodeUrl('');
onSuccess?.();
};
// Handle cancel
const handleCancel = () => {
setVisible(false);
setCodeUrl('');
};
return (
<>
<Button type="primary" icon={<MobileOutlined />} onClick={createQrCode}>
{buttonText}
</Button>
<Modal
title="扫码上传"
open={visible}
width={360}
onCancel={handleCancel}
footer={[
<Button key="cancel" onClick={handleCancel}>
</Button>,
<Button key="finish" type="primary" onClick={handleFinish}>
</Button>,
]}
centered
>
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
padding: 20,
}}
>
<div
style={{
padding: 16,
backgroundColor: '#fff',
borderRadius: 8,
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
}}
>
{codeUrl && (
<QRCode
value={codeUrl}
size={256}
level="M"
fgColor="#52c41a"
bgColor="#ffffff"
/>
)}
</div>
<div
style={{
marginTop: 16,
color: '#666',
fontSize: 14,
textAlign: 'center',
}}
>
使
</div>
</div>
</Modal>
</>
);
};
export default QRCodeUpload;

View File

@ -0,0 +1,7 @@
/**
* KRA - Upload Components Export
*/
export { default as CommonUpload } from './CommonUpload';
export { default as ImageUpload } from './ImageUpload';
export { default as Cropper } from './Cropper';
export { default as QRCodeUpload } from './QRCode';

View File

@ -0,0 +1,51 @@
/**
* KRA - Warning Bar Component
* Display warning/info messages with optional link
*/
import React from 'react';
import { Alert } from 'antd';
import { WarningOutlined, InfoCircleOutlined, CheckCircleOutlined, CloseCircleOutlined } from '@ant-design/icons';
interface WarningBarProps {
title: string;
type?: 'warning' | 'info' | 'success' | 'error';
href?: string;
showIcon?: boolean;
closable?: boolean;
className?: string;
style?: React.CSSProperties;
}
const WarningBar: React.FC<WarningBarProps> = ({
title,
type = 'warning',
href,
showIcon = true,
closable = false,
className,
style,
}) => {
const handleClick = () => {
if (href) {
window.open(href, '_blank');
}
};
return (
<Alert
message={title}
type={type}
showIcon={showIcon}
closable={closable}
className={className}
style={{
cursor: href ? 'pointer' : 'default',
marginBottom: 12,
...style,
}}
onClick={handleClick}
/>
);
};
export default WarningBar;

View File

@ -0,0 +1,2 @@
export { default as WarningBar } from './WarningBar';
export { default } from './WarningBar';

View File

@ -1,12 +1,28 @@
/**
*
* 便
* KRA - Components Export
*/
/**
*
*/
import Footer from './Footer';
import { Question, SelectLang } from './RightContent';
import { AvatarDropdown, AvatarName } from './RightContent/AvatarDropdown';
export { AvatarDropdown, AvatarName, Footer, Question, SelectLang };
// Upload components
export * from './Upload';
// Chart components
export * from './Charts';
// Rich text components
export * from './RichText';
// Select components
export * from './SelectFile';
export * from './SelectImage';
// Office preview components
export * from './Office';
// Utility components
export * from './SvgIcon';
export * from './Logo';
export * from './BottomInfo';
export * from './WarningBar';
export * from './ErrorPreview';
export * from './ArrayCtrl';
export * from './ExportExcel';

View File

@ -92,3 +92,111 @@ ol {
}
}
}
// 导入样式文件
@import './styles/variables.less';
@import './styles/transition.less';
// KRA 全局样式
.kra-container {
padding: 24px;
background: #fff;
border-radius: 4px;
}
.kra-form-box {
padding: 24px;
background: #fff;
border-radius: 4px;
}
.kra-table-box {
padding: 24px;
background: #fff;
border-radius: 4px;
}
// 工具类
.text-center {
text-align: center;
}
.text-right {
text-align: right;
}
.text-left {
text-align: left;
}
.mt-8 {
margin-top: 8px;
}
.mt-16 {
margin-top: 16px;
}
.mt-24 {
margin-top: 24px;
}
.mb-8 {
margin-bottom: 8px;
}
.mb-16 {
margin-bottom: 16px;
}
.mb-24 {
margin-bottom: 24px;
}
.ml-8 {
margin-left: 8px;
}
.ml-16 {
margin-left: 16px;
}
.mr-8 {
margin-right: 8px;
}
.mr-16 {
margin-right: 16px;
}
.p-8 {
padding: 8px;
}
.p-16 {
padding: 16px;
}
.p-24 {
padding: 24px;
}
// 滚动条样式
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 3px;
}
::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #a8a8a8;
}

1
web/src/hooks/.gitkeep Normal file
View File

@ -0,0 +1 @@
# KRA Custom React Hooks

9
web/src/hooks/index.ts Normal file
View File

@ -0,0 +1,9 @@
/**
* KRA - Hooks Export
*/
export { useAuth, default as useAuthHook } from './useAuth';
export { useAccess, default as useAccessHook } from './useAccess';
export { useDictionary, default as useDictionaryHook } from './useDictionary';
export { useCharts, getChartColors, chartColors } from './useCharts';
export { useResponsive, default as useResponsiveHook } from './useResponsive';
export { useWindowResize, default as useWindowResizeHook } from './useWindowResize';

View File

@ -0,0 +1,49 @@
/**
* KRA - Access Hook
* Permission checking utilities
*/
import { useCallback } from 'react';
import { useAccess as useUmiAccess } from '@umijs/max';
export function useAccess() {
const access = useUmiAccess();
// Check if user has specific route access
const canAccess = useCallback(
(path: string): boolean => {
// Check route permission
return access?.canAccessRoute?.(path) ?? true;
},
[access]
);
// Check if user has specific button permission
const hasButton = useCallback(
(buttonKey: string): boolean => {
// Check button permission
return access?.buttons?.includes(buttonKey) ?? false;
},
[access]
);
// Check if user has specific authority
const hasAuthority = useCallback(
(authorityId: number): boolean => {
return access?.authorityIds?.includes(authorityId) ?? false;
},
[access]
);
// Check if user is admin
const isAdmin = access?.isAdmin ?? false;
return {
access,
canAccess,
hasButton,
hasAuthority,
isAdmin,
};
}
export default useAccess;

51
web/src/hooks/useAuth.ts Normal file
View File

@ -0,0 +1,51 @@
/**
* KRA - Auth Hook
* Authentication state management
*/
import { useCallback } from 'react';
import { useModel, history } from '@umijs/max';
import { getToken, setToken, removeToken } from '@/utils/auth';
export function useAuth() {
const { initialState, setInitialState } = useModel('@@initialState');
const currentUser = initialState?.currentUser;
const token = getToken();
const isLoggedIn = !!token && !!currentUser;
const login = useCallback(
async (tokenValue: string, userInfo: any) => {
setToken(tokenValue);
await setInitialState((s: any) => ({
...s,
currentUser: userInfo,
}));
},
[setInitialState]
);
const logout = useCallback(async () => {
removeToken();
await setInitialState((s: any) => ({
...s,
currentUser: undefined,
}));
history.push('/user/login');
}, [setInitialState]);
const refreshUser = useCallback(async () => {
// Refresh user info from server
// This should be implemented based on your API
}, []);
return {
currentUser,
token,
isLoggedIn,
login,
logout,
refreshUser,
};
}
export default useAuth;

View File

@ -0,0 +1,36 @@
/**
* KRA - Charts Hook
* Chart configuration with theme support
*/
import { useMemo } from 'react';
import type { EChartsOption } from 'echarts';
interface UseChartsOptions {
isDark?: boolean;
}
export function useCharts(
optionGenerator: (isDark: boolean) => EChartsOption,
options: UseChartsOptions = {}
) {
const { isDark = false } = options;
const chartOption = useMemo(() => {
return optionGenerator(isDark);
}, [optionGenerator, isDark]);
return { chartOption };
}
// Common chart color palettes
export const chartColors = {
light: ['#5470c6', '#91cc75', '#fac858', '#ee6666', '#73c0de', '#3ba272', '#fc8452', '#9a60b4'],
dark: ['#4992ff', '#7cffb2', '#fddd60', '#ff6e76', '#58d9f9', '#05c091', '#ff8a45', '#8d48e3'],
};
// Get theme-aware colors
export function getChartColors(isDark: boolean): string[] {
return isDark ? chartColors.dark : chartColors.light;
}
export default useCharts;

View File

@ -0,0 +1,69 @@
/**
* KRA - Dictionary Hook
* Dictionary lookup and caching
*/
import { useState, useEffect, useCallback } from 'react';
import { useModel } from '@umijs/max';
interface DictItem {
label: string;
value: string | number;
status?: string;
tagType?: string;
}
export function useDictionary(type: string) {
const [options, setOptions] = useState<DictItem[]>([]);
const [loading, setLoading] = useState(false);
// Try to use global dictionary model if available
const { getDictionary } = useModel('dictionary', (model) => ({
getDictionary: model?.getDictionary,
})) || {};
const fetchDictionary = useCallback(async () => {
if (!type) return;
setLoading(true);
try {
if (getDictionary) {
const items = await getDictionary(type);
setOptions(items || []);
}
} finally {
setLoading(false);
}
}, [type, getDictionary]);
useEffect(() => {
fetchDictionary();
}, [fetchDictionary]);
// Get label by value
const getLabel = useCallback(
(value: string | number): string => {
const item = options.find((opt) => opt.value === value);
return item?.label || String(value);
},
[options]
);
// Get value by label
const getValue = useCallback(
(label: string): string | number | undefined => {
const item = options.find((opt) => opt.label === label);
return item?.value;
},
[options]
);
return {
options,
loading,
getLabel,
getValue,
refresh: fetchDictionary,
};
}
export default useDictionary;

View File

@ -0,0 +1,50 @@
/**
* KRA - Responsive Hook
* Detect device type based on screen width
*/
import { useState, useEffect, useCallback } from 'react';
import { useModel } from '@umijs/max';
const MOBILE_WIDTH = 992;
type DeviceType = 'mobile' | 'desktop';
export function useResponsive(immediate = true) {
const [device, setDevice] = useState<DeviceType>('desktop');
const [isMobile, setIsMobile] = useState(false);
const queryDevice = useCallback((): DeviceType => {
const rect = document.body.getBoundingClientRect();
return rect.width - 1 < MOBILE_WIDTH ? 'mobile' : 'desktop';
}, []);
const handleResize = useCallback(() => {
if (!document.hidden) {
const deviceType = queryDevice();
setDevice(deviceType);
setIsMobile(deviceType === 'mobile');
}
}, [queryDevice]);
useEffect(() => {
if (immediate) {
handleResize();
}
let timeoutId: NodeJS.Timeout;
const debouncedResize = () => {
clearTimeout(timeoutId);
timeoutId = setTimeout(handleResize, 100);
};
window.addEventListener('resize', debouncedResize);
return () => {
window.removeEventListener('resize', debouncedResize);
clearTimeout(timeoutId);
};
}, [immediate, handleResize]);
return { device, isMobile };
}
export default useResponsive;

View File

@ -0,0 +1,36 @@
/**
* KRA - Window Resize Hook
* Monitor window resize events
*/
import { useState, useEffect, useCallback } from 'react';
interface WindowSize {
width: number;
height: number;
}
export function useWindowResize(callback?: (width: number, height: number) => void): WindowSize {
const [size, setSize] = useState<WindowSize>({
width: typeof window !== 'undefined' ? window.innerWidth : 0,
height: typeof window !== 'undefined' ? window.innerHeight : 0,
});
const handleResize = useCallback(() => {
const width = window.innerWidth;
const height = window.innerHeight;
setSize({ width, height });
callback?.(width, height);
}, [callback]);
useEffect(() => {
// Initial call
handleResize();
window.addEventListener('resize', handleResize, { passive: true });
return () => window.removeEventListener('resize', handleResize);
}, [handleResize]);
return size;
}
export default useWindowResize;

View File

@ -0,0 +1,91 @@
/**
* KRA - Basic Layout
* Main application layout with sidebar and header
*/
import React, { useState, useEffect } from 'react';
import { Outlet, useLocation, useModel, history } from '@umijs/max';
import { ProLayout } from '@ant-design/pro-components';
import type { ProLayoutProps } from '@ant-design/pro-components';
import { Watermark } from 'antd';
import { Logo } from '@/components/Logo';
import { BottomInfo } from '@/components/BottomInfo';
import HeaderTools from './components/HeaderTools';
import Tabs from './components/Tabs';
import { useResponsive } from '@/hooks/useResponsive';
import defaultSettings from '../../config/defaultSettings';
const BasicLayout: React.FC = () => {
const location = useLocation();
const { initialState } = useModel('@@initialState');
const { isMobile } = useResponsive();
const [collapsed, setCollapsed] = useState(false);
const [pathname, setPathname] = useState(location.pathname);
const currentUser = initialState?.currentUser;
const settings = initialState?.settings || defaultSettings;
useEffect(() => {
setPathname(location.pathname);
}, [location.pathname]);
const layoutProps: ProLayoutProps = {
title: 'Kratos Admin',
logo: '/logo.svg',
layout: settings.layout || 'mix',
navTheme: settings.navTheme || 'light',
contentWidth: settings.contentWidth || 'Fluid',
fixedHeader: settings.fixedHeader ?? true,
fixSiderbar: settings.fixSiderbar ?? true,
colorPrimary: settings.colorPrimary || '#1890ff',
splitMenus: settings.splitMenus ?? false,
collapsed,
onCollapse: setCollapsed,
location: { pathname },
menu: {
locale: false,
request: async () => {
// Return menu from initialState
return initialState?.menus || [];
},
},
menuItemRender: (item, dom) => (
<div onClick={() => {
setPathname(item.path || '/');
history.push(item.path || '/');
}}>
{dom}
</div>
),
headerTitleRender: (logo, title) => (
<Logo collapsed={collapsed} showTitle={!collapsed} />
),
actionsRender: () => [
<HeaderTools key="tools" />,
],
footerRender: () => <BottomInfo />,
menuFooterRender: (props) => {
if (props?.collapsed) return undefined;
return (
<div style={{ textAlign: 'center', paddingBlockStart: 12 }}>
<div>© {new Date().getFullYear()} KRA</div>
</div>
);
},
};
return (
<Watermark
content={settings.showWatermark ? currentUser?.nickName : undefined}
font={{ color: 'rgba(0, 0, 0, 0.15)' }}
gap={[180, 150]}
zIndex={9999}
>
<ProLayout {...layoutProps}>
{settings.showTabs && <Tabs />}
<Outlet />
</ProLayout>
</Watermark>
);
};
export default BasicLayout;

View File

@ -0,0 +1,12 @@
/**
* KRA - Blank Layout
* Minimal layout for special pages
*/
import React from 'react';
import { Outlet } from '@umijs/max';
const BlankLayout: React.FC = () => {
return <Outlet />;
};
export default BlankLayout;

View File

@ -0,0 +1,37 @@
.container {
display: flex;
flex-direction: column;
min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.content {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 32px 0;
}
.header {
margin-bottom: 40px;
text-align: center;
color: #fff;
}
.main {
width: 100%;
max-width: 400px;
padding: 0 24px;
}
.footer {
padding: 24px 50px;
text-align: center;
color: rgba(255, 255, 255, 0.65);
a {
color: rgba(255, 255, 255, 0.85);
}
}

View File

@ -0,0 +1,29 @@
/**
* KRA - User Layout
* Layout for login/register pages
*/
import React from 'react';
import { Outlet } from '@umijs/max';
import { Logo } from '@/components/Logo';
import { BottomInfo } from '@/components/BottomInfo';
import styles from './UserLayout.less';
const UserLayout: React.FC = () => {
return (
<div className={styles.container}>
<div className={styles.content}>
<div className={styles.header}>
<Logo size="large" showTitle />
</div>
<div className={styles.main}>
<Outlet />
</div>
</div>
<div className={styles.footer}>
<BottomInfo />
</div>
</div>
);
};
export default UserLayout;

View File

@ -0,0 +1,105 @@
/**
* KRA - Header Tools Component
* Header toolbar with user info, settings, notifications
*/
import React from 'react';
import { Space, Dropdown, Avatar, Badge, Tooltip, Switch } from 'antd';
import {
UserOutlined,
SettingOutlined,
LogoutOutlined,
BellOutlined,
FullscreenOutlined,
FullscreenExitOutlined,
SunOutlined,
MoonOutlined,
} from '@ant-design/icons';
import type { MenuProps } from 'antd';
import { useModel, history } from '@umijs/max';
import { useAuth } from '@/hooks/useAuth';
import Screenfull from './Screenfull';
const HeaderTools: React.FC = () => {
const { initialState, setInitialState } = useModel('@@initialState');
const { logout } = useAuth();
const currentUser = initialState?.currentUser;
const isDark = initialState?.settings?.navTheme === 'realDark';
const handleMenuClick: MenuProps['onClick'] = ({ key }) => {
switch (key) {
case 'profile':
history.push('/person');
break;
case 'settings':
history.push('/system/config');
break;
case 'logout':
logout();
break;
}
};
const userMenuItems: MenuProps['items'] = [
{
key: 'profile',
icon: <UserOutlined />,
label: '个人中心',
},
{
key: 'settings',
icon: <SettingOutlined />,
label: '系统设置',
},
{ type: 'divider' },
{
key: 'logout',
icon: <LogoutOutlined />,
label: '退出登录',
},
];
const toggleTheme = () => {
setInitialState((s: any) => ({
...s,
settings: {
...s?.settings,
navTheme: isDark ? 'light' : 'realDark',
},
}));
};
return (
<Space size={16}>
<Tooltip title={isDark ? '切换到亮色模式' : '切换到暗色模式'}>
<Switch
checkedChildren={<MoonOutlined />}
unCheckedChildren={<SunOutlined />}
checked={isDark}
onChange={toggleTheme}
/>
</Tooltip>
<Screenfull />
<Badge count={0} size="small">
<Tooltip title="通知">
<BellOutlined style={{ fontSize: 16, cursor: 'pointer' }} />
</Tooltip>
</Badge>
<Dropdown menu={{ items: userMenuItems, onClick: handleMenuClick }} placement="bottomRight">
<Space style={{ cursor: 'pointer' }}>
<Avatar
size="small"
src={currentUser?.headerImg}
icon={<UserOutlined />}
/>
<span>{currentUser?.nickName || currentUser?.userName || '用户'}</span>
</Space>
</Dropdown>
</Space>
);
};
export default HeaderTools;

View File

@ -0,0 +1,50 @@
/**
* KRA - Screenfull Component
* Fullscreen toggle button
*/
import React, { useState, useEffect, useCallback } from 'react';
import { Tooltip } from 'antd';
import { FullscreenOutlined, FullscreenExitOutlined } from '@ant-design/icons';
const Screenfull: React.FC = () => {
const [isFullscreen, setIsFullscreen] = useState(false);
const checkFullscreen = useCallback(() => {
setIsFullscreen(!!document.fullscreenElement);
}, []);
useEffect(() => {
document.addEventListener('fullscreenchange', checkFullscreen);
return () => document.removeEventListener('fullscreenchange', checkFullscreen);
}, [checkFullscreen]);
const toggleFullscreen = async () => {
try {
if (!document.fullscreenElement) {
await document.documentElement.requestFullscreen();
} else {
await document.exitFullscreen();
}
} catch (error) {
console.error('Fullscreen error:', error);
}
};
return (
<Tooltip title={isFullscreen ? '退出全屏' : '全屏'}>
{isFullscreen ? (
<FullscreenExitOutlined
style={{ fontSize: 16, cursor: 'pointer' }}
onClick={toggleFullscreen}
/>
) : (
<FullscreenOutlined
style={{ fontSize: 16, cursor: 'pointer' }}
onClick={toggleFullscreen}
/>
)}
</Tooltip>
);
};
export default Screenfull;

View File

@ -0,0 +1,235 @@
/**
* KRA - Global Search Component
* Command palette style search for quick navigation
*/
import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react';
import { Modal, Input, List, Empty, Tag } from 'antd';
import { SearchOutlined, FileOutlined, SettingOutlined } from '@ant-design/icons';
import { history } from '@umijs/max';
import { useAsyncRouters } from '@/models';
import * as Icons from '@ant-design/icons';
interface SearchItem {
key: string;
title: string;
path: string;
icon?: string;
type: 'menu' | 'action';
parent?: string;
}
interface SearchProps {
/** Whether the search modal is visible */
visible: boolean;
/** Callback when modal closes */
onClose: () => void;
}
// Get icon component by name
const getIcon = (iconName?: string): React.ReactNode => {
if (!iconName) return <FileOutlined />;
const IconComponent = (Icons as any)[iconName] || (Icons as any)[`${iconName}Outlined`];
return IconComponent ? <IconComponent /> : <FileOutlined />;
};
const Search: React.FC<SearchProps> = ({ visible, onClose }) => {
const asyncRouters = useAsyncRouters();
const [keyword, setKeyword] = useState('');
const [selectedIndex, setSelectedIndex] = useState(0);
const inputRef = useRef<any>(null);
// Flatten menu items for search
const searchItems = useMemo(() => {
const items: SearchItem[] = [];
const flattenMenus = (menus: any[], parent?: string) => {
menus?.forEach((menu) => {
if (menu.hidden) return;
items.push({
key: menu.name,
title: menu.meta?.title || menu.name,
path: menu.path?.startsWith('/') ? menu.path : `/${menu.path}`,
icon: menu.meta?.icon,
type: 'menu',
parent,
});
if (menu.children?.length > 0) {
flattenMenus(menu.children, menu.meta?.title || menu.name);
}
});
};
const rootMenu = asyncRouters?.[0];
if (rootMenu?.children) {
flattenMenus(rootMenu.children);
}
// Add some common actions
items.push(
{ key: 'action-profile', title: '个人中心', path: '/person', icon: 'UserOutlined', type: 'action' },
{ key: 'action-settings', title: '系统设置', path: '/system/config', icon: 'SettingOutlined', type: 'action' },
);
return items;
}, [asyncRouters]);
// Filter items based on keyword
const filteredItems = useMemo(() => {
if (!keyword.trim()) {
return searchItems.slice(0, 10);
}
const lowerKeyword = keyword.toLowerCase();
return searchItems.filter((item) => {
return (
item.title.toLowerCase().includes(lowerKeyword) ||
item.path.toLowerCase().includes(lowerKeyword) ||
item.parent?.toLowerCase().includes(lowerKeyword)
);
}).slice(0, 20);
}, [searchItems, keyword]);
// Reset state when modal opens
useEffect(() => {
if (visible) {
setKeyword('');
setSelectedIndex(0);
setTimeout(() => {
inputRef.current?.focus();
}, 100);
}
}, [visible]);
// Handle keyboard navigation
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
setSelectedIndex((prev) => Math.min(prev + 1, filteredItems.length - 1));
break;
case 'ArrowUp':
e.preventDefault();
setSelectedIndex((prev) => Math.max(prev - 1, 0));
break;
case 'Enter':
e.preventDefault();
if (filteredItems[selectedIndex]) {
handleSelect(filteredItems[selectedIndex]);
}
break;
case 'Escape':
onClose();
break;
}
}, [filteredItems, selectedIndex, onClose]);
// Handle item selection
const handleSelect = (item: SearchItem) => {
history.push(item.path);
onClose();
};
return (
<Modal
open={visible}
onCancel={onClose}
footer={null}
closable={false}
width={600}
centered
styles={{
body: { padding: 0 },
}}
style={{ top: 100 }}
>
<div style={{ padding: '12px 16px', borderBottom: '1px solid #f0f0f0' }}>
<Input
ref={inputRef}
prefix={<SearchOutlined style={{ color: '#999' }} />}
placeholder="搜索菜单或功能..."
value={keyword}
onChange={(e) => {
setKeyword(e.target.value);
setSelectedIndex(0);
}}
onKeyDown={handleKeyDown}
bordered={false}
size="large"
style={{ fontSize: 16 }}
/>
</div>
<div style={{ maxHeight: 400, overflow: 'auto' }}>
{filteredItems.length > 0 ? (
<List
dataSource={filteredItems}
renderItem={(item, index) => (
<List.Item
onClick={() => handleSelect(item)}
style={{
padding: '12px 16px',
cursor: 'pointer',
backgroundColor: index === selectedIndex ? '#f5f5f5' : 'transparent',
}}
onMouseEnter={() => setSelectedIndex(index)}
>
<List.Item.Meta
avatar={getIcon(item.icon)}
title={
<span>
{item.title}
{item.parent && (
<Tag color="blue" style={{ marginLeft: 8, fontSize: 12 }}>
{item.parent}
</Tag>
)}
</span>
}
description={
<span style={{ color: '#999', fontSize: 12 }}>
{item.path}
</span>
}
/>
</List.Item>
)}
/>
) : (
<Empty
description="未找到匹配的菜单或功能"
style={{ padding: '40px 0' }}
/>
)}
</div>
<div
style={{
padding: '8px 16px',
borderTop: '1px solid #f0f0f0',
display: 'flex',
justifyContent: 'space-between',
color: '#999',
fontSize: 12,
}}
>
<span>
<kbd style={{ padding: '2px 6px', backgroundColor: '#f5f5f5', borderRadius: 4, marginRight: 4 }}></kbd>
<kbd style={{ padding: '2px 6px', backgroundColor: '#f5f5f5', borderRadius: 4, marginRight: 8 }}></kbd>
</span>
<span>
<kbd style={{ padding: '2px 6px', backgroundColor: '#f5f5f5', borderRadius: 4, marginRight: 4 }}>Enter</kbd>
</span>
<span>
<kbd style={{ padding: '2px 6px', backgroundColor: '#f5f5f5', borderRadius: 4, marginRight: 4 }}>Esc</kbd>
</span>
</div>
</Modal>
);
};
export default Search;

View File

@ -0,0 +1,575 @@
/**
* KRA - Settings Drawer Styles
*/
.kra-setting-drawer {
.ant-drawer-header {
padding: 16px 24px;
border-bottom: 1px solid #f0f0f0;
}
.ant-drawer-body {
padding: 0;
}
}
.kra-setting-drawer-header {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
}
.kra-setting-tabs {
.ant-tabs-nav {
margin-bottom: 0;
padding: 8px 24px;
background: #fafafa;
}
.ant-tabs-content-holder {
padding: 16px 24px;
max-height: calc(100vh - 150px);
overflow-y: auto;
}
}
.kra-setting-content {
padding-bottom: 24px;
}
.kra-setting-section {
margin-bottom: 32px;
&:last-child {
margin-bottom: 0;
}
}
.kra-setting-section-header {
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 16px;
}
.kra-setting-divider {
flex: 1;
height: 1px;
background: #e8e8e8;
}
.kra-setting-section-title {
padding: 0 16px;
font-size: 14px;
font-weight: 600;
color: #666;
}
.kra-setting-card {
background: #fafafa;
border: 1px solid #f0f0f0;
border-radius: 8px;
padding: 16px;
}
// Setting Item
.kra-setting-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 0;
border-bottom: 1px solid #f0f0f0;
&:last-child {
border-bottom: none;
}
}
.kra-setting-item-label {
display: flex;
flex-direction: column;
}
.kra-setting-label {
font-size: 14px;
font-weight: 500;
color: #333;
}
.kra-setting-description {
font-size: 12px;
color: #999;
margin-top: 2px;
}
// Theme Mode Selector
.kra-theme-mode-selector {
display: flex;
justify-content: center;
gap: 8px;
background: #f5f5f5;
border-radius: 8px;
padding: 8px;
}
.kra-theme-mode-item {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 12px 20px;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
min-width: 80px;
color: #666;
&:hover {
background: #e8e8e8;
}
&.active {
color: #fff;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
.anticon {
font-size: 20px;
margin-bottom: 4px;
}
span {
font-size: 12px;
font-weight: 500;
}
}
// Theme Color Picker
.kra-theme-color-picker {
display: flex;
flex-wrap: wrap;
gap: 12px;
justify-content: center;
}
.kra-color-item {
width: 36px;
height: 36px;
border-radius: 6px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
transition: all 0.2s;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
&:hover {
transform: scale(1.1);
}
&.active {
box-shadow: 0 0 0 2px #fff, 0 0 0 4px currentColor;
}
.anticon {
font-size: 16px;
}
}
// Layout Mode Selector
.kra-layout-mode-selector {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
}
.kra-layout-mode-item {
position: relative;
background: #fff;
border: 2px solid #e8e8e8;
border-radius: 8px;
padding: 12px;
cursor: pointer;
transition: all 0.2s;
&:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
&.active {
border-width: 2px;
}
}
.kra-layout-preview {
height: 60px;
background: #f5f5f5;
border-radius: 4px;
overflow: hidden;
display: flex;
margin-bottom: 8px;
.preview-side {
width: 20px;
background: #1890ff;
}
.preview-main {
flex: 1;
display: flex;
flex-direction: column;
}
.preview-header {
height: 12px;
background: #e8e8e8;
}
.preview-content {
flex: 1;
background: #fff;
}
.preview-top-header {
height: 12px;
background: #1890ff;
}
.preview-body {
flex: 1;
background: #fff;
}
}
.kra-layout-mode-title {
display: block;
text-align: center;
font-size: 12px;
color: #666;
}
.kra-layout-mode-check {
position: absolute;
top: -8px;
right: -8px;
width: 20px;
height: 20px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 10px;
}
// Size Config
.kra-size-config-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px;
background: #fff;
border: 1px solid #f0f0f0;
border-radius: 8px;
margin-bottom: 12px;
&:last-child {
margin-bottom: 0;
}
}
.kra-size-config-info {
h4 {
font-size: 14px;
font-weight: 500;
color: #333;
margin: 0 0 4px 0;
}
p {
font-size: 12px;
color: #999;
margin: 0;
}
}
.kra-size-config-control {
display: flex;
align-items: center;
gap: 8px;
.unit {
font-size: 12px;
color: #999;
}
}
// System Info Grid
.kra-system-info-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0;
}
.kra-system-info-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 0;
border-bottom: 1px solid #f0f0f0;
&:nth-last-child(-n+2) {
border-bottom: none;
}
.label {
font-size: 13px;
color: #666;
}
.value {
font-size: 13px;
font-weight: 600;
color: #333;
font-family: monospace;
}
}
// Config Action Items
.kra-config-action-item {
display: flex;
align-items: center;
gap: 16px;
padding: 16px;
background: #fff;
border: 1px solid #f0f0f0;
border-radius: 8px;
margin-bottom: 12px;
transition: all 0.2s;
&:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
}
&:last-child {
margin-bottom: 0;
}
}
.kra-config-action-icon {
width: 48px;
height: 48px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
flex-shrink: 0;
&.danger {
background: #fff1f0;
border: 1px solid #ffccc7;
color: #ff4d4f;
}
&.primary {
background: #e6f7ff;
border: 1px solid #91d5ff;
color: #1890ff;
}
&.success {
background: #f6ffed;
border: 1px solid #b7eb8f;
color: #52c41a;
}
}
.kra-config-action-info {
flex: 1;
h4 {
font-size: 14px;
font-weight: 500;
color: #333;
margin: 0 0 4px 0;
}
p {
font-size: 12px;
color: #999;
margin: 0;
}
}
// About Project
.kra-about-project {
display: flex;
gap: 20px;
align-items: flex-start;
}
.kra-about-logo {
width: 64px;
height: 64px;
background: #fff;
border: 1px solid #f0f0f0;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
.kra-about-info {
flex: 1;
h4 {
font-size: 18px;
font-weight: 600;
color: #333;
margin: 0 0 8px 0;
}
p {
font-size: 13px;
color: #666;
line-height: 1.6;
margin: 0 0 12px 0;
}
}
.kra-about-links {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
a {
color: #1890ff;
text-decoration: none;
display: flex;
align-items: center;
gap: 4px;
&:hover {
text-decoration: underline;
}
}
.divider {
color: #d9d9d9;
}
}
// Dark mode support
:global(.dark) {
.kra-setting-drawer {
.ant-drawer-header {
border-color: #303030;
}
}
.kra-setting-tabs {
.ant-tabs-nav {
background: #1f1f1f;
}
}
.kra-setting-section-title {
color: #999;
}
.kra-setting-divider {
background: #303030;
}
.kra-setting-card {
background: #1f1f1f;
border-color: #303030;
}
.kra-setting-label {
color: #e8e8e8;
}
.kra-setting-item {
border-color: #303030;
}
.kra-theme-mode-selector {
background: #262626;
}
.kra-theme-mode-item {
color: #999;
&:hover {
background: #303030;
}
}
.kra-layout-mode-item {
background: #262626;
border-color: #303030;
}
.kra-layout-preview {
background: #1f1f1f;
.preview-header {
background: #303030;
}
.preview-content,
.preview-body {
background: #262626;
}
}
.kra-layout-mode-title {
color: #999;
}
.kra-size-config-item,
.kra-config-action-item {
background: #262626;
border-color: #303030;
}
.kra-size-config-info,
.kra-config-action-info {
h4 {
color: #e8e8e8;
}
}
.kra-system-info-item {
border-color: #303030;
.value {
color: #e8e8e8;
}
}
.kra-about-logo {
background: #262626;
border-color: #303030;
}
.kra-about-info {
h4 {
color: #e8e8e8;
}
p {
color: #999;
}
}
}

View File

@ -0,0 +1,515 @@
/**
* KRA - Settings Drawer Component
* System configuration panel with appearance, layout, and general settings
*/
import React, { useState, useEffect } from 'react';
import { Drawer, Tabs, Switch, Select, InputNumber, Button, message, Modal, Upload } from 'antd';
import {
BgColorsOutlined,
LayoutOutlined,
SettingOutlined,
CheckOutlined,
SunOutlined,
MoonOutlined,
DesktopOutlined,
ReloadOutlined,
ExportOutlined,
ImportOutlined,
GithubOutlined,
BookOutlined,
} from '@ant-design/icons';
import { useAppStore, type DarkMode, type SideMode, type TransitionType, type GlobalSize } from '@/models/app';
import Logo from '@/components/Logo/Logo';
import './SettingDrawer.less';
interface SettingDrawerProps {
open: boolean;
onClose: () => void;
}
// Theme colors
const themeColors = [
{ name: '拂晓蓝', color: '#1890ff' },
{ name: '薄暮红', color: '#f5222d' },
{ name: '火山橙', color: '#fa541c' },
{ name: '日暮黄', color: '#faad14' },
{ name: '极光绿', color: '#52c41a' },
{ name: '明青', color: '#13c2c2' },
{ name: '酱紫', color: '#722ed1' },
{ name: '洋红', color: '#eb2f96' },
];
// Layout modes
const layoutModes: { key: SideMode; title: string; icon: React.ReactNode }[] = [
{ key: 'normal', title: '侧边菜单', icon: <LayoutOutlined /> },
{ key: 'head', title: '顶部菜单', icon: <DesktopOutlined /> },
{ key: 'combination', title: '混合菜单', icon: <LayoutOutlined /> },
];
// Section Header Component
const SectionHeader: React.FC<{ title: string }> = ({ title }) => (
<div className="kra-setting-section-header">
<div className="kra-setting-divider" />
<span className="kra-setting-section-title">{title}</span>
<div className="kra-setting-divider" />
</div>
);
// Setting Item Component
const SettingItem: React.FC<{
label: string;
description?: string;
children: React.ReactNode;
}> = ({ label, description, children }) => (
<div className="kra-setting-item">
<div className="kra-setting-item-label">
<span className="kra-setting-label">{label}</span>
{description && <span className="kra-setting-description">{description}</span>}
</div>
<div className="kra-setting-item-control">{children}</div>
</div>
);
// Appearance Settings Tab
const AppearanceSettings: React.FC = () => {
const { config, setDarkMode, setPrimaryColor, setWeakness, setGrey, setShowWatermark, setGlobalSize } = useAppStore();
const darkModeOptions: { key: DarkMode; icon: React.ReactNode; label: string }[] = [
{ key: 'light', icon: <SunOutlined />, label: '浅色' },
{ key: 'dark', icon: <MoonOutlined />, label: '深色' },
{ key: 'auto', icon: <DesktopOutlined />, label: '跟随系统' },
];
return (
<div className="kra-setting-content">
{/* Theme Mode */}
<div className="kra-setting-section">
<SectionHeader title="主题模式" />
<div className="kra-theme-mode-selector">
{darkModeOptions.map((option) => (
<div
key={option.key}
className={`kra-theme-mode-item ${config.darkMode === option.key ? 'active' : ''}`}
onClick={() => setDarkMode(option.key)}
style={config.darkMode === option.key ? { backgroundColor: config.primaryColor } : {}}
>
{option.icon}
<span>{option.label}</span>
</div>
))}
</div>
</div>
{/* Theme Color */}
<div className="kra-setting-section">
<SectionHeader title="主题颜色" />
<div className="kra-theme-color-picker">
{themeColors.map((item) => (
<div
key={item.color}
className={`kra-color-item ${config.primaryColor === item.color ? 'active' : ''}`}
style={{ backgroundColor: item.color }}
onClick={() => setPrimaryColor(item.color)}
title={item.name}
>
{config.primaryColor === item.color && <CheckOutlined />}
</div>
))}
</div>
</div>
{/* Global Size */}
<div className="kra-setting-section">
<SectionHeader title="全局尺寸" />
<div className="kra-setting-card">
<SettingItem label="全局尺寸" description="设置全局组件尺寸">
<Select
value={config.globalSize}
onChange={(value: GlobalSize) => setGlobalSize(value)}
style={{ width: 140 }}
options={[
{ value: 'default', label: '默认就好了' },
{ value: 'large', label: '大点好' },
{ value: 'small', label: '小的也不错' },
]}
/>
</SettingItem>
</div>
</div>
{/* Visual Accessibility */}
<div className="kra-setting-section">
<SectionHeader title="视觉辅助" />
<div className="kra-setting-card">
<SettingItem label="灰色模式" description="降低色彩饱和度">
<Switch checked={config.grey} onChange={setGrey} />
</SettingItem>
<SettingItem label="色弱模式" description="优化色彩对比度">
<Switch checked={config.weakness} onChange={setWeakness} />
</SettingItem>
<SettingItem label="显示水印" description="在页面显示水印标识">
<Switch checked={config.showWatermark} onChange={setShowWatermark} />
</SettingItem>
</div>
</div>
</div>
);
};
// Layout Settings Tab
const LayoutSettings: React.FC = () => {
const {
config,
setSideMode,
setShowTabs,
setTransitionType,
setLayoutSideWidth,
setLayoutSideCollapsedWidth,
} = useAppStore();
return (
<div className="kra-setting-content">
{/* Layout Mode */}
<div className="kra-setting-section">
<SectionHeader title="布局模式" />
<div className="kra-layout-mode-selector">
{layoutModes.map((mode) => (
<div
key={mode.key}
className={`kra-layout-mode-item ${config.sideMode === mode.key ? 'active' : ''}`}
onClick={() => setSideMode(mode.key)}
style={config.sideMode === mode.key ? { borderColor: config.primaryColor } : {}}
>
<div className="kra-layout-preview">
{mode.key === 'normal' && (
<>
<div className="preview-side" style={{ backgroundColor: config.primaryColor }} />
<div className="preview-main">
<div className="preview-header" />
<div className="preview-content" />
</div>
</>
)}
{mode.key === 'head' && (
<>
<div className="preview-top-header" style={{ backgroundColor: config.primaryColor }} />
<div className="preview-body" />
</>
)}
{mode.key === 'combination' && (
<>
<div className="preview-top-header" style={{ backgroundColor: config.primaryColor }} />
<div className="preview-main">
<div className="preview-side" />
<div className="preview-content" />
</div>
</>
)}
</div>
<span className="kra-layout-mode-title">{mode.title}</span>
{config.sideMode === mode.key && (
<div className="kra-layout-mode-check" style={{ backgroundColor: config.primaryColor }}>
<CheckOutlined />
</div>
)}
</div>
))}
</div>
</div>
{/* Interface Config */}
<div className="kra-setting-section">
<SectionHeader title="界面配置" />
<div className="kra-setting-card">
<SettingItem label="显示标签页" description="页面标签导航">
<Switch checked={config.showTabs} onChange={setShowTabs} />
</SettingItem>
<SettingItem label="页面切换动画" description="页面过渡效果">
<Select
value={config.transitionType}
onChange={(value: TransitionType) => setTransitionType(value)}
style={{ width: 120 }}
options={[
{ value: 'fade', label: '淡入淡出' },
{ value: 'slide', label: '滑动' },
{ value: 'zoom', label: '缩放' },
{ value: 'none', label: '无动画' },
]}
/>
</SettingItem>
</div>
</div>
{/* Size Config */}
<div className="kra-setting-section">
<SectionHeader title="尺寸配置" />
<div className="kra-setting-card">
<div className="kra-size-config-item">
<div className="kra-size-config-info">
<h4></h4>
<p></p>
</div>
<div className="kra-size-config-control">
<InputNumber
value={config.layoutSideWidth}
min={150}
max={400}
step={10}
onChange={(value) => value && setLayoutSideWidth(value)}
/>
<span className="unit">px</span>
</div>
</div>
<div className="kra-size-config-item">
<div className="kra-size-config-info">
<h4></h4>
<p></p>
</div>
<div className="kra-size-config-control">
<InputNumber
value={config.layoutSideCollapsedWidth}
min={60}
max={100}
onChange={(value) => value && setLayoutSideCollapsedWidth(value)}
/>
<span className="unit">px</span>
</div>
</div>
</div>
</div>
</div>
);
};
// General Settings Tab
const GeneralSettings: React.FC = () => {
const { config, resetConfig, updateConfig } = useAppStore();
const [browserInfo, setBrowserInfo] = useState('');
const [screenResolution, setScreenResolution] = useState('');
useEffect(() => {
const userAgent = navigator.userAgent;
if (userAgent.includes('Chrome')) {
setBrowserInfo('Chrome');
} else if (userAgent.includes('Firefox')) {
setBrowserInfo('Firefox');
} else if (userAgent.includes('Safari')) {
setBrowserInfo('Safari');
} else if (userAgent.includes('Edge')) {
setBrowserInfo('Edge');
} else {
setBrowserInfo('Unknown');
}
setScreenResolution(`${screen.width}×${screen.height}`);
}, []);
const handleResetConfig = () => {
Modal.confirm({
title: '重置配置',
content: '确定要重置所有配置吗?此操作不可撤销。',
okText: '确定',
cancelText: '取消',
onOk: () => {
resetConfig();
message.success('配置已重置');
},
});
};
const handleExportConfig = () => {
const configData = JSON.stringify(config, null, 2);
const blob = new Blob([configData], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `kra-config-${new Date().toISOString().split('T')[0]}.json`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
message.success('配置已导出');
};
const handleImportConfig = (file: File) => {
const reader = new FileReader();
reader.onload = (e) => {
try {
const importedConfig = JSON.parse(e.target?.result as string);
updateConfig(importedConfig);
message.success('配置已导入');
} catch {
message.error('配置文件格式错误');
}
};
reader.readAsText(file);
return false;
};
return (
<div className="kra-setting-content">
{/* System Info */}
<div className="kra-setting-section">
<SectionHeader title="系统信息" />
<div className="kra-setting-card">
<div className="kra-system-info-grid">
<div className="kra-system-info-item">
<span className="label"></span>
<span className="value">v1.0.0</span>
</div>
<div className="kra-system-info-item">
<span className="label"></span>
<span className="value">React 19</span>
</div>
<div className="kra-system-info-item">
<span className="label">UI </span>
<span className="value">Ant Design</span>
</div>
<div className="kra-system-info-item">
<span className="label"></span>
<span className="value">UMI</span>
</div>
<div className="kra-system-info-item">
<span className="label"></span>
<span className="value">{browserInfo}</span>
</div>
<div className="kra-system-info-item">
<span className="label"></span>
<span className="value">{screenResolution}</span>
</div>
</div>
</div>
</div>
{/* Config Management */}
<div className="kra-setting-section">
<SectionHeader title="配置管理" />
<div className="kra-setting-card">
<div className="kra-config-action-item">
<div className="kra-config-action-icon danger">
<ReloadOutlined />
</div>
<div className="kra-config-action-info">
<h4></h4>
<p></p>
</div>
<Button danger onClick={handleResetConfig}>
</Button>
</div>
<div className="kra-config-action-item">
<div className="kra-config-action-icon primary">
<ExportOutlined />
</div>
<div className="kra-config-action-info">
<h4></h4>
<p> JSON </p>
</div>
<Button type="primary" onClick={handleExportConfig}>
</Button>
</div>
<div className="kra-config-action-item">
<div className="kra-config-action-icon success">
<ImportOutlined />
</div>
<div className="kra-config-action-info">
<h4></h4>
<p> JSON </p>
</div>
<Upload
accept=".json"
showUploadList={false}
beforeUpload={handleImportConfig}
>
<Button style={{ backgroundColor: '#52c41a', borderColor: '#52c41a', color: '#fff' }}>
</Button>
</Upload>
</div>
</div>
</div>
{/* About Project */}
<div className="kra-setting-section">
<SectionHeader title="关于项目" />
<div className="kra-setting-card">
<div className="kra-about-project">
<div className="kra-about-logo">
<Logo size={48} />
</div>
<div className="kra-about-info">
<h4>Kratos Admin</h4>
<p> React 19 + Go Kratos </p>
<div className="kra-about-links">
<a href="https://github.com/go-kratos/kratos" target="_blank" rel="noopener noreferrer">
<GithubOutlined /> GitHub
</a>
<span className="divider">·</span>
<a href="https://go-kratos.dev/" target="_blank" rel="noopener noreferrer">
<BookOutlined />
</a>
</div>
</div>
</div>
</div>
</div>
</div>
);
};
// Main Setting Drawer Component
const SettingDrawer: React.FC<SettingDrawerProps> = ({ open, onClose }) => {
const { config, resetConfig } = useAppStore();
const tabItems = [
{
key: 'appearance',
label: '外观',
children: <AppearanceSettings />,
},
{
key: 'layout',
label: '布局',
children: <LayoutSettings />,
},
{
key: 'general',
label: '通用',
children: <GeneralSettings />,
},
];
return (
<Drawer
title={
<div className="kra-setting-drawer-header">
<span></span>
<Button
type="primary"
size="small"
onClick={resetConfig}
style={{ backgroundColor: config.primaryColor, borderColor: config.primaryColor }}
>
</Button>
</div>
}
placement="right"
width={500}
open={open}
onClose={onClose}
className="kra-setting-drawer"
>
<Tabs
defaultActiveKey="appearance"
items={tabItems}
centered
className="kra-setting-tabs"
/>
</Drawer>
);
};
export default SettingDrawer;

View File

@ -0,0 +1,243 @@
/**
* KRA - Sidebar Component
* Collapsible sidebar menu with multi-level navigation
*/
import React, { useState, useEffect, useMemo } from 'react';
import { Menu, Layout } from 'antd';
import type { MenuProps } from 'antd';
import { useModel, history, useLocation } from '@umijs/max';
import * as Icons from '@ant-design/icons';
import { useCollapsed, useConfig, useAsyncRouters } from '@/models';
const { Sider } = Layout;
interface MenuItem {
ID?: number;
name: string;
path: string;
hidden?: boolean;
component?: string;
meta?: {
title: string;
icon?: string;
keepAlive?: boolean;
defaultMenu?: boolean;
closeTab?: boolean;
activeName?: string;
};
children?: MenuItem[];
parameters?: Array<{ type: string; key: string; value: string }>;
}
// Get icon component by name
const getIcon = (iconName?: string): React.ReactNode => {
if (!iconName) return null;
const IconComponent = (Icons as any)[iconName] || (Icons as any)[`${iconName}Outlined`];
return IconComponent ? <IconComponent /> : null;
};
// Convert menu data to Ant Design menu items
const convertToMenuItems = (menus: MenuItem[]): MenuProps['items'] => {
return menus
.filter((item) => !item.hidden)
.map((item) => {
const hasChildren = item.children && item.children.filter((c) => !c.hidden).length > 0;
if (hasChildren) {
return {
key: item.name,
icon: getIcon(item.meta?.icon),
label: item.meta?.title || item.name,
children: convertToMenuItems(item.children!),
};
}
return {
key: item.name,
icon: getIcon(item.meta?.icon),
label: item.meta?.title || item.name,
};
});
};
interface SidebarProps {
/** Custom width */
width?: number;
/** Collapsed width */
collapsedWidth?: number;
/** Theme */
theme?: 'light' | 'dark';
/** Show logo */
showLogo?: boolean;
}
const Sidebar: React.FC<SidebarProps> = ({
width,
collapsedWidth,
theme = 'dark',
showLogo = true,
}) => {
const location = useLocation();
const collapsed = useCollapsed();
const config = useConfig();
const asyncRouters = useAsyncRouters();
const { routeMap } = useModel('router');
const [selectedKeys, setSelectedKeys] = useState<string[]>([]);
const [openKeys, setOpenKeys] = useState<string[]>([]);
// Get menu items from async routers
const menuItems = useMemo(() => {
const rootMenu = asyncRouters?.[0];
if (!rootMenu?.children) return [];
return convertToMenuItems(rootMenu.children);
}, [asyncRouters]);
// Find parent keys for a menu item
const findParentKeys = (name: string, menus: MenuItem[], parents: string[] = []): string[] => {
for (const menu of menus) {
if (menu.name === name) {
return parents;
}
if (menu.children) {
const found = findParentKeys(name, menu.children, [...parents, menu.name]);
if (found.length > 0 || menu.children.some((c) => c.name === name)) {
return found.length > 0 ? found : [...parents, menu.name];
}
}
}
return [];
};
// Update selected keys based on current route
useEffect(() => {
const pathname = location.pathname;
// Find the menu item that matches the current path
const findMenuByPath = (menus: MenuItem[]): MenuItem | null => {
for (const menu of menus) {
if (menu.path === pathname || `/${menu.path}` === pathname) {
return menu;
}
if (menu.children) {
const found = findMenuByPath(menu.children);
if (found) return found;
}
}
return null;
};
const rootMenu = asyncRouters?.[0];
if (rootMenu?.children) {
const currentMenu = findMenuByPath(rootMenu.children);
if (currentMenu) {
setSelectedKeys([currentMenu.name]);
const parentKeys = findParentKeys(currentMenu.name, rootMenu.children);
if (parentKeys.length > 0 && !collapsed) {
setOpenKeys(parentKeys);
}
}
}
}, [location.pathname, asyncRouters, collapsed]);
// Handle menu click
const handleMenuClick: MenuProps['onClick'] = ({ key }) => {
const menuItem = routeMap?.[key];
if (!menuItem) {
history.push(`/${key}`);
return;
}
// Handle external links
if (key.startsWith('http://') || key.startsWith('https://')) {
window.open(key, '_blank');
return;
}
// Build query and params from menu parameters
const query: Record<string, string> = {};
const params: Record<string, string> = {};
menuItem.parameters?.forEach((param: any) => {
if (param.type === 'query') {
query[param.key] = param.value;
} else {
params[param.key] = param.value;
}
});
history.push({
pathname: menuItem.path?.startsWith('/') ? menuItem.path : `/${menuItem.path}`,
query,
});
};
// Handle open keys change
const handleOpenChange = (keys: string[]) => {
setOpenKeys(keys);
};
const siderWidth = width || config?.sideWidth || 256;
const siderCollapsedWidth = collapsedWidth || config?.sideCollapsedWidth || 80;
return (
<Sider
collapsible
collapsed={collapsed}
width={siderWidth}
collapsedWidth={siderCollapsedWidth}
theme={theme}
trigger={null}
style={{
overflow: 'auto',
height: '100vh',
position: 'fixed',
left: 0,
top: 0,
bottom: 0,
}}
>
{showLogo && (
<div
style={{
height: 64,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: collapsed ? '0 8px' : '0 16px',
borderBottom: '1px solid rgba(255,255,255,0.1)',
}}
>
<img
src="/logo.svg"
alt="KRA"
style={{ height: 32, marginRight: collapsed ? 0 : 8 }}
/>
{!collapsed && (
<span
style={{
color: '#fff',
fontSize: 18,
fontWeight: 600,
whiteSpace: 'nowrap',
}}
>
Kratos Admin
</span>
)}
</div>
)}
<Menu
theme={theme}
mode="inline"
selectedKeys={selectedKeys}
openKeys={collapsed ? [] : openKeys}
onOpenChange={handleOpenChange}
onClick={handleMenuClick}
items={menuItems}
style={{ borderRight: 0 }}
/>
</Sider>
);
};
export default Sidebar;

View File

@ -0,0 +1,109 @@
/**
* KRA - Tabs Component
* Tab-based page management
*/
import React, { useState, useEffect } from 'react';
import { Tabs as AntTabs, Dropdown } from 'antd';
import type { MenuProps } from 'antd';
import { useLocation, history } from '@umijs/max';
import { CloseOutlined, ReloadOutlined, CloseCircleOutlined } from '@ant-design/icons';
interface TabItem {
key: string;
label: string;
closable?: boolean;
}
const Tabs: React.FC = () => {
const location = useLocation();
const [activeKey, setActiveKey] = useState(location.pathname);
const [tabs, setTabs] = useState<TabItem[]>([
{ key: '/dashboard', label: '首页', closable: false },
]);
useEffect(() => {
const path = location.pathname;
setActiveKey(path);
// Add new tab if not exists
if (!tabs.find((tab) => tab.key === path) && path !== '/') {
const title = document.title?.replace(' - Kratos Admin', '') || path;
setTabs((prev) => [...prev, { key: path, label: title, closable: true }]);
}
}, [location.pathname]);
const handleTabChange = (key: string) => {
setActiveKey(key);
history.push(key);
};
const handleTabEdit = (targetKey: string | React.MouseEvent | React.KeyboardEvent, action: 'add' | 'remove') => {
if (action === 'remove' && typeof targetKey === 'string') {
const newTabs = tabs.filter((tab) => tab.key !== targetKey);
setTabs(newTabs);
// Navigate to previous tab if current tab is closed
if (targetKey === activeKey && newTabs.length > 0) {
const lastTab = newTabs[newTabs.length - 1];
setActiveKey(lastTab.key);
history.push(lastTab.key);
}
}
};
const closeOthers = () => {
const newTabs = tabs.filter((tab) => !tab.closable || tab.key === activeKey);
setTabs(newTabs);
};
const closeAll = () => {
const newTabs = tabs.filter((tab) => !tab.closable);
setTabs(newTabs);
if (newTabs.length > 0) {
setActiveKey(newTabs[0].key);
history.push(newTabs[0].key);
}
};
const contextMenuItems: MenuProps['items'] = [
{ key: 'reload', icon: <ReloadOutlined />, label: '刷新当前' },
{ key: 'closeOthers', icon: <CloseCircleOutlined />, label: '关闭其他' },
{ key: 'closeAll', icon: <CloseOutlined />, label: '关闭所有' },
];
const handleContextMenu: MenuProps['onClick'] = ({ key }) => {
switch (key) {
case 'reload':
history.push(activeKey);
break;
case 'closeOthers':
closeOthers();
break;
case 'closeAll':
closeAll();
break;
}
};
return (
<Dropdown menu={{ items: contextMenuItems, onClick: handleContextMenu }} trigger={['contextMenu']}>
<div style={{ padding: '0 16px', background: '#fff', borderBottom: '1px solid #f0f0f0' }}>
<AntTabs
type="editable-card"
hideAdd
activeKey={activeKey}
onChange={handleTabChange}
onEdit={handleTabEdit}
items={tabs.map((tab) => ({
key: tab.key,
label: tab.label,
closable: tab.closable,
}))}
size="small"
/>
</div>
</Dropdown>
);
};
export default Tabs;

View File

@ -0,0 +1,9 @@
/**
* KRA - Layout Components Index
*/
export { default as HeaderTools } from './HeaderTools';
export { default as Screenfull } from './Screenfull';
export { default as Search } from './Search';
export { default as SettingDrawer } from './SettingDrawer';
export { default as Sidebar } from './Sidebar';
export { default as Tabs } from './Tabs';

6
web/src/layouts/index.ts Normal file
View File

@ -0,0 +1,6 @@
/**
* KRA - Layouts Export
*/
export { default as BasicLayout } from './BasicLayout';
export { default as UserLayout } from './UserLayout';
export { default as BlankLayout } from './BlankLayout';

View File

@ -0,0 +1,295 @@
/**
* KRA - Combination Mode Layout
* Combined top + side navigation layout
*/
import React, { useState, useEffect, useMemo } from 'react';
import { Layout, Menu } from 'antd';
import type { MenuProps } from 'antd';
import { MenuFoldOutlined, MenuUnfoldOutlined } from '@ant-design/icons';
import { useModel, history, useLocation } from '@umijs/max';
import * as Icons from '@ant-design/icons';
import { useCollapsed, useConfig, useAsyncRouters, useTopMenu, useLeftMenu, useAppStore } from '@/models';
const { Sider } = Layout;
interface MenuItem {
name: string;
path: string;
hidden?: boolean;
meta?: {
title: string;
icon?: string;
activeName?: string;
};
children?: MenuItem[];
parameters?: Array<{ type: string; key: string; value: string }>;
}
// Get icon component by name
const getIcon = (iconName?: string): React.ReactNode => {
if (!iconName) return null;
const IconComponent = (Icons as any)[iconName] || (Icons as any)[`${iconName}Outlined`];
return IconComponent ? <IconComponent /> : null;
};
// Convert menu data to Ant Design menu items (top level only for horizontal)
const convertToTopMenuItems = (menus: MenuItem[]): MenuProps['items'] => {
return menus
.filter((item) => !item.hidden)
.map((item) => ({
key: item.name,
icon: getIcon(item.meta?.icon),
label: item.meta?.title || item.name,
}));
};
// Convert menu data to Ant Design menu items (with children for sidebar)
const convertToSideMenuItems = (menus: MenuItem[]): MenuProps['items'] => {
return menus
.filter((item) => !item.hidden)
.map((item) => {
const hasChildren = item.children && item.children.filter((c) => !c.hidden).length > 0;
if (hasChildren) {
return {
key: item.name,
icon: getIcon(item.meta?.icon),
label: item.meta?.title || item.name,
children: convertToSideMenuItems(item.children!),
};
}
return {
key: item.name,
icon: getIcon(item.meta?.icon),
label: item.meta?.title || item.name,
};
});
};
interface CombinationModeProps {
/** Which part to render: 'head' for top menu, 'normal' for side menu */
mode: 'head' | 'normal';
/** Theme */
theme?: 'light' | 'dark';
}
const CombinationMode: React.FC<CombinationModeProps> = ({ mode, theme = 'dark' }) => {
const location = useLocation();
const collapsed = useCollapsed();
const config = useConfig();
const asyncRouters = useAsyncRouters();
const topMenu = useTopMenu();
const leftMenu = useLeftMenu();
const { setCollapsed, setLeftMenu, setTopActive } = useAppStore();
const { routeMap, topActive } = useModel('router');
const [selectedKeys, setSelectedKeys] = useState<string[]>([]);
const [openKeys, setOpenKeys] = useState<string[]>([]);
// Get top menu items
const topMenuItems = useMemo(() => {
if (!topMenu?.length) {
const rootMenu = asyncRouters?.[0];
if (!rootMenu?.children) return [];
return convertToTopMenuItems(rootMenu.children);
}
return convertToTopMenuItems(topMenu);
}, [topMenu, asyncRouters]);
// Get side menu items
const sideMenuItems = useMemo(() => {
if (!leftMenu?.length) return [];
return convertToSideMenuItems(leftMenu);
}, [leftMenu]);
// Find parent keys for a menu item
const findParentKeys = (name: string, menus: MenuItem[], parents: string[] = []): string[] => {
for (const menu of menus) {
if (menu.name === name) {
return parents;
}
if (menu.children) {
const found = findParentKeys(name, menu.children, [...parents, menu.name]);
if (found.length > 0 || menu.children.some((c) => c.name === name)) {
return found.length > 0 ? found : [...parents, menu.name];
}
}
}
return [];
};
// Update selected keys based on current route
useEffect(() => {
const pathname = location.pathname;
const findMenuByPath = (menus: MenuItem[]): MenuItem | null => {
for (const menu of menus) {
if (menu.path === pathname || `/${menu.path}` === pathname) {
return menu;
}
if (menu.children) {
const found = findMenuByPath(menu.children);
if (found) return found;
}
}
return null;
};
if (leftMenu?.length) {
const currentMenu = findMenuByPath(leftMenu);
if (currentMenu) {
setSelectedKeys([currentMenu.name]);
const parentKeys = findParentKeys(currentMenu.name, leftMenu);
if (parentKeys.length > 0 && !collapsed) {
setOpenKeys(parentKeys);
}
}
}
}, [location.pathname, leftMenu, collapsed]);
// Handle top menu click
const handleTopMenuClick: MenuProps['onClick'] = ({ key }) => {
setTopActive?.(key);
// Find the menu item and set left menu
const rootMenu = asyncRouters?.[0];
const topMenuItem = rootMenu?.children?.find((item: MenuItem) => item.name === key);
if (topMenuItem?.children?.length) {
setLeftMenu?.(topMenuItem.children);
// Navigate to first visible child
const firstChild = topMenuItem.children.find((c: MenuItem) => !c.hidden);
if (firstChild) {
navigateToMenu(firstChild.name);
}
} else {
setLeftMenu?.([]);
navigateToMenu(key);
}
};
// Handle side menu click
const handleSideMenuClick: MenuProps['onClick'] = ({ key }) => {
navigateToMenu(key);
};
// Navigate to menu
const navigateToMenu = (key: string) => {
const menuItem = routeMap?.[key];
if (!menuItem) {
history.push(`/${key}`);
return;
}
if (key.startsWith('http://') || key.startsWith('https://')) {
window.open(key, '_blank');
return;
}
const query: Record<string, string> = {};
const params: Record<string, string> = {};
menuItem.parameters?.forEach((param: any) => {
if (param.type === 'query') {
query[param.key] = param.value;
} else {
params[param.key] = param.value;
}
});
history.push({
pathname: menuItem.path?.startsWith('/') ? menuItem.path : `/${menuItem.path}`,
query,
});
};
const handleOpenChange = (keys: string[]) => {
setOpenKeys(keys);
};
const toggleCollapse = () => {
setCollapsed(!collapsed);
};
const siderWidth = config?.sideWidth || 256;
const siderCollapsedWidth = config?.sideCollapsedWidth || 80;
// Render top menu (horizontal)
if (mode === 'head') {
return (
<div
style={{
flex: 1,
overflow: 'auto',
display: 'flex',
alignItems: 'center',
}}
>
<Menu
theme={theme === 'dark' ? 'dark' : 'light'}
mode="horizontal"
selectedKeys={topActive ? [topActive] : []}
onClick={handleTopMenuClick}
items={topMenuItems}
style={{
flex: 1,
border: 'none',
backgroundColor: 'transparent',
lineHeight: '62px',
}}
/>
</div>
);
}
// Render side menu (vertical)
return (
<Sider
collapsible
collapsed={collapsed}
width={siderWidth}
collapsedWidth={siderCollapsedWidth}
theme={theme}
trigger={null}
style={{
overflow: 'auto',
height: '100%',
position: 'relative',
}}
>
<Menu
theme={theme}
mode="inline"
selectedKeys={selectedKeys}
openKeys={collapsed ? [] : openKeys}
onOpenChange={handleOpenChange}
onClick={handleSideMenuClick}
items={sideMenuItems}
style={{ borderRight: 0, height: 'calc(100% - 48px)' }}
/>
{/* Collapse Toggle Button */}
<div
onClick={toggleCollapse}
style={{
position: 'absolute',
bottom: 16,
left: '50%',
transform: 'translateX(-50%)',
width: 32,
height: 32,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: theme === 'dark' ? 'rgba(255,255,255,0.1)' : '#f5f5f5',
borderRadius: 4,
cursor: 'pointer',
color: theme === 'dark' ? '#fff' : '#333',
}}
>
{collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
</div>
</Sider>
);
};
export default CombinationMode;

View File

@ -0,0 +1,155 @@
/**
* KRA - Head Mode Layout
* Top navigation layout with horizontal menu
*/
import React, { useState, useEffect, useMemo } from 'react';
import { Menu } from 'antd';
import type { MenuProps } from 'antd';
import { useModel, history, useLocation } from '@umijs/max';
import * as Icons from '@ant-design/icons';
import { useAsyncRouters } from '@/models';
interface MenuItem {
name: string;
path: string;
hidden?: boolean;
meta?: {
title: string;
icon?: string;
activeName?: string;
};
children?: MenuItem[];
parameters?: Array<{ type: string; key: string; value: string }>;
}
// Get icon component by name
const getIcon = (iconName?: string): React.ReactNode => {
if (!iconName) return null;
const IconComponent = (Icons as any)[iconName] || (Icons as any)[`${iconName}Outlined`];
return IconComponent ? <IconComponent /> : null;
};
// Convert menu data to Ant Design menu items
const convertToMenuItems = (menus: MenuItem[]): MenuProps['items'] => {
return menus
.filter((item) => !item.hidden)
.map((item) => {
const hasChildren = item.children && item.children.filter((c) => !c.hidden).length > 0;
if (hasChildren) {
return {
key: item.name,
icon: getIcon(item.meta?.icon),
label: item.meta?.title || item.name,
children: convertToMenuItems(item.children!),
};
}
return {
key: item.name,
icon: getIcon(item.meta?.icon),
label: item.meta?.title || item.name,
};
});
};
interface HeadModeProps {
/** Theme */
theme?: 'light' | 'dark';
}
const HeadMode: React.FC<HeadModeProps> = ({ theme = 'light' }) => {
const location = useLocation();
const asyncRouters = useAsyncRouters();
const { routeMap } = useModel('router');
const [selectedKeys, setSelectedKeys] = useState<string[]>([]);
// Get menu items from async routers
const menuItems = useMemo(() => {
const rootMenu = asyncRouters?.[0];
if (!rootMenu?.children) return [];
return convertToMenuItems(rootMenu.children);
}, [asyncRouters]);
// Update selected keys based on current route
useEffect(() => {
const pathname = location.pathname;
const findMenuByPath = (menus: MenuItem[]): MenuItem | null => {
for (const menu of menus) {
if (menu.path === pathname || `/${menu.path}` === pathname) {
return menu;
}
if (menu.children) {
const found = findMenuByPath(menu.children);
if (found) return found;
}
}
return null;
};
const rootMenu = asyncRouters?.[0];
if (rootMenu?.children) {
const currentMenu = findMenuByPath(rootMenu.children);
if (currentMenu) {
setSelectedKeys([currentMenu.name]);
}
}
}, [location.pathname, asyncRouters]);
// Handle menu click
const handleMenuClick: MenuProps['onClick'] = ({ key }) => {
const menuItem = routeMap?.[key];
if (!menuItem) {
history.push(`/${key}`);
return;
}
if (key.startsWith('http://') || key.startsWith('https://')) {
window.open(key, '_blank');
return;
}
const query: Record<string, string> = {};
const params: Record<string, string> = {};
menuItem.parameters?.forEach((param: any) => {
if (param.type === 'query') {
query[param.key] = param.value;
} else {
params[param.key] = param.value;
}
});
history.push({
pathname: menuItem.path?.startsWith('/') ? menuItem.path : `/${menuItem.path}`,
query,
});
};
return (
<div
style={{
flex: 1,
overflow: 'auto',
display: 'flex',
alignItems: 'center',
}}
>
<Menu
theme={theme}
mode="horizontal"
selectedKeys={selectedKeys}
onClick={handleMenuClick}
items={menuItems}
style={{
flex: 1,
border: 'none',
backgroundColor: 'transparent',
lineHeight: '62px',
}}
/>
</div>
);
};
export default HeadMode;

View File

@ -0,0 +1,217 @@
/**
* KRA - Normal Mode Layout
* Standard sidebar layout with collapsible menu
*/
import React, { useState, useEffect, useMemo } from 'react';
import { Layout, Menu } from 'antd';
import type { MenuProps } from 'antd';
import { MenuFoldOutlined, MenuUnfoldOutlined } from '@ant-design/icons';
import { useModel, history, useLocation } from '@umijs/max';
import * as Icons from '@ant-design/icons';
import { useCollapsed, useConfig, useAsyncRouters, useAppStore } from '@/models';
const { Sider } = Layout;
interface MenuItem {
name: string;
path: string;
hidden?: boolean;
meta?: {
title: string;
icon?: string;
activeName?: string;
};
children?: MenuItem[];
parameters?: Array<{ type: string; key: string; value: string }>;
}
// Get icon component by name
const getIcon = (iconName?: string): React.ReactNode => {
if (!iconName) return null;
const IconComponent = (Icons as any)[iconName] || (Icons as any)[`${iconName}Outlined`];
return IconComponent ? <IconComponent /> : null;
};
// Convert menu data to Ant Design menu items
const convertToMenuItems = (menus: MenuItem[]): MenuProps['items'] => {
return menus
.filter((item) => !item.hidden)
.map((item) => {
const hasChildren = item.children && item.children.filter((c) => !c.hidden).length > 0;
if (hasChildren) {
return {
key: item.name,
icon: getIcon(item.meta?.icon),
label: item.meta?.title || item.name,
children: convertToMenuItems(item.children!),
};
}
return {
key: item.name,
icon: getIcon(item.meta?.icon),
label: item.meta?.title || item.name,
};
});
};
interface NormalModeProps {
/** Theme */
theme?: 'light' | 'dark';
}
const NormalMode: React.FC<NormalModeProps> = ({ theme = 'dark' }) => {
const location = useLocation();
const collapsed = useCollapsed();
const config = useConfig();
const asyncRouters = useAsyncRouters();
const { setCollapsed } = useAppStore();
const { routeMap } = useModel('router');
const [selectedKeys, setSelectedKeys] = useState<string[]>([]);
const [openKeys, setOpenKeys] = useState<string[]>([]);
// Get menu items from async routers
const menuItems = useMemo(() => {
const rootMenu = asyncRouters?.[0];
if (!rootMenu?.children) return [];
return convertToMenuItems(rootMenu.children);
}, [asyncRouters]);
// Find parent keys for a menu item
const findParentKeys = (name: string, menus: MenuItem[], parents: string[] = []): string[] => {
for (const menu of menus) {
if (menu.name === name) {
return parents;
}
if (menu.children) {
const found = findParentKeys(name, menu.children, [...parents, menu.name]);
if (found.length > 0 || menu.children.some((c) => c.name === name)) {
return found.length > 0 ? found : [...parents, menu.name];
}
}
}
return [];
};
// Update selected keys based on current route
useEffect(() => {
const pathname = location.pathname;
const findMenuByPath = (menus: MenuItem[]): MenuItem | null => {
for (const menu of menus) {
if (menu.path === pathname || `/${menu.path}` === pathname) {
return menu;
}
if (menu.children) {
const found = findMenuByPath(menu.children);
if (found) return found;
}
}
return null;
};
const rootMenu = asyncRouters?.[0];
if (rootMenu?.children) {
const currentMenu = findMenuByPath(rootMenu.children);
if (currentMenu) {
setSelectedKeys([currentMenu.name]);
const parentKeys = findParentKeys(currentMenu.name, rootMenu.children);
if (parentKeys.length > 0 && !collapsed) {
setOpenKeys(parentKeys);
}
}
}
}, [location.pathname, asyncRouters, collapsed]);
// Handle menu click
const handleMenuClick: MenuProps['onClick'] = ({ key }) => {
const menuItem = routeMap?.[key];
if (!menuItem) {
history.push(`/${key}`);
return;
}
if (key.startsWith('http://') || key.startsWith('https://')) {
window.open(key, '_blank');
return;
}
const query: Record<string, string> = {};
const params: Record<string, string> = {};
menuItem.parameters?.forEach((param: any) => {
if (param.type === 'query') {
query[param.key] = param.value;
} else {
params[param.key] = param.value;
}
});
history.push({
pathname: menuItem.path?.startsWith('/') ? menuItem.path : `/${menuItem.path}`,
query,
});
};
const handleOpenChange = (keys: string[]) => {
setOpenKeys(keys);
};
const toggleCollapse = () => {
setCollapsed(!collapsed);
};
const siderWidth = config?.sideWidth || 256;
const siderCollapsedWidth = config?.sideCollapsedWidth || 80;
return (
<Sider
collapsible
collapsed={collapsed}
width={siderWidth}
collapsedWidth={siderCollapsedWidth}
theme={theme}
trigger={null}
style={{
overflow: 'auto',
height: '100%',
position: 'relative',
}}
>
<Menu
theme={theme}
mode="inline"
selectedKeys={selectedKeys}
openKeys={collapsed ? [] : openKeys}
onOpenChange={handleOpenChange}
onClick={handleMenuClick}
items={menuItems}
style={{ borderRight: 0, height: 'calc(100% - 48px)' }}
/>
{/* Collapse Toggle Button */}
<div
onClick={toggleCollapse}
style={{
position: 'absolute',
bottom: 16,
left: '50%',
transform: 'translateX(-50%)',
width: 32,
height: 32,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: theme === 'dark' ? 'rgba(255,255,255,0.1)' : '#f5f5f5',
borderRadius: 4,
cursor: 'pointer',
color: theme === 'dark' ? '#fff' : '#333',
}}
>
{collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
</div>
</Sider>
);
};
export default NormalMode;

View File

@ -0,0 +1,6 @@
/**
* KRA - Layout Modes Export
*/
export { default as NormalMode } from './NormalMode';
export { default as HeadMode } from './HeadMode';
export { default as CombinationMode } from './CombinationMode';

1
web/src/models/.gitkeep Normal file
View File

@ -0,0 +1 @@
# KRA State Management Models

228
web/src/models/app.ts Normal file
View File

@ -0,0 +1,228 @@
/**
* KRA - App Model (Zustand Store)
* Application configuration and theme management
*/
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { setBodyPrimaryColor } from '@/utils/format';
export type DarkMode = 'light' | 'dark' | 'auto';
export type SideMode = 'normal' | 'head' | 'combination';
export type TransitionType = 'slide' | 'fade' | 'zoom' | 'none';
export type GlobalSize = 'small' | 'default' | 'large';
export interface AppConfig {
weakness: boolean;
grey: boolean;
primaryColor: string;
showTabs: boolean;
darkMode: DarkMode;
layoutSideWidth: number;
layoutSideCollapsedWidth: number;
layoutSideItemHeight: number;
showWatermark: boolean;
sideMode: SideMode;
transitionType: TransitionType;
globalSize: GlobalSize;
}
interface AppState {
device: 'desktop' | 'mobile' | '';
drawerSize: string;
operateMinWidth: string;
collapsed: boolean;
isDark: boolean;
config: AppConfig;
}
interface AppActions {
setDevice: (device: 'desktop' | 'mobile') => void;
setCollapsed: (collapsed: boolean) => void;
toggleCollapsed: () => void;
setDarkMode: (mode: DarkMode) => void;
toggleDark: () => void;
setPrimaryColor: (color: string) => void;
setWeakness: (weakness: boolean) => void;
setGrey: (grey: boolean) => void;
setShowTabs: (show: boolean) => void;
setSideMode: (mode: SideMode) => void;
setTransitionType: (type: TransitionType) => void;
setGlobalSize: (size: GlobalSize) => void;
setLayoutSideWidth: (width: number) => void;
setLayoutSideCollapsedWidth: (width: number) => void;
setShowWatermark: (show: boolean) => void;
updateConfig: (config: Partial<AppConfig>) => void;
resetConfig: () => void;
applyTheme: () => void;
}
const defaultConfig: AppConfig = {
weakness: false,
grey: false,
primaryColor: '#1890ff',
showTabs: true,
darkMode: 'auto',
layoutSideWidth: 256,
layoutSideCollapsedWidth: 80,
layoutSideItemHeight: 48,
showWatermark: true,
sideMode: 'normal',
transitionType: 'slide',
globalSize: 'default',
};
export const useAppStore = create<AppState & AppActions>()(
persist(
(set, get) => ({
device: '',
drawerSize: '800',
operateMinWidth: '240',
collapsed: false,
isDark: false,
config: { ...defaultConfig },
setDevice: (device) => {
const isMobile = device === 'mobile';
set({
device,
drawerSize: isMobile ? '100%' : '800',
operateMinWidth: isMobile ? '80' : '240',
collapsed: isMobile,
});
},
setCollapsed: (collapsed) => set({ collapsed }),
toggleCollapsed: () => set((state) => ({ collapsed: !state.collapsed })),
setDarkMode: (mode) => {
set((state) => ({
config: { ...state.config, darkMode: mode },
}));
get().applyTheme();
},
toggleDark: () => {
const { config } = get();
const newMode = config.darkMode === 'dark' ? 'light' : 'dark';
set((state) => ({
config: { ...state.config, darkMode: newMode },
isDark: newMode === 'dark',
}));
get().applyTheme();
},
setPrimaryColor: (color) => {
set((state) => ({
config: { ...state.config, primaryColor: color },
}));
get().applyTheme();
},
setWeakness: (weakness) => {
set((state) => ({
config: { ...state.config, weakness },
}));
document.documentElement.classList.toggle('html-weakness', weakness);
},
setGrey: (grey) => {
set((state) => ({
config: { ...state.config, grey },
}));
document.documentElement.classList.toggle('html-grey', grey);
},
setShowTabs: (show) => {
set((state) => ({
config: { ...state.config, showTabs: show },
}));
},
setSideMode: (mode) => {
set((state) => ({
config: { ...state.config, sideMode: mode },
}));
},
setTransitionType: (type) => {
set((state) => ({
config: { ...state.config, transitionType: type },
}));
},
setGlobalSize: (size) => {
set((state) => ({
config: { ...state.config, globalSize: size },
}));
},
setLayoutSideWidth: (width) => {
set((state) => ({
config: { ...state.config, layoutSideWidth: width },
}));
},
setLayoutSideCollapsedWidth: (width) => {
set((state) => ({
config: { ...state.config, layoutSideCollapsedWidth: width },
}));
},
setShowWatermark: (show) => {
set((state) => ({
config: { ...state.config, showWatermark: show },
}));
},
updateConfig: (newConfig) => {
set((state) => ({
config: { ...state.config, ...newConfig },
}));
get().applyTheme();
},
resetConfig: () => {
set({ config: { ...defaultConfig } });
get().applyTheme();
},
applyTheme: () => {
const { config } = get();
let isDark = false;
if (config.darkMode === 'auto') {
isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
} else {
isDark = config.darkMode === 'dark';
}
set({ isDark });
// Apply dark mode class
document.documentElement.classList.toggle('dark', isDark);
// Apply primary color
setBodyPrimaryColor(config.primaryColor, isDark ? 'dark' : 'light');
// Apply weakness and grey
document.documentElement.classList.toggle('html-weakness', config.weakness);
document.documentElement.classList.toggle('html-grey', config.grey);
},
}),
{
name: 'kra-app-storage',
partialize: (state) => ({
config: state.config,
collapsed: state.collapsed,
}),
}
)
);
// Helper hooks
export const useIsDark = () => useAppStore((state) => state.isDark);
export const useConfig = () => useAppStore((state) => state.config);
export const useCollapsed = () => useAppStore((state) => state.collapsed);
export default useAppStore;

View File

@ -0,0 +1,133 @@
/**
* KRA - Dictionary Model (Zustand Store)
* Dictionary cache and lookup management
*/
import { create } from 'zustand';
import {
DictionaryItem,
filterTreeByDepth,
flattenTree,
normalizeTreeData,
findNodeByValue,
buildCacheKey,
} from '@/utils/dictionary';
interface DictionaryState {
dictionaryMap: Record<string, DictionaryItem[]>;
loading: Record<string, boolean>;
}
interface DictionaryActions {
setDictionary: (key: string, data: DictionaryItem[]) => void;
getDictionary: (
type: string,
fetchFn: (type: string) => Promise<DictionaryItem[]>,
depth?: number,
value?: any
) => Promise<DictionaryItem[]>;
getDictionarySync: (type: string, depth?: number) => DictionaryItem[];
clearDictionary: (type?: string) => void;
setLoading: (key: string, loading: boolean) => void;
}
export const useDictionaryStore = create<DictionaryState & DictionaryActions>((set, get) => ({
dictionaryMap: {},
loading: {},
setDictionary: (key, data) => {
set((state) => ({
dictionaryMap: { ...state.dictionaryMap, [key]: data },
}));
},
getDictionary: async (type, fetchFn, depth = 0, value = null) => {
const cacheKey = buildCacheKey(type, depth, value);
const { dictionaryMap, setDictionary, setLoading } = get();
// Return cached data if exists
if (dictionaryMap[cacheKey] && dictionaryMap[cacheKey].length > 0) {
return dictionaryMap[cacheKey];
}
// Set loading state
setLoading(cacheKey, true);
try {
// Fetch tree data
const treeData = await fetchFn(type);
if (!treeData || treeData.length === 0) {
setLoading(cacheKey, false);
return [];
}
let resultData: DictionaryItem[];
// If value is provided, find specific node's children
if (value !== null && value !== undefined) {
const targetNodeChildren = findNodeByValue(treeData, value, 1, depth);
if (targetNodeChildren !== null) {
if (depth === 0) {
resultData = targetNodeChildren;
} else {
resultData = flattenTree(targetNodeChildren);
}
} else {
resultData = [];
}
} else {
// No value filter
if (depth === 0) {
resultData = normalizeTreeData(treeData);
} else {
const filteredData = filterTreeByDepth(treeData, 1, depth);
resultData = flattenTree(filteredData);
}
}
setDictionary(cacheKey, resultData);
setLoading(cacheKey, false);
return resultData;
} catch (error) {
console.error('Failed to fetch dictionary:', error);
setLoading(cacheKey, false);
return [];
}
},
getDictionarySync: (type, depth = 0) => {
const cacheKey = buildCacheKey(type, depth);
const { dictionaryMap } = get();
return dictionaryMap[cacheKey] || [];
},
clearDictionary: (type) => {
if (type) {
set((state) => {
const newMap = { ...state.dictionaryMap };
Object.keys(newMap).forEach((key) => {
if (key.startsWith(type)) {
delete newMap[key];
}
});
return { dictionaryMap: newMap };
});
} else {
set({ dictionaryMap: {} });
}
},
setLoading: (key, loading) => {
set((state) => ({
loading: { ...state.loading, [key]: loading },
}));
},
}));
// Helper hooks
export const useDictionaryMap = () => useDictionaryStore((state) => state.dictionaryMap);
export const useDictionaryLoading = (key: string) =>
useDictionaryStore((state) => state.loading[key] || false);
export default useDictionaryStore;

22
web/src/models/index.ts Normal file
View File

@ -0,0 +1,22 @@
/**
* KRA - Models Index
* Export all Zustand stores
*/
export { useUserStore, useToken, useUserInfo, useIsLoggedIn } from './user';
export type { UserInfo, UserAuthority } from './user';
export { useAppStore, useIsDark, useConfig, useCollapsed } from './app';
export type { AppConfig, DarkMode, SideMode, TransitionType, GlobalSize } from './app';
export {
useRouterStore,
useAsyncRouters,
useTabs,
useActiveTab,
useTopMenu,
useLeftMenu,
} from './router';
export type { TabItem } from './router';
export { useDictionaryStore, useDictionaryMap, useDictionaryLoading } from './dictionary';

271
web/src/models/router.ts Normal file
View File

@ -0,0 +1,271 @@
/**
* KRA - Router Model (Zustand Store)
* Dynamic route and menu management
*/
import { create } from 'zustand';
import { asyncRouterHandle, RouteConfig, MenuItem } from '@/utils/asyncRouter';
export interface TabItem {
path: string;
name: string;
title: string;
icon?: string;
closable?: boolean;
}
interface RouterState {
asyncRouters: RouteConfig[];
routeMap: Record<string, RouteConfig>;
keepAliveRouters: string[];
asyncRouterFlag: number;
topMenu: MenuItem[];
leftMenu: MenuItem[];
topActive: string;
tabs: TabItem[];
activeTab: string;
}
interface RouterActions {
setAsyncRouters: (menus: MenuItem[]) => void;
setTopActive: (name: string) => void;
setLeftMenu: (name: string) => MenuItem[] | undefined;
addKeepAlive: (name: string) => void;
removeKeepAlive: (name: string) => void;
setKeepAliveRouters: (tabs: TabItem[]) => void;
addTab: (tab: TabItem) => void;
removeTab: (path: string) => void;
removeOtherTabs: (path: string) => void;
removeAllTabs: () => void;
setActiveTab: (path: string) => void;
clearRoutes: () => void;
}
const formatRouter = (
routes: MenuItem[],
routeMap: Record<string, RouteConfig>,
parent?: MenuItem
) => {
routes?.forEach((item) => {
const route: RouteConfig = {
path: item.path,
name: item.name,
component: item.component,
redirect: item.redirect,
hideInMenu: item.hidden,
meta: {
title: item.meta?.title || item.name,
icon: item.meta?.icon,
keepAlive: item.meta?.keepAlive,
defaultMenu: item.meta?.defaultMenu,
closeTab: item.meta?.closeTab,
parent: parent?.name,
},
};
routeMap[item.name] = route;
if (item.children && item.children.length > 0) {
formatRouter(item.children, routeMap, item);
}
});
};
const extractKeepAlive = (routes: MenuItem[]): string[] => {
const result: string[] = [];
const traverse = (items: MenuItem[]) => {
items?.forEach((item) => {
if (item.meta?.keepAlive || item.children?.some((ch) => ch.meta?.keepAlive)) {
result.push(item.name);
}
if (item.children && item.children.length > 0) {
traverse(item.children);
}
});
};
traverse(routes);
return result;
};
export const useRouterStore = create<RouterState & RouterActions>((set, get) => ({
asyncRouters: [],
routeMap: {},
keepAliveRouters: [],
asyncRouterFlag: 0,
topMenu: [],
leftMenu: [],
topActive: '',
tabs: [],
activeTab: '',
setAsyncRouters: (menus) => {
const routeMap: Record<string, RouteConfig> = {};
const menuMap: Record<string, MenuItem> = {};
// Add reload route
const asyncRouter = [
...menus,
{
path: 'reload',
name: 'Reload',
hidden: true,
meta: { title: '', closeTab: true },
component: 'pages/error/reload',
},
];
formatRouter(asyncRouter, routeMap);
// Build base router structure
const baseRouter: RouteConfig[] = [
{
path: '/',
name: 'layout',
component: 'layouts/BasicLayout',
meta: { title: 'KRA Admin' },
routes: asyncRouterHandle(asyncRouter),
},
];
// Extract top menu
const topMenu: MenuItem[] = [];
asyncRouter.forEach((item) => {
if (!item.hidden) {
menuMap[item.name] = item;
topMenu.push({ ...item, children: [] });
}
});
// Extract keep alive routes
const keepAliveRouters = extractKeepAlive(asyncRouter);
set({
asyncRouters: baseRouter,
routeMap,
topMenu,
keepAliveRouters,
asyncRouterFlag: get().asyncRouterFlag + 1,
});
// Set initial left menu
if (topMenu.length > 0) {
const savedTopActive = sessionStorage.getItem('topActive');
const initialTop = savedTopActive && menuMap[savedTopActive] ? savedTopActive : topMenu[0].name;
get().setLeftMenu(initialTop);
}
},
setTopActive: (name) => {
sessionStorage.setItem('topActive', name);
set({ topActive: name });
},
setLeftMenu: (name) => {
const { topMenu } = get();
const menuItem = topMenu.find((item) => item.name === name);
sessionStorage.setItem('topActive', name);
set({
topActive: name,
leftMenu: menuItem?.children || [],
});
return menuItem?.children;
},
addKeepAlive: (name) => {
set((state) => {
if (!state.keepAliveRouters.includes(name)) {
return { keepAliveRouters: [...state.keepAliveRouters, name] };
}
return state;
});
},
removeKeepAlive: (name) => {
set((state) => ({
keepAliveRouters: state.keepAliveRouters.filter((n) => n !== name),
}));
},
setKeepAliveRouters: (tabs) => {
const { routeMap, keepAliveRouters } = get();
const keepArrTemp = [...keepAliveRouters];
tabs.forEach((tab) => {
const routeInfo = routeMap[tab.name];
if (routeInfo?.meta?.keepAlive && !keepArrTemp.includes(tab.name)) {
keepArrTemp.push(tab.name);
}
});
set({ keepAliveRouters: [...new Set(keepArrTemp)] });
},
addTab: (tab) => {
set((state) => {
const exists = state.tabs.find((t) => t.path === tab.path);
if (exists) {
return { activeTab: tab.path };
}
return {
tabs: [...state.tabs, tab],
activeTab: tab.path,
};
});
},
removeTab: (path) => {
set((state) => {
const newTabs = state.tabs.filter((t) => t.path !== path);
let newActiveTab = state.activeTab;
if (state.activeTab === path && newTabs.length > 0) {
const index = state.tabs.findIndex((t) => t.path === path);
newActiveTab = newTabs[Math.min(index, newTabs.length - 1)]?.path || '';
}
return { tabs: newTabs, activeTab: newActiveTab };
});
},
removeOtherTabs: (path) => {
set((state) => ({
tabs: state.tabs.filter((t) => t.path === path || !t.closable),
activeTab: path,
}));
},
removeAllTabs: () => {
set((state) => ({
tabs: state.tabs.filter((t) => !t.closable),
activeTab: state.tabs.find((t) => !t.closable)?.path || '',
}));
},
setActiveTab: (path) => {
set({ activeTab: path });
},
clearRoutes: () => {
set({
asyncRouters: [],
routeMap: {},
keepAliveRouters: [],
topMenu: [],
leftMenu: [],
topActive: '',
tabs: [],
activeTab: '',
});
},
}));
// Helper hooks
export const useAsyncRouters = () => useRouterStore((state) => state.asyncRouters);
export const useTabs = () => useRouterStore((state) => state.tabs);
export const useActiveTab = () => useRouterStore((state) => state.activeTab);
export const useTopMenu = () => useRouterStore((state) => state.topMenu);
export const useLeftMenu = () => useRouterStore((state) => state.leftMenu);
export default useRouterStore;

106
web/src/models/user.ts Normal file
View File

@ -0,0 +1,106 @@
/**
* KRA - User Model (Zustand Store)
* User authentication and info management
*/
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { clearAuth, setToken as saveToken, setOsType } from '@/utils/auth';
export interface UserAuthority {
authorityId: string | number;
authorityName: string;
defaultRouter?: string;
}
export interface UserInfo {
uuid: string;
userName: string;
nickName: string;
headerImg: string;
authority: UserAuthority;
authorities?: UserAuthority[];
sideMode?: string;
activeColor?: string;
baseColor?: string;
originSetting?: Record<string, any>;
}
interface UserState {
userInfo: UserInfo;
token: string;
loading: boolean;
}
interface UserActions {
setUserInfo: (info: Partial<UserInfo>) => void;
setToken: (token: string) => void;
setLoading: (loading: boolean) => void;
resetUserInfo: (value?: Partial<UserInfo>) => void;
clearUser: () => void;
}
const initialUserInfo: UserInfo = {
uuid: '',
userName: '',
nickName: '',
headerImg: '',
authority: {
authorityId: '',
authorityName: '',
defaultRouter: '',
},
authorities: [],
};
export const useUserStore = create<UserState & UserActions>()(
persist(
(set, get) => ({
userInfo: initialUserInfo,
token: '',
loading: false,
setUserInfo: (info) => {
set((state) => ({
userInfo: { ...state.userInfo, ...info },
}));
},
setToken: (token) => {
saveToken(token);
set({ token });
},
setLoading: (loading) => {
set({ loading });
},
resetUserInfo: (value = {}) => {
set((state) => ({
userInfo: { ...state.userInfo, ...value },
}));
},
clearUser: () => {
clearAuth();
set({
userInfo: initialUserInfo,
token: '',
});
},
}),
{
name: 'kra-user-storage',
partialize: (state) => ({
token: state.token,
userInfo: state.userInfo,
}),
}
)
);
// Helper hooks
export const useToken = () => useUserStore((state) => state.token);
export const useUserInfo = () => useUserStore((state) => state.userInfo);
export const useIsLoggedIn = () => useUserStore((state) => !!state.token);
export default useUserStore;

View File

@ -0,0 +1,283 @@
/**
* KRA - About Page
* System information and credits
*/
import React from 'react';
import { Card, Row, Col, Descriptions, Tag, Typography, Space, Divider, Button } from 'antd';
import {
GithubOutlined,
BookOutlined,
TeamOutlined,
CodeOutlined,
CloudServerOutlined,
DatabaseOutlined,
ApiOutlined,
SafetyOutlined,
} from '@ant-design/icons';
import { Helmet } from '@umijs/max';
import Logo from '@/components/Logo/Logo';
import { createStyles } from 'antd-style';
const { Title, Paragraph, Text, Link } = Typography;
const useStyles = createStyles(({ token }) => ({
container: {
padding: '24px',
minHeight: '100vh',
background: '#f5f5f5',
},
headerCard: {
borderRadius: '16px',
marginBottom: '24px',
textAlign: 'center',
padding: '48px 24px',
},
logoWrapper: {
marginBottom: '24px',
},
title: {
fontSize: '32px',
fontWeight: 700,
marginBottom: '8px',
},
subtitle: {
fontSize: '16px',
color: token.colorTextSecondary,
marginBottom: '24px',
},
infoCard: {
borderRadius: '12px',
height: '100%',
},
featureItem: {
display: 'flex',
alignItems: 'flex-start',
gap: '16px',
padding: '16px 0',
borderBottom: `1px solid ${token.colorBorderSecondary}`,
'&:last-child': {
borderBottom: 'none',
},
},
featureIcon: {
width: '48px',
height: '48px',
borderRadius: '12px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '24px',
flexShrink: 0,
},
featureTitle: {
fontWeight: 600,
marginBottom: '4px',
},
featureDesc: {
color: token.colorTextSecondary,
fontSize: '13px',
},
}));
const features = [
{
icon: <CloudServerOutlined />,
title: 'Go Kratos 框架',
desc: '基于 Go Kratos 微服务框架,支持 gRPC 和 HTTP 双协议',
color: '#1890ff',
bg: '#e6f7ff',
},
{
icon: <CodeOutlined />,
title: 'React 19',
desc: '采用最新 React 19 + TypeScript提供现代化开发体验',
color: '#52c41a',
bg: '#f6ffed',
},
{
icon: <DatabaseOutlined />,
title: '多数据库支持',
desc: '支持 MySQL、PostgreSQL、SQLite、Oracle、SQL Server',
color: '#722ed1',
bg: '#f9f0ff',
},
{
icon: <ApiOutlined />,
title: 'RESTful API',
desc: '规范的 RESTful API 设计,支持 OpenAPI 文档生成',
color: '#faad14',
bg: '#fffbe6',
},
{
icon: <SafetyOutlined />,
title: 'RBAC 权限',
desc: '完善的角色权限管理支持菜单、按钮、API 级别控制',
color: '#eb2f96',
bg: '#fff0f6',
},
{
icon: <TeamOutlined />,
title: '多租户支持',
desc: '支持多租户架构,数据隔离安全可靠',
color: '#13c2c2',
bg: '#e6fffb',
},
];
const AboutPage: React.FC = () => {
const { styles } = useStyles();
return (
<div className={styles.container}>
<Helmet>
<title> - Kratos Admin</title>
</Helmet>
{/* 头部信息 */}
<Card className={styles.headerCard} bordered={false}>
<div className={styles.logoWrapper}>
<Logo size={80} />
</div>
<Title level={2} className={styles.title}>
Kratos Admin
</Title>
<Paragraph className={styles.subtitle}>
Go Kratos + React 19
</Paragraph>
<Space size="middle">
<Button
type="primary"
icon={<GithubOutlined />}
href="https://github.com/go-kratos/kratos"
target="_blank"
>
GitHub
</Button>
<Button
icon={<BookOutlined />}
href="https://go-kratos.dev/"
target="_blank"
>
</Button>
</Space>
</Card>
<Row gutter={[24, 24]}>
{/* 系统信息 */}
<Col xs={24} lg={12}>
<Card title="系统信息" className={styles.infoCard} bordered={false}>
<Descriptions column={1} labelStyle={{ width: '120px' }}>
<Descriptions.Item label="系统名称">Kratos Admin</Descriptions.Item>
<Descriptions.Item label="当前版本">
<Tag color="blue">v1.0.0</Tag>
</Descriptions.Item>
<Descriptions.Item label="后端框架">
<Space>
<Tag color="cyan">Go Kratos</Tag>
<Tag color="green">Golang</Tag>
</Space>
</Descriptions.Item>
<Descriptions.Item label="前端框架">
<Space>
<Tag color="blue">React 19</Tag>
<Tag color="purple">TypeScript</Tag>
<Tag color="orange">Ant Design</Tag>
</Space>
</Descriptions.Item>
<Descriptions.Item label="构建工具">
<Tag color="geekblue">UMI</Tag>
</Descriptions.Item>
<Descriptions.Item label="状态管理">
<Tag color="volcano">Zustand</Tag>
</Descriptions.Item>
<Descriptions.Item label="开源协议">
<Tag color="green">MIT License</Tag>
</Descriptions.Item>
</Descriptions>
</Card>
</Col>
{/* 技术栈 */}
<Col xs={24} lg={12}>
<Card title="技术栈" className={styles.infoCard} bordered={false}>
<Descriptions column={1} labelStyle={{ width: '120px' }}>
<Descriptions.Item label="后端语言">Go 1.21+</Descriptions.Item>
<Descriptions.Item label="微服务框架">Kratos v2</Descriptions.Item>
<Descriptions.Item label="ORM 框架">GORM / Ent</Descriptions.Item>
<Descriptions.Item label="缓存">Redis</Descriptions.Item>
<Descriptions.Item label="消息队列">Kafka / RabbitMQ</Descriptions.Item>
<Descriptions.Item label="服务发现">Consul / Nacos / Etcd</Descriptions.Item>
<Descriptions.Item label="链路追踪">Jaeger / Zipkin</Descriptions.Item>
</Descriptions>
</Card>
</Col>
{/* 核心特性 */}
<Col xs={24}>
<Card title="核心特性" className={styles.infoCard} bordered={false}>
<Row gutter={[24, 0]}>
{features.map((feature, index) => (
<Col xs={24} md={12} lg={8} key={index}>
<div className={styles.featureItem}>
<div
className={styles.featureIcon}
style={{ background: feature.bg, color: feature.color }}
>
{feature.icon}
</div>
<div>
<div className={styles.featureTitle}>{feature.title}</div>
<div className={styles.featureDesc}>{feature.desc}</div>
</div>
</div>
</Col>
))}
</Row>
</Card>
</Col>
{/* 鸣谢 */}
<Col xs={24}>
<Card title="鸣谢" className={styles.infoCard} bordered={false}>
<Paragraph>
Kratos Admin
</Paragraph>
<Space wrap size={[8, 8]}>
<Link href="https://go-kratos.dev/" target="_blank">
<Tag color="blue">Go Kratos</Tag>
</Link>
<Link href="https://react.dev/" target="_blank">
<Tag color="cyan">React</Tag>
</Link>
<Link href="https://ant.design/" target="_blank">
<Tag color="blue">Ant Design</Tag>
</Link>
<Link href="https://umijs.org/" target="_blank">
<Tag color="purple">UMI</Tag>
</Link>
<Link href="https://gorm.io/" target="_blank">
<Tag color="green">GORM</Tag>
</Link>
<Link href="https://redis.io/" target="_blank">
<Tag color="red">Redis</Tag>
</Link>
<Link href="https://www.typescriptlang.org/" target="_blank">
<Tag color="blue">TypeScript</Tag>
</Link>
<Link href="https://zustand-demo.pmnd.rs/" target="_blank">
<Tag color="orange">Zustand</Tag>
</Link>
</Space>
<Divider />
<Paragraph type="secondary" style={{ textAlign: 'center', marginBottom: 0 }}>
© 2024 Kratos Admin Team. All rights reserved.
</Paragraph>
</Card>
</Col>
</Row>
</div>
);
};
export default AboutPage;

View File

@ -0,0 +1,37 @@
/**
* KRA - Dashboard Banner Component
*/
import React from 'react';
import { Card } from 'antd';
import Logo from '@/components/Logo/Logo';
const Banner: React.FC = () => {
return (
<Card
className="kra-dashboard-card"
bodyStyle={{ padding: 0, height: '160px', overflow: 'hidden' }}
bordered={false}
>
<div style={{
height: '100%',
background: 'linear-gradient(135deg, #1890ff 0%, #722ed1 100%)',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
color: '#fff',
padding: '24px',
}}>
<Logo size={48} />
<h3 style={{ margin: '12px 0 4px', fontSize: '18px', fontWeight: 600 }}>
Kratos Admin
</h3>
<p style={{ margin: 0, fontSize: '12px', opacity: 0.85 }}>
</p>
</div>
</Card>
);
};
export default Banner;

View File

@ -0,0 +1,39 @@
/**
* KRA - Dashboard Charts Component
*/
import React from 'react';
import { Card } from 'antd';
import LineChart from '@/components/Charts/LineChart';
const Charts: React.FC = () => {
const chartData = {
xAxis: ['周一', '周二', '周三', '周四', '周五', '周六', '周日'],
series: [
{
name: '访问量',
data: [820, 932, 901, 934, 1290, 1330, 1320],
},
{
name: '下载量',
data: [220, 182, 191, 234, 290, 330, 310],
},
],
};
return (
<Card
title="内容数据"
className="kra-dashboard-card"
bordered={false}
bodyStyle={{ height: '320px' }}
>
<LineChart
xAxisData={chartData.xAxis}
series={chartData.series}
height="280px"
/>
</Card>
);
};
export default Charts;

View File

@ -0,0 +1,63 @@
/**
* KRA - Dashboard Notice Component
*/
import React from 'react';
import { Card, List, Tag, Typography } from 'antd';
import { NotificationOutlined } from '@ant-design/icons';
const { Text } = Typography;
interface NoticeItem {
id: number;
title: string;
type: 'info' | 'warning' | 'success';
time: string;
}
const notices: NoticeItem[] = [
{ id: 1, title: '系统将于今晚进行维护升级', type: 'warning', time: '2小时前' },
{ id: 2, title: '新版本 v1.0.0 已发布', type: 'success', time: '1天前' },
{ id: 3, title: '欢迎使用 Kratos Admin', type: 'info', time: '3天前' },
];
const typeColors = {
info: 'blue',
warning: 'orange',
success: 'green',
};
const Notice: React.FC = () => {
return (
<Card
title="公告"
className="kra-dashboard-card"
bordered={false}
extra={<a href="#"></a>}
>
<List
size="small"
dataSource={notices}
renderItem={(item) => (
<List.Item style={{ padding: '8px 0' }}>
<div style={{ width: '100%' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '4px' }}>
<NotificationOutlined style={{ color: '#999', fontSize: '12px' }} />
<Text ellipsis style={{ flex: 1, fontSize: '13px' }}>
{item.title}
</Text>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Tag color={typeColors[item.type]} style={{ margin: 0 }}>
{item.type === 'info' ? '通知' : item.type === 'warning' ? '警告' : '成功'}
</Tag>
<Text type="secondary" style={{ fontSize: '12px' }}>{item.time}</Text>
</div>
</div>
</List.Item>
)}
/>
</Card>
);
};
export default Notice;

View File

@ -0,0 +1,138 @@
/**
* KRA - Dashboard Plugin Table Component
*/
import React from 'react';
import { Card, Table, Tag, Space, Button } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { GithubOutlined, LinkOutlined } from '@ant-design/icons';
interface PluginItem {
key: string;
name: string;
description: string;
version: string;
status: 'active' | 'inactive' | 'beta';
author: string;
}
const plugins: PluginItem[] = [
{
key: '1',
name: '公告管理',
description: '系统公告发布与管理',
version: 'v1.0.0',
status: 'active',
author: 'KRA Team',
},
{
key: '2',
name: '邮件服务',
description: '邮件配置与发送服务',
version: 'v1.0.0',
status: 'active',
author: 'KRA Team',
},
{
key: '3',
name: '代码生成器',
description: '自动生成 CRUD 代码',
version: 'v0.9.0',
status: 'beta',
author: 'KRA Team',
},
{
key: '4',
name: '文件管理',
description: '文件上传与管理',
version: 'v1.0.0',
status: 'active',
author: 'KRA Team',
},
];
const statusColors = {
active: 'green',
inactive: 'default',
beta: 'orange',
};
const statusLabels = {
active: '已启用',
inactive: '未启用',
beta: '测试中',
};
const columns: ColumnsType<PluginItem> = [
{
title: '插件名称',
dataIndex: 'name',
key: 'name',
width: 120,
},
{
title: '描述',
dataIndex: 'description',
key: 'description',
ellipsis: true,
},
{
title: '版本',
dataIndex: 'version',
key: 'version',
width: 80,
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 80,
render: (status: keyof typeof statusColors) => (
<Tag color={statusColors[status]}>{statusLabels[status]}</Tag>
),
},
{
title: '作者',
dataIndex: 'author',
key: 'author',
width: 100,
},
];
const PluginTable: React.FC = () => {
return (
<Card
title="最新插件"
className="kra-dashboard-card"
bordered={false}
extra={
<Space>
<Button
type="link"
icon={<GithubOutlined />}
href="https://github.com/go-kratos/kratos"
target="_blank"
>
GitHub
</Button>
<Button
type="link"
icon={<LinkOutlined />}
href="https://go-kratos.dev/"
target="_blank"
>
</Button>
</Space>
}
>
<Table
columns={columns}
dataSource={plugins}
pagination={false}
size="small"
/>
</Card>
);
};
export default PluginTable;

View File

@ -0,0 +1,85 @@
/**
* KRA - Dashboard Quick Links Component
*/
import React from 'react';
import { Card, Row, Col } from 'antd';
import {
UserOutlined,
SettingOutlined,
FileTextOutlined,
DatabaseOutlined,
ApiOutlined,
SafetyOutlined,
} from '@ant-design/icons';
import { history } from '@umijs/max';
interface QuickLink {
title: string;
icon: React.ReactNode;
path: string;
color: string;
}
const links: QuickLink[] = [
{ title: '用户管理', icon: <UserOutlined />, path: '/system/user', color: '#1890ff' },
{ title: '角色管理', icon: <SafetyOutlined />, path: '/system/authority', color: '#52c41a' },
{ title: '菜单管理', icon: <FileTextOutlined />, path: '/system/menu', color: '#faad14' },
{ title: 'API管理', icon: <ApiOutlined />, path: '/system/api', color: '#722ed1' },
{ title: '字典管理', icon: <DatabaseOutlined />, path: '/system/dictionary', color: '#eb2f96' },
{ title: '系统配置', icon: <SettingOutlined />, path: '/systemTools/system', color: '#13c2c2' },
];
const QuickLinks: React.FC = () => {
const handleClick = (path: string) => {
history.push(path);
};
return (
<Card
title="快捷功能"
className="kra-dashboard-card"
bordered={false}
>
<Row gutter={[12, 12]}>
{links.map((link, index) => (
<Col span={12} key={index}>
<div
onClick={() => handleClick(link.path)}
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
padding: '12px 8px',
borderRadius: '8px',
cursor: 'pointer',
transition: 'all 0.2s',
background: '#fafafa',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = `${link.color}10`;
e.currentTarget.style.transform = 'translateY(-2px)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = '#fafafa';
e.currentTarget.style.transform = 'translateY(0)';
}}
>
<div
style={{
fontSize: '20px',
color: link.color,
marginBottom: '4px',
}}
>
{link.icon}
</div>
<span style={{ fontSize: '12px', color: '#666' }}>{link.title}</span>
</div>
</Col>
))}
</Row>
</Card>
);
};
export default QuickLinks;

View File

@ -0,0 +1,73 @@
/**
* KRA - Dashboard Statistics Cards
*/
import React from 'react';
import { Row, Col, Card, Statistic } from 'antd';
import { UserOutlined, TeamOutlined, CheckCircleOutlined } from '@ant-design/icons';
interface StatItem {
title: string;
value: number;
icon: React.ReactNode;
color: string;
suffix?: string;
}
const stats: StatItem[] = [
{
title: '访问人数',
value: 12580,
icon: <UserOutlined />,
color: '#1890ff',
},
{
title: '新增客户',
value: 368,
icon: <TeamOutlined />,
color: '#52c41a',
},
{
title: '解决数量',
value: 1024,
icon: <CheckCircleOutlined />,
color: '#722ed1',
},
];
const StatCards: React.FC = () => {
return (
<Row gutter={[16, 16]}>
{stats.map((stat, index) => (
<Col xs={24} sm={8} key={index}>
<Card className="kra-dashboard-card" bordered={false}>
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
<div
style={{
width: '48px',
height: '48px',
borderRadius: '8px',
background: `${stat.color}15`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '24px',
color: stat.color,
}}
>
{stat.icon}
</div>
<Statistic
title={stat.title}
value={stat.value}
suffix={stat.suffix}
valueStyle={{ color: stat.color }}
/>
</div>
</Card>
</Col>
))}
</Row>
);
};
export default StatCards;

View File

@ -0,0 +1,9 @@
/**
* KRA - Dashboard Components Index
*/
export { default as Banner } from './Banner';
export { default as StatCards } from './StatCards';
export { default as Charts } from './Charts';
export { default as QuickLinks } from './QuickLinks';
export { default as Notice } from './Notice';
export { default as PluginTable } from './PluginTable';

View File

@ -0,0 +1,44 @@
/**
* KRA - Dashboard Styles
*/
.kra-dashboard {
padding: 16px;
}
.kra-dashboard-card {
background: #fff;
border-radius: 8px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.03);
height: 100%;
}
.kra-dashboard-card-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px;
border-bottom: 1px solid #f0f0f0;
h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
}
}
.kra-dashboard-card-body {
padding: 16px;
}
// Dark mode
:global(.dark) {
.kra-dashboard-card {
background: #1f1f1f;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
}
.kra-dashboard-card-header {
border-color: #303030;
}
}

View File

@ -0,0 +1,58 @@
/**
* KRA - Dashboard Page
* Main dashboard with statistics, charts, and quick links
*/
import React from 'react';
import { Row, Col } from 'antd';
import { Helmet } from '@umijs/max';
import Banner from './components/Banner';
import StatCards from './components/StatCards';
import Charts from './components/Charts';
import QuickLinks from './components/QuickLinks';
import Notice from './components/Notice';
import PluginTable from './components/PluginTable';
import './index.less';
const Dashboard: React.FC = () => {
return (
<div className="kra-dashboard">
<Helmet>
<title> - Kratos Admin</title>
</Helmet>
<Row gutter={[16, 16]}>
{/* Statistics Cards */}
<Col xs={24} lg={18}>
<StatCards />
</Col>
{/* Quick Links */}
<Col xs={24} lg={6}>
<QuickLinks />
</Col>
{/* Main Chart */}
<Col xs={24} lg={18}>
<Charts />
</Col>
{/* Notice */}
<Col xs={24} lg={6}>
<Notice />
</Col>
{/* Plugin Table */}
<Col xs={24} lg={18}>
<PluginTable />
</Col>
{/* Banner */}
<Col xs={24} lg={6}>
<Banner />
</Col>
</Row>
</div>
);
};
export default Dashboard;

View File

@ -0,0 +1,23 @@
/**
* KRA - 403 Forbidden Page
*/
import React from 'react';
import { Button, Result } from 'antd';
import { history } from '@umijs/max';
const ForbiddenPage: React.FC = () => {
return (
<Result
status="403"
title="403"
subTitle="抱歉,您没有权限访问此页面"
extra={
<Button type="primary" onClick={() => history.push('/')}>
</Button>
}
/>
);
};
export default ForbiddenPage;

View File

@ -0,0 +1,23 @@
/**
* KRA - 404 Not Found Page
*/
import React from 'react';
import { Button, Result } from 'antd';
import { history } from '@umijs/max';
const NotFoundPage: React.FC = () => {
return (
<Result
status="404"
title="404"
subTitle="抱歉,您访问的页面不存在"
extra={
<Button type="primary" onClick={() => history.push('/')}>
</Button>
}
/>
);
};
export default NotFoundPage;

View File

@ -0,0 +1,23 @@
/**
* KRA - 500 Server Error Page
*/
import React from 'react';
import { Button, Result } from 'antd';
import { history } from '@umijs/max';
const ServerErrorPage: React.FC = () => {
return (
<Result
status="500"
title="500"
subTitle="抱歉,服务器出错了"
extra={
<Button type="primary" onClick={() => history.push('/')}>
</Button>
}
/>
);
};
export default ServerErrorPage;

View File

@ -0,0 +1,7 @@
/**
* KRA - Error Pages Index
*/
export { default as NotFoundPage } from './404';
export { default as ForbiddenPage } from './403';
export { default as ServerErrorPage } from './500';
export { default as ReloadPage } from './reload';

View File

@ -0,0 +1,36 @@
/**
* KRA - Reload Page
* Used for route refresh without full page reload
*/
import React, { useEffect } from 'react';
import { Spin } from 'antd';
import { history, useLocation } from '@umijs/max';
const ReloadPage: React.FC = () => {
const location = useLocation();
useEffect(() => {
const params = new URLSearchParams(location.search);
const redirect = params.get('redirect') || '/';
// Small delay to ensure the route change is processed
const timer = setTimeout(() => {
history.replace(redirect);
}, 100);
return () => clearTimeout(timer);
}, [location.search]);
return (
<div style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: '100vh',
}}>
<Spin size="large" tip="页面刷新中..." />
</div>
);
};
export default ReloadPage;

View File

@ -0,0 +1,120 @@
.breakpoint-container {
padding: 16px;
.upload-section {
margin-bottom: 24px;
.section-title {
font-size: 16px;
font-weight: 500;
margin-bottom: 16px;
padding-bottom: 8px;
border-bottom: 1px solid #f0f0f0;
}
.button-container {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 8px;
}
.file-upload-btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0 16px;
height: 32px;
background: #1890ff;
color: #fff;
border-radius: 4px;
cursor: pointer;
transition: all 0.3s;
&:hover {
background: #40a9ff;
}
input {
display: none;
}
}
.upload-tip {
font-size: 12px;
color: #999;
margin-top: 8px;
}
}
.file-list {
margin-top: 16px;
.file-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: #fafafa;
border-radius: 4px;
margin-bottom: 8px;
.file-icon {
font-size: 16px;
color: #1890ff;
}
.file-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.percentage {
width: 50px;
text-align: right;
color: #1890ff;
font-weight: 500;
}
.progress-bar {
width: 100%;
margin-top: 4px;
}
}
}
.tips {
margin-top: 24px;
padding: 12px 16px;
background: #fffbe6;
border: 1px solid #ffe58f;
border-radius: 4px;
font-size: 13px;
color: #666;
line-height: 1.6;
}
}
// 暗色主题适配
.ant-layout-content[data-theme='dark'] {
.breakpoint-container {
.upload-section {
.section-title {
border-color: #303030;
}
}
.file-list {
.file-item {
background: #262626;
}
}
.tips {
background: #2b2111;
border-color: #594214;
}
}
}

View File

@ -0,0 +1,209 @@
import React, { useState, useRef } from 'react';
import { Card, Button, Progress, message } from 'antd';
import { FileOutlined } from '@ant-design/icons';
import SparkMD5 from 'spark-md5';
import { findFile, breakpointContinue, breakpointContinueFinish, removeChunk } from '@/services/kratos/fileUpload';
import './index.less';
interface FormDataItem {
key: number;
formData: FormData;
}
/**
*
*/
const BreakpointUpload: React.FC = () => {
const fileInputRef = useRef<HTMLInputElement>(null);
const [file, setFile] = useState<File | null>(null);
const [fileMd5, setFileMd5] = useState('');
const [percentage, setPercentage] = useState(0);
const [formDataList, setFormDataList] = useState<FormDataItem[]>([]);
const [waitUpload, setWaitUpload] = useState<FormDataItem[]>([]);
const [uploading, setUploading] = useState(false);
// 选择文件
const handleSelectFile = () => {
fileInputRef.current?.click();
};
// 文件选择处理
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (!files?.length) return;
const selectedFile = files[0];
const maxSize = 5 * 1024 * 1024; // 5MB
if (selectedFile.size > maxSize) {
message.error('请上传小于5MB的文件!');
return;
}
setFile(selectedFile);
setPercentage(0);
// 读取文件并计算MD5
const reader = new FileReader();
reader.readAsArrayBuffer(selectedFile);
reader.onload = async (event) => {
const blob = event.target?.result as ArrayBuffer;
const spark = new SparkMD5.ArrayBuffer();
spark.append(blob);
const md5 = spark.end();
setFileMd5(md5);
// 切片
const sliceSize = 1 * 1024 * 1024; // 1MB per slice
let start = 0;
let end = 0;
let i = 0;
const slices: FormDataItem[] = [];
while (end < selectedFile.size) {
start = i * sliceSize;
end = Math.min((i + 1) * sliceSize, selectedFile.size);
const fileSlice = selectedFile.slice(start, end);
const formData = new FormData();
formData.append('fileMd5', md5);
formData.append('file', fileSlice);
formData.append('chunkNumber', String(i));
formData.append('fileName', selectedFile.name);
slices.push({ key: i, formData });
i++;
}
setFormDataList(slices);
// 检查已上传的切片
try {
const res = await findFile({
fileName: selectedFile.name,
fileMd5: md5,
chunkTotal: slices.length,
});
if (res.data?.code === 0) {
const fileInfo = res.data.data?.file;
if (fileInfo?.IsFinish) {
setWaitUpload([]);
setPercentage(100);
message.success('文件已秒传!');
} else {
const finishList = fileInfo?.ExaFileChunk || [];
const needUpload = slices.filter((item) =>
!finishList.some((fi: any) => fi.FileChunkNumber === item.key)
);
setWaitUpload(needUpload);
}
}
} catch (error) {
setWaitUpload(slices);
}
};
};
// 上传文件
const handleUpload = async () => {
if (!file) {
message.warning('请先选择文件!');
return;
}
if (percentage === 100) {
message.success('上传已完成!');
return;
}
setUploading(true);
let uploadedCount = formDataList.length - waitUpload.length;
for (const item of waitUpload) {
item.formData.append('chunkTotal', String(formDataList.length));
// 计算切片MD5
const fileSlice = item.formData.get('file') as Blob;
const sliceBuffer = await fileSlice.arrayBuffer();
const spark = new SparkMD5.ArrayBuffer();
spark.append(sliceBuffer);
item.formData.append('chunkMd5', spark.end());
try {
const res = await breakpointContinue(item.formData);
if (res.data?.code === 0) {
uploadedCount++;
setPercentage(Math.floor((uploadedCount / formDataList.length) * 100));
}
} catch (error) {
console.error('切片上传失败:', error);
}
}
// 合并文件
if (uploadedCount === formDataList.length) {
try {
const finishRes = await breakpointContinueFinish({
fileName: file.name,
fileMd5: fileMd5,
});
if (finishRes.data?.code === 0) {
message.success('上传成功');
// 删除缓存切片
await removeChunk({
fileName: file.name,
fileMd5: fileMd5,
filePath: finishRes.data.data?.filePath,
});
}
} catch (error) {
message.error('文件合并失败');
}
}
setUploading(false);
};
return (
<div className="breakpoint-container">
<Card>
<div className="upload-section">
<div className="section-title"></div>
<div className="button-container">
<div className="file-upload-btn" onClick={handleSelectFile}>
<span></span>
<input
ref={fileInputRef}
type="file"
onChange={handleFileChange}
/>
</div>
<Button type="primary" onClick={handleUpload} loading={uploading} disabled={percentage === 100}>
</Button>
</div>
<div className="upload-tip">5MB的文件</div>
</div>
{file && (
<div className="file-list">
<div className="file-item">
<FileOutlined className="file-icon" />
<span className="file-name">{file.name}</span>
<span className="percentage">{percentage}%</span>
</div>
<Progress percent={percentage} showInfo={false} strokeWidth={2} />
</div>
)}
<div className="tips">
breakpointDir fileDir
</div>
</Card>
</div>
);
};
export default BreakpointUpload;

View File

@ -0,0 +1,30 @@
.customer-container {
padding: 16px;
.customer-alert {
margin-bottom: 16px;
}
.customer-table-card {
.table-toolbar {
margin-bottom: 16px;
}
}
.drawer-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 16px 0;
border-top: 1px solid #f0f0f0;
}
}
// 暗色主题适配
.ant-layout-content[data-theme='dark'] {
.customer-container {
.drawer-footer {
border-color: #303030;
}
}
}

View File

@ -0,0 +1,251 @@
import React, { useState, useEffect } from 'react';
import { Card, Table, Button, Space, Alert, Drawer, Form, Input, message, Modal } from 'antd';
import { PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import {
getExaCustomerList,
getExaCustomer,
createExaCustomer,
updateExaCustomer,
deleteExaCustomer,
type Customer,
} from '@/services/kratos/customer';
import { formatDate } from '@/utils/format';
import './index.less';
/**
*
*/
const CustomerPage: React.FC = () => {
const [form] = Form.useForm();
const [loading, setLoading] = useState(false);
const [tableData, setTableData] = useState<Customer[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [drawerVisible, setDrawerVisible] = useState(false);
const [editType, setEditType] = useState<'create' | 'update'>('create');
// 获取表格数据
const getTableData = async () => {
setLoading(true);
try {
const res = await getExaCustomerList({ page, pageSize });
if (res.data?.code === 0) {
setTableData(res.data.data?.list || []);
setTotal(res.data.data?.total || 0);
}
} catch (error) {
console.error('获取客户列表失败:', error);
} finally {
setLoading(false);
}
};
useEffect(() => {
getTableData();
}, [page, pageSize]);
// 打开新增抽屉
const openDrawer = () => {
setEditType('create');
form.resetFields();
setDrawerVisible(true);
};
// 编辑客户
const handleEdit = async (record: Customer) => {
try {
const res = await getExaCustomer({ ID: record.ID! });
if (res.data?.code === 0) {
setEditType('update');
form.setFieldsValue(res.data.data?.customer);
setDrawerVisible(true);
}
} catch (error) {
message.error('获取客户信息失败');
}
};
// 删除客户
const handleDelete = (record: Customer) => {
Modal.confirm({
title: '提示',
content: '确定要删除吗?',
okText: '确定',
cancelText: '取消',
onOk: async () => {
try {
const res = await deleteExaCustomer({ ID: record.ID! });
if (res.data?.code === 0) {
message.success('删除成功');
// 如果当前页只有一条数据且不是第一页,则回到上一页
if (tableData.length === 1 && page > 1) {
setPage(page - 1);
} else {
getTableData();
}
}
} catch (error) {
message.error('删除失败');
}
},
});
};
// 关闭抽屉
const closeDrawer = () => {
setDrawerVisible(false);
form.resetFields();
};
// 提交表单
const handleSubmit = async () => {
try {
const values = await form.validateFields();
let res;
if (editType === 'create') {
res = await createExaCustomer(values);
} else {
res = await updateExaCustomer(values);
}
if (res.data?.code === 0) {
message.success(editType === 'create' ? '创建成功' : '更新成功');
closeDrawer();
getTableData();
}
} catch (error) {
console.error('提交失败:', error);
}
};
// 表格列定义
const columns: ColumnsType<Customer> = [
{
title: '接入日期',
dataIndex: 'CreatedAt',
key: 'CreatedAt',
width: 180,
render: (text) => formatDate(text),
},
{
title: '姓名',
dataIndex: 'customerName',
key: 'customerName',
width: 120,
},
{
title: '电话',
dataIndex: 'customerPhoneData',
key: 'customerPhoneData',
width: 120,
},
{
title: '接入人ID',
dataIndex: 'sysUserID',
key: 'sysUserID',
width: 120,
},
{
title: '操作',
key: 'action',
width: 160,
render: (_, record) => (
<Space>
<Button
type="link"
icon={<EditOutlined />}
onClick={() => handleEdit(record)}
>
</Button>
<Button
type="link"
danger
icon={<DeleteOutlined />}
onClick={() => handleDelete(record)}
>
</Button>
</Space>
),
},
];
return (
<div className="customer-container">
<Alert
message="在资源权限中将此角色的资源权限清空 或者不包含创建者的角色 即可屏蔽此客户资源的显示"
type="warning"
showIcon
className="customer-alert"
/>
<Card className="customer-table-card">
<div className="table-toolbar">
<Button type="primary" icon={<PlusOutlined />} onClick={openDrawer}>
</Button>
</div>
<Table
rowKey="ID"
columns={columns}
dataSource={tableData}
loading={loading}
pagination={{
current: page,
pageSize: pageSize,
total: total,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (total) => `${total}`,
pageSizeOptions: ['10', '30', '50', '100'],
onChange: (p, ps) => {
setPage(p);
setPageSize(ps);
},
}}
/>
</Card>
{/* 新增/编辑抽屉 */}
<Drawer
title="客户"
open={drawerVisible}
onClose={closeDrawer}
width={400}
extra={
<Space>
<Button onClick={closeDrawer}></Button>
<Button type="primary" onClick={handleSubmit}>
</Button>
</Space>
}
>
<Form form={form} layout="vertical">
<Form.Item name="ID" hidden>
<Input />
</Form.Item>
<Form.Item
name="customerName"
label="客户名"
rules={[{ required: true, message: '请输入客户名' }]}
>
<Input placeholder="请输入客户名" />
</Form.Item>
<Form.Item
name="customerPhoneData"
label="客户电话"
rules={[{ required: true, message: '请输入客户电话' }]}
>
<Input placeholder="请输入客户电话" />
</Form.Item>
</Form>
</Drawer>
</div>
);
};
export default CustomerPage;

View File

@ -0,0 +1,95 @@
.upload-container {
padding: 16px;
.upload-layout {
display: flex;
gap: 16px;
.category-panel {
width: 260px;
flex-shrink: 0;
background: #fff;
border-radius: 8px;
padding: 16px;
.category-tree {
max-height: calc(100vh - 350px);
overflow-y: auto;
.tree-node {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 4px 0;
.node-title {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
&.active {
color: #1890ff;
font-weight: 500;
}
}
}
}
}
.file-panel {
flex: 1;
background: #fff;
border-radius: 8px;
.upload-alert {
margin-bottom: 16px;
}
.upload-toolbar {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 16px;
align-items: center;
.search-input {
width: 280px;
}
}
.file-preview {
width: 60px;
height: 60px;
object-fit: cover;
border-radius: 4px;
cursor: pointer;
}
.file-name {
cursor: pointer;
color: #1890ff;
&:hover {
text-decoration: underline;
}
}
}
}
}
// 暗色主题适配
.ant-layout-content[data-theme='dark'] {
.upload-container {
.upload-layout {
.category-panel {
background: #1f1f1f;
}
.file-panel {
background: #1f1f1f;
}
}
}
}

View File

@ -0,0 +1,398 @@
import React, { useState, useEffect } from 'react';
import { Card, Table, Button, Space, Alert, Input, Tag, Tree, Dropdown, Modal, Form, message, Image } from 'antd';
import { UploadOutlined, SearchOutlined, DownloadOutlined, DeleteOutlined, PlusOutlined, MoreOutlined, EditOutlined } from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import type { DataNode } from 'antd/es/tree';
import { getFileList, deleteFile, editFileName, importURL } from '@/services/kratos/fileUpload';
import { getCategoryList, addCategory, deleteCategory } from '@/services/kratos/attachmentCategory';
import { formatDate, createUUID } from '@/utils/format';
import './index.less';
interface FileItem {
ID: number;
UpdatedAt: string;
name: string;
url: string;
tag: string;
key: string;
}
interface Category {
ID: number;
name: string;
pid: number;
children?: Category[];
}
/**
*
*/
const UploadPage: React.FC = () => {
const [form] = Form.useForm();
const [loading, setLoading] = useState(false);
const [tableData, setTableData] = useState<FileItem[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [keyword, setKeyword] = useState('');
const [classId, setClassId] = useState(0);
const [categories, setCategories] = useState<Category[]>([]);
const [categoryModalVisible, setCategoryModalVisible] = useState(false);
const [categoryFormData, setCategoryFormData] = useState({ ID: 0, pid: 0, name: '' });
// 获取分类列表
const fetchCategories = async () => {
try {
const res = await getCategoryList();
if (res.data?.code === 0) {
const data = res.data.data || [];
setCategories([{ ID: 0, name: '全部分类', pid: 0, children: [] }, ...data]);
}
} catch (error) {
console.error('获取分类失败:', error);
}
};
// 获取文件列表
const getTableData = async () => {
setLoading(true);
try {
const res = await getFileList({ page, pageSize, keyword, classId });
if (res.data?.code === 0) {
setTableData(res.data.data?.list || []);
setTotal(res.data.data?.total || 0);
}
} catch (error) {
console.error('获取文件列表失败:', error);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchCategories();
}, []);
useEffect(() => {
getTableData();
}, [page, pageSize, classId]);
// 搜索
const handleSearch = () => {
setClassId(0);
setPage(1);
getTableData();
};
// 上传成功回调
const onUploadSuccess = () => {
setKeyword('');
setPage(1);
getTableData();
};
// 删除文件
const handleDeleteFile = (record: FileItem) => {
Modal.confirm({
title: '提示',
content: '此操作将永久删除文件, 是否继续?',
okText: '确定',
cancelText: '取消',
onOk: async () => {
try {
const res = await deleteFile(record);
if (res.data?.code === 0) {
message.success('删除成功!');
if (tableData.length === 1 && page > 1) {
setPage(page - 1);
} else {
getTableData();
}
}
} catch (error) {
message.error('删除失败');
}
},
});
};
// 下载文件
const handleDownload = (record: FileItem) => {
const url = record.url.startsWith('http') ? record.url : `${process.env.NODE_ENV === 'development' ? '/api' : ''}/${record.url}`;
const a = document.createElement('a');
a.href = url;
a.download = record.name;
a.target = '_blank';
a.click();
};
// 编辑文件名
const handleEditFileName = (record: FileItem) => {
Modal.confirm({
title: '编辑',
content: (
<Input
defaultValue={record.name}
placeholder="请输入文件名或者备注"
onChange={(e) => { record.name = e.target.value; }}
/>
),
okText: '确定',
cancelText: '取消',
onOk: async () => {
if (!record.name?.trim()) {
message.error('文件名不能为空');
return Promise.reject();
}
try {
const res = await editFileName(record);
if (res.data?.code === 0) {
message.success('编辑成功!');
getTableData();
}
} catch (error) {
message.error('编辑失败');
}
},
});
};
// 导入URL
const handleImportUrl = () => {
let inputValue = '';
Modal.confirm({
title: '导入',
content: (
<div>
<p style={{ marginBottom: 8, color: '#666' }}>|</p>
<Input.TextArea
rows={4}
placeholder={'我的图片|https://my-oss.com/my.png\nhttps://my-oss.com/my_1.png'}
onChange={(e) => { inputValue = e.target.value; }}
/>
</div>
),
okText: '确定',
cancelText: '取消',
width: 500,
onOk: async () => {
if (!inputValue?.trim()) {
message.error('内容不能为空');
return Promise.reject();
}
const lines = inputValue.split('\n');
const importData = lines.map((line) => {
const parts = line.trim().split('|');
let url: string, name: string;
if (parts.length > 1) {
name = parts[0].trim();
url = parts[1].trim();
} else {
url = parts[0].trim();
const str = url.substring(url.lastIndexOf('/') + 1);
name = str.substring(0, str.lastIndexOf('.')) || str;
}
if (url) {
return {
name,
url,
classId,
tag: url.substring(url.lastIndexOf('.') + 1),
key: createUUID(),
};
}
return null;
}).filter(Boolean);
if (importData.length === 0) {
message.error('没有有效的URL');
return Promise.reject();
}
try {
const res = await importURL(importData);
if (res.data?.code === 0) {
message.success('导入成功!');
getTableData();
}
} catch (error) {
message.error('导入失败');
}
},
});
};
// 点击分类节点
const handleCategoryClick = (selectedKeys: React.Key[]) => {
if (selectedKeys.length > 0) {
setKeyword('');
setClassId(Number(selectedKeys[0]));
setPage(1);
}
};
// 添加分类
const handleAddCategory = (parentId: number) => {
setCategoryFormData({ ID: 0, pid: parentId, name: '' });
setCategoryModalVisible(true);
};
// 编辑分类
const handleEditCategory = (category: Category) => {
setCategoryFormData({ ID: category.ID, pid: category.pid, name: category.name });
setCategoryModalVisible(true);
};
// 删除分类
const handleDeleteCategory = async (id: number) => {
try {
const res = await deleteCategory({ id });
if (res.data?.code === 0) {
message.success('删除成功');
fetchCategories();
}
} catch (error) {
message.error('删除失败');
}
};
// 确认添加/编辑分类
const handleConfirmCategory = async () => {
try {
const values = await form.validateFields();
const res = await addCategory({ ...categoryFormData, ...values });
if (res.data?.code === 0) {
message.success('操作成功');
fetchCategories();
setCategoryModalVisible(false);
form.resetFields();
}
} catch (error) {
console.error('操作失败:', error);
}
};
// 转换分类为树形数据
const convertToTreeData = (data: Category[]): DataNode[] => {
return data.map((item) => ({
key: item.ID,
title: (
<div className="tree-node">
<span className={`node-title ${classId === item.ID ? 'active' : ''}`}>{item.name}</span>
<Dropdown
menu={{
items: item.ID > 0 ? [
{ key: 'add', label: '添加分类', onClick: () => handleAddCategory(item.ID) },
{ key: 'edit', label: '编辑分类', onClick: () => handleEditCategory(item) },
{ key: 'delete', label: '删除分类', onClick: () => handleDeleteCategory(item.ID) },
] : [
{ key: 'add', label: '添加分类', onClick: () => handleAddCategory(item.ID) },
],
}}
trigger={['click']}
>
{item.ID > 0 ? <MoreOutlined /> : <PlusOutlined />}
</Dropdown>
</div>
),
children: item.children ? convertToTreeData(item.children) : undefined,
}));
};
// 表格列定义
const columns: ColumnsType<FileItem> = [
{
title: '预览',
dataIndex: 'url',
key: 'preview',
width: 100,
render: (url) => (
<Image src={url.startsWith('http') ? url : `/api/${url}`} width={60} height={60} style={{ objectFit: 'cover' }} />
),
},
{ title: '日期', dataIndex: 'UpdatedAt', key: 'UpdatedAt', width: 180, render: (text) => formatDate(text) },
{
title: '文件名/备注',
dataIndex: 'name',
key: 'name',
width: 180,
render: (text, record) => (
<span className="file-name" onClick={() => handleEditFileName(record)}>{text}</span>
),
},
{ title: '链接', dataIndex: 'url', key: 'url', ellipsis: true },
{
title: '标签',
dataIndex: 'tag',
key: 'tag',
width: 100,
render: (tag) => <Tag color={tag?.toLowerCase() === 'jpg' ? 'default' : 'success'}>{tag}</Tag>,
},
{
title: '操作',
key: 'action',
width: 160,
render: (_, record) => (
<Space>
<Button type="link" icon={<DownloadOutlined />} onClick={() => handleDownload(record)}></Button>
<Button type="link" danger icon={<DeleteOutlined />} onClick={() => handleDeleteFile(record)}></Button>
</Space>
),
},
];
return (
<div className="upload-container">
<div className="upload-layout">
<div className="category-panel">
<div className="category-tree">
<Tree
treeData={convertToTreeData(categories)}
defaultExpandAll
selectedKeys={[classId]}
onSelect={handleCategoryClick}
/>
</div>
</div>
<Card className="file-panel">
<Alert message="点击'文件名'可以编辑;选择的类别即是上传的类别。" type="info" showIcon className="upload-alert" />
<div className="upload-toolbar">
<Button icon={<UploadOutlined />}></Button>
<Button icon={<UploadOutlined />}></Button>
<Button icon={<UploadOutlined />}></Button>
<Button icon={<UploadOutlined />}></Button>
<Button type="primary" icon={<UploadOutlined />} onClick={handleImportUrl}>URL</Button>
<Input
className="search-input"
placeholder="请输入文件名或备注"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
onPressEnter={handleSearch}
/>
<Button type="primary" icon={<SearchOutlined />} onClick={handleSearch}></Button>
</div>
<Table rowKey="ID" columns={columns} dataSource={tableData} loading={loading}
pagination={{ current: page, pageSize, total, showSizeChanger: true, showQuickJumper: true,
showTotal: (t) => `${t}`, pageSizeOptions: ['10', '30', '50', '100'],
onChange: (p, ps) => { setPage(p); setPageSize(ps); },
}}
/>
</Card>
</div>
<Modal title={categoryFormData.ID === 0 ? '添加分类' : '编辑分类'} open={categoryModalVisible}
onOk={handleConfirmCategory} onCancel={() => { setCategoryModalVisible(false); form.resetFields(); }}>
<Form form={form} layout="vertical" initialValues={categoryFormData}>
<Form.Item name="name" label="分类名称" rules={[{ required: true, message: '请输入分类名称' }, { max: 20, message: '最多20位字符' }]}>
<Input placeholder="分类名称" />
</Form.Item>
</Form>
</Modal>
</div>
);
};
export default UploadPage;

View File

@ -0,0 +1,69 @@
.scan-upload-container {
padding: 16px;
display: flex;
flex-direction: column;
align-items: center;
.upload-area {
margin-bottom: 24px;
:global {
.ant-upload {
width: 128px;
height: 128px;
border: 1px dashed #d9d9d9;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: border-color 0.3s;
&:hover {
border-color: #1890ff;
}
.anticon {
font-size: 32px;
color: #999;
}
}
}
}
.preview-area {
width: 100%;
min-height: calc(100vh - 350px);
display: flex;
justify-content: center;
align-items: center;
background: #f5f5f5;
border-radius: 8px;
overflow: hidden;
.preview-image {
max-width: 100%;
max-height: calc(100vh - 350px);
object-fit: contain;
}
.cropper-wrapper {
width: 100%;
height: calc(100vh - 350px);
}
}
.toolbar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
padding: 12px 16px;
background: #1f1f1f;
display: flex;
justify-content: space-between;
align-items: center;
z-index: 100;
box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.1);
}
}

View File

@ -0,0 +1,119 @@
import React, { useState, useRef, useEffect } from 'react';
import { Upload, Button, Switch, Space, message } from 'antd';
import { PlusOutlined, RotateLeftOutlined, RotateRightOutlined, ZoomInOutlined, ZoomOutOutlined } from '@ant-design/icons';
import { useSearchParams } from '@umijs/max';
import { getToken } from '@/utils/auth';
import './scanUpload.less';
/**
*
*/
const ScanUpload: React.FC = () => {
const [searchParams] = useSearchParams();
const [imgSrc, setImgSrc] = useState<string>('');
const [isCrop, setIsCrop] = useState(false);
const [uploading, setUploading] = useState(false);
const classId = searchParams.get('id') || '0';
const token = searchParams.get('token') || getToken() || '';
// 获取上传地址
const getUploadUrl = () => {
const baseUrl = process.env.NODE_ENV === 'development' ? '/api' : '';
return `${baseUrl}/fileUploadAndDownload/upload`;
};
// 文件选择处理
const handleFileChange = (info: any) => {
const file = info.file?.originFileObj || info.file;
if (!file) return;
const isImage = file.type?.includes('image');
if (!isImage) {
message.error('请选择图片文件');
return;
}
if (file.size / 1024 / 1024 > 8) {
message.error('文件大小不能超过8MB!');
return;
}
const reader = new FileReader();
reader.onload = (e) => {
setImgSrc(e.target?.result as string);
};
reader.readAsDataURL(file);
};
// 上传处理
const handleUpload = () => {
if (!imgSrc) {
message.warning('请先选择图片');
return;
}
setUploading(true);
// 模拟上传
setTimeout(() => {
setUploading(false);
setImgSrc('');
message.success('上传成功');
}, 1500);
};
// 上传成功回调
const handleUploadSuccess = (res: any) => {
if (res?.data) {
setImgSrc('');
setUploading(false);
message.success('上传成功');
}
};
return (
<div className="scan-upload-container">
<div className="upload-area">
<Upload
accept="image/*"
showUploadList={false}
beforeUpload={() => false}
onChange={handleFileChange}
>
<PlusOutlined />
</Upload>
</div>
<div className="preview-area">
{imgSrc ? (
isCrop ? (
<div className="cropper-wrapper">
{/* 裁剪组件占位 - 需要集成 react-cropper */}
<img src={imgSrc} alt="preview" className="preview-image" />
</div>
) : (
<img src={imgSrc} alt="preview" className="preview-image" />
)
) : (
<span style={{ color: '#999' }}></span>
)}
</div>
<div className="toolbar">
{isCrop && (
<Space.Compact>
<Button icon={<RotateLeftOutlined />} title="向左旋转" />
<Button icon={<RotateRightOutlined />} title="向右旋转" />
<Button icon={<ZoomInOutlined />} title="放大" />
<Button icon={<ZoomOutOutlined />} title="缩小" />
</Space.Compact>
)}
<Switch checked={isCrop} onChange={setIsCrop} checkedChildren="裁剪" unCheckedChildren="裁剪" />
<Button type="primary" loading={uploading} onClick={handleUpload}>
{uploading ? '上传中...' : '上传'}
</Button>
</div>
</div>
);
};
export default ScanUpload;

View File

@ -0,0 +1,358 @@
/**
* KRA - Database Init Page
* Database initialization wizard
*/
import React, { useState } from 'react';
import { Form, Input, Select, Button, message, Modal, Spin } from 'antd';
import { history, Helmet } from '@umijs/max';
import { initDB } from '@/services/kratos/initdb';
import { createStyles } from 'antd-style';
type DBType = 'mysql' | 'pgsql' | 'oracle' | 'mssql' | 'sqlite';
interface InitFormData {
adminPassword: string;
dbType: DBType;
host: string;
port: string;
userName: string;
password: string;
dbName: string;
dbPath?: string;
template?: string;
}
const dbDefaults: Record<DBType, Partial<InitFormData>> = {
mysql: {
host: '127.0.0.1',
port: '3306',
userName: 'root',
dbName: 'kra',
},
pgsql: {
host: '127.0.0.1',
port: '5432',
userName: 'postgres',
dbName: 'kra',
template: 'template0',
},
oracle: {
host: '127.0.0.1',
port: '1521',
userName: 'oracle',
dbName: 'kra',
},
mssql: {
host: '127.0.0.1',
port: '1433',
userName: 'mssql',
dbName: 'kra',
},
sqlite: {
host: '',
port: '',
userName: '',
dbName: 'kra',
dbPath: '',
},
};
const useStyles = createStyles(({ token }) => ({
container: {
width: '100%',
height: '100vh',
display: 'flex',
position: 'relative',
overflow: 'hidden',
},
leftSection: {
flex: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: '#fff',
position: 'relative',
zIndex: 10,
},
rightSection: {
width: '50%',
height: '100%',
background: '#194bfb',
display: 'none',
'@media (min-width: 768px)': {
display: 'block',
},
},
rightBanner: {
width: '100%',
height: '100%',
objectFit: 'cover',
},
oblique: {
position: 'absolute',
height: '130%',
width: '60%',
background: '#fff',
transform: 'rotate(-12deg)',
marginLeft: '-320px',
zIndex: 5,
},
content: {
zIndex: 999,
maxWidth: '500px',
padding: '24px',
},
readmeSection: {
animation: 'slideInFwdTop 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94) both',
},
formSection: {
width: '384px',
animation: 'slideInLeft 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94) both',
},
title: {
fontSize: '32px',
fontWeight: 700,
textAlign: 'center' as const,
marginBottom: '16px',
color: token.colorText,
},
subtitle: {
fontSize: '16px',
color: token.colorTextSecondary,
marginBottom: '8px',
},
notice: {
fontSize: '14px',
color: token.colorTextSecondary,
marginBottom: '8px',
lineHeight: 1.8,
},
link: {
color: '#1890ff',
fontWeight: 600,
},
highlight: {
color: '#ff4d4f',
fontWeight: 700,
fontSize: '24px',
marginLeft: '8px',
},
buttonGroup: {
display: 'flex',
justifyContent: 'space-between',
marginTop: '32px',
},
formItem: {
marginBottom: '16px',
},
}));
const InitPage: React.FC = () => {
const [form] = Form.useForm();
const [showForm, setShowForm] = useState(false);
const [loading, setLoading] = useState(false);
const { styles } = useStyles();
const handleShowForm = () => {
setShowForm(true);
};
const handleGoDoc = () => {
window.open('https://go-kratos.dev/docs/getting-started/start', '_blank');
};
const handleDBTypeChange = (value: DBType) => {
const defaults = dbDefaults[value];
form.setFieldsValue({
...defaults,
password: '',
adminPassword: form.getFieldValue('adminPassword'),
});
};
const handleSubmit = async (values: InitFormData) => {
if (values.adminPassword.length < 6) {
message.error('密码长度不能小于6位');
return;
}
setLoading(true);
try {
const res = await initDB(values);
if (res.data?.code === 0) {
message.success(res.data?.msg || '初始化成功');
Modal.confirm({
title: '配置完成',
content: '已经完成基础数据库初始化!建议先阅读官方文档,以获得更好的开发体验。',
okText: '查看文档',
cancelText: '稍后查看',
centered: true,
onOk: () => {
window.open('https://go-kratos.dev/docs/', '_blank');
history.push('/user/login');
},
onCancel: () => {
history.push('/user/login');
},
});
} else {
message.error(res.data?.msg || '初始化失败');
}
} catch (error: any) {
message.error(error?.message || '初始化失败');
} finally {
setLoading(false);
}
};
return (
<div className={styles.container}>
<Helmet>
<title> - Kratos Admin</title>
</Helmet>
<Spin spinning={loading} tip="正在初始化数据库,请稍候..." fullscreen />
<div className={styles.leftSection}>
<div className={styles.oblique} />
{!showForm ? (
<div className={`${styles.content} ${styles.readmeSection}`}>
<h1 className={styles.title}>KRATOS-ADMIN</h1>
<p className={styles.subtitle}></p>
<p className={styles.notice}>1. React Golang </p>
<p className={styles.notice}>
2.
<a className={styles.link} href="https://go-kratos.dev/docs/" target="_blank" rel="noopener noreferrer">
</a>
</p>
<p className={styles.notice}>3. </p>
<p className={styles.notice}>
4. 使 MySQL
<span className={styles.highlight}>InnoDB</span>
</p>
<p className={styles.notice}></p>
<div className={styles.buttonGroup}>
<Button type="primary" size="large" onClick={handleGoDoc}>
</Button>
<Button type="primary" size="large" onClick={handleShowForm}>
</Button>
</div>
</div>
) : (
<div className={`${styles.content} ${styles.formSection}`}>
<Form
form={form}
layout="horizontal"
labelCol={{ span: 6 }}
wrapperCol={{ span: 18 }}
initialValues={{
adminPassword: '123456',
dbType: 'mysql',
...dbDefaults.mysql,
password: '',
}}
onFinish={handleSubmit}
size="large"
>
<Form.Item
label="管理员密码"
name="adminPassword"
className={styles.formItem}
rules={[
{ required: true, message: '请输入管理员密码' },
{ min: 6, message: '密码至少6个字符' },
]}
>
<Input.Password placeholder="admin账号的默认密码" />
</Form.Item>
<Form.Item
label="数据库类型"
name="dbType"
className={styles.formItem}
>
<Select onChange={handleDBTypeChange}>
<Select.Option value="mysql">MySQL</Select.Option>
<Select.Option value="pgsql">PostgreSQL</Select.Option>
<Select.Option value="oracle">Oracle</Select.Option>
<Select.Option value="mssql">SQL Server</Select.Option>
<Select.Option value="sqlite">SQLite</Select.Option>
</Select>
</Form.Item>
<Form.Item noStyle shouldUpdate={(prev, cur) => prev.dbType !== cur.dbType}>
{({ getFieldValue }) => {
const dbType = getFieldValue('dbType');
if (dbType === 'sqlite') return null;
return (
<>
<Form.Item label="Host" name="host" className={styles.formItem}>
<Input placeholder="请输入数据库链接" />
</Form.Item>
<Form.Item label="Port" name="port" className={styles.formItem}>
<Input placeholder="请输入数据库端口" />
</Form.Item>
<Form.Item label="用户名" name="userName" className={styles.formItem}>
<Input placeholder="请输入数据库用户名" />
</Form.Item>
<Form.Item label="密码" name="password" className={styles.formItem}>
<Input.Password placeholder="请输入数据库密码(没有则为空)" />
</Form.Item>
</>
);
}}
</Form.Item>
<Form.Item label="数据库名" name="dbName" className={styles.formItem}>
<Input placeholder="请输入数据库名称" />
</Form.Item>
<Form.Item noStyle shouldUpdate={(prev, cur) => prev.dbType !== cur.dbType}>
{({ getFieldValue }) => {
const dbType = getFieldValue('dbType');
if (dbType === 'sqlite') {
return (
<Form.Item label="数据库路径" name="dbPath" className={styles.formItem}>
<Input placeholder="请输入sqlite数据库文件存放路径" />
</Form.Item>
);
}
if (dbType === 'pgsql') {
return (
<Form.Item label="Template" name="template" className={styles.formItem}>
<Input placeholder="请输入postgresql指定template" />
</Form.Item>
);
}
return null;
}}
</Form.Item>
<Form.Item wrapperCol={{ offset: 6, span: 18 }}>
<Button type="primary" htmlType="submit" block>
</Button>
</Form.Item>
</Form>
</div>
)}
</div>
<div className={styles.rightSection}>
<img
src="/login_right_banner.jpg"
alt="banner"
className={styles.rightBanner}
/>
</div>
</div>
);
};
export default InitPage;

Some files were not shown because too many files have changed in this diff Show More