Compare commits

13 Commits

Author SHA1 Message Date
siujamo 83f3120fdc fix: add rel attribute to external link for security 2026-03-12 13:56:23 +08:00
zihluwang 9b642c85d0 chore(deps): update dependencies 2026-01-28 22:02:25 +08:00
siujamo d5074a92a2 feat: 支持多因素认证登录,更新相关类型定义 2026-01-28 17:05:38 +08:00
siujamo d5109e1333 refactor: 移除 Token 配置 2026-01-06 09:21:43 +08:00
siujamo 0d3b0bd28d refactor: 调整 axios 配置 2026-01-05 10:10:51 +08:00
siujamo a00488be5d refactor: 优化报错信息 2026-01-05 09:59:53 +08:00
siujamo 29ce5e45f1 refactor: 使用 useApp hook 获取 modal 上下文 2025-12-31 11:15:08 +08:00
siujamo d3723ba485 feat: 添加左上角应用名称自定义功能 2025-12-29 14:33:08 +08:00
siujamo 944d176240 feat: 完成角色删除功能对接 2025-12-29 14:20:38 +08:00
siujamo 1ae17823ef refactor: 优化错误处理逻辑 2025-12-29 11:49:44 +08:00
siujamo b0ccd8f832 feat: 完成角色编辑功能 2025-12-26 17:53:09 +08:00
siujamo 3b0bd56001 fix: 修复编辑表单打开后展示数据错误的问题 2025-12-26 17:49:56 +08:00
siujamo 8aaad677b6 feat: 完成创建角色功能 2025-12-26 16:00:40 +08:00
26 changed files with 1322 additions and 968 deletions
+2
View File
@@ -10,3 +10,5 @@ VITE_MSAL_TENANT_ID=msal-tenant-id
# - US: United States # - US: United States
# - DE: Germany # - DE: Germany
VITE_DEFAULT_REGION_ABBREVIATION=GB VITE_DEFAULT_REGION_ABBREVIATION=GB
# Application title
VITE_APP_TITLE='OnixByte Technology Co., Ltd'
+17 -17
View File
@@ -10,33 +10,33 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@ant-design/cssinjs": "^2.0.1", "@ant-design/cssinjs": "^2.0.3",
"@ant-design/icons": "^6.1.0", "@ant-design/icons": "^6.1.0",
"@azure/msal-browser": "^4.27.0", "@azure/msal-browser": "^4.28.1",
"@azure/msal-react": "^3.0.23", "@azure/msal-react": "^3.0.25",
"@reduxjs/toolkit": "^2.11.1", "@reduxjs/toolkit": "^2.11.2",
"@tailwindcss/vite": "^4.1.17", "@tailwindcss/vite": "^4.1.18",
"antd": "^6.1.0", "antd": "^6.2.2",
"axios": "^1.13.2", "axios": "^1.13.4",
"dayjs": "^1.11.19", "dayjs": "^1.11.19",
"libphonenumber-js": "^1.12.31", "libphonenumber-js": "^1.12.35",
"react": "^19.2.1", "react": "^19.2.4",
"react-dom": "^19.2.1", "react-dom": "^19.2.4",
"react-redux": "^9.2.0", "react-redux": "^9.2.0",
"react-router": "^7.10.1", "react-router": "^7.13.0",
"react-router-dom": "^7.10.1", "react-router-dom": "^7.13.0",
"redux-persist": "^6.0.0", "redux-persist": "^6.0.0",
"tailwindcss": "^4.1.17" "tailwindcss": "^4.1.18"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^20.19.26", "@types/node": "^20.19.30",
"@types/react": "^19.2.7", "@types/react": "^19.2.10",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^4.7.0", "@vitejs/plugin-react": "^4.7.0",
"globals": "^16.5.0", "globals": "^16.5.0",
"prettier": "^3.7.4", "prettier": "^3.8.1",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"vite": "^7.2.7", "vite": "^7.3.1",
"vite-plugin-svgr": "^4.5.0" "vite-plugin-svgr": "^4.5.0"
} }
} }
+880 -853
View File
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -1,6 +1,6 @@
import webClient from "@/service/web-client" import webClient from "@/client/web-client"
import { HttpStatus } from "@/constant" import { HttpStatus } from "@/constant"
import type { CaptchaResponse, UserAuthResponse } from "@/types/web/response" import type { CaptchaResponse, MfaAuthResponse, UserAuthResponse } from "@/types/web/response"
import type { UsernamePasswordLoginRequest } from "@/types/web/request" import type { UsernamePasswordLoginRequest } from "@/types/web/request"
/** /**
@@ -21,8 +21,8 @@ async function getCaptcha(): Promise<CaptchaResponse | null> {
*/ */
async function usernamePasswordLogin( async function usernamePasswordLogin(
request: UsernamePasswordLoginRequest request: UsernamePasswordLoginRequest
): Promise<UserAuthResponse | null> { ): Promise<UserAuthResponse | MfaAuthResponse | null> {
const { data } = await webClient.post<UserAuthResponse>("/auth/login", request) const { data } = await webClient.post<UserAuthResponse | MfaAuthResponse>("/auth/login", request)
return data return data
} }
+1 -1
View File
@@ -1,4 +1,4 @@
import webClient from "@/service/web-client" import webClient from "@/client/web-client"
import type { TreeNode } from "@/types/tree" import type { TreeNode } from "@/types/tree"
import type { Department } from "@/types/entity" import type { Department } from "@/types/entity"
+1 -1
View File
@@ -1,4 +1,4 @@
import webClient from "@/service/web-client" import webClient from "@/client/web-client"
import type { TreeNode } from "@/types/tree" import type { TreeNode } from "@/types/tree"
import type { MenuItem } from "@/types/entity" import type { MenuItem } from "@/types/entity"
+1 -1
View File
@@ -1,4 +1,4 @@
import webClient from "@/service/web-client" import webClient from "@/client/web-client"
import type { QueryPositionRequest } from "@/types/web/request" import type { QueryPositionRequest } from "@/types/web/request"
import type { PageResponse } from "@/types/web/response" import type { PageResponse } from "@/types/web/response"
import type { Position } from "@/types/entity" import type { Position } from "@/types/entity"
+14 -1
View File
@@ -1,6 +1,7 @@
import type { QueryRoleRequest } from "@/types/web/request" import type { QueryRoleRequest } from "@/types/web/request"
import webClient from "@/service/web-client" import webClient from "@/client/web-client"
import type { PageResponse, RoleResponse } from "@/types/web/response" import type { PageResponse, RoleResponse } from "@/types/web/response"
import type { RoleFormValues } from "@/components/role-display-form"
export async function fetchRoles( export async function fetchRoles(
request: QueryRoleRequest | null request: QueryRoleRequest | null
@@ -24,3 +25,15 @@ export async function fetchRoles(
const { data } = await webClient.get<RoleResponse>(`/roles?${params.toString()}`) const { data } = await webClient.get<RoleResponse>(`/roles?${params.toString()}`)
return data return data
} }
export async function addRole(request: RoleFormValues) {
return await webClient.post("/roles", request)
}
export async function editRole(request: RoleFormValues) {
return await webClient.put("/roles", request)
}
export async function deleteRole(id: number | string) {
return await webClient.delete(`/roles/${id}`)
}
+1 -1
View File
@@ -1,4 +1,4 @@
import webClient from "@/service/web-client" import webClient from "@/client/web-client"
import { getCountryCallingCode } from "libphonenumber-js" import { getCountryCallingCode } from "libphonenumber-js"
import { getDefaultCountryCode } from "@/utils/phone-number-utils" import { getDefaultCountryCode } from "@/utils/phone-number-utils"
import type { AddUserRequest, EditUserRequest, QueryUserRequest } from "@/types/web/request" import type { AddUserRequest, EditUserRequest, QueryUserRequest } from "@/types/web/request"
@@ -1,22 +1,19 @@
import axios, { type AxiosError } from "axios" import axios, { type AxiosError } from "axios"
import dayjs from "dayjs" import dayjs from "dayjs"
import store from "@/store" import store from "@/store"
import type { GeneralErrorResponse } from "@/types"
import { HttpStatus } from "@/constant" import { HttpStatus } from "@/constant"
import { logout } from "@/store/auth-slice" import { logout } from "@/store/auth-slice"
import type { GeneralErrorResponse } from "@/types/web/response"
const webClient = axios.create({ const webClient = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL, baseURL: import.meta.env.VITE_API_BASE_URL,
timeout: dayjs.duration({ seconds: 10 }).asMilliseconds(), timeout: dayjs.duration({ seconds: 10 }).asMilliseconds(),
withCredentials: true,
withXSRFToken: true,
}) })
webClient.interceptors.request.use( webClient.interceptors.request.use(
(config) => { (config) => {
const state = store.getState()
if (state.auth.isAuthenticated) {
config.headers["Authorization"] = `Bearer ${state.auth.token!}`
}
return config return config
}, },
(error: unknown) => { (error: unknown) => {
@@ -25,14 +22,17 @@ webClient.interceptors.request.use(
} }
) )
webClient.interceptors.response.use((response) => { webClient.interceptors.response.use(
return response (response) => {
}, (error: unknown) => { return response
const err = error as AxiosError<GeneralErrorResponse> },
if (err.response?.status == HttpStatus.UNAUTHORIZED) { (error: unknown) => {
store.dispatch(logout()) const err = error as AxiosError<GeneralErrorResponse>
if (err.response?.status == HttpStatus.UNAUTHORIZED) {
store.dispatch(logout())
}
return Promise.reject(error as AxiosError)
} }
return Promise.reject(error as AxiosError) )
})
export default webClient export default webClient
@@ -0,0 +1,24 @@
import type { FormInstance } from "antd"
import RoleDisplayForm, { type RoleFormValues } from "@/components/role-display-form"
export interface AddRoleDialogueProps {
form: FormInstance<RoleFormValues>
}
export default function AddRoleDialogue({ form }: AddRoleDialogueProps) {
return (
<RoleDisplayForm
form={form}
initialValues={{
id: null,
name: "",
code: "",
sort: 0,
description: null,
defaultValue: false,
status: "ACTIVE",
}}
mode="add"
/>
)
}
@@ -0,0 +1,12 @@
import type { FormInstance } from "antd"
import RoleDisplayForm, { type RoleFormValues } from "@/components/role-display-form"
import type { Role } from "@/types/entity"
export interface EditRoleDialogueProps {
form: FormInstance<RoleFormValues>
initialValues: RoleFormValues
}
export default function EditRoleDialogue({ form, initialValues }: EditRoleDialogueProps) {
return <RoleDisplayForm form={form} initialValues={initialValues} mode="edit" />
}
@@ -0,0 +1,82 @@
import { App, Form, type FormInstance, Input, InputNumber, Select, Switch } from "antd"
import { type Status, StatusOptions } from "@/types/constant"
import type { FormMode } from "@/types/form"
import { useEffect, useMemo } from "react"
/**
* Role form values.
*/
export interface RoleFormValues {
id: number | string | null
name: string
code: string
sort: number
defaultValue: boolean
description: string | null
status: Status
}
/**
* Component props.
*/
export interface RoleDisplayFormProps {
initialValues?: RoleFormValues
form: FormInstance<RoleFormValues>
mode: FormMode
}
export default function RoleDisplayForm({ initialValues, form, mode }: RoleDisplayFormProps) {
const isEditing = useMemo<boolean>(() => mode == "edit", [mode])
// Initialise form values
useEffect(() => {
if (initialValues) {
form.setFieldsValue(initialValues)
} else {
form.resetFields()
}
}, [initialValues, form])
return (
<Form<RoleFormValues>
form={form}
initialValues={initialValues}
layout="vertical"
labelAlign="right"
validateTrigger="onBlur">
<Form.Item<RoleFormValues> label="角色编号" hidden={!isEditing} name="id">
<Input disabled />
</Form.Item>
<Form.Item<RoleFormValues>
label="角色名称"
name="name"
rules={[{ required: true, message: "角色名称不能为空" }]}>
<Input />
</Form.Item>
<Form.Item<RoleFormValues>
label="角色编码"
name="code"
rules={[
{ required: true, message: "角色编码不能为空" },
{ pattern: /^[a-z-]+$/, message: "角色编码格式错误,仅支持小写英文字母及 '-'" },
]}>
<Input />
</Form.Item>
<Form.Item<RoleFormValues>
label="排序编码"
name="sort"
rules={[
{ required: true, message: "排序不能为空" },
{ type: "number", min: 0, max: Number.MAX_VALUE, message: "排序必须是正数" },
]}>
<InputNumber type="number" />
</Form.Item>
<Form.Item<RoleFormValues> label="是否为默认角色" name="defaultValue">
<Switch disabled={isEditing} />
</Form.Item>
<Form.Item<RoleFormValues> label="角色状态" name="status">
<Select<Status> options={StatusOptions} />
</Form.Item>
</Form>
)
}
+50 -39
View File
@@ -1,19 +1,24 @@
import React, { useEffect, useState } from "react" import { type ReactNode, useEffect, useMemo, useState } from "react"
import { useNavigate } from "react-router" import { Link, Navigate, useNavigate } from "react-router"
import { Avatar, Breadcrumb, Dropdown, Layout, Menu, type MenuProps, Modal, Space } from "antd" import { App, Avatar, Breadcrumb, Dropdown, Layout, Menu, type MenuProps, Space } from "antd"
import { DownOutlined } from "@ant-design/icons" import { DownOutlined } from "@ant-design/icons"
import { ApplicationLogo } from "@/components/icon" import { ApplicationLogo } from "@/components/icon"
import { useAppDispatch, useAppSelector } from "@/store" import { useAppDispatch, useAppSelector } from "@/store"
import { useAntBreadcrumbs } from "@/hooks" import { useAntBreadcrumbs } from "@/hooks"
import { logout } from "@/store/auth-slice" import { logout } from "@/store/auth-slice"
import { MenuApi } from "@/api" import { MenuApi } from "@/api"
import type { AxiosError } from "axios" import axios from "axios"
import type { TreeNode } from "@/types/tree" import type { TreeNode } from "@/types/tree"
import type { MenuItem } from "@/types/entity" import type { MenuItem } from "@/types/entity"
import { AppUtils } from "@/utils"
import type { GeneralErrorResponse } from "@/types/web/response"
const { Header, Footer, Sider, Content } = Layout
type AntMenuItem = Required<MenuProps>["items"][number] type AntMenuItem = Required<MenuProps>["items"][number]
export interface DashboardLayoutProps {
children: ReactNode
}
function transformMenuData(nodes: TreeNode<MenuItem>[]): AntMenuItem[] { function transformMenuData(nodes: TreeNode<MenuItem>[]): AntMenuItem[] {
if (!nodes || nodes.length === 0) { if (!nodes || nodes.length === 0) {
return [] return []
@@ -25,21 +30,33 @@ function transformMenuData(nodes: TreeNode<MenuItem>[]): AntMenuItem[] {
const { item, children } = node const { item, children } = node
const hasChildren = children && children.length > 0 const hasChildren = children && children.length > 0
const menuItem: AntMenuItem = { if (item.isVisible) {
key: item.code, const menuItem: AntMenuItem = {
label: item.name, key: item.code,
} label: item.name,
}
if (hasChildren) { if (item.path) {
// Append children if (item.isExternalLink) {
return { ...menuItem, children: transformMenuData(children) } menuItem.extra = <a href={item.path} target="_blank" rel="noopener noreferrer" />
} } else {
menuItem.extra = <Link to={item.path} />
}
}
return menuItem if (hasChildren) {
// Append children
return { ...menuItem, children: transformMenuData(children) }
}
return menuItem
}
return null
}) })
} }
export default function DashboardLayout({ children }: { children: React.ReactNode }) { export default function DashboardLayout({ children }: DashboardLayoutProps) {
const { modal, message } = App.useApp()
const user = useAppSelector((store) => store.auth.user!) const user = useAppSelector((store) => store.auth.user!)
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
const breadcrumbItems = useAntBreadcrumbs() const breadcrumbItems = useAntBreadcrumbs()
@@ -47,7 +64,7 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
const onLogout = ({ key }: { key: string }) => { const onLogout = ({ key }: { key: string }) => {
if (key == "logout") { if (key == "logout") {
Modal.confirm({ modal.confirm({
title: "确定要注销吗?", title: "确定要注销吗?",
okText: "确定", okText: "确定",
cancelText: "取消", cancelText: "取消",
@@ -69,8 +86,14 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
setMenuItems(transformMenuData(response)) setMenuItems(transformMenuData(response))
}) })
.catch((error: unknown) => { .catch((error: unknown) => {
const err = error as AxiosError console.log(error)
console.log(err) const errorMessage =
axios.isAxiosError<GeneralErrorResponse>(error) && error.response?.data.message
? error.response?.data.message
: "无法读取菜单数据"
void message.error({
content: errorMessage,
})
}) })
}, []) }, [])
@@ -82,12 +105,14 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
}, },
] ]
const appTitle = useMemo<string>(() => AppUtils.getAppTitle(), [])
return ( return (
<Layout className="h-[100%]"> <Layout className="h-full">
<Header className="flex items-center justify-between bg-gradient-to-br from-blue-50 to-indigo-100"> <Layout.Header className="flex items-center justify-between bg-linear-to-br from-blue-50 to-indigo-100">
<div className="flex gap-4 items-center"> <div className="flex gap-4 items-center">
<ApplicationLogo className="text-4xl" /> <ApplicationLogo className="text-4xl" />
<span className="text-xl">Onixbyte Hi-Tech Co., Ltd</span> <span className="text-xl">{appTitle}</span>
</div> </div>
<div className="flex gap-4 items-center"> <div className="flex gap-4 items-center">
@@ -101,32 +126,18 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
</Dropdown> </Dropdown>
<Avatar src={user.avatarUrl} alt="用户头像" /> <Avatar src={user.avatarUrl} alt="用户头像" />
</div> </div>
</Header> </Layout.Header>
<Layout> <Layout>
<Sider width={200} className="bg-white"> <Layout.Sider width={200} className="bg-white">
<Menu <Menu
mode="inline" mode="inline"
className="h-full max-h-full border-e-0" className="h-full max-h-full border-e-0"
items={menuItems} items={menuItems}
onSelect={({ key }) => {
console.log(`key = ${key}`)
switch (key) {
case "user-mgmt":
void navigate("/users")
break
case "role-mgmt":
void navigate("/roles")
break
case "menu-mgmt":
void navigate("/menus")
break
}
}}
/> />
</Sider> </Layout.Sider>
<Layout className="pt-0 px-6 pb-6"> <Layout className="pt-0 px-6 pb-6">
<Breadcrumb items={breadcrumbItems} className="my-4 mx-0" /> <Breadcrumb items={breadcrumbItems} className="my-4 mx-0" />
<Content className="p-6 m-0 min-h-70 bg-white">{children}</Content> <Layout.Content className="p-6 m-0 min-h-70 bg-white">{children}</Layout.Content>
</Layout> </Layout>
</Layout> </Layout>
</Layout> </Layout>
+19 -19
View File
@@ -1,9 +1,10 @@
import { type MouseEvent, useCallback, useEffect, useState } from "react" import { type MouseEvent, useCallback, useEffect, useState } from "react"
import { Link, useNavigate } from "react-router" import { Link, useNavigate } from "react-router"
import { Form, Input, Button, Card, message, Divider } from "antd" import { Form, Input, Button, Card, message, Divider, App } from "antd"
import dayjs from "dayjs" import dayjs from "dayjs"
import type { AxiosError } from "axios" import type { AxiosError } from "axios"
// import { useMsal } from "@azure/msal-react" import { GithubFilled } from "@ant-design/icons"
import { useMsal } from "@azure/msal-react"
import { AuthApi } from "@/api" import { AuthApi } from "@/api"
import { useAppDispatch, useAppSelector } from "@/store" import { useAppDispatch, useAppSelector } from "@/store"
import { loginSuccess, updateRegistrationEnabled } from "@/store/auth-slice" import { loginSuccess, updateRegistrationEnabled } from "@/store/auth-slice"
@@ -18,8 +19,6 @@ import {
DiscordFilled, DiscordFilled,
EmailFilled, EmailFilled,
} from "@/components/icon" } from "@/components/icon"
import { GithubFilled } from "@ant-design/icons"
import { fetchRegisterEnabled } from "@/api/auth" import { fetchRegisterEnabled } from "@/api/auth"
import type { UsernamePasswordLoginRequest } from "@/types/web/request" import type { UsernamePasswordLoginRequest } from "@/types/web/request"
import type { CaptchaResponse, GeneralErrorResponse } from "@/types/web/response" import type { CaptchaResponse, GeneralErrorResponse } from "@/types/web/response"
@@ -29,9 +28,7 @@ export default function LoginPage() {
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
const navigate = useNavigate() const navigate = useNavigate()
// const msalContext = useMsal() const { message } = App.useApp()
const [messageApi, contextHolder] = message.useMessage()
const [form] = Form.useForm<UsernamePasswordLoginRequest>() const [form] = Form.useForm<UsernamePasswordLoginRequest>()
// const [isLoading, setIsLoading] = useState<boolean>(false) // const [isLoading, setIsLoading] = useState<boolean>(false)
@@ -77,28 +74,33 @@ export default function LoginPage() {
console.log("Login values:", values) console.log("Login values:", values)
const loginResponse = await AuthApi.usernamePasswordLogin(values) const loginResponse = await AuthApi.usernamePasswordLogin(values)
if (loginResponse) {
if (loginResponse == null) {
message.error("登录失败:服务器响应异常。")
return
}
if ('mfaToken' in loginResponse) {
} else {
dispatch( dispatch(
loginSuccess({ loginSuccess({
user: loginResponse.user, user: loginResponse
token: loginResponse.accessToken,
}) })
) )
messageApi.success("登录成功", dayjs.duration({ seconds: 3 }).asSeconds()) message.success("登录成功", dayjs.duration({ seconds: 3 }).asSeconds())
await navigate("/") await navigate("/")
} else {
messageApi.error("登录失败:服务器响应异常。")
} }
} catch (errorInfo: unknown) { } catch (errorInfo: unknown) {
const error = errorInfo as AxiosError<GeneralErrorResponse> const error = errorInfo as AxiosError<GeneralErrorResponse>
console.log(error) console.log(error)
messageApi.error( message.error(
error.response?.data.message ?? "登录失败,请稍后再试", error.response?.data.message ?? "登录失败,请稍后再试",
dayjs.duration({ seconds: 3 }).asSeconds() dayjs.duration({ seconds: 3 }).asSeconds()
) )
} }
}, },
[dispatch, navigate, messageApi] [dispatch, navigate]
) )
/** /**
@@ -190,9 +192,7 @@ export default function LoginPage() {
} }
return ( return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 flex items-center justify-center p-4"> <div className="min-h-screen bg-linear-to-br from-blue-50 to-indigo-100 flex items-center justify-center p-4">
{contextHolder}
{/* 背景装饰元素 */} {/* 背景装饰元素 */}
<div className="absolute top-0 left-0 w-72 h-72 bg-blue-200 rounded-full mix-blend-multiply filter blur-xl opacity-70"></div> <div className="absolute top-0 left-0 w-72 h-72 bg-blue-200 rounded-full mix-blend-multiply filter blur-xl opacity-70"></div>
<div className="absolute top-0 right-0 w-72 h-72 bg-purple-200 rounded-full mix-blend-multiply filter blur-xl opacity-70"></div> <div className="absolute top-0 right-0 w-72 h-72 bg-purple-200 rounded-full mix-blend-multiply filter blur-xl opacity-70"></div>
@@ -275,7 +275,7 @@ export default function LoginPage() {
htmlType="submit" htmlType="submit"
block block
size="large" size="large"
className="h-12 rounded-lg font-semibold text-base bg-gradient-to-r from-blue-500 to-purple-600 border-0 hover:from-blue-600 hover:to-purple-700 transition-all duration-300 shadow-lg hover:shadow-xl"> className="h-12 rounded-lg font-semibold text-base bg-linear-to-r from-blue-500 to-purple-600 border-0 hover:from-blue-600 hover:to-purple-700 transition-all duration-300 shadow-lg hover:shadow-xl">
</Button> </Button>
</Form.Item> </Form.Item>
+139 -9
View File
@@ -1,10 +1,10 @@
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import type { AxiosError } from "axios" import axios, { type AxiosError } from "axios"
import { App, Button, Form, Input, Select, Space, Switch, Table } from "antd" import { App, Button, Form, Input, Select, Space, Switch, Table } from "antd"
import type { Role } from "@/types/entity" import type { Role } from "@/types/entity"
import { RoleApi } from "@/api" import { RoleApi } from "@/api"
import type { QueryRoleRequest } from "@/types/web/request" import type { QueryRoleRequest } from "@/types/web/request"
import type { GeneralErrorResponse, RoleResponse } from "@/types/web/response" import type { GeneralErrorResponse } from "@/types/web/response"
import { import {
DeleteOutlined, DeleteOutlined,
ExportOutlined, ExportOutlined,
@@ -15,15 +15,22 @@ import {
} from "@ant-design/icons" } from "@ant-design/icons"
import type { QueryRoleForm } from "@/types/form" import type { QueryRoleForm } from "@/types/form"
import type { Status } from "@/types/constant" import type { Status } from "@/types/constant"
import AddRoleDialogue from "@/components/add-role-dialogue"
import type { RoleFormValues } from "@/components/role-display-form"
import EditRoleDialogue from "@/components/edit-role-dialogue"
export default function RolePage() { export default function RolePage() {
const { message } = App.useApp() const { message, modal } = App.useApp()
const [queryForm] = Form.useForm<QueryRoleForm>() const [queryForm] = Form.useForm<QueryRoleForm>()
const [addRoleForm] = Form.useForm<RoleFormValues>()
const [editRoleForm] = Form.useForm<RoleFormValues>()
const [roles, setRoles] = useState<Role[]>([]) const [roles, setRoles] = useState<Role[]>([])
const [pageNum, setPageNum] = useState<number>(1) const [pageNum, setPageNum] = useState<number>(1)
const [pageSize, setPageSize] = useState<number>(10) const [pageSize, setPageSize] = useState<number>(10)
const [totalElementCount, setTotalElementCount] = useState<number>(0) const [totalElementCount, setTotalElementCount] = useState<number>(0)
const [isLastPage, setIsLastPage] = useState<boolean>(false)
const [isFirstPage, setIsFirstPage] = useState<boolean>(false)
const queryRoles = (pageNum: number, pageSize: number, queryRoleForm: QueryRoleForm | null) => { const queryRoles = (pageNum: number, pageSize: number, queryRoleForm: QueryRoleForm | null) => {
const queryRoleRequest: QueryRoleRequest = { const queryRoleRequest: QueryRoleRequest = {
@@ -36,11 +43,12 @@ export default function RolePage() {
RoleApi.fetchRoles(queryRoleRequest) RoleApi.fetchRoles(queryRoleRequest)
.then((response) => { .then((response) => {
console.log("role response", response)
setPageNum(response.pageable.pageNumber + 1) setPageNum(response.pageable.pageNumber + 1)
setPageSize(response.pageable.pageSize) setPageSize(response.pageable.pageSize)
setTotalElementCount(response.totalElements) setTotalElementCount(response.totalElements)
setRoles(response.content.sort((role1, role2) => role1.sort - role2.sort)) setRoles(response.content.sort((role1, role2) => role1.sort - role2.sort))
setIsLastPage(response.last)
setIsFirstPage(response.first)
}) })
.catch((error) => { .catch((error) => {
const err = error as AxiosError<GeneralErrorResponse> const err = error as AxiosError<GeneralErrorResponse>
@@ -55,6 +63,126 @@ export default function RolePage() {
) )
} }
const onAddRoleFinish = async () => {
try {
const values = await addRoleForm.validateFields()
console.log(values)
await RoleApi.addRole(values)
void message.success(`角色 ${values.name} 创建成功`)
return true
} catch (error: unknown) {
if (axios.isAxiosError<GeneralErrorResponse>(error)) {
// 处理 Axios 请求错误
const serverMsg = error.response?.data?.message
void message.error(serverMsg ?? "服务异常,请稍后再试")
} else {
// 处理其他预期外的错误(如代码逻辑 Bug)
console.error("未知错误:", error)
void message.error("发生系统错误,请联系管理员")
}
return false
}
}
const handleAddRole = () => {
modal
.confirm({
title: "添加用户",
content: <AddRoleDialogue form={addRoleForm} />,
width: 600,
onOk: onAddRoleFinish,
})
.then(
() => {
const formValues = queryForm.getFieldsValue()
queryRoles(pageNum, pageSize, formValues)
},
() => {
console.error("用户取消添加角色")
}
)
.finally(() => {
addRoleForm.resetFields()
})
}
const onEditRoleFinish = async () => {
try {
const values = await editRoleForm.validateFields()
console.log(values)
await RoleApi.editRole(values)
void message.success(`角色 ${values.name} 更新成功`)
return true
} catch (error: unknown) {
if (axios.isAxiosError<GeneralErrorResponse>(error)) {
const serverMsg = error.response?.data?.message
void message.error(serverMsg ?? "后端服务异常,请稍后再试")
} else {
console.error("未知错误:", error)
void message.error("发生系统错误,请联系管理员")
}
return false
}
}
const handleEditRole = (role: Role) => {
modal
.confirm({
title: "修改用户",
content: <EditRoleDialogue form={editRoleForm} initialValues={role} />,
width: 600,
onOk: onEditRoleFinish,
})
.then(
() => {
const formValues = queryForm.getFieldsValue()
queryRoles(pageNum, pageSize, formValues)
},
() => {
console.error("用户取消添加角色")
}
)
.finally(() => {
editRoleForm.resetFields()
})
}
const onDeleteRoleFinish = async (role: Role) => {
try {
await RoleApi.deleteRole(role.id)
void message.info(`角色 ${role.name} 删除成功`)
// Refresh roles
if (roles.length == 1 && isLastPage && !isFirstPage) {
setPageNum(pageNum - 1)
} else {
// Execute refresh manually
const searchParams = queryForm.getFieldsValue()
queryRoles(pageNum, pageSize, searchParams)
}
return true
} catch (error: unknown) {
if (axios.isAxiosError<GeneralErrorResponse>(error)) {
const serverMsg = error.response?.data?.message
void message.error(serverMsg ?? "服务异常,请稍后再试")
} else {
console.error("未知错误:", error)
void message.error("发生系统错误,请联系管理员")
}
return false
}
}
const handleDeleteRole = async (role: Role) => {
modal
.confirm({
title: "删除角色确认",
content: <></>,
onOk: () => onDeleteRoleFinish(role),
})
}
useEffect(() => { useEffect(() => {
queryRoles(pageNum, pageSize, null) queryRoles(pageNum, pageSize, null)
}, [pageNum, pageSize]) }, [pageNum, pageSize])
@@ -94,19 +222,19 @@ export default function RolePage() {
/> />
</Form.Item> </Form.Item>
<Form.Item<QueryRoleForm>> <Form.Item<QueryRoleForm>>
<Space.Compact> <Space>
<Button color="primary" variant="solid" htmlType="submit" icon={<SearchOutlined />}> <Button color="primary" variant="solid" htmlType="submit" icon={<SearchOutlined />}>
</Button> </Button>
<Button color="orange" variant="solid" htmlType="reset" icon={<UndoOutlined />}> <Button color="orange" variant="solid" htmlType="reset" icon={<UndoOutlined />}>
</Button> </Button>
</Space.Compact> </Space>
</Form.Item> </Form.Item>
</Form> </Form>
<Space size={8}> <Space size={8}>
<Button variant="solid" type="primary" icon={<PlusOutlined />} onClick={() => {}}> <Button variant="solid" type="primary" icon={<PlusOutlined />} onClick={handleAddRole}>
</Button> </Button>
<Button variant="solid" danger icon={<DeleteOutlined />}> <Button variant="solid" danger icon={<DeleteOutlined />}>
@@ -153,8 +281,10 @@ export default function RolePage() {
render: (role: Role) => ( render: (role: Role) => (
<> <>
<Space.Compact> <Space.Compact>
<Button variant="solid"></Button> <Button variant="solid" onClick={() => handleEditRole(role)}>
<Button variant="solid" danger>
</Button>
<Button variant="solid" danger onClick={() => handleDeleteRole(role)}>
</Button> </Button>
</Space.Compact> </Space.Compact>
-5
View File
@@ -4,14 +4,12 @@ import type { User } from "@/types/entity"
interface AuthState { interface AuthState {
isAuthenticated: boolean isAuthenticated: boolean
user: User | null user: User | null
token: string | null,
registrationEnabled: boolean registrationEnabled: boolean
} }
const initialState: AuthState = { const initialState: AuthState = {
isAuthenticated: false, isAuthenticated: false,
user: null, user: null,
token: null,
registrationEnabled: false registrationEnabled: false
} }
@@ -23,18 +21,15 @@ const authSlice = createSlice({
state, state,
action: PayloadAction<{ action: PayloadAction<{
user: User user: User
token: string
}> }>
) { ) {
console.log("更新用户信息:", action.payload.user) console.log("更新用户信息:", action.payload.user)
state.isAuthenticated = true state.isAuthenticated = true
state.user = action.payload.user state.user = action.payload.user
state.token = action.payload.token
}, },
logout(state) { logout(state) {
state.isAuthenticated = false state.isAuthenticated = false
state.user = null state.user = null
state.token = null
}, },
updateRegistrationEnabled(state, action: PayloadAction<boolean>) { updateRegistrationEnabled(state, action: PayloadAction<boolean>) {
state.registrationEnabled = action.payload state.registrationEnabled = action.payload
+20
View File
@@ -12,3 +12,23 @@ export interface AntTreeSelectOption<T> {
selectable?: boolean selectable?: boolean
checkable?: boolean checkable?: boolean
} }
export interface AntSelectOptionItem {
label: string
value: string
}
export type AntSelectOption = AntSelectOptionItem[]
export interface AntFieldError {
errors: string[]
name: string[]
warnings: unknown[]
}
export interface AntFormValidationError<T> {
values: T
message: string
outOfDate: boolean
errorFields: AntFieldError[]
}
+12
View File
@@ -1,6 +1,18 @@
import type { AntSelectOption } from "@/types/antd"
/** /**
* Status * Status
*/ */
export type Status = "ACTIVE" | "INACTIVE" export type Status = "ACTIVE" | "INACTIVE"
export type UserStatus = Status | "LOCKED" export type UserStatus = Status | "LOCKED"
export const StatusOptions: AntSelectOption = [
{ label: "已启用", value: "ACTIVE" },
{ label: "已停用", value: "INACTIVE" },
]
export const UserStatusOptions: AntSelectOption = [
...StatusOptions,
{ label: "已禁用", value: "LOCKED" },
]
+1
View File
@@ -30,6 +30,7 @@ export interface MenuItem {
parentId: number | null parentId: number | null
code: string code: string
sort: number sort: number
path: string | null
isExternalLink: boolean isExternalLink: boolean
isVisible: boolean isVisible: boolean
status: Status status: Status
+2
View File
@@ -3,6 +3,8 @@ import type { CountryCode as RegionAbbreviation } from "libphonenumber-js"
import type { Status } from "@/types/constant" import type { Status } from "@/types/constant"
import type { Dayjs } from "dayjs" import type { Dayjs } from "dayjs"
export type FormMode = "add" | "edit"
export interface UserFormValues extends Omit< export interface UserFormValues extends Omit<
User, User,
"id" | "password" | "regionAbbreviation" | "departmentId" | "positionId" | "createdAt" | "updatedAt" "id" | "password" | "regionAbbreviation" | "departmentId" | "positionId" | "createdAt" | "updatedAt"
+5 -3
View File
@@ -14,9 +14,11 @@ export interface PageResponse<T> {
pageable: Pageable pageable: Pageable
} }
export interface UserAuthResponse { export interface UserAuthResponse extends User {
user: User }
accessToken: string
export interface MfaAuthResponse {
mfaToken: string
} }
export interface UserDetailResponse extends User { export interface UserDetailResponse extends User {
+5
View File
@@ -0,0 +1,5 @@
import type { AntFormValidationError } from "@/types/antd"
export function getFieldErrorMessage(error: AntFormValidationError<unknown>): string {
return error.errorFields.map((errorField) => errorField.errors.join("")).join("")
}
+6
View File
@@ -0,0 +1,6 @@
/**
* Get application title.
*/
export function getAppTitle(): string {
return import.meta.env.VITE_APP_TITLE ?? "Helix"
}
+2
View File
@@ -1,2 +1,4 @@
export * as PhoneNumberUtils from "./phone-number-utils" export * as PhoneNumberUtils from "./phone-number-utils"
export * as DepartmentUtils from "./department-utils" export * as DepartmentUtils from "./department-utils"
export * as AntUtils from "./ant-utils"
export * as AppUtils from "./app-utils"
+8
View File
@@ -19,7 +19,15 @@ interface ImportMetaEnv {
*/ */
readonly VITE_MSAL_TENANT_ID: string readonly VITE_MSAL_TENANT_ID: string
/**
* Default region for new users
*/
readonly VITE_DEFAULT_REGION_ABBREVIATION: RegionAbbreviation readonly VITE_DEFAULT_REGION_ABBREVIATION: RegionAbbreviation
/**
* Application title, can be set to any preferred string as your wish.
*/
readonly VITE_APP_TITLE: string
} }
interface ImportMeta { interface ImportMeta {