上一篇文章我们介绍了项目的目录规划、修改vite
配置并且创建了项目的路由信息。此篇文章我们会先实现登录页面和管理页面的 Layout
骨架。
在xxl-job-admin
的IndexController
中有我们需要的登录和登出接口;但是从接口输出方式可以看出来,并不符合前后端分离的模式,所有我们这里需要做兼容。另外还需考虑的是在不修改 xxl-job-admin 中的代码(不然我们后面对照功能的时候不方便)的前提下,如何可以给我们现在的项目提供接口呢?
所有我这里创建了一个新的项目 xxl-job-admin-new
,此时我们项目目录结构如下:
在这个项目中,maven
依赖相较 xxl-job-admin
添加 lombok
和validation
这两个,其他的不变;另外我添加全局异常处理和一些必要class
,结构如下:
对于登录接口也无大的改动,仅将之前的POST
表单接口改为 POST JSON
方法提交,并添加了表单验证。代码如下:
@Slf4j
@RestController
@RequestMapping("user")
public class UserController {
private final XxlJobService xxlJobService;
private final LoginService loginService;
public UserController(XxlJobService xxlJobService, LoginService loginService) {
this.xxlJobService = xxlJobService;
this.loginService = loginService;
}
@PostMapping("login")
@PermissionLimit(limit=false)
public ReturnT<UserLoginResponseDto> loginDo(@RequestBody @Validated UserLoginRequestDto param, HttpServletRequest request, HttpServletResponse response){
return new ReturnT<>(loginService.login(param, request, response));
}
}
这里的@PermissionLimit
是做鉴权的,具体可以看PermissionInterceptor
拦截器。
这里的登录逻辑也很简单,先校验登录表单,然后通过用户名称获取用户实体XxlJobUser
,密码比对;接着将XxlJobUser
转为十六进制的字符串;最后将登录信息写入 Cookie
并返回。
这里不做详细的
Java
代码分析,后面有时间在写一个专门把xxl-job
源码分析的专栏吧。
登出接口也不变,就是清除 Cookie
。
@Slf4j
@RestController
@RequestMapping("user")
public class UserController {
private final XxlJobService xxlJobService;
private final LoginService loginService;
public UserController(XxlJobService xxlJobService, LoginService loginService) {
this.xxlJobService = xxlJobService;
this.loginService = loginService;
}
@GetMapping("logout")
@PermissionLimit(limit=false)
public ReturnT<String> logout(HttpServletRequest request, HttpServletResponse response){
return new ReturnT<>(loginService.logout(request, response));
}
}
修改密码的接口,改为 Get
请求,代码如下 :
@Slf4j
@RestController
@RequestMapping("user")
public class UserController {
private final XxlJobService xxlJobService;
private final LoginService loginService;
public UserController(XxlJobService xxlJobService, LoginService loginService) {
this.xxlJobService = xxlJobService;
this.loginService = loginService;
}
@GetMapping("updatePwd")
public ReturnT<String> updatePwd(HttpServletRequest request, @RequestParam("password") String password){
password = password.trim();
if (!(password.length()>=4 && password.length()<=20)) {
return new ReturnT<String>(PASSWORD_LEN_ERROR, PASSWORD_LEN_ERROR.getMsg());
}
// md5 password
String md5Password = DigestUtils.md5DigestAsHex(password.getBytes());
// update pwd
XxlJobUser loginUser = (XxlJobUser) request.getAttribute(LoginServiceImpl.LOGIN_IDENTITY_KEY);
// 修改
loginService.updateUser(loginUser.getId(), md5Password);
return new ReturnT<>("密码修改成功");
}
}
这里的代码,仅修改了loginService.updateUser(loginUser.getId(), md5Password);
将修改的逻辑下放到了 service
层。
最后我们需要将application.properties
中关于 freemarker
和 resources
的配置移除,并将启动端口改为 9090
;另外还需要将 logback.xml
中的日志文件修改一个其他的名字。
此时我们启动项目是无法启动,因为报一个i18n
的错误,此时需要我们在 XxlJobScheduler.java
中将 iniI8n()
的初始化方法注释就可以了。
好了,现在我们可以启动 xxl-job-admin-new
的服务了。
上面的部分是关于后端接口修改,不太理解的前端同学可以直接访问项目启动后端服务就可以了!
这里我们是用的是 axios
请求接口,一般来说大家都会封装下,我在 naive 的开源项目naive-ui-admin 中看到一个封装很不错的 axios
请求工具,我在此基础上修改了下封装到了 utils
中的 request.ts
工具类。
这里封装比较麻烦,但是很好用,有时间可以开一篇文章详细介绍下,如何封装的。
在之前文章中,我们在 src
目录中有一个 api
的目录,就是专门存放,我们调用后端接口的 api
函数,这里我们将用户相关的操作都放到 user.ts
中。
import {User} from "@/types";
import {https} from "@/utils/request.ts";
namespace UserApi {
/**
* 登录
* @param param
* @constructor
*/
export const UserLogin = (param: User.UserLoginFormProp): Promise<User.UserLoginResultProp> => {
return https.request({
url: '/user/login',
method: 'post',
data: param
})
}
/**
* 退出登录
* @constructor
*/
export const UserLogout = (): Promise<string> => {
return https.request({
url: '/user/logout',
method: 'get'
})
}
/**
* 修改密码
* @param pwd
* @constructor
*/
export const UpdatePassword = (pwd: string): Promise<string> => {
return https.request({
url: '/user/updatePwd',
method: 'get',
params: {password: pwd}
})
}
}
export default UserApi
这里面的User.UserLoginFormProp
和User.UserLoginResultProp
是登录的输入和输出模型定义(文章后面会有定义的具体属性)。
这里我们还需要在vite.config.ts
中开启代理:
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from "path";
// https://vitejs.dev/config/
export default defineConfig({
server: {
port: 9091,
proxy: {
"/api": {
target: "http://localhost:9090/xxl-job-admin",
changeOrigin: true, // 跨域设置
rewrite: (path) => path.replace(/^\/api/, ""), // 将 api 前缀去掉
},
},
},
})
这里的登录页面我们这里借鉴 Ant Design Pro的登录页面,略作修改就可以了。
这里登录页面就是一个 form
表单,直接使用https://ant-design.antgroup.com/components/form-cn的表单组件。代码如下:
import styled from "@emotion/styled";
import {Button, Card, Checkbox, Form, Input, message, Typography} from "antd";
import {LockOutlined, UserOutlined} from "@ant-design/icons";
import {User} from "@/types";
import {useNavigate} from "react-router-dom";
import {DEFAULT_HOME_PATH} from "@/config/constant.ts"; // 定义的一些静态常量
const LoginPage = () => {
const navigate = useNavigate(); // 路由切换 hooks
const [form] = Form.useForm<User.UserLoginFormProp>();
const onSubmit = () => {
form.validateFields().then(value => {
console.log("value => ", value);
// TODO 调用登录接口
})
}
return <Container>
<Card className="login-card" style={{width: 400}}>
<Typography.Title level={3} style={{textAlign: 'center', marginBottom: 40}}>任务调度中心</Typography.Title>
<Form
name="normal_login"
className="login-form"
initialValues={{ remember: true }}
form={form}
>
<Form.Item
name="username"
rules={[{ required: true, message: '请输入账号名称!' }]}
>
<Input prefix={<UserOutlined className="site-form-item-icon" />} placeholder="请输入登录账号" />
</Form.Item>
<Form.Item
name="password"
rules={[{ required: true, message: '请输入账号密码!' }]}
>
<Input prefix={<LockOutlined className="site-form-item-icon" />} type="password" placeholder="请输入登录密码"/>
</Form.Item>
<Form.Item className="form-login-btn">
<Form.Item name="ifRemember" valuePropName="unchecked" noStyle>
<Checkbox>记住密码</Checkbox>
</Form.Item>
<Button type="primary" className="login-form-button" onClick={onSubmit}>登录</Button>
</Form.Item>
</Form>
</Card>
</Container>
}
这里的
标签是通过@emotion/styled
创建样式化组件。
const Container = styled.div`
display: flex;
flex-direction: column;
align-items: center;
height: 100vh;
overflow: auto;
background-image: url('https://mdn.alipayobjects.com/yuyan_qk0oxh/afts/img/V-_oS6r-i7wAAAAAAAAAAAAAFl94AQBr');
background-size: 100% 100%;
.login-card {
position: absolute;
top: 20%;
.ant-card-body {
padding-top: 8px;
.form-login-btn {
.ant-form-item-control-input-content {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
}
}
}
}
`
我们在 types
目录添加一个 user.type.ts
文件,存放用户相关的接口模型(这个模型就是上面定义的)。
namespace User {
// UserLoginFormProp 用户登录表单属性
export interface UserLoginFormProp {
username: string; // 用户名
password: string; // 密码
ifRemember: boolean; // 记住密码
}
// UserLoginResultProp 用户登录返回属性
export interface UserLoginResultProp {
userId: number; // 用户名
username: string; // 密码
role: number;
token: string;
permission: string[];
}
}
export default User;
最后使用 index.ts
导出
import User from "@/types/user.type.ts";
export type {
User
}
这里对接登录接口,我们使用了 ahooks
中的 useRequest
,具体使用就是官方文档中有详细的介绍。https://ahooks.gitee.io/zh-CN/hooks/use-request/index
下面看一下如何借助 useRequest 轻松的调用我们的接口:
const loginRequest = useRequest(UserApi.UserLogin, {
manual: true, // 表示手动触发
onSuccess: (result) => {
console.log(result);
message.success('登录成功');
navigate(DEFAULT_HOME_PATH); // 跳转到首页
}
})
const onSubmit = () => {
form.validateFields().then(value => {
loginRequest.run(value) // 手动调用登录接口
})
}
整个 Layout
骨架主要分为左侧菜单、Header
和内容三部分,这里我们直接使用 antd
实现骨架。
直接上代码:
<Container>
{/* 菜单 */}
<Layout.Sider trigger={null} collapsible collapsed={collapsed}>
<div className="xxl-logo-vertical" />
<Menu
theme="dark"
mode="inline"
selectedKeys={selectedKeys}
onClick={({key}) => clickMenu(key)}
items={[
{
key: '/xxl-job/report',
icon: <BarChartOutlined />,
label: '运行报表',
},
{
key: '/xxl-job/task',
icon: <SnippetsOutlined />,
label: '任务管理',
},
{
key: '/xxl-job/dispatch',
icon: <CalendarOutlined />,
label: '调度日志',
},
{
key: '/xxl-job/executor',
icon: <MenuOutlined />,
label: '执行器管理',
},
{
key: '/xxl-job/user',
icon: <UserOutlined />,
label: '用户管理',
},
{
key: '/xxl-job/course',
icon: <BulbOutlined />,
label: '使用教程',
},
]}
/>
</Layout.Sider>
{/* 内容 */}
<Layout>
{/* 头 */}
<Layout.Header style={{ padding: 0, background: colorBgContainer, display: "flex" }}>
<Button
type="text"
icon={collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
onClick={() => setCollapsed(!collapsed)}
style={{
fontSize: '16px',
width: 64,
height: 64,
}}
/>
<div style={{flex: '1 1 0%'}} />
<div className="header-right-content">
<div style={{height: '100%'}}>
<div className="header-right-content-inner">
<Dropdown key="translate" menu={{ items: translates}} placement="bottom" arrow={{ pointAtCenter: true }}>
<div className="ant-dropdown-trigger-str">
<TranslationOutlined />
</div>
</Dropdown>
<Dropdown key="items" menu={{ items, onClick: logoutClick}} placement="bottom" arrow={{ pointAtCenter: true }}>
<div className="ant-dropdown-trigger-logout">
<Avatar src="https://zos.alipayobjects.com/rmsportal/ODTLcjxAfvqbxHnVXCYX.png" />
<span>管理员</span>
</div>
</Dropdown>
</div>
</div>
</div>
</Layout.Header>
{/* 内容 */}
<Layout.Content style={{
margin: '16px 16px',
padding: 24,
minHeight: 280,
background: colorBgContainer,
borderRadius: borderRadiusLG,
}}>
<Outlet />
</Layout.Content>
</Layout>
<Modal title="修改密码" open={showModel} onOk={okUpdatePassword} onCancel={closeUpdatePassword}>
<Input placeholder="请输入新密码" value={password} onChange={e => setPassword(e.target.value)}/>
</Modal>
</Container>
这里的 Menu
菜单涉及到一个点击菜单切换路由的功能,代码如下:
const navigate = useNavigate(); // 路由切换 hooks
const [selectedKeys, setSelectedKeys] = useState<string[]>([DEFAULT_MENU_KEY]); // 默认路由
const clickMenu = (data: string) => {
setSelectedKeys([data]) // 选中菜单
navigate(data) // 切换路由
}
路由切换提现为组件切换,主要是通过
,前面的文章已经介绍过了。
接着我们这个需要在介绍下,Header
部分的代码,这里包含一个菜单的收缩按钮,另一个是就是一个用户头像(包含修改密码和退出登录);还有一个切换语言的下拉组件,后续还会添加一个主题切换的下拉按组件。这两个大同小异,代码如下:
const translates: MenuProps['items'] = [
{
key: 'chine',
label: (
<span>
<span role="img" aria-label="English" style={{marginRight: 8}}></span>
中文
</span>
)
},
{
key: 'english',
label: (
<span>
<span role="img" aria-label="English" style={{marginRight: 8}}></span>
English
</span>
)
}
];
const items: MenuProps['items'] = [
{
key: '1',
label: '修改密码',
icon: <EditOutlined />
},
{
key: '2',
label: '退出登录',
icon: <LogoutOutlined />,
danger: true
}
];
// 用户头像下拉组件的点击事件
const logoutClick: MenuProps['onClick'] = (e) => {
if (e.key === '2') {
logoutRequest.run(); // 退出登录
} else if (e.key === '1') {
setShowModel(true); // 修改密码的 model 弹窗控制事件
}
};
修改密码这里是通过模态框弹出一个Input
组件,用户输出新的密码,调用修改密码接口执行操作的。
<Modal title="修改密码" open={showModel} onOk={okUpdatePassword} onCancel={closeUpdatePassword}>
<Input placeholder="请输入新密码" value={password} onChange={e => setPassword(e.target.value)}/>
</Modal>
对应的点击事件代码如下:
const [showModel, setShowModel] = useState(false);
const [password, setPassword] = useState<string>("");
// 更新密码
const updatePasswordRequest = useRequest(UpdatePassword, {
manual: true,
onSuccess: (res) => {
message.success(res);
setShowModel(false);
}
})
// 模态框 确定按钮事件
const okUpdatePassword = () => {
updatePasswordRequest.run(password);
}
// 模态框 取消按钮事件
const closeUpdatePassword = () => {
setShowModel(false);
setPassword(''); // 这里需要清空 input 框内容
}
退出登录,后端接口会将当前的 cookie
清除,前端只需要调用退出登录的接口,并将返回登录页面,代码如下:
const logoutRequest = useRequest(UserLogout, {
manual: true,
onSuccess: (res) => {
message.success(res);
navigate(LOGIN_PAGE);
}
})
上面代码并没有全不展示出来,仅展示重要的代码。需要查看完整代码可以查看仓库:https://gitee.com/molonglove/xxl-job/tree/2.4.0.1/。
下一篇文章,我们将实现用户管理部分的代码页面和接口。