feat: 初始提交

This commit is contained in:
siujamo
2025-12-25 16:12:01 +08:00
commit faff32475f
77 changed files with 6123 additions and 0 deletions
+68
View File
@@ -0,0 +1,68 @@
import webClient from "@/service/web-client"
import { HttpStatus } from "@/constant"
import type { CaptchaResponse, UserAuthResponse } from "@/types/web/response"
import type { UsernamePasswordLoginRequest } from "@/types/web/request"
/**
* 获取验证码图片及验证码 UUID
*/
async function getCaptcha(): Promise<CaptchaResponse | null> {
const { data, status } = await webClient.get<CaptchaResponse | null>("/captcha")
if (status == HttpStatus.OK) {
return data as CaptchaResponse
} else {
return null
}
}
/**
* 使用用户名密码登录
* @param request 用户名密码
*/
async function usernamePasswordLogin(
request: UsernamePasswordLoginRequest
): Promise<UserAuthResponse | null> {
const { data } = await webClient.post<UserAuthResponse>("/auth/login", request)
return data
}
/**
* 使用企业微信登录
* @param code 由企业微信提供的身份验证 code
*/
async function wecomLogin(code: string): Promise<UserAuthResponse> {
const urlSearchParams = new URLSearchParams()
urlSearchParams.append("code", code)
const { data } = await webClient.get<UserAuthResponse>(`/auth/wecom/login?${urlSearchParams.toString()}`)
return data
}
/**
* 使用 Microsoft Entra 登录
* @param msalToken 由 Microsoft Entra 提供的用户身份令牌
*/
async function msalLogin(msalToken: string): Promise<UserAuthResponse> {
const { data, headers } = await webClient.post<UserAuthResponse>(`/auth/msal/login`, {
msalToken,
})
const token = (headers as Record<string, string>).authorization ?? ""
if (!token) {
return Promise.reject(new Error("未获取到身份令牌,登录失败"))
}
return data
}
/**
* 获取注册功能是否启用
*/
async function fetchRegisterEnabled() {
const { data } = await webClient.get<boolean>("/auth/register-enabled")
return data
}
export { usernamePasswordLogin, wecomLogin, msalLogin, getCaptcha, fetchRegisterEnabled }
+13
View File
@@ -0,0 +1,13 @@
import webClient from "@/service/web-client"
import type { TreeNode } from "@/types/tree"
import type { Department } from "@/types/entity"
export async function fetchDepartmentTree(): Promise<TreeNode<Department>> {
const { data } = await webClient.get<TreeNode<Department>>("/departments/tree")
return data
}
export async function fetchDepartments(): Promise<Department[]> {
const { data } = await webClient.get<Department[]>("/departments")
return data
}
+6
View File
@@ -0,0 +1,6 @@
export * as AuthApi from "./auth"
export * as MenuApi from "./menu"
export * as DeptApi from "./department"
export * as UserApi from "./user"
export * as PositionApi from "./position"
export * as RoleApi from "./role"
+8
View File
@@ -0,0 +1,8 @@
import webClient from "@/service/web-client"
import type { TreeNode } from "@/types/tree"
import type { MenuItem } from "@/types/entity"
export async function fetchMenuTree() {
const { data } = await webClient.get<TreeNode<MenuItem>[]>("/menus")
return data
}
+9
View File
@@ -0,0 +1,9 @@
import webClient from "@/service/web-client"
import type { QueryPositionRequest } from "@/types/web/request"
import type { PageResponse } from "@/types/web/response"
import type { Position } from "@/types/entity"
export async function fetchPositions(request: QueryPositionRequest): Promise<PageResponse<Position>> {
const { data } = await webClient.get<PageResponse<Position>>("/positions")
return data
}
+26
View File
@@ -0,0 +1,26 @@
import type { QueryRoleRequest } from "@/types/web/request"
import webClient from "@/service/web-client"
import type { PageResponse, RoleResponse } from "@/types/web/response"
export async function fetchRoles(
request: QueryRoleRequest | null
): Promise<RoleResponse> {
const params = new URLSearchParams()
params.append("pageNum", `${request?.pageNum ?? 1}`)
params.append("pageSize", `${request?.pageSize ?? 1}`)
if (request?.name) {
params.append("name", request.name)
}
if (request?.code) {
params.append("code", request.code)
}
if (request?.status) {
params.append("status", request.status)
}
const { data } = await webClient.get<RoleResponse>(`/roles?${params.toString()}`)
return data
}
+88
View File
@@ -0,0 +1,88 @@
import webClient from "@/service/web-client"
import { getCountryCallingCode } from "libphonenumber-js"
import { getDefaultCountryCode } from "@/utils/phone-number-utils"
import type { AddUserRequest, EditUserRequest, QueryUserRequest } from "@/types/web/request"
import type { PageResponse, UserDetailResponse } from "@/types/web/response"
import type { UserFormValues } from "@/types/form"
export async function fetchUsers(
request?: QueryUserRequest
): Promise<PageResponse<UserDetailResponse>> {
const urlSearchParam = new URLSearchParams()
urlSearchParam.append("pageNum", `${request?.pageNum ?? 1}`)
urlSearchParam.append("pageSize", `${request?.pageSize ?? 10}`)
if (request?.departmentId) {
urlSearchParam.append("departmentId", `${request.departmentId}`)
}
if (request?.username) {
urlSearchParam.append("username", request.username)
}
if (request?.regionAbbreviation) {
urlSearchParam.append("regionAbbreviation", request.regionAbbreviation)
}
if (request?.phoneNumber) {
urlSearchParam.append("phoneNumber", request.phoneNumber)
}
if (request?.status) {
urlSearchParam.append("status", request.status)
}
if (request?.createdAtStart) {
urlSearchParam.append("createdAtStart", request.createdAtStart)
}
if (request?.createdAtEnd) {
urlSearchParam.append("createdAtEnd", request.createdAtEnd)
}
const { data } = await webClient.get<PageResponse<UserDetailResponse>>(
`/users?${urlSearchParam.toString()}`
)
return data
}
export async function fetchUserById(userId: number): Promise<UserDetailResponse> {
const { data } = await webClient.get<UserDetailResponse>(`/users/${userId}`)
return data
}
export async function addUser(values: UserFormValues) {
await webClient.post("/users", {
username: values.username,
password: values.password,
fullName: values.fullName,
email: values.email,
regionAbbreviation: values.regionAbbreviation ?? getDefaultCountryCode(),
phoneNumber: values.phoneNumber,
avatarUrl: values.avatarUrl,
status: values.status,
departmentId: values.departmentId,
positionId: values.positionId,
roleIds: [],
} as AddUserRequest)
}
export async function editUser(values: UserFormValues) {
await webClient.put("/users", {
id: values.id,
username: values.username,
fullName: values.fullName,
email: values.email,
regionAbbreviation: values.regionAbbreviation ?? getDefaultCountryCode(),
phoneNumber: values.phoneNumber,
avatarUrl: values.avatarUrl,
status: values.status,
departmentId: values.departmentId,
positionId: values.positionId,
roleIds: [],
} as EditUserRequest)
}
export async function deleteUser(userId: number | string) {
await webClient.delete(`/users/${userId}`)
}
+1
View File
@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1755053408771" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="3503" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M908.474 381.92c-1.767 7.47-6.128 18.419-12.246 31.624h0.132l-0.7 1.223c-35.695 76.492-128.894 226.512-128.894 226.512s-0.119-0.354-0.486-0.938l-27.235 47.485h131.254L619.61 1021.705l56.903-227.129H573.236l35.886-150.19c-29.033 7.011-63.35 16.654-103.993 29.74 0 0-54.982 32.249-158.382-62.037 0 0-69.736-61.533-29.304-76.909 17.196-6.538 83.49-14.832 135.645-21.894 70.487-9.543 113.848-14.596 113.848-14.596s-217.316 3.255-268.87-4.866c-51.557-8.12-116.959-94.275-130.89-170.019 0 0-21.547-41.58 46.334-21.894s348.855 76.632 348.855 76.632-365.423-112.204-389.74-139.578c-24.312-27.374-71.557-149.423-65.41-224.42 0 0 2.665-18.698 21.804-13.687 0 0 270.149 123.64 454.875 191.323 184.724 67.683 345.33 102.108 324.58 189.739z" fill="#3296FA" p-id="3504"></path></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

+1
View File
@@ -0,0 +1 @@
<svg height="1828" viewBox="-10.63 -.07077792 823.87 610.06955549" width="2500" xmlns="http://www.w3.org/2000/svg"><path d="m678.27 51.62c90.35 132.84 134.97 282.68 118.29 455.18-.07.73-.45 1.4-1.05 1.84-68.42 50.24-134.71 80.73-200.07 100.95a2.55 2.55 0 0 1 -2.81-.95c-15.1-21.01-28.82-43.16-40.84-66.42-.69-1.37-.06-3.02 1.36-3.56 21.79-8.21 42.51-18.05 62.44-29.7 1.57-.92 1.67-3.17.22-4.25-4.23-3.14-8.42-6.44-12.43-9.74-.75-.61-1.76-.73-2.61-.32-129.39 59.75-271.13 59.75-402.05 0-.85-.38-1.86-.25-2.59.35-4 3.3-8.2 6.57-12.39 9.71-1.45 1.08-1.33 3.33.25 4.25 19.93 11.43 40.65 21.49 62.41 29.74 1.41.54 2.08 2.15 1.38 3.52-11.76 23.29-25.48 45.44-40.86 66.45-.67.85-1.77 1.24-2.81.92-65.05-20.22-131.34-50.71-199.76-100.95-.57-.44-.98-1.14-1.04-1.87-13.94-149.21 14.47-300.29 118.18-455.18.25-.41.63-.73 1.07-.92 51.03-23.42 105.7-40.65 162.84-50.49 1.04-.16 2.08.32 2.62 1.24 7.06 12.5 15.13 28.53 20.59 41.63 60.23-9.2 121.4-9.2 182.89 0 5.46-12.82 13.25-29.13 20.28-41.63a2.47 2.47 0 0 1 2.62-1.24c57.17 9.87 111.84 27.1 162.83 50.49.45.19.82.51 1.04.95zm-339.04 283.7c.63-44.11-31.53-80.61-71.9-80.61-40.04 0-71.89 36.18-71.89 80.61 0 44.42 32.48 80.6 71.89 80.6 40.05 0 71.9-36.18 71.9-80.6zm265.82 0c.63-44.11-31.53-80.61-71.89-80.61-40.05 0-71.9 36.18-71.9 80.61 0 44.42 32.48 80.6 71.9 80.6 40.36 0 71.89-36.18 71.89-80.6z" fill="#5865f2"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+1
View File
@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1762846300029" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="8286" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M874.666667 181.333333H149.333333c-40.533333 0-74.666667 34.133333-74.666666 74.666667v512c0 40.533333 34.133333 74.666667 74.666666 74.666667h725.333334c40.533333 0 74.666667-34.133333 74.666666-74.666667V256c0-40.533333-34.133333-74.666667-74.666666-74.666667z m-725.333334 64h725.333334c6.4 0 10.666667 4.266667 10.666666 10.666667v25.6L512 516.266667l-373.333333-234.666667V256c0-6.4 4.266667-10.666667 10.666666-10.666667z m725.333334 533.333334H149.333333c-6.4 0-10.666667-4.266667-10.666666-10.666667V356.266667l356.266666 224c4.266667 4.266667 10.666667 4.266667 17.066667 4.266666s12.8-2.133333 17.066667-4.266666l356.266666-224V768c0 6.4-4.266667 10.666667-10.666666 10.666667z" fill="#666666" p-id="8287"></path></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+1
View File
@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1762845900662" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="6418" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M932.317184 567.76704L885.10464 422.46144l-93.57312-287.997952c-4.8128-14.81728-25.776128-14.81728-30.590976 0L667.36128 422.459392H356.62848L263.051264 134.46144c-4.8128-14.81728-25.776128-14.81728-30.593024 0l-93.57312 287.997952-47.210496 145.309696a32.165888 32.165888 0 0 0 11.68384 35.96288l408.6272 296.890368L920.61696 603.734016c11.272192-8.192 15.990784-22.71232 11.68384-35.964928" fill="#FC6D26" p-id="6419"></path><path d="M512.002048 900.62848l155.365376-478.171136H356.634624z" fill="#E24329" p-id="6420"></path><path d="M512.004096 900.62848L356.63872 422.47168H138.901504z" fill="#FC6D26" p-id="6421"></path><path d="M138.891264 422.465536l-47.214592 145.309696a32.16384 32.16384 0 0 0 11.685888 35.96288L511.991808 900.62848z" fill="#FCA326" p-id="6422"></path><path d="M138.893312 422.459392h217.737216L263.053312 134.46144c-4.8128-14.819328-25.778176-14.819328-30.590976 0z" fill="#E24329" p-id="6423"></path><path d="M512.002048 900.62848l155.365376-478.154752H885.10464z" fill="#FC6D26" p-id="6424"></path><path d="M885.11488 422.465536l47.214592 145.309696a32.16384 32.16384 0 0 1-11.685888 35.96288L512.014336 900.62848z" fill="#FCA326" p-id="6425"></path><path d="M885.096448 422.459392H667.36128l93.577216-287.997952c4.814848-14.819328 25.778176-14.819328 30.590976 0z" fill="#E24329" p-id="6426"></path></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

+1
View File
@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1762845811623" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="5438" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M214.101333 512c0-32.512 5.546667-63.701333 15.36-92.928L57.173333 290.218667A491.861333 491.861333 0 0 0 4.693333 512c0 79.701333 18.858667 154.88 52.394667 221.610667l172.202667-129.066667A290.56 290.56 0 0 1 214.101333 512" fill="#FBBC05" p-id="5439"></path><path d="M516.693333 216.192c72.106667 0 137.258667 25.002667 188.458667 65.962667L854.101333 136.533333C763.349333 59.178667 646.997333 11.392 516.693333 11.392c-202.325333 0-376.234667 113.28-459.52 278.826667l172.373334 128.853333c39.68-118.016 152.832-202.88 287.146666-202.88" fill="#EA4335" p-id="5440"></path><path d="M516.693333 807.808c-134.357333 0-247.509333-84.864-287.232-202.88l-172.288 128.853333c83.242667 165.546667 257.152 278.826667 459.52 278.826667 124.842667 0 244.053333-43.392 333.568-124.757333l-163.584-123.818667c-46.122667 28.458667-104.234667 43.776-170.026666 43.776" fill="#34A853" p-id="5441"></path><path d="M1005.397333 512c0-29.568-4.693333-61.44-11.648-91.008H516.650667V614.4h274.602666c-13.696 65.962667-51.072 116.650667-104.533333 149.632l163.541333 123.818667c93.994667-85.418667 155.136-212.650667 155.136-375.850667" fill="#4285F4" p-id="5442"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+1
View File
@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1762844620229" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="5184" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M0 0h1024v1024H0V0z m244.622222 193.422222c6.6048 19.820089 11.593956 22.840889 28.091734 34.844445C302.887822 251.044978 330.763378 275.717689 358.4 301.511111l13.641956 12.515556C435.325156 374.328889 496.116622 450.2528 534.755556 529.066667v22.755555c-12.686222 11.576889-12.686222 11.576889-29.513956 23.113956l-16.594489 11.576889L472.177778 597.333333l-14.153956 10.643911C443.733333 614.4 443.733333 614.4 427.554133 611.202844c-107.679289-49.043911-207.9232-121.400889-289.934222-206.648888C126.674489 391.224889 126.674489 391.224889 113.777778 392.533333a18880.682667 18880.682667 0 0 0-1.479111 140.942223c-0.142222 21.816889-0.341333 43.633778-0.6656 65.444977a5641.563022 5641.563022 0 0 0-0.551823 63.186489c-0.056889 8.027022-0.159289 16.054044-0.312888 24.081067a1867.036444 1867.036444 0 0 0-0.221867 33.780622l-0.187733 19.410489c5.125689 25.856 20.997689 38.269156 42.183111 52.736l9.568711 4.886756 10.934044 5.597866 11.491556 5.575111 12.026311 5.927823c114.346667 54.516622 245.731556 61.513956 365.550933 20.718933 151.614578-55.153778 240.497778-162.9184 312.888889-302.290489l8.391111-16.145067c5.461333-10.552889 10.882844-21.122844 16.270222-31.715555 12.982044-25.2416 25.673956-48.014222 44.691912-69.381689-4.881067-12.8-4.881067-12.8-19.626667-17.464889C859.704889 376.638578 799.857778 374.328889 733.866667 392.533333l-1.553067-19.933866c-3.345067-26.447644-12.481422-47.286044-24.758044-70.729956L701.44 289.905778c-18.875733-35.9424-41.216-67.726222-69.973333-96.483556-16.816356-1.672533-16.816356-1.672533-36.807111-1.291378h-11.434667c-12.498489 0-24.991289 0.091022-37.489778 0.182045l-25.969778 0.045511c-22.806756 0.045511-45.607822 0.159289-68.408889 0.284444-23.256178 0.113778-46.518044 0.170667-69.779911 0.227556-45.653333 0.113778-91.306667 0.312889-136.954311 0.551822z" fill="#FEFEFE" p-id="5185"></path><path d="M113.777778 392.533333c16.611556 7.953067 27.653689 15.251911 40.180622 28.802845 126.765511 129.991111 331.172978 252.683378 514.844444 268.8 49.237333-0.762311 98.9184-17.590044 133.330489-52.980622l11.377778 5.688888c-86.971733 112.878933-195.4816 189.2352-337.755022 213.799823C354.833067 870.570667 224.398222 848.2816 125.155556 773.688889c-13.448533-20.1728-12.765867-28.529778-12.669156-52.4288v-22.084267l0.182044-23.864889 0.045512-24.416711c0.045511-21.407289 0.159289-42.814578 0.284444-64.221866 0.113778-21.845333 0.170667-43.702044 0.227556-65.547378A46936.746667 46936.746667 0 0 1 113.777778 392.533333z" fill="#3171FE" p-id="5186"></path><path d="M244.622222 193.422222c49.749333-1.058133 99.498667-1.865956 149.253689-2.3552 23.108267-0.238933 46.205156-0.5632 69.307733-1.080889 22.317511-0.494933 44.629333-0.762311 66.946845-0.881777 8.4992-0.085333 16.992711-0.250311 25.486222-0.494934 66.417778-1.848889 66.417778-1.848889 89.759289 14.637511A216.558933 216.558933 0 0 1 671.288889 238.933333l11.798755 17.379556c52.718933 92.16 52.718933 92.16 50.779023 136.220444l11.377777 5.688889-10.643911 3.777422-14.597689 5.467023-14.205155 5.199644c-46.432711 22.124089-81.891556 53.327644-117.356089 90.089245l-12.452978 12.686222A5490.0224 5490.0224 0 0 0 546.133333 546.133333a1010.961067 1010.961067 0 0 1-23.131022-36.443022C493.983289 461.909333 463.701333 417.547378 426.666667 375.466667c-4.152889-4.778667-8.305778-9.545956-12.572445-14.466845-44.987733-50.898489-91.022222-97.934222-146.067911-137.864533C260.039111 217.258667 252.302222 211.057778 244.622222 204.8v-11.377778z" fill="#01D6BA" p-id="5187"></path><path d="M938.666667 403.911111l5.688889 11.377778-7.4752 9.984c-27.022222 37.182578-47.775289 75.531378-67.549867 116.952178a7167.106844 7167.106844 0 0 1-17.3056 35.84l-7.537778 15.786666c-26.168889 47.314489-68.1984 78.984533-119.864889 94.151111C629.105778 712.533333 529.789156 662.272 443.733333 625.777778v-11.377778c8.664178-5.802667 17.618489-11.167289 26.669511-16.355556 47.866311-29.622044 87.552-68.437333 126.464-108.845511C688.651378 394.183111 811.121778 348.171378 938.666667 403.911111z" fill="#0C3A9B" p-id="5188"></path></svg>

After

Width:  |  Height:  |  Size: 4.3 KiB

+66
View File
@@ -0,0 +1,66 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="500"
zoomAndPan="magnify" viewBox="0 0 375 374.999991" height="500"
preserveAspectRatio="xMidYMid meet" version="1.0">
<metadata/>
<defs>
<g/>
</defs>
<rect x="-37.5" width="450" fill="#ffffff" y="-37.499999" height="449.999989" fill-opacity="1"/>
<rect x="-37.5" width="450" fill="#ffffff" y="-37.499999" height="449.999989" fill-opacity="1"/>
<g fill="#324370" fill-opacity="1">
<g transform="translate(27.506677, 211.874986)">
<g>
<path d="M 12.359375 -0.453125 C 8.003906 -1.816406 5.003906 -4.3125 3.359375 -7.9375 C 1.710938 -11.5625 1.550781 -16.253906 2.875 -22.015625 C 4.195312 -27.773438 6.519531 -32.46875 9.84375 -36.09375 C 13.175781 -39.71875 17.34375 -42.210938 22.34375 -43.578125 Z M 27.578125 -34.109375 L 30.015625 -44.796875 L 31.875 -44.796875 C 39.46875 -44.796875 44.882812 -42.851562 48.125 -38.96875 C 51.363281 -35.09375 52.128906 -29.441406 50.421875 -22.015625 C 48.722656 -14.585938 45.351562 -8.929688 40.3125 -5.046875 C 35.28125 -1.171875 28.96875 0.765625 21.375 0.765625 C 20.519531 0.765625 19.898438 0.742188 19.515625 0.703125 L 21.953125 -9.921875 C 22.722656 -9.835938 23.34375 -9.796875 23.8125 -9.796875 C 26.84375 -9.796875 29.367188 -10.710938 31.390625 -12.546875 C 33.421875 -14.378906 34.796875 -16.851562 35.515625 -19.96875 L 36.421875 -24.0625 C 37.140625 -27.175781 36.910156 -29.648438 35.734375 -31.484375 C 34.566406 -33.316406 32.46875 -34.234375 29.4375 -34.234375 C 28.96875 -34.234375 28.347656 -34.191406 27.578125 -34.109375 Z M 27.578125 -34.109375 "/>
</g>
</g>
</g>
<g fill="#324370" fill-opacity="1">
<g transform="translate(80.815405, 211.874986)">
<g>
<path d="M 31.171875 -34.5625 C 28.816406 -34.5625 26.363281 -34.003906 23.8125 -32.890625 L 21.953125 -24.890625 C 22.460938 -25.066406 23.0625 -25.15625 23.75 -25.15625 C 25.15625 -25.15625 26.125 -24.71875 26.65625 -23.84375 C 27.1875 -22.96875 27.28125 -21.804688 26.9375 -20.359375 L 22.265625 0 L 35 0 L 40.0625 -21.890625 C 41 -25.984375 40.726562 -29.117188 39.25 -31.296875 C 37.78125 -33.472656 35.085938 -34.5625 31.171875 -34.5625 Z M 17.28125 -19.96875 L 17.796875 -28.671875 L 18.171875 -33.796875 L 7.75 -33.796875 L -0.0625 0 L 12.671875 0 L 17.21875 -19.84375 Z M 17.28125 -19.96875 "/>
</g>
</g>
</g>
<g fill="#324370" fill-opacity="1">
<g transform="translate(123.500788, 211.874986)">
<g>
<path d="M 19.515625 -33.796875 L 11.703125 0 L -1.03125 0 L 6.78125 -33.796875 Z M 22.46875 -46.40625 L 20.484375 -37.765625 L 7.75 -37.765625 L 9.71875 -46.40625 Z M 22.46875 -46.40625 "/>
</g>
</g>
</g>
<g fill="#324370" fill-opacity="1">
<g transform="translate(144.811483, 211.874986)">
<g>
<path d="M 45.5625 -33.796875 L 29.953125 -17.59375 L 38.53125 0 L 23.671875 0 L 19.703125 -10.625 L 19.453125 -10.625 L 10.5625 0 L -3.578125 0 L 13.5625 -17.921875 L 5.5 -33.796875 L 20.484375 -33.796875 L 23.8125 -24.828125 L 24.0625 -24.828125 L 31.484375 -33.796875 Z M 45.5625 -33.796875 "/>
</g>
</g>
</g>
<g fill="#324370" fill-opacity="1">
<g transform="translate(187.496867, 211.874986)">
<g>
<path d="M 24 -44.03125 L 13.890625 0 L -0.25 0 L 9.859375 -44.03125 Z M 29.25 -34.375 L 31.421875 -44.03125 L 39.171875 -44.03125 C 42.578125 -44.03125 45.21875 -43.003906 47.09375 -40.953125 C 48.976562 -38.910156 49.554688 -36.332031 48.828125 -33.21875 C 47.585938 -27.800781 44.347656 -24.382812 39.109375 -22.96875 L 39.109375 -22.71875 C 44.390625 -21.4375 46.328125 -17.769531 44.921875 -11.71875 C 44.109375 -8.257812 42.21875 -5.441406 39.25 -3.265625 C 36.289062 -1.085938 33.019531 0 29.4375 0 L 21.3125 0 L 23.671875 -10.234375 L 27 -10.234375 C 27.601562 -10.234375 28.179688 -10.382812 28.734375 -10.6875 C 29.285156 -10.988281 29.765625 -11.40625 30.171875 -11.9375 C 30.578125 -12.46875 30.863281 -13.054688 31.03125 -13.703125 L 31.171875 -14.34375 C 31.421875 -15.320312 31.300781 -16.140625 30.8125 -16.796875 C 30.320312 -17.460938 29.628906 -17.796875 28.734375 -17.796875 L 25.40625 -17.796875 L 27.515625 -26.875 L 29.828125 -26.875 C 30.722656 -26.875 31.5625 -27.203125 32.34375 -27.859375 C 33.132812 -28.523438 33.640625 -29.351562 33.859375 -30.34375 L 33.984375 -30.96875 C 34.191406 -31.914062 34.0625 -32.71875 33.59375 -33.375 C 33.125 -34.039062 32.441406 -34.375 31.546875 -34.375 Z M 29.25 -34.375 "/>
</g>
</g>
</g>
<g fill="#324370" fill-opacity="1">
<g transform="translate(237.285815, 211.874986)">
<g>
<path d="M 44.609375 -33.796875 L 25.015625 -1.03125 C 24.847656 -0.769531 24.703125 -0.507812 24.578125 -0.25 L 23.671875 -16.890625 L 32.0625 -33.796875 Z M 21.25 4.609375 C 19.03125 7.460938 16.585938 9.644531 13.921875 11.15625 C 11.253906 12.675781 8.148438 13.4375 4.609375 13.4375 C 1.835938 13.4375 -0.53125 13.050781 -2.5 12.28125 L -0.765625 4.671875 L 4.34375 4.671875 C 7.550781 4.671875 9.816406 3.113281 11.140625 0 L 5.5 -33.796875 L 19.203125 -33.796875 Z M 21.25 4.609375 "/>
</g>
</g>
</g>
<g fill="#324370" fill-opacity="1">
<g transform="translate(276.387414, 211.874986)">
<g>
<path d="M 24.390625 -44.03125 L 22.015625 -33.796875 L 29.1875 -33.796875 L 27.203125 -25.15625 L 20.03125 -25.15625 L 17.03125 -12.28125 C 16.644531 -10.832031 16.628906 -9.734375 16.984375 -8.984375 C 17.347656 -8.242188 18.210938 -7.875 19.578125 -7.875 L 23.171875 -7.875 L 21.4375 -0.390625 C 18.96875 0.378906 16.148438 0.765625 12.984375 0.765625 C 9.273438 0.765625 6.554688 0.0820312 4.828125 -1.28125 C 3.097656 -2.644531 2.617188 -4.96875 3.390625 -8.25 L 7.296875 -25.15625 L 2.5625 -25.15625 L 4.546875 -33.796875 L 9.796875 -33.796875 L 14.90625 -44.03125 Z M 24.390625 -44.03125 "/>
</g>
</g>
</g>
<g fill="#324370" fill-opacity="1">
<g transform="translate(304.801673, 211.874986)">
<g>
<path d="M 25.40625 -34.5625 C 38.125 -34.5625 43.117188 -28.671875 40.390625 -16.890625 L 39.875 -14.71875 L 18.109375 -14.71875 L 19.515625 -20.734375 L 28.421875 -20.734375 C 28.796875 -22.484375 28.597656 -23.867188 27.828125 -24.890625 C 27.066406 -25.921875 25.789062 -26.4375 24 -26.4375 C 22.84375 -26.4375 21.753906 -26.265625 20.734375 -25.921875 L 22.65625 -34.4375 C 23.894531 -34.519531 24.8125 -34.5625 25.40625 -34.5625 Z M 18.9375 -33.921875 L 14.53125 -14.71875 C 13.414062 -9.8125 15.117188 -7.359375 19.640625 -7.359375 C 21.773438 -7.359375 23.441406 -7.804688 24.640625 -8.703125 C 25.835938 -9.597656 26.601562 -10.789062 26.9375 -12.28125 L 39.296875 -12.28125 C 38.359375 -8.1875 36.0625 -4.988281 32.40625 -2.6875 C 28.757812 -0.382812 23.953125 0.765625 17.984375 0.765625 C 11.710938 0.765625 7.1875 -0.691406 4.40625 -3.609375 C 1.632812 -6.535156 0.929688 -10.960938 2.296875 -16.890625 C 3.367188 -21.546875 5.351562 -25.3125 8.25 -28.1875 C 11.15625 -31.070312 14.71875 -32.984375 18.9375 -33.921875 Z M 18.9375 -33.921875 "/>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 7.2 KiB

+1
View File
@@ -0,0 +1 @@
<svg enable-background="new 0 0 2499.6 2500" viewBox="0 0 2499.6 2500" xmlns="http://www.w3.org/2000/svg"><path d="m1187.9 1187.9h-1187.9v-1187.9h1187.9z" fill="#f1511b"/><path d="m2499.6 1187.9h-1188v-1187.9h1187.9v1187.9z" fill="#80cc28"/><path d="m1187.9 2500h-1187.9v-1187.9h1187.9z" fill="#00adef"/><path d="m2499.6 2500h-1188v-1187.9h1187.9v1187.9z" fill="#fbbc09"/></svg>

After

Width:  |  Height:  |  Size: 378 B

+1
View File
@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1755065235771" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="7343" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M853.333 362.667a42.667 42.667 0 1 0 85.334 0h-85.334zM464 597.333A42.667 42.667 0 1 0 464 512v85.333z m304 0a42.667 42.667 0 1 0 85.333 0H768z m-170.667 0a42.667 42.667 0 1 0 85.334 0h-85.334z m-384-384h597.334V128H213.333v85.333z m-42.666 256V256H85.333v213.333h85.334zM853.333 256v106.667h85.334V256h-85.334z m-256 384h256v-85.333h-256V640z m256 0v170.667h85.334V640h-85.334z m0 170.667h-256V896h256v-85.333z m-256 0V640H512v170.667h85.333z m0 0H512A85.333 85.333 0 0 0 597.333 896v-85.333z m256 0V896a85.333 85.333 0 0 0 85.334-85.333h-85.334z m0-170.667h85.334a85.333 85.333 0 0 0-85.334-85.333V640z m-256-85.333A85.333 85.333 0 0 0 512 640h85.333v-85.333zM768 512v85.333h85.333V512H768z m-85.333 85.333V512h-85.334v85.333h85.334z m42.666-128A42.667 42.667 0 0 1 768 512h85.333a128 128 0 0 0-128-128v85.333z m0-85.333a128 128 0 0 0-128 128h85.334a42.667 42.667 0 0 1 42.666-42.667V384z m-640 85.333a128 128 0 0 0 128 128V512a42.667 42.667 0 0 1-42.666-42.667H85.333z m725.334-256A42.667 42.667 0 0 1 853.333 256h85.334a128 128 0 0 0-128-128v85.333zM213.333 128a128 128 0 0 0-128 128h85.334a42.667 42.667 0 0 1 42.666-42.667V128zM464 512H213.333v85.333H464V512zM256 362.667a64 64 0 1 0 128 0 64 64 0 1 0-128 0zM448 362.667a64 64 0 1 0 128 0 64 64 0 1 0-128 0z" p-id="7344"></path></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

+1
View File
@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1762844995065" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="7045" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M244.224 643.84c0 59.221333-45.098667 107.264-100.778667 107.264C87.808 751.104 42.666667 703.061333 42.666667 643.84c0-59.221333 45.141333-107.264 100.778666-107.264h100.778667v107.264zM294.613333 643.84c0-59.306667 45.141333-107.306667 100.821334-107.306667 55.637333 0 100.778667 48.042667 100.778666 107.264v268.288c0 59.264-45.141333 107.306667-100.778666 107.306667-55.68 0-100.821333-48.042667-100.821334-107.306667v-268.288z" fill="#E01E5A" p-id="7046"></path><path d="M395.392 214.613333c-55.637333 0-100.778667-48.042667-100.778667-107.306666C294.613333 48.042667 339.754667 0 395.392 0c55.68 0 100.821333 48.042667 100.821333 107.306667V214.613333H395.392zM395.392 268.245333c55.68 0 100.821333 48.085333 100.821333 107.306667 0 59.306667-45.141333 107.306667-100.821333 107.306667H143.445333C87.808 482.858667 42.666667 434.816 42.666667 375.552c0-59.221333 45.141333-107.306667 100.778666-107.306667h251.946667z" fill="#36C5F0" p-id="7047"></path><path d="M798.549333 375.552c0-59.221333 45.098667-107.306667 100.778667-107.306667 55.637333 0 100.778667 48.085333 100.778667 107.306667 0 59.306667-45.141333 107.306667-100.778667 107.306667h-100.778667V375.552zM748.16 375.552c0 59.306667-45.141333 107.306667-100.821333 107.306667-55.637333 0-100.778667-48.042667-100.778667-107.306667V107.306667C546.56 48.042667 591.701333 0 647.338667 0c55.68 0 100.821333 48.042667 100.821333 107.306667v268.245333z" fill="#2EB67D" p-id="7048"></path><path d="M647.381333 804.778667c55.637333 0 100.778667 48.042667 100.778667 107.306666 0 59.264-45.141333 107.306667-100.778667 107.306667-55.68 0-100.821333-48.042667-100.821333-107.306667v-107.306666h100.821333zM647.381333 751.104c-55.68 0-100.821333-48.042667-100.821333-107.306667 0-59.221333 45.141333-107.306667 100.821333-107.306666h251.904c55.68 0 100.778667 48.085333 100.778667 107.306666 0 59.306667-45.098667 107.306667-100.778667 107.306667h-251.904z" fill="#ECB22E" p-id="7049"></path></svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

+1
View File
@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1755053393715" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="2527" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M665.856 748.330667a13.653333 13.653333 0 0 0 1.706667 20.821333 175.189333 175.189333 0 0 1 53.973333 105.216 57.045333 57.045333 0 1 0 60.970667-71.722667 175.061333 175.061333 0 0 1-97.365334-54.314666 13.653333 13.653333 0 0 0-19.285333 0z" fill="#FB6500" p-id="2528"></path><path d="M897.450667 658.048c-9.258667 9.216-15.061333 21.333333-16.426667 34.346667a175.061333 175.061333 0 0 1-54.101333 97.493333 13.653333 13.653333 0 1 0 20.821333 17.408 175.061333 175.061333 0 0 1 105.173333-53.930667 57.045333 57.045333 0 1 0-55.296-95.317333h-0.213333z" fill="#0082EF" p-id="2529"></path><path d="M736.725333 497.109333a57.002667 57.002667 0 0 0 34.346667 97.024c37.546667 6.912 71.765333 25.941333 97.493333 54.101334a13.653333 13.653333 0 1 0 17.450667-20.821334A175.189333 175.189333 0 0 1 832 522.24a57.088 57.088 0 0 0-95.317333-25.130667z" fill="#2DBC00" p-id="2530"></path><path d="M708.693333 586.24l-0.981333 1.024a174.890667 174.890667 0 0 1-106.752 55.893333 56.661333 56.661333 0 0 0-25.258667 95.274667 57.045333 57.045333 0 0 0 96.981334-34.346667c6.997333-37.546667 26.069333-71.808 54.314666-97.493333a13.653333 13.653333 0 1 0-18.261333-20.352z" fill="#FFCC00" p-id="2531"></path><path d="M364.074667 93.866667c-102.272 11.306667-194.986667 55.04-261.632 123.306666A345.472 345.472 0 0 0 38.570667 307.072a312.874667 312.874667 0 0 0 22.058666 315.946667 490.666667 490.666667 0 0 0 74.965334 85.76l-12.288 96.64-1.365334 4.096c-0.341333 1.194667-0.341333 2.56-0.512 3.754666l-0.341333 3.072 0.341333 3.072a31.104 31.104 0 0 0 46.762667 24.064h0.512l1.877333-1.365333 29.397334-14.677333L287.573333 783.36c41.642667 11.946667 84.778667 17.92 128.085334 17.621333a459.349333 459.349333 0 0 0 157.141333-27.306666 56.832 56.832 0 0 1-38.784-59.648 385.749333 385.749333 0 0 1-161.536 16.085333l-8.746667-1.194667a390.528 390.528 0 0 1-58.026666-12.16 39.68 39.68 0 0 0-31.104 3.285334l-2.389334 1.152-72.106666 42.368-3.029334 1.877333c-1.706667 1.024-2.56 1.365333-3.413333 1.365333a4.949333 4.949333 0 0 1-4.608-5.12l2.730667-11.093333 3.242666-12.117333 5.12-20.010667 5.973334-22.186667a30.208 30.208 0 0 0-10.922667-33.621333 315.050667 315.050667 0 0 1-72.746667-75.861333 246.4 246.4 0 0 1-17.792-248.96 284.672 284.672 0 0 1 51.242667-71.722667C210.56 209.706667 287.402667 173.824 372.48 164.608a403.370667 403.370667 0 0 1 88.448 0c84.522667 9.728 161.024 46.08 215.338667 102.144 21.034667 21.674667 38.101333 46.08 50.730666 72.064 16.896 34.474667 25.429333 71.04 25.429334 108.416 0 3.925333-0.341333 7.850667-0.512 11.648a56.874667 56.874667 0 0 1 70.016 8.192l2.56 3.072a311.168 311.168 0 0 0-31.061334-161.92 348.757333 348.757333 0 0 0-63.189333-89.813333 428.629333 428.629333 0 0 0-260.608-124.16A480.384 480.384 0 0 0 364.074667 93.866667z" fill="#0082EF" p-id="2532"></path></svg>

After

Width:  |  Height:  |  Size: 3.1 KiB

@@ -0,0 +1,30 @@
import { type FormInstance } from "antd"
import UserDisplayForm from "@/components/user-display-form"
import type { UserFormValues } from "@/types/form"
interface AddUserProps {
form: FormInstance<UserFormValues>
}
export default function AddUserDialogue({ form }: AddUserProps) {
return (
<UserDisplayForm
isEditing={true}
form={form}
initialValues={{
id: null,
username: "",
password: "",
regionAbbreviation: import.meta.env.VITE_DEFAULT_REGION_CODE,
fullName: "",
email: "",
phoneNumber: "",
avatarUrl: "",
status: "ACTIVE",
departmentId: null,
positionId: null,
}}
isAdding
/>
)
}
@@ -0,0 +1,22 @@
import type { FormInstance } from "antd"
import UserDisplayForm from "@/components/user-display-form"
import type { User } from "@/types/entity"
import type { UserFormValues } from "@/types/form"
interface EditUserProps {
user: User
form: FormInstance<UserFormValues>
}
export default function EditUserDialogue({ user, form }: EditUserProps) {
return (
<UserDisplayForm
initialValues={{
...user,
password: null
}}
isEditing={true}
form={form}
/>
)
}
+99
View File
@@ -0,0 +1,99 @@
import type { ForwardRefExoticComponent } from "react"
import Icon from "@ant-design/icons"
import BrandMicrosoft from "@/assets/microsoft.svg?react"
import BrandDingTalk from "@/assets/dingtalk.svg?react"
import BrandWeCom from "@/assets/wecom.svg?react"
import BrandLark from "@/assets/lark.svg?react"
import BrandSlack from "@/assets/slack.svg?react"
import BrandGoogle from "@/assets/google.svg?react"
import BrandGitlab from "@/assets/gitlab.svg?react"
import BrandEmail from "@/assets/email.svg?react"
import BrandDiscord from "@/assets/discord.svg?react"
import Logo from "@/assets/logo.svg?react"
import type { AntIconComponentProps } from "@/types/antd"
/**
* Microsoft 图标
* @param props Icon 属性
* @constructor
*/
export function MicrosoftFilled(props: Partial<AntIconComponentProps>) {
return <Icon component={BrandMicrosoft as ForwardRefExoticComponent<unknown>} {...props} />
}
/**
* 钉钉图标
* @param props Icon 属性
* @constructor
*/
export function DingTalkFilled(props: Partial<AntIconComponentProps>) {
return <Icon component={BrandDingTalk as ForwardRefExoticComponent<unknown>} {...props} />
}
/**
* 企业微信图标
* @param props Icon 属性
* @constructor
*/
export function WeComFilled(props: Partial<AntIconComponentProps>) {
return <Icon component={BrandWeCom as ForwardRefExoticComponent<unknown>} {...props} />
}
/**
* 飞书图标
* @param props Icon 属性
* @constructor
*/
export function LarkFilled(props: Partial<AntIconComponentProps>) {
return <Icon component={BrandLark as ForwardRefExoticComponent<unknown>} {...props} />
}
/**
* Slack 图标
* @param props Icon 属性
* @constructor
*/
export function SlackFilled(props: Partial<AntIconComponentProps>) {
return <Icon component={BrandSlack as ForwardRefExoticComponent<unknown>} {...props} />
}
/**
* Google 图标
* @param props Icon 属性
* @constructor
*/
export function GoogleFilled(props: Partial<AntIconComponentProps>) {
return <Icon component={BrandGoogle as ForwardRefExoticComponent<unknown>} {...props} />
}
/**
* GitLab 图标
* @param props Icon 属性
* @constructor
*/
export function GitlabFilled(props: Partial<AntIconComponentProps>) {
return <Icon component={BrandGitlab as ForwardRefExoticComponent<unknown>} {...props} />
}
/**
* Email 图标
* @param props Icon 属性
* @constructor
*/
export function EmailFilled(props: Partial<AntIconComponentProps>) {
return <Icon component={BrandEmail as ForwardRefExoticComponent<unknown>} {...props} />
}
/**
* Discord 图标
* @param props Icon 属性
* @constructor
*/
export function DiscordFilled(props: Partial<AntIconComponentProps>) {
return <Icon component={BrandDiscord as ForwardRefExoticComponent<unknown>} {...props} />
}
export function ApplicationLogo(props: Partial<AntIconComponentProps>) {
return <Icon component={Logo as ForwardRefExoticComponent<unknown>} {...props} />
}
@@ -0,0 +1,5 @@
import { Spin } from "antd"
export default function LoadingFallback() {
return <Spin fullscreen />
}
+22
View File
@@ -0,0 +1,22 @@
import { Navigate, Outlet, useLocation } from "react-router"
import { useAppSelector } from "@/store"
import DashboardLayout from "@/layouts/dashboard-layout"
/**
* 需要身份验证的前置组件
* @constructor
*/
export default function ProtectedRoute() {
const isAuthenticated = useAppSelector((state) => state.auth.isAuthenticated)
const location = useLocation()
if (!isAuthenticated) {
return <Navigate to="/login" state={{ from: location }} replace />
}
return (
<DashboardLayout >
<Outlet />
</DashboardLayout>
)
}
+200
View File
@@ -0,0 +1,200 @@
import { useEffect, useMemo, useState } from "react"
import { App, Checkbox, Form, type FormInstance, Input, Select, Space, TreeSelect } from "antd"
import type { AxiosError } from "axios"
import { getCountries, getCountryCallingCode } from "libphonenumber-js"
import { DeptApi, PositionApi } from "@/api"
import type { UserFormValues } from "@/types/form"
import type { Department, Position } from "@/types/entity"
import type { GeneralErrorResponse } from "@/types/web/response"
import type { AntTreeSelectOption } from "@/types/antd"
interface UserDisplayFormProps {
initialValues?: UserFormValues
isEditing: boolean
isAdding?: boolean
form: FormInstance<UserFormValues>
}
const countryOptions = getCountries().map((country) => {
const callingCode = getCountryCallingCode(country)
return {
label: `${country} (+${callingCode})`,
value: country,
key: country,
}
})
function transformDepartmentToTree(departments: Department[]): AntTreeSelectOption<number>[] {
const sortedDepartments = [...departments].sort((a, b) => a.sort - b.sort)
const map: Record<number, AntTreeSelectOption<number>> = {}
const tree: AntTreeSelectOption<number>[] = []
sortedDepartments.forEach((dept) => {
map[dept.id] = {
label: dept.name,
value: dept.id,
children: [],
}
})
sortedDepartments.forEach((dept) => {
const node = map[dept.id]
if (dept.parentId !== null && map[dept.parentId]) {
map[dept.parentId].children!.push(node)
} else {
tree.push(node)
}
})
return tree
}
export default function UserDisplayForm({
initialValues,
isEditing,
form,
isAdding = false,
}: UserDisplayFormProps) {
// Get message wrapper from Antd
const { message } = App.useApp()
// Build form values.
const initialFormValues: UserFormValues | undefined = initialValues
? ({
...initialValues,
password: "",
} as UserFormValues)
: undefined
// Department state used to save all departments
const [departments, setDepartments] = useState<Department[]>([])
// Position state used to save all positions
const [positions, setPositions] = useState<Position[]>([])
// Departments options
const departmentOptions = useMemo(() => {
return transformDepartmentToTree(departments)
}, [departments])
const positionOptions = useMemo(() => {
return positions.map((position) => ({
label: position.name,
value: position.id,
key: position.id,
}))
}, [positions])
// Initialise form values
useEffect(() => {
if (initialFormValues) {
form.setFieldsValue(initialFormValues)
} else {
form.resetFields()
}
}, [initialValues, form])
// Initialise department data on component mounted
useEffect(() => {
const fetchDepartmentsFuture = DeptApi.fetchDepartments()
const fetchPositionsFuture = PositionApi.fetchPositions({ pageNum: 1, pageSize: 999 })
Promise.all([fetchDepartmentsFuture, fetchPositionsFuture])
.then(([departments, positionPage]) => {
setDepartments(departments)
setPositions(positionPage.content)
})
.catch((error: unknown) => {
const err = error as AxiosError<GeneralErrorResponse>
void message.error(err.response?.data.message ?? "获取部门或岗位数据失败,请稍后再试")
})
}, [])
return (
<Form<UserFormValues>
form={form}
initialValues={initialFormValues}
layout="vertical"
labelAlign="right"
disabled={!isEditing}>
<Form.Item<UserFormValues> label="用户 ID" name="id" hidden={isAdding}>
<Input disabled />
</Form.Item>
<Form.Item<UserFormValues>
label="全名"
name="fullName"
rules={[{ required: true, message: "用户全名不能为空" }]}>
<Input />
</Form.Item>
<Form.Item<UserFormValues>
label="用户名"
name="username"
rules={[{ required: true, message: "请输入用户名" }]}>
<Input />
</Form.Item>
{isAdding && (
<Form.Item<UserFormValues>
label="密码"
name="password"
rules={[{ required: true, message: "请输入密码" }]}>
<Input.Password />
</Form.Item>
)}
<Form.Item<UserFormValues>
label="电子邮箱"
name="email"
rules={[
{ type: "email", message: "邮箱格式不正确" },
{ required: true, message: "邮箱不能为空" },
]}>
<Input />
</Form.Item>
<Form.Item<UserFormValues> label="联系电话" required>
<Space.Compact className="w-full">
<Form.Item<UserFormValues>
noStyle
name="regionAbbreviation"
rules={[{ required: true, message: "请选择国际电信区域码" }]}>
<Select
options={countryOptions}
showSearch
placeholder="国家/地区"
className="w-[26%]"
/>
</Form.Item>
<Form.Item<UserFormValues>
noStyle
name="phoneNumber"
rules={[{ required: true, message: "请输入电话号码" }]}>
<Input className="w-[74%]" />
</Form.Item>
</Space.Compact>
</Form.Item>
<Form.Item<UserFormValues>
label="用户状态"
name="status"
rules={[{ required: true, message: "用户状态不能为空" }]}>
<Select
options={[
{ label: "已启用", value: "ACTIVE" },
{ label: "已停用", value: "INACTIVE" },
{ label: "已禁用", value: "LOCKED" },
]}
/>
</Form.Item>
<Form.Item<UserFormValues> label="所在部门" name="departmentId">
<TreeSelect treeData={departmentOptions} placeholder="选择部门" />
</Form.Item>
<Form.Item<UserFormValues> label="岗位" name="positionId">
<Select options={positionOptions} placeholder="选择岗位" />
</Form.Item>
</Form>
)
}
+11
View File
@@ -0,0 +1,11 @@
// 配置插件
import dayjs from "dayjs"
import duration from "dayjs/plugin/duration"
dayjs.extend(duration)
// 配置语言
import "dayjs/locale/zh-cn"
dayjs.locale("zh-cn")
console.log("Global Dayjs plugins initialised.")
+17
View File
@@ -0,0 +1,17 @@
import { type Configuration, PublicClientApplication } from "@azure/msal-browser"
const clientId = import.meta.env.VITE_MSAL_CLIENT_ID
const tenantId = import.meta.env.VITE_MSAL_TENANT_ID
const msalConfig: Configuration = {
auth: {
clientId,
authority: `https://login.microsoftonline.com/${tenantId}`,
redirectUri: "http://localhost:5173"
},
cache: {
cacheLocation: "localStorage",
}
}
export const msalInstance = new PublicClientApplication(msalConfig)
+66
View File
@@ -0,0 +1,66 @@
/**
* HTTP 状态码
*/
export const HttpStatus = {
CONTINUE: 100,
SWITCHING_PROTOCOLS: 101,
PROCESSING: 102,
EARLY_HINTS: 103,
OK: 200,
CREATED: 201,
ACCEPTED: 202,
NON_AUTHORITATIVE_INFORMATION: 203,
NO_CONTENT: 204,
RESET_CONTENT: 205,
PARTIAL_CONTENT: 206,
MULTI_STATUS: 207,
ALREADY_REPORTED: 208,
IM_USED: 226,
MULTIPLE_CHOICES: 300,
MOVED_PERMANENTLY: 301,
FOUND: 302,
SEE_OTHER: 303,
NOT_MODIFIED: 304,
USE_PROXY: 305,
TEMPORARY_REDIRECT: 307,
PERMANENT_REDIRECT: 308,
BAD_REQUEST: 400,
UNAUTHORIZED: 401,
PAYMENT_REQUIRED: 402,
FORBIDDEN: 403,
NOT_FOUND: 404,
METHOD_NOT_ALLOWED: 405,
NOT_ACCEPTABLE: 406,
PROXY_AUTHENTICATION_REQUIRED: 407,
REQUEST_TIMEOUT: 408,
CONFLICT: 409,
GONE: 410,
LENGTH_REQUIRED: 411,
PRECONDITION_FAILED: 412,
PAYLOAD_TOO_LARGE: 413,
URI_TOO_LONG: 414,
UNSUPPORTED_MEDIA_TYPE: 415,
RANGE_NOT_SATISFIABLE: 416,
EXPECTATION_FAILED: 417,
I_AM_A_TEAPOT: 418,
UNPROCESSABLE_ENTITY: 422,
LOCKED: 423,
FAILED_DEPENDENCY: 424,
TOO_EARLY: 425,
UPGRADE_REQUIRED: 426,
PRECONDITION_REQUIRED: 428,
TOO_MANY_REQUESTS: 429,
REQUEST_HEADER_FIELDS_TOO_LARGE: 431,
UNAVAILABLE_FOR_LEGAL_REASONS: 451,
INTERNAL_SERVER_ERROR: 500,
NOT_IMPLEMENTED: 501,
BAD_GATEWAY: 502,
SERVICE_UNAVAILABLE: 503,
GATEWAY_TIMEOUT: 504,
HTTP_VERSION_NOT_SUPPORTED: 505,
VARIANT_ALSO_NEGOTIATES: 506,
INSUFFICIENT_STORAGE: 507,
LOOP_DETECTED: 508,
NOT_EXTENDED: 510,
NETWORK_AUTHENTICATION_REQUIRED: 511,
}
+1
View File
@@ -0,0 +1 @@
export { HttpStatus } from "./http-status"
+20
View File
@@ -0,0 +1,20 @@
import { useMatches } from "react-router"
import type { BreadcrumbItemType } from "antd/es/breadcrumb/Breadcrumb"
import type { RouteHandle } from "@/types/route"
export function useAntBreadcrumbs(): BreadcrumbItemType[] {
const matches = useMatches()
return matches
.filter((match) => (match.handle as RouteHandle)?.label)
.map((match) => {
const handle = match.handle as RouteHandle
const path = match.pathname
const title = handle.label || ""
return {
title,
}
})
}
+13
View File
@@ -0,0 +1,13 @@
@layer theme, base, antd, components, utilities;
@import "tailwindcss";
html, body {
margin: 0;
padding: 0;
height: 100vh;
overflow: hidden;
}
#root {
height: 100%;
}
+134
View File
@@ -0,0 +1,134 @@
import React, { useEffect, useState } from "react"
import { useNavigate } from "react-router"
import { Avatar, Breadcrumb, Dropdown, Layout, Menu, type MenuProps, Modal, Space } from "antd"
import { DownOutlined } from "@ant-design/icons"
import { ApplicationLogo } from "@/components/icon"
import { useAppDispatch, useAppSelector } from "@/store"
import { useAntBreadcrumbs } from "@/hooks"
import { logout } from "@/store/auth-slice"
import { MenuApi } from "@/api"
import type { AxiosError } from "axios"
import type { TreeNode } from "@/types/tree"
import type { MenuItem } from "@/types/entity"
const { Header, Footer, Sider, Content } = Layout
type AntMenuItem = Required<MenuProps>["items"][number]
function transformMenuData(nodes: TreeNode<MenuItem>[]): AntMenuItem[] {
if (!nodes || nodes.length === 0) {
return []
}
return nodes
.sort((a, b) => a.item.sort - b.item.sort)
.map((node) => {
const { item, children } = node
const hasChildren = children && children.length > 0
const menuItem: AntMenuItem = {
key: item.code,
label: item.name,
}
if (hasChildren) {
// Append children
return { ...menuItem, children: transformMenuData(children) }
}
return menuItem
})
}
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
const user = useAppSelector((store) => store.auth.user!)
const dispatch = useAppDispatch()
const breadcrumbItems = useAntBreadcrumbs()
const navigate = useNavigate()
const onLogout = ({ key }: { key: string }) => {
if (key == "logout") {
Modal.confirm({
title: "确定要注销吗?",
okText: "确定",
cancelText: "取消",
onOk: () => {
dispatch(logout())
void navigate("/login")
},
maskClosable: false,
keyboard: false,
})
}
}
const [menuItems, setMenuItems] = useState<AntMenuItem[]>([])
useEffect(() => {
MenuApi.fetchMenuTree()
.then((response) => {
setMenuItems(transformMenuData(response))
})
.catch((error: unknown) => {
const err = error as AxiosError
console.log(err)
})
}, [])
const dropDownMenuItems: MenuProps["items"] = [
{
key: "logout",
danger: true,
label: "注销",
},
]
return (
<Layout className="h-[100%]">
<Header className="flex items-center justify-between bg-gradient-to-br from-blue-50 to-indigo-100">
<div className="flex gap-4 items-center">
<ApplicationLogo className="text-4xl" />
<span className="text-xl">Onixbyte Hi-Tech Co., Ltd</span>
</div>
<div className="flex gap-4 items-center">
<Dropdown
className="text-white text-xl"
menu={{ items: dropDownMenuItems, onClick: onLogout }}>
<Space>
{user.fullName}
<DownOutlined />
</Space>
</Dropdown>
<Avatar src={user.avatarUrl} alt="用户头像" />
</div>
</Header>
<Layout>
<Sider width={200} className="bg-white">
<Menu
mode="inline"
className="h-full max-h-full border-e-0"
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 className="pt-0 px-6 pb-6">
<Breadcrumb items={breadcrumbItems} className="my-4 mx-0" />
<Content className="p-6 m-0 min-h-70 bg-white">{children}</Content>
</Layout>
</Layout>
</Layout>
)
}
+32
View File
@@ -0,0 +1,32 @@
import { StrictMode } from "react"
import { createRoot } from "react-dom/client"
import { Provider as ReduxProvider } from "react-redux"
import { PersistGate } from "redux-persist/integration/react"
import { RouterProvider } from "react-router"
import { App as AntApp, ConfigProvider as AntConfigProvider } from "antd"
import { StyleProvider } from "@ant-design/cssinjs"
import AntSimplifiedChinese from "antd/locale/zh_CN"
import "./index.css"
import "@/config/dayjs-config"
import store, { persistor } from "@/store"
import router from "@/router"
createRoot(document.getElementById("root")!).render(
<StrictMode>
<ReduxProvider store={store}>
<PersistGate loading={null} persistor={persistor}>
<StyleProvider layer>
<AntConfigProvider
locale={AntSimplifiedChinese}
button={{
autoInsertSpace: false,
}}>
<AntApp className="h-full w-full">
<RouterProvider router={router} />
</AntApp>
</AntConfigProvider>
</StyleProvider>
</PersistGate>
</ReduxProvider>
</StrictMode>
)
+33
View File
@@ -0,0 +1,33 @@
import React from "react"
import { Link } from "react-router-dom"
/**
* 携带错误信息的通用错误展示页
*
* @param message 自定义的错误信息,默认为`发生了未知错误,请稍后再试`
* @constructor
*/
export default function ErrorPage({ message = "发生了未知错误,请稍后再试" }) {
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="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 bottom-0 left-1/2 w-72 h-72 bg-pink-200 rounded-full mix-blend-multiply filter blur-xl opacity-70"></div>
<div className="text-center bg-white p-10 lg:p-14 rounded-xl shadow-lg md:shadow-xl max-w-xl w-full">
<h1 className="text-5xl md:text-6xl mb-5 leading-none"></h1>
<h2 className="text-3xl md:text-4xl text-red-600 m-0 font-bold mb-4">
</h2>
<p className="text-lg text-gray-700 leading-relaxed mb-4">{message}</p>
<p className="text-base text-gray-600 mb-8"></p>
<Link
to="/"
className="inline-block bg-blue-600 text-white py-3 px-7 rounded-lg no-underline
text-lg font-semibold transition-all duration-300 ease-in-out
shadow-md shadow-blue-500/20 hover:bg-blue-700 hover:-translate-y-0.5 hover:shadow-lg hover:shadow-blue-500/30">
</Link>
</div>
</div>
)
}
+7
View File
@@ -0,0 +1,7 @@
import { useAppSelector } from "@/store"
export default function HomePage() {
const user = useAppSelector((store) => store.auth.user!)
return <>Welcome to Helix, {user.fullName}</>
}
+373
View File
@@ -0,0 +1,373 @@
import { type MouseEvent, useCallback, useEffect, useState } from "react"
import { Link, useNavigate } from "react-router"
import { Form, Input, Button, Card, message, Divider } from "antd"
import dayjs from "dayjs"
import type { AxiosError } from "axios"
// import { useMsal } from "@azure/msal-react"
import { AuthApi } from "@/api"
import { useAppDispatch, useAppSelector } from "@/store"
import { loginSuccess, updateRegistrationEnabled } from "@/store/auth-slice"
import {
DingTalkFilled,
GoogleFilled,
LarkFilled,
MicrosoftFilled,
SlackFilled,
WeComFilled,
GitlabFilled,
DiscordFilled,
EmailFilled,
} from "@/components/icon"
import { GithubFilled } from "@ant-design/icons"
import { fetchRegisterEnabled } from "@/api/auth"
import type { UsernamePasswordLoginRequest } from "@/types/web/request"
import type { CaptchaResponse, GeneralErrorResponse } from "@/types/web/response"
export default function LoginPage() {
const registrationEnabled = useAppSelector((state) => state.auth.registrationEnabled)
const dispatch = useAppDispatch()
const navigate = useNavigate()
// const msalContext = useMsal()
const [messageApi, contextHolder] = message.useMessage()
const [form] = Form.useForm<UsernamePasswordLoginRequest>()
// const [isLoading, setIsLoading] = useState<boolean>(false)
const [hasCaptcha, setHasCaptcha] = useState<boolean>(false)
const [captchaData, setCaptchaData] = useState<CaptchaResponse | null>()
const fetchCaptcha = useCallback(async () => {
try {
const response = await AuthApi.getCaptcha()
if (response) {
setHasCaptcha(true)
setCaptchaData(response)
form.setFieldValue("uuid", response.uuid)
} else {
setHasCaptcha(false)
setCaptchaData(null)
form.setFieldValue("uuid", null)
}
} catch (error) {
console.error("Failed to fetch captcha due to an error:", error)
setHasCaptcha(false)
setCaptchaData(null)
form.setFieldValue("uuid", null)
}
}, [form])
useEffect(() => {
void fetchCaptcha()
}, [fetchCaptcha])
useEffect(() => {
void fetchRegisterEnabled().then((enabled) => {
dispatch(updateRegistrationEnabled(enabled))
})
}, [dispatch])
/**
* 用户名密码登录
*/
const performLogin = useCallback(
async (values: UsernamePasswordLoginRequest) => {
try {
console.log("Login values:", values)
const loginResponse = await AuthApi.usernamePasswordLogin(values)
if (loginResponse) {
dispatch(
loginSuccess({
user: loginResponse.user,
token: loginResponse.accessToken,
})
)
messageApi.success("登录成功", dayjs.duration({ seconds: 3 }).asSeconds())
await navigate("/")
} else {
messageApi.error("登录失败:服务器响应异常。")
}
} catch (errorInfo: unknown) {
const error = errorInfo as AxiosError<GeneralErrorResponse>
console.log(error)
messageApi.error(
error.response?.data.message ?? "登录失败,请稍后再试",
dayjs.duration({ seconds: 3 }).asSeconds()
)
}
},
[dispatch, navigate, messageApi]
)
/**
* 刷新验证码图片
*/
const refreshCaptcha = (event: MouseEvent) => {
event.preventDefault()
void fetchCaptcha()
}
/**
* 使用 Microsoft Entra ID 登录
*/
const performMsalLogin = () => {
console.log("使用 Microsoft 账号登录")
// void doMsalLogin(msalContext.instance, dispatch, () => void navigate("/"))
}
/**
* 使用 DingTalk 登录
*/
const performDingTalkLogin = () => {
console.log("使用钉钉登录")
// todo implement this
}
/**
* 使用 WeCom 登录
*/
const performWeComLogin = () => {
console.log("使用企业微信登录")
// todo implement this
}
/**
* 使用 Lark 登录
*/
const performLarkLogin = () => {
console.log("使用飞书登录")
// todo implement this
}
/**
* 使用 Slack 登录
*/
const performSlackLogin = () => {
console.log("使用 Slack 登录")
// todo implement this
}
/**
* 使用 Github 登录
*/
const performGithubLogin = () => {
console.log("使用 GitHub 登录")
// todo implement this
}
/**
* 使用 Google 登录
*/
const performGoogleLogin = () => {
console.log("使用 Google 登录")
// todo implement this
}
/**
* 使用 Gitlab 登录
*/
const performGitlabLogin = () => {
console.log("使用 Gitlab 登录")
// todo implement this
}
/**
* 使用 Discord 登录
*/
const performDiscordLogin = () => {
console.log("使用 Discord 登录")
// todo implement this
}
/**
* 使用 Email 登录
*/
const performEmailLogin = () => {
console.log("使用 Email 登录")
// todo implement this
}
return (
<div className="min-h-screen bg-gradient-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 right-0 w-72 h-72 bg-purple-200 rounded-full mix-blend-multiply filter blur-xl opacity-70"></div>
<div className="absolute bottom-0 left-1/2 w-72 h-72 bg-pink-200 rounded-full mix-blend-multiply filter blur-xl opacity-70"></div>
<Card
title={
<div className="text-center py-4">
<h1 className="text-2xl font-bold text-gray-800 mb-2"></h1>
<p className="text-gray-600 text-sm"></p>
</div>
}
className="w-full max-w-md shadow-2xl border-0 backdrop-blur-sm bg-white/90 relative z-10"
styles={{
body: {
padding: "32px",
},
}}>
<Form<UsernamePasswordLoginRequest>
name="usernamePasswordLoginForm"
form={form}
onFinish={(values) => {
void performLogin(values)
}}
layout="vertical"
className="space-y-4">
<Form.Item<UsernamePasswordLoginRequest>
label={<span className="text-gray-700 font-medium"></span>}
name="username"
rules={[{ required: true, message: "请输入用户名!" }]}>
<Input
placeholder="请输入用户名"
size="large"
className="rounded-lg border-gray-300 hover:border-blue-400 focus:border-blue-500 transition-colors"
/>
</Form.Item>
<Form.Item<UsernamePasswordLoginRequest>
label={<span className="text-gray-700 font-medium"></span>}
name="password"
rules={[{ required: true, message: "请输入密码!" }]}>
<Input.Password
placeholder="请输入密码"
size="large"
className="rounded-lg border-gray-300 hover:border-blue-400 focus:border-blue-500 transition-colors"
/>
</Form.Item>
{hasCaptcha ? (
<>
<Form.Item<UsernamePasswordLoginRequest>
label={<span className="text-gray-700 font-medium"></span>}
name="captcha"
rules={[{ required: true, message: "请输入验证码!" }]}>
<div className="flex items-center gap-3">
<Input
placeholder="请输入验证码"
size="large"
className="rounded-lg border-gray-300 hover:border-blue-400 focus:border-blue-500 transition-colors"
/>
{captchaData?.captcha && (
<img
src={captchaData.captcha}
alt="验证码"
onClick={refreshCaptcha}
className="cursor-pointer h-10 border border-gray-300 rounded-lg transition-transform hover:scale-105"
/>
)}
</div>
</Form.Item>
<Form.Item<UsernamePasswordLoginRequest> name="uuid" hidden>
<div>Placeholder</div>
</Form.Item>
</>
) : null}
<Form.Item className="mb-0">
<Button
type="primary"
htmlType="submit"
block
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">
</Button>
</Form.Item>
</Form>
{registrationEnabled ? (
<div className="mt-4 flex justify-end">
<Link to="/register"></Link>
</div>
) : null}
<Divider className="my-6 text-gray-400">
<span className="text-gray-500 text-sm font-medium"></span>
</Divider>
<div className="grid grid-cols-5 gap-4 justify-items-center w-full">
<Button
icon={<DingTalkFilled className="text-blue-600" />}
size="large"
onClick={performDingTalkLogin}
className="w-14 h-12 rounded-xl border border-gray-200 bg-white hover:bg-gray-50 hover:border-blue-300 transition-all duration-300 shadow-sm hover:shadow-md flex items-center justify-center"
title="钉钉登录"
/>
<Button
icon={<DiscordFilled className="text-indigo-600" />}
size="large"
onClick={performDiscordLogin}
className="w-14 h-12 rounded-xl border border-gray-200 bg-white hover:bg-gray-50 hover:border-indigo-300 transition-all duration-300 shadow-sm hover:shadow-md flex items-center justify-center"
title="Discord登录"
/>
<Button
icon={<EmailFilled className="text-red-600" />}
size="large"
onClick={performEmailLogin}
className="w-14 h-12 rounded-xl border border-gray-200 bg-white hover:bg-gray-50 hover:border-red-300 transition-all duration-300 shadow-sm hover:shadow-md flex items-center justify-center"
title="邮箱登录"
/>
<Button
icon={<GithubFilled className="text-gray-800" />}
size="large"
onClick={performGithubLogin}
className="w-14 h-12 rounded-xl border border-gray-200 bg-white hover:bg-gray-50 hover:border-gray-400 transition-all duration-300 shadow-sm hover:shadow-md flex items-center justify-center"
title="GitHub登录"
/>
<Button
icon={<GitlabFilled className="text-orange-600" />}
size="large"
onClick={performGitlabLogin}
className="w-14 h-12 rounded-xl border border-gray-200 bg-white hover:bg-gray-50 hover:border-orange-300 transition-all duration-300 shadow-sm hover:shadow-md flex items-center justify-center"
title="GitLab登录"
/>
<Button
icon={<GoogleFilled className="text-red-500" />}
size="large"
onClick={performGoogleLogin}
className="w-14 h-12 rounded-xl border border-gray-200 bg-white hover:bg-gray-50 hover:border-red-300 transition-all duration-300 shadow-sm hover:shadow-md flex items-center justify-center"
title="Google登录"
/>
<Button
icon={<LarkFilled className="text-blue-700" />}
size="large"
onClick={performLarkLogin}
className="w-14 h-12 rounded-xl border border-gray-200 bg-white hover:bg-gray-50 hover:border-blue-400 transition-all duration-300 shadow-sm hover:shadow-md flex items-center justify-center"
title="飞书登录"
/>
<Button
icon={<MicrosoftFilled className="text-blue-600" />}
size="large"
onClick={performMsalLogin}
className="w-14 h-12 rounded-xl border border-gray-200 bg-white hover:bg-gray-50 hover:border-blue-300 transition-all duration-300 shadow-sm hover:shadow-md flex items-center justify-center"
title="Microsoft登录"
/>
<Button
icon={<SlackFilled className="text-purple-600" />}
size="large"
onClick={performSlackLogin}
className="w-14 h-12 rounded-xl border border-gray-200 bg-white hover:bg-gray-50 hover:border-purple-300 transition-all duration-300 shadow-sm hover:shadow-md flex items-center justify-center"
title="Slack登录"
/>
<Button
icon={<WeComFilled className="text-green-600" />}
size="large"
onClick={performWeComLogin}
className="w-14 h-12 rounded-xl border border-gray-200 bg-white hover:bg-gray-50 hover:border-green-300 transition-all duration-300 shadow-sm hover:shadow-md flex items-center justify-center"
title="企业微信登录"
/>
</div>
<div className="mt-8 text-center">
<p className="text-gray-500 text-xs">&copy; 2024 Your Company. All rights reserved.</p>
</div>
</Card>
</div>
)
}
+7
View File
@@ -0,0 +1,7 @@
export default function MenuPage() {
return (
<>
<div></div>
</>
)
}
+30
View File
@@ -0,0 +1,30 @@
import { Link } from "react-router"
/**
* General page not found page.
* @constructor
*/
export default function NotFoundPage() {
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="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 bottom-0 left-1/2 w-72 h-72 bg-pink-200 rounded-full mix-blend-multiply filter blur-xl opacity-70"></div>
<div className="text-center bg-white p-10 lg:p-14 rounded-xl shadow-lg md:shadow-xl max-w-xl w-full">
<h1 className="text-7xl lg:text-8xl text-amber-500 m-0 font-bold tracking-wider">404</h1>
<h2 className="text-3xl md:text-4xl text-gray-700 mt-4 mb-6"></h2>
<p className="text-lg text-gray-600 leading-relaxed mb-9">
</p>
<Link
to="/"
className="inline-block bg-blue-600 text-white py-3 px-7 rounded-lg no-underline
text-lg font-semibold transition-all duration-300 ease-in-out
shadow-md shadow-blue-500/20 hover:bg-blue-700 hover:-translate-y-0.5 hover:shadow-lg hover:shadow-blue-500/30">
</Link>
</div>
</div>
)
}
+49
View File
@@ -0,0 +1,49 @@
import { useEffect } from "react"
import { useNavigate } from "react-router"
import dayjs from "dayjs"
import type { AxiosError } from "axios"
import { message } from "antd"
import { AuthApi } from "@/api"
import type { GeneralErrorResponse } from "@/types/web/response"
export default function RegisterPage() {
const [messageApi, contextHolder] = message.useMessage()
const navigate = useNavigate()
useEffect(() => {
let cancelled = false
AuthApi.fetchRegisterEnabled()
.then((enabled) => {
if (!enabled && !cancelled) {
void messageApi
.error("注册功能暂未开放", dayjs.duration({ seconds: 3 }).asSeconds())
.then(() => {
if (!cancelled) {
void navigate("/login")
}
})
}
})
.catch((reason: unknown) => {
if (cancelled) return
const error = reason as AxiosError<GeneralErrorResponse>
void messageApi.error(error.response?.data.message ?? "发生未知错误,请稍后再试")
})
return () => {
cancelled = true
}
}, [messageApi, navigate])
return (
<div className="min-h-screen bg-gradient-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 right-0 w-72 h-72 bg-purple-200 rounded-full mix-blend-multiply filter blur-xl opacity-70"></div>
<div className="absolute bottom-0 left-1/2 w-72 h-72 bg-pink-200 rounded-full mix-blend-multiply filter blur-xl opacity-70"></div>
<div></div>
</div>
)
}
+190
View File
@@ -0,0 +1,190 @@
import { useEffect, useState } from "react"
import type { AxiosError } from "axios"
import { App, Button, Form, Input, Select, Space, Switch, Table } from "antd"
import type { Role } from "@/types/entity"
import { RoleApi } from "@/api"
import type { QueryRoleRequest } from "@/types/web/request"
import type { GeneralErrorResponse, RoleResponse } from "@/types/web/response"
import {
DeleteOutlined,
ExportOutlined,
ImportOutlined,
PlusOutlined,
SearchOutlined,
UndoOutlined,
} from "@ant-design/icons"
import type { QueryRoleForm } from "@/types/form"
import type { Status } from "@/types/constant"
export default function RolePage() {
const { message } = App.useApp()
const [queryForm] = Form.useForm<QueryRoleForm>()
const [roles, setRoles] = useState<Role[]>([])
const [pageNum, setPageNum] = useState<number>(1)
const [pageSize, setPageSize] = useState<number>(10)
const [totalElementCount, setTotalElementCount] = useState<number>(0)
const queryRoles = (pageNum: number, pageSize: number, queryRoleForm: QueryRoleForm | null) => {
const queryRoleRequest: QueryRoleRequest = {
code: queryRoleForm?.qCode ?? null,
name: queryRoleForm?.qName ?? null,
status: queryRoleForm?.qStatus ?? null,
pageNum,
pageSize,
}
RoleApi.fetchRoles(queryRoleRequest)
.then((response) => {
console.log("role response", response)
setPageNum(response.pageable.pageNumber + 1)
setPageSize(response.pageable.pageSize)
setTotalElementCount(response.totalElements)
setRoles(response.content.sort((role1, role2) => role1.sort - role2.sort))
})
.catch((error) => {
const err = error as AxiosError<GeneralErrorResponse>
void message.error(err.response?.data.message ?? "获取角色失败")
})
}
const changeRoleStatus = (roleId: number | string, checked: boolean) => {
const status: Status = checked ? "ACTIVE" : "INACTIVE"
setRoles((prevRoles) =>
prevRoles.map((role) => (role.id === roleId ? { ...role, status } : role))
)
}
useEffect(() => {
queryRoles(pageNum, pageSize, null)
}, [pageNum, pageSize])
return (
<div className="flex gap-6 flex-col">
<Form<QueryRoleForm>
form={queryForm}
className="mt-0 mb-6"
layout="inline"
labelAlign="right"
onFinish={(values) => {
queryRoles(pageNum, pageSize, values)
}}
onReset={() => {
queryRoles(pageNum, pageSize, null)
}}>
<Form.Item<QueryRoleForm> label="角色名称" name="qName">
<Input />
</Form.Item>
<Form.Item<QueryRoleForm> label="角色编码" name="qCode">
<Input />
</Form.Item>
<Form.Item<QueryRoleForm> label="角色状态" name="qStatus">
<Select<Status>
className="w-26"
options={[
{
label: "已启用",
value: "ACTIVE",
},
{
label: "未启用",
value: "INACTIVE",
},
]}
/>
</Form.Item>
<Form.Item<QueryRoleForm>>
<Space.Compact>
<Button color="primary" variant="solid" htmlType="submit" icon={<SearchOutlined />}>
</Button>
<Button color="orange" variant="solid" htmlType="reset" icon={<UndoOutlined />}>
</Button>
</Space.Compact>
</Form.Item>
</Form>
<Space size={8}>
<Button variant="solid" type="primary" icon={<PlusOutlined />} onClick={() => {}}>
</Button>
<Button variant="solid" danger icon={<DeleteOutlined />}>
</Button>
<Button variant="solid" color="green" icon={<ImportOutlined />}>
</Button>
<Button variant="solid" color="green" icon={<ExportOutlined />}>
</Button>
</Space>
<Table<Role>
columns={[
{ title: "角色编号", dataIndex: "id" },
{ title: "角色名称", dataIndex: "name" },
{ title: "角色编码", dataIndex: "code" },
{ title: "显示顺序", dataIndex: "sort" },
{
title: "状态",
key: "status",
render: (role: Role) => (
<>
<Switch
value={role.status == "ACTIVE"}
onChange={(checked) => changeRoleStatus(role.id, checked)}
/>
</>
),
},
{
title: "默认角色",
key: "defaultValue",
render: (role: Role) => (
<>
<Switch value={role.defaultValue} disabled />
</>
),
},
{ title: "创建时间", dataIndex: "createdAt" },
{
title: "操作",
render: (role: Role) => (
<>
<Space.Compact>
<Button variant="solid"></Button>
<Button variant="solid" danger>
</Button>
</Space.Compact>
</>
),
key: "operations",
},
]}
dataSource={roles}
rowKey="id"
pagination={{
current: pageNum,
total: totalElementCount,
pageSize: pageSize,
defaultCurrent: 1,
defaultPageSize: 10,
pageSizeOptions: [10, 25, 50],
showSizeChanger: true,
onShowSizeChange: (pageNum, pageSize) => {
console.log(`onSizeChange ==> pageNum = ${pageNum}, pageSize = ${pageSize}`)
setPageNum(pageNum)
setPageSize(pageSize)
},
onChange: (pageNum, pageSize) => {
console.log(`onChange ==> pageNum = ${pageNum}, pageSize = ${pageSize}`)
setPageNum(pageNum)
setPageSize(pageSize)
},
}}
/>
</div>
)
}
+463
View File
@@ -0,0 +1,463 @@
import { type Key, useEffect, useState } from "react"
import {
Avatar,
Button,
DatePicker,
Form,
Input,
Select,
Space,
Table,
Tag,
Tree,
type TreeDataNode,
Typography,
} from "antd"
import { App } from "antd"
import { type AxiosError } from "axios"
import { getCountries, getCountryCallingCode } from "libphonenumber-js"
import { DeptApi, UserApi } from "@/api"
import { PhoneNumberUtils, DepartmentUtils } from "@/utils"
import {
DeleteOutlined,
EditOutlined,
ExportOutlined,
ImportOutlined,
KeyOutlined,
PlusOutlined,
SearchOutlined,
UndoOutlined,
} from "@ant-design/icons"
import AddUserDialogue from "@/components/add-user-dialogue"
import EditUserDialogue from "@/components/edit-user-dialogue"
import type { QueryUserRequest } from "@/types/web/request"
import type { QueryUserForm, UserFormValues } from "@/types/form"
import type { GeneralErrorResponse, UserDetailResponse } from "@/types/web/response"
import type { Department } from "@/types/entity"
import type { TreeNode } from "@/types/tree"
import { useAppSelector } from "@/store"
export default function UserPage() {
const { message, modal } = App.useApp()
const user = useAppSelector((state) => state.auth.user!)
const [queryForm] = Form.useForm<QueryUserForm>()
const [addUserForm] = Form.useForm<UserFormValues>()
const [editUserForm] = Form.useForm<UserFormValues>()
const [department, setDepartment] = useState<TreeNode<Department>>()
const [departmentTree, setDepartmentTree] = useState<TreeDataNode[]>([])
const [selectedDepartment, setSelectedDepartment] = useState<number>(1)
const [users, setUsers] = useState<UserDetailResponse[]>([])
const [pageNum, setPageNum] = useState<number>(1)
const [totalElementCount, setTotalElementCount] = useState<number>(0)
const [pageSize, setPageSize] = useState<number>(10)
const onAddUserFinish = async () => {
try {
const values = await addUserForm.validateFields()
await UserApi.addUser(values)
void message.success(`用户 ${values.username} 创建成功`)
return true
} catch (error: unknown) {
console.log(error)
if (error instanceof Error && error.message.includes("Validation Failed")) {
return false
}
const err = error as AxiosError<GeneralErrorResponse>
void message.error(err.response?.data.message ?? "创建失败,请稍后再试")
return false
}
}
const handleAddUser = () => {
modal
.confirm({
title: "添加用户",
content: <AddUserDialogue form={addUserForm} />,
width: 600,
onOk: onAddUserFinish,
})
.then(
() => {
const formValues = queryForm.getFieldsValue()
queryUsers(pageNum, pageSize, selectedDepartment!, formValues)
},
() => {
console.error("用户取消添加用户")
}
)
}
const onEditUserFinish = async () => {
try {
const values = await editUserForm.validateFields()
await UserApi.editUser(values)
void message.success(`用户更新成功`)
return true
} catch (error: unknown) {
if (error instanceof Error && error.message.includes("Validation Failed")) {
return false
}
const err = error as AxiosError<GeneralErrorResponse>
void message.error(err.response?.data.message ?? "更新失败,请稍后再试")
return false
}
}
const handleEditUser = (user: UserDetailResponse) => {
modal
.confirm({
title: `编辑用户: ${user.username}`,
content: <EditUserDialogue form={editUserForm} user={user} />,
width: 600,
onOk: onEditUserFinish,
})
.then(
() => {
const formValues = queryForm.getFieldsValue()
queryUsers(pageNum, pageSize, selectedDepartment, formValues)
},
() => {
console.error("用户取消添加用户")
}
)
}
const handleDeleteUser = (user: UserDetailResponse) => {
const isLastElementOnPage = users.length === 1 && pageNum > 1
modal.confirm({
title: (
<>
<Typography.Text code>{user.username}</Typography.Text>
</>
),
content: "删除后数据将无法恢复。",
okText: "删除",
cancelText: "取消",
okButtonProps: { danger: true },
onOk: async () => {
try {
await UserApi.deleteUser(user.id)
void message.success(`用户 ${user.username} 删除成功`)
setTotalElementCount((count) => count - 1)
if (isLastElementOnPage) {
setPageNum((prevPage) => prevPage - 1)
} else {
queryUsers(pageNum, pageSize, selectedDepartment, queryForm.getFieldsValue())
}
} catch (error: unknown) {
const err = error as AxiosError<GeneralErrorResponse>
void message.error(err.response?.data.message ?? "删除失败,请稍后再试")
}
},
})
}
// Query users
const queryUsers = (
pageNum: number,
pageSize: number,
departmentId: number,
formValues: QueryUserForm | null
) => {
const queryUserRequest: QueryUserRequest = {
createdAtEnd: null,
createdAtStart: null,
phoneNumber: null,
regionAbbreviation: null,
status: null,
username: null,
pageNum,
pageSize,
departmentId,
}
if (formValues && formValues.qUsername) {
queryUserRequest.username = formValues.qUsername
}
if (formValues && formValues.qRegionAbbreviation) {
queryUserRequest.regionAbbreviation = formValues.qRegionAbbreviation
}
if (formValues && formValues.qPhoneNumber) {
queryUserRequest.phoneNumber = formValues.qPhoneNumber
}
if (formValues && formValues.qStatus) {
queryUserRequest.status = formValues.qStatus
}
if (formValues && formValues.qCreatedAtStart) {
queryUserRequest.createdAtStart = formValues.qCreatedAtStart.format("YYYY-MM-DD HH:mm:ss")
}
if (formValues && formValues.qCreatedAtEnd) {
queryUserRequest.createdAtEnd = formValues.qCreatedAtEnd.format("YYYY-MM-DD HH:mm:ss")
}
console.log(`queryUsers ==> `, queryUserRequest)
UserApi.fetchUsers(queryUserRequest)
.then((userResponse) => {
console.log(userResponse)
setUsers(userResponse.content)
setPageNum(userResponse.pageable.pageNumber + 1)
setPageSize(userResponse.pageable.pageSize)
setTotalElementCount(userResponse.totalElements)
})
.catch((error: unknown) => {
const err = error as AxiosError<GeneralErrorResponse>
void message.error(err.response?.data.message ?? "发生未知错误,请稍后再试")
})
}
useEffect(() => {
DeptApi.fetchDepartmentTree()
.then((departmentResponse) => {
setDepartment(departmentResponse)
setDepartmentTree(DepartmentUtils.transformDepartmentData([departmentResponse]))
})
.catch((error: unknown) => {
const err = error as AxiosError<GeneralErrorResponse>
void message.error(err.response?.data.message ?? "发生未知错误,请稍后再试")
})
}, [])
useEffect(() => {
const formValues = queryForm.getFieldsValue()
queryUsers(pageNum, pageSize, selectedDepartment, formValues)
}, [pageNum, pageSize, selectedDepartment])
const regionOptions = getCountries().map((country) => {
const callingCode = getCountryCallingCode(country)
return {
label: `${country} (+${callingCode})`,
value: country,
key: country,
}
})
const userStatusOptions = [
{
label: "已启用",
value: "ACTIVE",
key: "ACTIVE",
},
{
label: "已停用",
value: "INACTIVE",
key: "INACTIVE",
},
{
label: "已禁用",
value: "LOCKED",
key: "LOCKED",
},
]
return (
<div className="flex h-full gap-2">
<Tree
multiple={false}
treeData={departmentTree}
classNames={{
item: "w-64",
}}
onSelect={([selectedKey]) => {
if (typeof selectedKey == "number") {
setSelectedDepartment(selectedKey)
}
}}
selectedKeys={[selectedDepartment]}
/>
<div className="flex flex-col w-full">
<Form<QueryUserForm>
className="mt-0 mb-6"
form={queryForm}
layout="inline"
labelAlign="right"
onFinish={(values) => {
console.log("Query User Form ==> ", values)
queryUsers(pageNum, pageSize, Number(selectedDepartment), values)
}}
onReset={() => {
queryUsers(pageNum, pageSize, Number(selectedDepartment), null)
}}>
<Form.Item<QueryUserForm> label="用户名" name="qUsername">
<Input className="w-40" />
</Form.Item>
<Form.Item<QueryUserForm> label="用户状态" name="qStatus">
<Select
options={userStatusOptions}
className="w-24"
allowClear
onSelect={(item: unknown, option) => {
console.log(`selectedItem = ${item} ${option}`)
}}
/>
</Form.Item>
<Form.Item label="手机号码">
<Space.Compact>
<Form.Item<QueryUserForm> noStyle name="qRegionAbbreviation">
<Select
options={regionOptions}
className="w-28"
allowClear
showSearch={{
optionFilterProp: "label",
}}
placeholder="国家/地区"
/>
</Form.Item>
<Form.Item<QueryUserForm> noStyle name="qPhoneNumber">
<Input className="w-40" />
</Form.Item>
</Space.Compact>
</Form.Item>
<Form.Item label="创建时间">
<Space.Compact className="w-full align-baseline">
<Form.Item<QueryUserForm> name="qCreatedAtStart">
<DatePicker showTime={true} showNow={true} format="YYYY-MM-DD HH:mm:ss" />
</Form.Item>
<Form.Item<QueryUserForm> name="qCreatedAtEnd">
<DatePicker showTime={true} showNow={true} format="YYYY-MM-DD HH:mm:ss" />
</Form.Item>
</Space.Compact>
</Form.Item>
<Form.Item>
<Space size={8}>
<Button htmlType="submit" type="primary" icon={<SearchOutlined />}>
</Button>
<Button
htmlType="reset"
type="primary"
icon={<UndoOutlined />}
onClick={() => {
if (department) {
// Set department to first root department
const { item } = department
setSelectedDepartment(item.id)
} else {
void message.error("未获取到任何部门信息")
}
}}>
</Button>
</Space>
</Form.Item>
</Form>
<Space size={8} className="mb-6">
<Button variant="solid" type="primary" icon={<PlusOutlined />} onClick={handleAddUser}>
</Button>
<Button variant="solid" danger icon={<DeleteOutlined />}>
</Button>
<Button variant="solid" color="green" icon={<ImportOutlined />}>
</Button>
<Button variant="solid" color="green" icon={<ExportOutlined />}>
</Button>
</Space>
<Table<UserDetailResponse>
rowKey="id"
dataSource={users}
pagination={{
current: pageNum,
total: totalElementCount,
pageSize: pageSize,
defaultCurrent: 1,
defaultPageSize: 10,
pageSizeOptions: [10, 25, 50],
showSizeChanger: true,
onShowSizeChange: (pageNum, pageSize) => {
console.log(`onSizeChange ==> pageNum = ${pageNum}, pageSize = ${pageSize}`)
setPageNum(pageNum)
setPageSize(pageSize)
},
onChange: (pageNum, pageSize) => {
console.log(`onChange ==> pageNum = ${pageNum}, pageSize = ${pageSize}`)
setPageNum(pageNum)
setPageSize(pageSize)
},
}}
columns={[
{ title: "用户 ID", dataIndex: "id" },
{ title: "用户名称", dataIndex: "username" },
{ title: "姓名", dataIndex: "fullName" },
{ title: "电子邮箱", dataIndex: "email" },
{
title: "联系电话",
render: (user: UserDetailResponse) => {
return PhoneNumberUtils.formatInternationalPhoneNumber(
user.regionAbbreviation,
user.phoneNumber
)
},
key: "phoneNumber",
},
{
title: "头像",
render: (user: UserDetailResponse) => {
return <Avatar src={user.avatarUrl} alt="用户头像" />
},
key: "avatarUrl",
},
{
title: "用户状态",
render: (user: UserDetailResponse) => {
const colour = user.status == "ACTIVE" ? "green" : "red"
return (
<Tag color={colour} key={`${user.id}-${user.status}`} variant="outlined">
{user.status}
</Tag>
)
},
key: "status",
},
{ title: "所在部门", dataIndex: "departmentName" },
{ title: "用户岗位", dataIndex: "positionName" },
{
title: "操作",
render: (value: UserDetailResponse) => {
return String(value.id) == String(user.id) ? (
<></>
) : (
<Space.Compact>
<Button icon={<EditOutlined />} onClick={() => handleEditUser(value)}>
</Button>
<Button icon={<KeyOutlined />} onClick={() => {}}>
</Button>
<Button
danger
icon={<DeleteOutlined />}
onClick={() => handleDeleteUser(value)}>
</Button>
</Space.Compact>
)
},
},
]}
/>
</div>
</div>
)
}
+86
View File
@@ -0,0 +1,86 @@
import type { ComponentType } from "react"
import { createBrowserRouter } from "react-router-dom"
import ErrorPage from "@/page/error"
import LoadingFallback from "@/components/loading-fallback"
/**
* 懒加载组件
* @param importer
*/
function lazyLoading<T extends { default: ComponentType<unknown> }>(importer: () => Promise<T>) {
return async () => {
const module = await importer()
return {
Component: module.default,
}
}
}
const router = createBrowserRouter([
{
path: "/login",
lazy: lazyLoading(() => import("@/page/login")),
handle: {
label: "用户登录",
},
hydrateFallbackElement: <LoadingFallback />,
},
{
path: "/register",
lazy: lazyLoading(() => import("@/page/register")),
handle: {
label: "用户注册",
},
hydrateFallbackElement: <LoadingFallback />,
},
{
path: "/",
lazy: lazyLoading(() => import("@/components/protected-route")),
errorElement: <ErrorPage />,
handle: {
label: "控制台",
},
hydrateFallbackElement: <LoadingFallback />,
children: [
{
index: true,
lazy: lazyLoading(() => import("@/page/home")),
handle: {
label: "用户主页",
},
hydrateFallbackElement: <LoadingFallback />,
},
{
path: "users",
lazy: lazyLoading(() => import("@/page/user")),
handle: {
label: "用户管理",
},
hydrateFallbackElement: <LoadingFallback />,
},
{
path: "roles",
lazy: lazyLoading(() => import("@/page/role")),
handle: {
label: "角色管理",
},
hydrateFallbackElement: <LoadingFallback />,
},
{
path: "menus",
lazy: lazyLoading(() => import("@/page/menu")),
handle: {
label: "菜单管理",
},
hydrateFallbackElement: <LoadingFallback />,
},
],
},
{
path: "*",
lazy: lazyLoading(() => import("@/page/not-found")),
hydrateFallbackElement: <LoadingFallback />,
},
])
export default router
View File
+29
View File
@@ -0,0 +1,29 @@
import type { IPublicClientApplication } from "@azure/msal-browser"
import * as AuthApi from "@/api/auth"
import type { AppDispatch } from "@/store"
import { loginSuccess } from "@/store/auth-slice"
/**
* Login with Microsoft Entra ID.
*
* @param instance Microsoft Entra ID application instance
* @param dispatch app dispatcher
* @param onSuccess callback when login succeeded
*/
export async function doMsalLogin(
instance: IPublicClientApplication,
dispatch: AppDispatch,
onSuccess?: () => void
) {
try {
const response = await instance.loginPopup({
scopes: ["openid", "profile", "email"],
})
const { accessToken, user } = await AuthApi.msalLogin(response.idToken)
dispatch(loginSuccess({ user, token: accessToken }))
if (onSuccess) onSuccess()
} catch (err) {
console.error("MSAL login failed", err)
}
}
+38
View File
@@ -0,0 +1,38 @@
import axios, { type AxiosError } from "axios"
import dayjs from "dayjs"
import store from "@/store"
import type { GeneralErrorResponse } from "@/types"
import { HttpStatus } from "@/constant"
import { logout } from "@/store/auth-slice"
const webClient = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL,
timeout: dayjs.duration({ seconds: 10 }).asMilliseconds(),
})
webClient.interceptors.request.use(
(config) => {
const state = store.getState()
if (state.auth.isAuthenticated) {
config.headers["Authorization"] = `Bearer ${state.auth.token!}`
}
return config
},
(error: unknown) => {
console.log(error)
return Promise.reject(new Error("请求错误,请稍后再试"))
}
)
webClient.interceptors.response.use((response) => {
return response
}, (error: unknown) => {
const err = error as AxiosError<GeneralErrorResponse>
if (err.response?.status == HttpStatus.UNAUTHORIZED) {
store.dispatch(logout())
}
return Promise.reject(error as AxiosError)
})
export default webClient
+46
View File
@@ -0,0 +1,46 @@
import { createSlice, type PayloadAction } from "@reduxjs/toolkit"
import type { User } from "@/types/entity"
interface AuthState {
isAuthenticated: boolean
user: User | null
token: string | null,
registrationEnabled: boolean
}
const initialState: AuthState = {
isAuthenticated: false,
user: null,
token: null,
registrationEnabled: false
}
const authSlice = createSlice({
name: "auth",
initialState,
reducers: {
loginSuccess(
state,
action: PayloadAction<{
user: User
token: string
}>
) {
console.log("更新用户信息:", action.payload.user)
state.isAuthenticated = true
state.user = action.payload.user
state.token = action.payload.token
},
logout(state) {
state.isAuthenticated = false
state.user = null
state.token = null
},
updateRegistrationEnabled(state, action: PayloadAction<boolean>) {
state.registrationEnabled = action.payload
}
},
})
export const { loginSuccess, logout, updateRegistrationEnabled } = authSlice.actions
export default authSlice.reducer
+47
View File
@@ -0,0 +1,47 @@
import { configureStore, combineReducers } from "@reduxjs/toolkit"
import { useDispatch, useSelector } from "react-redux"
import authReducer from "./auth-slice"
import {
persistStore,
persistReducer,
FLUSH,
REHYDRATE,
PAUSE,
PERSIST,
PURGE,
REGISTER,
} from "redux-persist"
import storage from "redux-persist/lib/storage/session"
const persistConfig = {
key: "root",
storage,
whitelist: ["auth"],
// blacklist: ['department'],
}
const rootReducer = combineReducers({
auth: authReducer,
})
const persistedReducer = persistReducer(persistConfig, rootReducer)
const store = configureStore({
reducer: persistedReducer,
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware({
serializableCheck: {
ignoredActions: [FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER],
},
}),
})
export const persistor = persistStore(store)
export default store
export type RootState = ReturnType<typeof rootReducer>
export type AppDispatch = typeof store.dispatch
export type AppStore = typeof store
export const useAppDispatch = useDispatch.withTypes<AppDispatch>()
export const useAppSelector = useSelector.withTypes<RootState>()
+14
View File
@@ -0,0 +1,14 @@
import type { GetProps } from "antd"
import type Icon from "@ant-design/icons"
export type AntIconComponentProps = GetProps<typeof Icon>
export interface AntTreeSelectOption<T> {
value: T
label: string
children: AntTreeSelectOption<T>[]
disabled?: boolean
disableCheckbox?: boolean
selectable?: boolean
checkable?: boolean
}
+14
View File
@@ -0,0 +1,14 @@
/**
* WeCom config
*/
export type WecomConfig = {
/**
* Corporation ID
*/
corpId: string
/**
* Application ID
*/
agentId: string
}
+6
View File
@@ -0,0 +1,6 @@
/**
* Status
*/
export type Status = "ACTIVE" | "INACTIVE"
export type UserStatus = Status | "LOCKED"
+73
View File
@@ -0,0 +1,73 @@
import type { Status, UserStatus } from "@/types/constant"
import type { CountryCode as RegionAbbreviation } from "libphonenumber-js"
import type { Dayjs } from "dayjs"
/**
* User information
*/
export interface User {
id: number
username: string
password: string
fullName: string
email: string
regionAbbreviation: RegionAbbreviation
phoneNumber: string
avatarUrl: string
status: UserStatus
departmentId: number
positionId: number
createdAt: string
updatedAt: string
}
/**
* Menu Item
*/
export interface MenuItem {
id: number
name: string
parentId: number | null
code: string
sort: number
isExternalLink: boolean
isVisible: boolean
status: Status
authorityCode: string | null
icon: string | null
createdAt: string
updatedAt: string
}
export interface Department {
id: number
name: string
parentId: number | null
sort: number
status: Status
createdAt: string
updatedAt: string
}
export interface Position {
id: number
name: string
code: string | null
description: string | null
sort: number
status: Status
createdAt: string
updatedAt: string
}
export interface Role {
id: number | string
name: string
code: string
sort: number
defaultValue: boolean
description: string | null
status: Status
createdAt: Dayjs | string
updatedAt: Dayjs | string
}
+31
View File
@@ -0,0 +1,31 @@
import type { User } from "@/types/entity"
import type { CountryCode as RegionAbbreviation } from "libphonenumber-js"
import type { Status } from "@/types/constant"
import type { Dayjs } from "dayjs"
export interface UserFormValues extends Omit<
User,
"id" | "password" | "regionAbbreviation" | "departmentId" | "positionId" | "createdAt" | "updatedAt"
> {
id: number | string | null
password: string | null
regionAbbreviation: RegionAbbreviation | null
departmentId: number | null
positionId: number | null
}
export interface QueryRoleForm {
qName: string | null
qCode: string | null
qStatus: Status | null
}
export interface QueryUserForm {
qDepartmentId: number | null
qUsername: string | null
qRegionAbbreviation: RegionAbbreviation | null
qPhoneNumber: string | null
qStatus: Status | null
qCreatedAtStart: Dayjs | null
qCreatedAtEnd: Dayjs | null
}
View File
+14
View File
@@ -0,0 +1,14 @@
export interface Sortable {
empty: boolean
sorted: boolean
unsorted: boolean
}
export interface Pageable {
pageNumber: number
pageSize: number
sort: Sortable
offset: number
paged: boolean
unpaged: boolean
}
+6
View File
@@ -0,0 +1,6 @@
/**
* React Router Metadata
*/
export interface RouteHandle {
label: string
}
+37
View File
@@ -0,0 +1,37 @@
/**
* Represents a single node within a tree structure.
*
* Each node holds a data item of type T and an array of its children.
*
* @template T the type of the data item stored in the node
*/
export interface TreeNode<T> {
/**
* The actual data item contained within this node.
*/
item: T
/**
* A list of child nodes belonging to this node.
*
* This array will be empty if the node is a leaf node.
*/
children: TreeNode<T>[]
}
/**
* Defines a tree structure, which is simply a top-level tree node.
*
* It represents the root of the hierarchical data structure.
*
* @template T the type of the data items within the tree
*/
export type Tree<T> = TreeNode<T>
/**
* Defines a forest, which is an array of zero or more disjoint trees.
*
* Each element in the array is the root of an independent tree.
*
* @template T the type of the data items within the trees
*/
export type Forest<T> = TreeNode<T>[]
+54
View File
@@ -0,0 +1,54 @@
import type { CountryCode as RegionAbbreviation } from "libphonenumber-js"
import type { Status, UserStatus } from "@/types/constant"
import type { User } from "@/types/entity"
export interface PageRequest {
pageNum?: number
pageSize?: number
}
export interface UsernamePasswordLoginRequest {
username: string
password: string
captcha?: string
uuid?: string
}
export interface QueryUserRequest extends PageRequest {
departmentId: number | null
username: string | null
regionAbbreviation: RegionAbbreviation | null
phoneNumber: string | null
status: Status | null
createdAtStart: string | null
createdAtEnd: string | null
}
export interface QueryPositionRequest extends PageRequest {
}
export interface AddUserRequest extends Omit<User, "id" | "createdAt" | "updatedAt">{
roleIds: number[] | null
}
export interface EditUserRequest {
id: number | string
username: string | null
fullName: string | null
email: string | null
regionAbbreviation: string | null
phoneNumber: string | null
avatarUrl: string | null
status: UserStatus | null
departmentId: number | null
positionId: number | null
roleIds: number[] | null
}
export interface QueryRoleRequest extends PageRequest {
name: string | null
code: string | null
status: Status | null
}
+39
View File
@@ -0,0 +1,39 @@
import type { Role, User } from "@/types/entity"
import type { Pageable, Sortable } from "@/types/page"
export interface PageResponse<T> {
content: T[]
last: boolean
totalPages: number
totalElements: number
size: number
sort: Sortable
first: boolean
numberOfElements: number
empty: boolean,
pageable: Pageable
}
export interface UserAuthResponse {
user: User
accessToken: string
}
export interface UserDetailResponse extends User {
departmentName: string
positionName: string
}
export interface CaptchaResponse {
uuid: string
captcha: string
}
export interface GeneralErrorResponse {
message: string
timestamp: string
}
export interface RoleResponse extends PageResponse<Role> {
}
+28
View File
@@ -0,0 +1,28 @@
import { type TreeDataNode } from "antd"
import type { TreeNode } from "@/types/tree"
import type { Department } from "@/types/entity"
export function transformDepartmentData(departments: TreeNode<Department>[]): TreeDataNode[] {
if (!departments || departments.length === 0) {
return []
}
return departments
.sort((a, b) => a.item.sort - b.item.sort)
.map((node) => {
const { item, children } = node
const hasChildren = children && children.length > 0
const treeItem: TreeDataNode = {
key: item.id,
title: item.name,
}
if (hasChildren) {
// Append children
return { ...treeItem, children: transformDepartmentData(children) }
}
return treeItem
})
}
+2
View File
@@ -0,0 +1,2 @@
export * as PhoneNumberUtils from "./phone-number-utils"
export * as DepartmentUtils from "./department-utils"
+28
View File
@@ -0,0 +1,28 @@
import { type CountryCode as RegionAbbreviation, getCountryCallingCode, parsePhoneNumberWithError, PhoneNumber } from "libphonenumber-js"
/**
* Format user's phone number as user's country/region.
*
* @param regionAbbreviation user's region abbreviation
* @param phoneNumber user's phone number
* @return formatted phone number
*/
export function formatInternationalPhoneNumber(regionAbbreviation: string, phoneNumber: string): string {
try {
const _phoneNumber = parsePhoneNumberWithError(phoneNumber, regionAbbreviation as RegionAbbreviation)
if (!_phoneNumber.isValid()) {
console.warn(`Phone number ${_phoneNumber.formatInternational()} is not valid`)
}
return _phoneNumber.formatInternational()
} catch (error) {
console.error("Phone number parsing failed:", error)
const _regionAbbreviation: RegionAbbreviation = regionAbbreviation as RegionAbbreviation
const callingCode = getCountryCallingCode(_regionAbbreviation)
return `+${callingCode} ${phoneNumber}`
}
}
export function getDefaultCountryCode(): RegionAbbreviation {
return import.meta.env.VITE_DEFAULT_REGION_CODE
}
+27
View File
@@ -0,0 +1,27 @@
/// <reference types="vite/client" />
/// <reference types="vite-plugin-svgr/client" />
import type { CountryCode as RegionAbbreviation } from "libphonenumber-js"
interface ImportMetaEnv {
/**
* Server Base URL
*/
readonly VITE_API_BASE_URL: string
/**
* Microsoft Entra ID Client ID
*/
readonly VITE_MSAL_CLIENT_ID: string
/**
* Microsoft Entra ID Tenant ID
*/
readonly VITE_MSAL_TENANT_ID: string
readonly VITE_DEFAULT_REGION_ABBREVIATION: RegionAbbreviation
}
interface ImportMeta {
readonly env: ImportMetaEnv
}