前端重构
This commit is contained in:
parent
d76655a30a
commit
f12f2e0f00
|
|
@ -168,7 +168,7 @@ export default defineConfig({
|
|||
mock: {
|
||||
include: ["mock/**/*", "src/pages/**/_mock.ts"],
|
||||
},
|
||||
utoopack: {},
|
||||
// utoopack: {},
|
||||
requestRecord: {},
|
||||
exportStatic: {},
|
||||
define: {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
190
web/src/app.tsx
190
web/src/app.tsx
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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;
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
export { default as ArrayCtrl } from './ArrayCtrl';
|
||||
export { default } from './ArrayCtrl';
|
||||
|
|
@ -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;
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
export { default as BottomInfo } from './BottomInfo';
|
||||
export { default } from './BottomInfo';
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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';
|
||||
|
|
@ -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;
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
export { default as ErrorPreview } from './ErrorPreview';
|
||||
export { default } from './ErrorPreview';
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
export { default as ExportExcel } from './ExportExcel';
|
||||
export { default as ImportExcel } from './ImportExcel';
|
||||
|
|
@ -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,
|
||||
},
|
||||
]}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
export { default as Logo } from './Logo';
|
||||
export { default } from './Logo';
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
export { default as PDFViewer } from './PDFViewer';
|
||||
export { default as ExcelViewer } from './ExcelViewer';
|
||||
export { default as DocxViewer } from './DocxViewer';
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
export { default as RichTextEditor } from './RichTextEditor';
|
||||
export { default as RichTextViewer } from './RichTextViewer';
|
||||
|
|
@ -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;
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
export { default as SelectFile } from './SelectFile';
|
||||
export { default } from './SelectFile';
|
||||
|
|
@ -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;
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
export { default as SelectImage } from './SelectImage';
|
||||
export { default } from './SelectImage';
|
||||
|
|
@ -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;
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
export { default as SvgIcon } from './SvgIcon';
|
||||
export { default } from './SvgIcon';
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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';
|
||||
|
|
@ -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;
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
export { default as WarningBar } from './WarningBar';
|
||||
export { default } from './WarningBar';
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
# KRA Custom React Hooks
|
||||
|
|
@ -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';
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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';
|
||||
|
|
@ -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';
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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';
|
||||
|
|
@ -0,0 +1 @@
|
|||
# KRA State Management Models
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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';
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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';
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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';
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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
Loading…
Reference in New Issue