feat: 添加密码找回功能和相关接口
- 在 `auth.ts` 中添加 `PasswordPolicyPublic`、`PasswordRecoverySendCodePayload` 和 `PasswordRecoveryResetPayload` 接口 - 添加 `fetchPublicPasswordPolicy`、`sendPasswordRecoveryCode` 和 `resetPasswordByRecovery` API 函数 - 在 `index.ts` 中添加 `resetUserPassword` API 函数 - 更新 `http.ts` 中的 `AUTH_WHITELIST`,添加新的密码找回相关路径 - 在 `App.tsx` 中添加 `ForgotPasswordPage` 路由 - 在 `zh-CN.json` 和 `en-US.json` 中添加密码规则相关的翻译 - 在 `AndroidPushGrpcService.java` 中添加日志记录 - 添加 `LettuceRedisConfig` 配置类 - 在 `login/index.tsx` 中添加忘记密码链接 - 新增 `forgot-password/index.tsx` 页面,实现密码找回功能 - 更新 `global.css` 和 `reset-password/index.tsx` 的样式和逻辑 - 更新 `application.yml`,添加邮件配置 - 更新 `tsconfig.tsbuildinfo` 文件dev_na
parent
7233f13598
commit
ee1e75eda2
|
|
@ -55,6 +55,10 @@
|
|||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-aop</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-mail</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.baomidou</groupId>
|
||||
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,42 @@
|
|||
package com.imeeting.config;
|
||||
|
||||
import io.lettuce.core.RedisClient;
|
||||
import io.lettuce.core.RedisURI;
|
||||
import io.lettuce.core.api.StatefulRedisConnection;
|
||||
import io.lettuce.core.codec.StringCodec;
|
||||
import org.springframework.boot.autoconfigure.data.redis.RedisProperties;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
@Configuration
|
||||
public class LettuceRedisConfig {
|
||||
|
||||
@Bean(destroyMethod = "shutdown")
|
||||
public RedisClient redisClient(RedisProperties redisProperties) {
|
||||
RedisURI.Builder builder = RedisURI.builder()
|
||||
.withHost(redisProperties.getHost())
|
||||
.withPort(redisProperties.getPort())
|
||||
.withDatabase(redisProperties.getDatabase());
|
||||
|
||||
if (StringUtils.hasText(redisProperties.getUsername())) {
|
||||
builder.withAuthentication(redisProperties.getUsername(), redisProperties.getPassword());
|
||||
} else if (StringUtils.hasText(redisProperties.getPassword())) {
|
||||
builder.withPassword(redisProperties.getPassword().toCharArray());
|
||||
}
|
||||
|
||||
Duration timeout = redisProperties.getTimeout();
|
||||
if (timeout != null && !timeout.isZero() && !timeout.isNegative()) {
|
||||
builder.withTimeout(timeout);
|
||||
}
|
||||
|
||||
return RedisClient.create(builder.build());
|
||||
}
|
||||
|
||||
@Bean(destroyMethod = "close")
|
||||
public StatefulRedisConnection<String, String> redisConnection(RedisClient redisClient) {
|
||||
return redisClient.connect(StringCodec.UTF8);
|
||||
}
|
||||
}
|
||||
|
|
@ -177,6 +177,7 @@ public class AndroidPushGrpcService extends PushServiceGrpc.PushServiceImplBase
|
|||
}
|
||||
|
||||
private void handleAck(AckRequest request) {
|
||||
log.info(buildLog("gRPC请求", "ACK消息:" + request.getMessageId(), deviceId, appVersion, platform));
|
||||
if (!validateConnected()) {
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,7 +19,40 @@ spring:
|
|||
serialization:
|
||||
write-dates-as-timestamps: false
|
||||
time-zone: GMT+8
|
||||
|
||||
mail:
|
||||
# SMTP服务器地址
|
||||
host: ${MAIL_HOST}
|
||||
# 端口(QQ邮箱使用587或465)
|
||||
port: 465
|
||||
# 你的完整邮箱地址
|
||||
username: ${MAIL_USERNAME}
|
||||
# 授权码(不是登录密码!)
|
||||
password: ${MAIL_PASSWORD}
|
||||
# 默认编码
|
||||
default-encoding: UTF-8
|
||||
# 协议(默认为smtp)
|
||||
protocol: smtp
|
||||
# 测试连接(可选)
|
||||
test-connection: false
|
||||
# 额外属性配置
|
||||
properties:
|
||||
mail:
|
||||
smtp:
|
||||
# 启用认证
|
||||
auth: true
|
||||
# 启用STARTTLS加密(465端口为隐式SSL,无需STARTTLS)
|
||||
starttls:
|
||||
enable: false
|
||||
required: false
|
||||
# 465端口必须启用隐式SSL,否则连接会一直阻塞直到超时
|
||||
ssl:
|
||||
enable: true
|
||||
# 超时配置(避免线程阻塞)
|
||||
connectiontimeout: 5000
|
||||
timeout: 3000
|
||||
writetimeout: 5000
|
||||
# 启用调试日志(生产环境建议关闭)
|
||||
debug: true
|
||||
springdoc:
|
||||
api-docs:
|
||||
enabled: true
|
||||
|
|
|
|||
|
|
@ -5,6 +5,21 @@ export interface CaptchaResponse {
|
|||
imageBase64: string;
|
||||
}
|
||||
|
||||
export interface PasswordPolicyPublic {
|
||||
enabled: boolean;
|
||||
minLength: number;
|
||||
maxLength: number;
|
||||
requireUppercase: boolean;
|
||||
requireLowercase: boolean;
|
||||
requireDigit: boolean;
|
||||
requireSpecialChar: boolean;
|
||||
specialCharSet?: string;
|
||||
forbidUsernameContain: boolean;
|
||||
forbidSequentialChars: boolean;
|
||||
forbidRepeatedChars: boolean;
|
||||
customRuleMessage?: string;
|
||||
}
|
||||
|
||||
export interface TenantInfo {
|
||||
tenantId: number;
|
||||
tenantCode: string;
|
||||
|
|
@ -39,11 +54,30 @@ export interface DeviceCodePayload {
|
|||
deviceName?: string;
|
||||
}
|
||||
|
||||
export interface PasswordRecoverySendCodePayload {
|
||||
username: string;
|
||||
captchaId?: string;
|
||||
captchaCode?: string;
|
||||
channel: "EMAIL";
|
||||
}
|
||||
|
||||
export interface PasswordRecoveryResetPayload {
|
||||
username: string;
|
||||
channel: "EMAIL";
|
||||
code: string;
|
||||
newPassword: string;
|
||||
}
|
||||
|
||||
export async function fetchCaptcha() {
|
||||
const resp = await http.get("/sys/auth/captcha");
|
||||
return resp.data.data as CaptchaResponse;
|
||||
}
|
||||
|
||||
export async function fetchPublicPasswordPolicy() {
|
||||
const resp = await http.get("/sys/auth/password-policy/public");
|
||||
return resp.data.data as PasswordPolicyPublic;
|
||||
}
|
||||
|
||||
export async function login(payload: LoginPayload) {
|
||||
const resp = await http.post("/sys/auth/login", payload);
|
||||
return resp.data.data as TokenResponse;
|
||||
|
|
@ -63,3 +97,13 @@ export async function switchTenant(tenantId: number) {
|
|||
const resp = await http.post(`/sys/auth/switch-tenant?tenantId=${tenantId}`);
|
||||
return resp.data.data as TokenResponse;
|
||||
}
|
||||
|
||||
export async function sendPasswordRecoveryCode(payload: PasswordRecoverySendCodePayload) {
|
||||
const resp = await http.post("/sys/auth/password-recovery/send-code", payload);
|
||||
return resp.data as { code: string; msg: string; data: boolean };
|
||||
}
|
||||
|
||||
export async function resetPasswordByRecovery(payload: PasswordRecoveryResetPayload) {
|
||||
const resp = await http.post("/sys/auth/password-recovery/reset", payload);
|
||||
return resp.data.data as boolean;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import axios from "axios";
|
||||
import axios from "axios";
|
||||
import { message } from "antd";
|
||||
|
||||
declare module "axios" {
|
||||
|
|
@ -21,7 +21,15 @@ const refreshClient = axios.create({
|
|||
timeout: 15000
|
||||
});
|
||||
|
||||
const AUTH_WHITELIST = ["/sys/auth/login", "/sys/auth/refresh", "/sys/auth/captcha", "/sys/auth/device-code"];
|
||||
const AUTH_WHITELIST = [
|
||||
"/sys/auth/login",
|
||||
"/sys/auth/refresh",
|
||||
"/sys/auth/captcha",
|
||||
"/sys/auth/device-code",
|
||||
"/sys/auth/password-policy/public",
|
||||
"/sys/auth/password-recovery/send-code",
|
||||
"/sys/auth/password-recovery/reset"
|
||||
];
|
||||
const API_SUCCESS_CODE = "200";
|
||||
const REFRESH_AHEAD_MS = 60 * 1000;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import http from "./http";
|
||||
import http from "./http";
|
||||
import {
|
||||
BotCredential, DeviceInfo, RoleDataScope, SysPermission, SysRole, SysUser, UserProfile, SysParamVO, SysParamQuery, PageResult,
|
||||
PermissionNode
|
||||
|
|
@ -114,6 +114,11 @@ export async function updateMyPassword(payload: any) {
|
|||
return resp.data.data as boolean;
|
||||
}
|
||||
|
||||
export async function resetUserPassword(id: number, payload: { newPassword: string }) {
|
||||
const resp = await http.post(`/sys/api/users/${id}/reset-password`, payload);
|
||||
return resp.data.data as boolean;
|
||||
}
|
||||
|
||||
export async function getMyBotCredential() {
|
||||
const resp = await http.get("/sys/api/users/bot-credential");
|
||||
return resp.data.data as BotCredential;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,21 @@
|
|||
.page-toolbar {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.page-toolbar__group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.page-toolbar__group--end {
|
||||
margin-left: auto;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
import React from "react";
|
||||
import "./PageToolbar.css";
|
||||
|
||||
interface PageToolbarProps {
|
||||
left?: React.ReactNode;
|
||||
right?: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const PageToolbar: React.FC<PageToolbarProps> = ({left, right, className = ""}) => {
|
||||
const classes = ["page-toolbar", className].filter(Boolean).join(" ");
|
||||
|
||||
return (
|
||||
<div className={classes}>
|
||||
<div className="page-toolbar__group app-page__toolbar">{left}</div>
|
||||
<div className="page-toolbar__group page-toolbar__group--end app-page__toolbar">{right}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PageToolbar;
|
||||
|
|
@ -258,6 +258,7 @@
|
|||
"basicInfo": "Basic Info",
|
||||
"security": "Security",
|
||||
"currentPassword": "Current Password",
|
||||
"passwordRules": "Password Rules",
|
||||
"newPassword": "New Password",
|
||||
"confirmNewPassword": "Confirm New Password",
|
||||
"saveChanges": "Save Changes",
|
||||
|
|
|
|||
|
|
@ -259,6 +259,7 @@
|
|||
"security": "安全设置",
|
||||
"currentPassword": "当前密码",
|
||||
"newPassword": "新密码",
|
||||
"passwordRules": "密码规则",
|
||||
"confirmNewPassword": "确认新密码",
|
||||
"saveChanges": "保存修改",
|
||||
"updatePassword": "更新密码",
|
||||
|
|
|
|||
|
|
@ -26,6 +26,24 @@
|
|||
max-width: 400px;
|
||||
}
|
||||
|
||||
.users-content-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 18px 24px 14px;
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.16);
|
||||
background: linear-gradient(180deg, rgba(248, 250, 252, 0.95) 0%, rgba(255, 255, 255, 0.75) 100%);
|
||||
}
|
||||
|
||||
.users-content-header__count {
|
||||
font-size: 28px;
|
||||
line-height: 1;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.user-avatar-placeholder {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
|
|
@ -80,3 +98,16 @@
|
|||
padding-left: 8px !important;
|
||||
padding-right: 8px !important;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.users-content-header {
|
||||
padding: 16px;
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.users-search-input {
|
||||
width: 100% !important;
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,14 +1,61 @@
|
|||
import { Avatar, Button, Card, Col, Drawer, Form, Input, Popconfirm, Row, Select, Space, Switch, Table, Tag, TreeSelect, Typography, Upload, message } from "antd";
|
||||
import {
|
||||
Avatar,
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
Drawer,
|
||||
Form,
|
||||
Input,
|
||||
Modal,
|
||||
Popconfirm,
|
||||
Row,
|
||||
Select,
|
||||
Space,
|
||||
Switch,
|
||||
Table,
|
||||
Tag,
|
||||
Tooltip,
|
||||
TreeSelect,
|
||||
Typography,
|
||||
Upload,
|
||||
message
|
||||
} from "antd";
|
||||
import type { DefaultOptionType } from "antd/es/select";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ApartmentOutlined, DeleteOutlined, EditOutlined, MinusCircleOutlined, PlusOutlined, SearchOutlined, ShopOutlined, UploadOutlined, UserOutlined } from "@ant-design/icons";
|
||||
import { createUser, deleteUser, getUserDetail, listOrgs, listRoles, listTenants, listUserRoles, listUsers, updateUser, uploadPlatformAsset } from "@/api";
|
||||
import {
|
||||
ApartmentOutlined,
|
||||
DeleteOutlined,
|
||||
EditOutlined,
|
||||
KeyOutlined,
|
||||
MinusCircleOutlined,
|
||||
PlusOutlined,
|
||||
QuestionCircleOutlined,
|
||||
SearchOutlined,
|
||||
ShopOutlined,
|
||||
UploadOutlined,
|
||||
UserOutlined
|
||||
} from "@ant-design/icons";
|
||||
import {
|
||||
createUser,
|
||||
deleteUser,
|
||||
getUserDetail,
|
||||
listOrgs,
|
||||
listRoles,
|
||||
listTenants,
|
||||
listUserRoles,
|
||||
listUsers,
|
||||
resetUserPassword,
|
||||
updateUser,
|
||||
uploadPlatformAsset
|
||||
} from "@/api";
|
||||
import {fetchPublicPasswordPolicy, type PasswordPolicyPublic} from "@/api/auth";
|
||||
import { useDict } from "@/hooks/useDict";
|
||||
import { usePermission } from "@/hooks/usePermission";
|
||||
import PageHeader from "@/components/shared/PageHeader";
|
||||
import PageContainer from "@/components/shared/PageContainer";
|
||||
import { LOGIN_NAME_PATTERN, sanitizeLoginName } from "@/utils/loginName";
|
||||
import {buildPasswordPolicyValidator, buildPolicyHints} from "@/utils/password";
|
||||
import { getStandardPagination } from "@/utils/pagination";
|
||||
import type { SysOrg, SysRole, SysTenant, SysUser } from "@/types";
|
||||
import "./index.less";
|
||||
|
|
@ -18,6 +65,7 @@ const { Title, Text } = Typography;
|
|||
type OrgTreeNode = { value: number; title: string; children: OrgTreeNode[] };
|
||||
|
||||
type Membership = { tenantId?: number; orgId?: number; orgName?: string };
|
||||
type ResetPasswordValues = { newPassword: string; confirmPassword: string };
|
||||
|
||||
function buildOrgTree(list: SysOrg[]): OrgTreeNode[] {
|
||||
const map = new Map<number, OrgTreeNode>();
|
||||
|
|
@ -91,6 +139,11 @@ export default function Users() {
|
|||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<SysUser | null>(null);
|
||||
const [form] = Form.useForm();
|
||||
const [resetPasswordOpen, setResetPasswordOpen] = useState(false);
|
||||
const [resetPasswordLoading, setResetPasswordLoading] = useState(false);
|
||||
const [resetPasswordTarget, setResetPasswordTarget] = useState<SysUser | null>(null);
|
||||
const [passwordPolicy, setPasswordPolicy] = useState<PasswordPolicyPublic | null>(null);
|
||||
const [resetPasswordForm] = Form.useForm<ResetPasswordValues>();
|
||||
|
||||
const isPlatformMode = useMemo(() => {
|
||||
const profileStr = sessionStorage.getItem("userProfile");
|
||||
|
|
@ -165,6 +218,18 @@ export default function Users() {
|
|||
loadBaseData();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
setPasswordPolicy(await fetchPublicPasswordPolicy());
|
||||
} catch {
|
||||
setPasswordPolicy(null);
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
const policyHints = useMemo(() => buildPolicyHints(passwordPolicy), [passwordPolicy]);
|
||||
|
||||
useEffect(() => {
|
||||
loadUsersData();
|
||||
}, [filterTenantId]);
|
||||
|
|
@ -195,6 +260,8 @@ export default function Users() {
|
|||
);
|
||||
}, [data, searchText]);
|
||||
|
||||
const activeFilterLabel = filterTenantId ? tenantMap[filterTenantId] || `Tenant ${filterTenantId}` : "全部租户";
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing(null);
|
||||
form.resetFields();
|
||||
|
|
@ -216,8 +283,6 @@ export default function Users() {
|
|||
form.setFieldsValue({
|
||||
...detail,
|
||||
roleIds: roleIds || [],
|
||||
password: "",
|
||||
confirmPassword: "",
|
||||
tenantId: (detail as any).tenantId || detail.memberships?.[0]?.tenantId,
|
||||
orgId: (detail as any).orgId || detail.memberships?.[0]?.orgId,
|
||||
memberships: detail.memberships || []
|
||||
|
|
@ -269,13 +334,12 @@ export default function Users() {
|
|||
userPayload.memberships = values.memberships || [];
|
||||
}
|
||||
|
||||
if (values.password) {
|
||||
userPayload.password = values.password;
|
||||
}
|
||||
|
||||
if (editing) {
|
||||
await updateUser(editing.userId, userPayload);
|
||||
} else {
|
||||
if (values.password) {
|
||||
userPayload.password = values.password;
|
||||
}
|
||||
await createUser(userPayload);
|
||||
}
|
||||
|
||||
|
|
@ -287,6 +351,29 @@ export default function Users() {
|
|||
}
|
||||
};
|
||||
|
||||
const openResetPassword = (record: SysUser) => {
|
||||
setResetPasswordTarget(record);
|
||||
resetPasswordForm.resetFields();
|
||||
setResetPasswordOpen(true);
|
||||
};
|
||||
|
||||
const submitResetPassword = async () => {
|
||||
if (!resetPasswordTarget) {
|
||||
return;
|
||||
}
|
||||
const values = await resetPasswordForm.validateFields();
|
||||
setResetPasswordLoading(true);
|
||||
try {
|
||||
await resetUserPassword(resetPasswordTarget.userId, {newPassword: values.newPassword});
|
||||
message.success("密码已重置");
|
||||
setResetPasswordOpen(false);
|
||||
setResetPasswordTarget(null);
|
||||
resetPasswordForm.resetFields();
|
||||
} finally {
|
||||
setResetPasswordLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const columns: any[] = [
|
||||
{
|
||||
title: t("users.userInfo"),
|
||||
|
|
@ -368,11 +455,17 @@ export default function Users() {
|
|||
{
|
||||
title: t("common.action"),
|
||||
key: "action",
|
||||
width: 100,
|
||||
width: 200,
|
||||
fixed: "right" as const,
|
||||
render: (_: any, record: SysUser) => (
|
||||
<Space>
|
||||
{can("sys:user:update") && <Button type="text" icon={<EditOutlined aria-hidden="true" />} onClick={() => openEdit(record)} aria-label={t("common.edit")} />}
|
||||
{can("sys:user:update") && (
|
||||
<Tooltip title="重置密码">
|
||||
<Button type="text" icon={<KeyOutlined aria-hidden="true"/>} onClick={() => openResetPassword(record)}
|
||||
aria-label="重置密码"/>
|
||||
</Tooltip>
|
||||
)}
|
||||
{can("sys:user:delete") && record.userId !== 1 && <Popconfirm title={t("usersExt.deleteConfirm")} okText={t("common.confirm")} cancelText={t("common.cancel")} onConfirm={() => handleDelete(record.userId)}><Button type="text" danger icon={<DeleteOutlined aria-hidden="true" />} aria-label={t("common.delete")} /></Popconfirm>}
|
||||
</Space>
|
||||
)
|
||||
|
|
@ -380,121 +473,104 @@ export default function Users() {
|
|||
];
|
||||
|
||||
return (
|
||||
<PageContainer
|
||||
title={t("users.title")}
|
||||
subtitle={t("users.subtitle")}
|
||||
headerExtra={
|
||||
can("sys:user:create") && (
|
||||
<Button type="primary" icon={<PlusOutlined aria-hidden="true" />} onClick={openCreate}>
|
||||
{t("common.create")}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
toolbar={
|
||||
<>
|
||||
<Space size="middle" wrap>
|
||||
{isPlatformMode && (
|
||||
<Select
|
||||
placeholder={t("users.tenantFilter")}
|
||||
style={{ width: 200 }}
|
||||
allowClear
|
||||
value={filterTenantId}
|
||||
onChange={setFilterTenantId}
|
||||
options={tenants.map((tenant) => ({ label: tenant.tenantName, value: tenant.id }))}
|
||||
suffixIcon={<ShopOutlined aria-hidden="true" />}
|
||||
/>
|
||||
)}
|
||||
<Input
|
||||
placeholder={t("users.searchPlaceholder")}
|
||||
prefix={<SearchOutlined aria-hidden="true" />}
|
||||
style={{ width: 300 }}
|
||||
value={searchText}
|
||||
onChange={(event) => {
|
||||
<div className="app-page users-page">
|
||||
<PageHeader title={t("users.title")} subtitle={t("users.subtitle")}/>
|
||||
|
||||
<Card className="users-table-card app-page__filter-card" styles={{body: {padding: "16px"}}}>
|
||||
<div className="users-table-toolbar">
|
||||
<Space size="middle" wrap className="app-page__toolbar"
|
||||
style={{justifyContent: "space-between", width: "100%"}}>
|
||||
<Space size="middle" wrap className="app-page__toolbar">
|
||||
{isPlatformMode &&
|
||||
<Select placeholder={t("users.tenantFilter")} style={{width: 200}} allowClear value={filterTenantId}
|
||||
onChange={setFilterTenantId}
|
||||
options={tenants.map((tenant) => ({label: tenant.tenantName, value: tenant.id}))}
|
||||
suffixIcon={<ShopOutlined aria-hidden="true"/>}/>}
|
||||
<Input placeholder={t("users.searchPlaceholder")} prefix={<SearchOutlined aria-hidden="true"/>}
|
||||
className="users-search-input" style={{width: 300}} value={searchText} onChange={(event) => {
|
||||
setSearchText(event.target.value);
|
||||
setCurrent(1);
|
||||
}}
|
||||
allowClear
|
||||
aria-label={t("common.search")}
|
||||
/>
|
||||
<Button type="primary" icon={<SearchOutlined aria-hidden="true" />} onClick={handleSearch}>
|
||||
{t("common.search")}
|
||||
</Button>
|
||||
<Button onClick={handleResetSearch}>{t("common.reset")}</Button>
|
||||
}} allowClear aria-label={t("common.search")}/>
|
||||
<Button type="primary" icon={<SearchOutlined aria-hidden="true"/>}
|
||||
onClick={handleSearch}>{t("common.search")}</Button>
|
||||
<Button onClick={handleResetSearch}>{t("common.reset")}</Button>
|
||||
</Space>
|
||||
{can("sys:user:create") && <Button type="primary" icon={<PlusOutlined aria-hidden="true"/>}
|
||||
onClick={openCreate}>{t("common.create")}</Button>}
|
||||
</Space>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Table
|
||||
rowKey="userId"
|
||||
columns={columns}
|
||||
dataSource={filteredData}
|
||||
loading={loading}
|
||||
size="middle"
|
||||
scroll={{ y: "calc(100vh - 380px)" }}
|
||||
pagination={getStandardPagination(filteredData.length, current, pageSize, (page, size) => {
|
||||
setCurrent(page);
|
||||
setPageSize(size);
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Drawer
|
||||
title={
|
||||
<div className="user-drawer-title">
|
||||
<UserOutlined className="mr-2" aria-hidden="true" />
|
||||
{editing ? t("users.drawerTitleEdit") : t("users.drawerTitleCreate")}
|
||||
</div>
|
||||
}
|
||||
open={drawerOpen}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
width={520}
|
||||
destroyOnClose
|
||||
footer={
|
||||
<div className="app-page__drawer-footer">
|
||||
<Button onClick={() => setDrawerOpen(false)}>{t("common.cancel")}</Button>
|
||||
<Button type="primary" loading={saving} onClick={submit}>
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Card className="app-page__content-card flex-1 flex flex-col overflow-hidden"
|
||||
styles={{body: {padding: 0, flex: 1, display: "flex", flexDirection: "column", overflow: "hidden"}}}>
|
||||
<Table
|
||||
rowKey="userId"
|
||||
columns={columns}
|
||||
dataSource={filteredData}
|
||||
loading={loading}
|
||||
size="middle"
|
||||
scroll={{y: "calc(100vh - 430px)"}}
|
||||
pagination={getStandardPagination(filteredData.length, current, pageSize, (page, size) => {
|
||||
setCurrent(page);
|
||||
setPageSize(size);
|
||||
})}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Drawer title={<div className="user-drawer-title"><UserOutlined className="mr-2"
|
||||
aria-hidden="true"/>{editing ? t("users.drawerTitleEdit") : t("users.drawerTitleCreate")}
|
||||
</div>} open={drawerOpen} onClose={() => setDrawerOpen(false)} width={520} destroyOnClose
|
||||
footer={<div className="app-page__drawer-footer"><Button
|
||||
onClick={() => setDrawerOpen(false)}>{t("common.cancel")}</Button><Button type="primary"
|
||||
loading={saving}
|
||||
onClick={submit}>{t("common.save")}</Button>
|
||||
</div>}>
|
||||
<Form form={form} layout="vertical" className="user-form">
|
||||
<Title level={5} style={{ marginBottom: 16 }}>{t("usersExt.basicInfo")}</Title>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item label={t("users.username")} name="username" rules={[{ required: true, message: t("users.username") }, { pattern: LOGIN_NAME_PATTERN, message: t("usersExt.usernameFormatTip", { defaultValue: "登录名只能输入数字、小写英文、@ 和 _" }) }]} getValueFromEvent={(event) => sanitizeLoginName(event?.target?.value)} extra={t("usersExt.usernameFormatTip", { defaultValue: "登录名只能输入数字、小写英文、@ 和 _" })}>
|
||||
<Input placeholder={t("usersExt.usernamePlaceholder", { defaultValue: "仅支持 a-z、0-9、@、_" })} disabled={!!editing} className="tabular-nums" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label={t("users.displayName")} name="displayName" rules={[{ required: true, message: t("users.displayName") }]}>
|
||||
<Input placeholder={t("users.displayName")} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}><Form.Item label={t("users.username")} name="username" rules={[{
|
||||
required: true,
|
||||
message: t("users.username")
|
||||
}, {
|
||||
pattern: LOGIN_NAME_PATTERN,
|
||||
message: t("usersExt.usernameFormatTip", {defaultValue: "登录名只能输入数字、小写英文、@ 和 _"})
|
||||
}]} getValueFromEvent={(event) => sanitizeLoginName(event?.target?.value)}
|
||||
extra={t("usersExt.usernameFormatTip", {defaultValue: "登录名只能输入数字、小写英文、@ 和 _"})}><Input
|
||||
placeholder={t("usersExt.usernamePlaceholder", {defaultValue: "仅支持 a-z、0-9、@、_"})} disabled={!!editing}
|
||||
className="tabular-nums"/></Form.Item></Col>
|
||||
<Col span={12}><Form.Item label={t("users.displayName")} name="displayName"
|
||||
rules={[{required: true, message: t("users.displayName")}]}><Input
|
||||
placeholder={t("users.displayName")}/></Form.Item></Col>
|
||||
</Row>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item label={t("users.email")} name="email">
|
||||
<Input placeholder={t("usersExt.emailPlaceholder")} className="tabular-nums" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label={t("users.phone")} name="phone">
|
||||
<Input placeholder={t("users.phone")} className="tabular-nums" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}><Form.Item label={t("users.email")} name="email"
|
||||
rules={[{required: true, message: "请输入邮箱地址"}, {
|
||||
type: "email",
|
||||
message: "请输入正确的邮箱格式"
|
||||
}]}><Input placeholder={t("usersExt.emailPlaceholder")} className="tabular-nums"/></Form.Item></Col>
|
||||
<Col span={12}><Form.Item label={t("users.phone")} name="phone"><Input placeholder={t("users.phone")}
|
||||
className="tabular-nums"/></Form.Item></Col>
|
||||
</Row>
|
||||
<Form.Item label={t("profile.avatarUrl")} name="avatarUrl">
|
||||
<Input placeholder={t("profile.avatarUrlPlaceholder")} />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("profile.avatarUrl")} name="avatarUrl"><Input
|
||||
placeholder={t("profile.avatarUrlPlaceholder")}/></Form.Item>
|
||||
<Upload accept="image/*" showUploadList={false} beforeUpload={handleAvatarUpload}>
|
||||
<Button icon={<UploadOutlined />} loading={avatarUploading} style={{ marginBottom: 16 }}>
|
||||
{t("profile.uploadAvatar")}
|
||||
</Button>
|
||||
<Button icon={<UploadOutlined/>} loading={avatarUploading}
|
||||
style={{marginBottom: 16}}>{t("profile.uploadAvatar")}</Button>
|
||||
</Upload>
|
||||
<Form.Item label={t("users.password")} name="password" rules={[{ required: !editing, message: t("users.password") }]}>
|
||||
<Input.Password placeholder={editing ? t("usersExt.passwordKeepPlaceholder") : t("usersExt.passwordInitPlaceholder")} autoComplete="new-password" />
|
||||
</Form.Item>
|
||||
{passwordValue && (
|
||||
{!editing ? (
|
||||
<Form.Item
|
||||
label={t("users.password")}
|
||||
name="password"
|
||||
validateFirst
|
||||
rules={[
|
||||
{required: true, message: t("users.password")},
|
||||
{validator: buildPasswordPolicyValidator(passwordPolicy, () => form.getFieldValue("username"))}
|
||||
]}
|
||||
>
|
||||
<Input.Password placeholder={t("usersExt.passwordInitPlaceholder")} autoComplete="new-password"/>
|
||||
</Form.Item>
|
||||
) : null}
|
||||
{!editing && passwordValue ? (
|
||||
<Form.Item
|
||||
label={t("usersExt.confirmPassword")}
|
||||
name="confirmPassword"
|
||||
|
|
@ -516,28 +592,19 @@ export default function Users() {
|
|||
>
|
||||
<Input.Password placeholder={t("usersExt.confirmPasswordPlaceholder")} autoComplete="new-password" />
|
||||
</Form.Item>
|
||||
)}
|
||||
<Form.Item label={t("users.roles")} name="roleIds" rules={[{ required: true, message: t("users.roles") }]}>
|
||||
<Select mode="multiple" placeholder={t("users.roles")} options={roleOptions} optionFilterProp={isPlatformMode ? "searchText" : "label"} />
|
||||
</Form.Item>
|
||||
{!isPlatformMode && (
|
||||
<Form.Item label={t("users.orgNode")} name="orgId">
|
||||
<TreeSelect placeholder={t("usersExt.selectOrgPlaceholder")} allowClear treeData={orgTreeData} />
|
||||
</Form.Item>
|
||||
)}
|
||||
) : null}
|
||||
<Form.Item label={t("users.roles")} name="roleIds"
|
||||
rules={[{required: true, message: t("users.roles")}]}><Select mode="multiple"
|
||||
placeholder={t("users.roles")}
|
||||
options={roleOptions}
|
||||
optionFilterProp={isPlatformMode ? "searchText" : "label"}/></Form.Item>
|
||||
{!isPlatformMode && <Form.Item label={t("users.orgNode")} name="orgId"><TreeSelect
|
||||
placeholder={t("usersExt.selectOrgPlaceholder")} allowClear treeData={orgTreeData}/></Form.Item>}
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item label={t("common.status")} name="status" initialValue={1}>
|
||||
<Select options={statusDict.map((item) => ({ label: item.itemLabel, value: Number(item.itemValue) }))} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
{isPlatformMode && (
|
||||
<Col span={12}>
|
||||
<Form.Item label={t("users.platformAdmin")} name="isPlatformAdmin" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
)}
|
||||
<Col span={12}><Form.Item label={t("common.status")} name="status" initialValue={1}><Select
|
||||
options={statusDict.map((item) => ({label: item.itemLabel, value: Number(item.itemValue)}))}/></Form.Item></Col>
|
||||
{isPlatformMode &&
|
||||
<Col span={12}><Form.Item label={t("users.platformAdmin")} name="isPlatformAdmin" valuePropName="checked"><Switch/></Form.Item></Col>}
|
||||
</Row>
|
||||
{isPlatformMode && (
|
||||
<>
|
||||
|
|
@ -559,9 +626,8 @@ export default function Users() {
|
|||
</Row>
|
||||
</Card>
|
||||
))}
|
||||
<Button type="dashed" onClick={() => add()} block icon={<PlusOutlined />}>
|
||||
{t("usersExt.addMembership")}
|
||||
</Button>
|
||||
<Button type="dashed" onClick={() => add()} block
|
||||
icon={<PlusOutlined/>}>{t("usersExt.addMembership")}</Button>
|
||||
</>
|
||||
)}
|
||||
</Form.List>
|
||||
|
|
@ -569,6 +635,71 @@ export default function Users() {
|
|||
)}
|
||||
</Form>
|
||||
</Drawer>
|
||||
</PageContainer>
|
||||
|
||||
<Modal
|
||||
title={resetPasswordTarget ? `重置密码: ${resetPasswordTarget.displayName}` : "重置密码"}
|
||||
open={resetPasswordOpen}
|
||||
onCancel={() => {
|
||||
setResetPasswordOpen(false);
|
||||
setResetPasswordTarget(null);
|
||||
resetPasswordForm.resetFields();
|
||||
}}
|
||||
onOk={() => void submitResetPassword()}
|
||||
confirmLoading={resetPasswordLoading}
|
||||
destroyOnClose
|
||||
>
|
||||
<Form form={resetPasswordForm} layout="vertical">
|
||||
<Form.Item
|
||||
label={
|
||||
<Space size={4}>
|
||||
<span>新密码</span>
|
||||
<Tooltip
|
||||
title={
|
||||
policyHints.length > 0 ? (
|
||||
<ul style={{paddingLeft: 18, margin: 0, fontSize: 13, lineHeight: 1.6}}>
|
||||
{policyHints.map((hint) => (
|
||||
<li key={hint}>{hint}</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
t("profile.passwordRules")
|
||||
)
|
||||
}
|
||||
overlayStyle={{maxWidth: 300}}
|
||||
>
|
||||
<QuestionCircleOutlined style={{color: "rgba(0,0,0,0.45)", cursor: "help"}}/>
|
||||
</Tooltip>
|
||||
</Space>
|
||||
}
|
||||
name="newPassword"
|
||||
validateFirst
|
||||
rules={[
|
||||
{required: true, message: "请输入新密码"},
|
||||
{validator: buildPasswordPolicyValidator(passwordPolicy, () => resetPasswordTarget?.username)}
|
||||
]}
|
||||
>
|
||||
<Input.Password autoComplete="new-password"/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="确认新密码"
|
||||
name="confirmPassword"
|
||||
dependencies={["newPassword"]}
|
||||
rules={[
|
||||
{required: true, message: "请再次输入新密码"},
|
||||
({getFieldValue}) => ({
|
||||
validator(_, value) {
|
||||
if (!value || getFieldValue("newPassword") === value) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return Promise.reject(new Error(t("profile.passwordsDoNotMatch")));
|
||||
}
|
||||
})
|
||||
]}
|
||||
>
|
||||
<Input.Password autoComplete="new-password"/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,315 @@
|
|||
.auth-shell {
|
||||
min-height: 100vh;
|
||||
padding: 32px 24px;
|
||||
background: radial-gradient(circle at top left, rgba(22, 119, 255, 0.16), transparent 32%),
|
||||
radial-gradient(circle at bottom right, rgba(59, 130, 246, 0.12), transparent 28%),
|
||||
linear-gradient(180deg, #eef4fb 0%, #f7f9fc 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.auth-shell__surface {
|
||||
width: min(1120px, 100%);
|
||||
min-height: 680px;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(320px, 0.92fr) minmax(420px, 1fr);
|
||||
border-radius: 28px;
|
||||
overflow: hidden;
|
||||
background: rgba(255, 255, 255, 0.82);
|
||||
border: 1px solid rgba(148, 163, 184, 0.18);
|
||||
box-shadow: 0 24px 60px rgba(15, 23, 42, 0.12);
|
||||
backdrop-filter: blur(14px);
|
||||
}
|
||||
|
||||
.auth-shell__aside {
|
||||
padding: 56px 48px;
|
||||
color: #f8fbff;
|
||||
background: radial-gradient(circle at top left, rgba(12, 74, 110, 0.4), transparent 50%),
|
||||
linear-gradient(165deg, #0b1b36 0%, #0c4a6e 60%, #0369a1 100%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
gap: 32px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.auth-shell__aside::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: auto -80px -120px auto;
|
||||
width: 240px;
|
||||
height: 240px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, rgba(255, 255, 255, 0.15) 0%, rgba(255, 255, 255, 0) 68%);
|
||||
}
|
||||
|
||||
.auth-shell__aside-top {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.auth-shell__eyebrow {
|
||||
width: fit-content;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 14px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
color: rgba(255, 255, 255, 0.95);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.auth-shell__aside-extra {
|
||||
padding: 16px 20px;
|
||||
border-radius: 14px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.auth-shell__aside-extra-title {
|
||||
display: block;
|
||||
margin-bottom: 10px;
|
||||
color: rgba(255, 255, 255, 0.95);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.auth-shell__aside-extra-list {
|
||||
margin: 0;
|
||||
padding-left: 18px;
|
||||
color: rgba(226, 232, 240, 0.9);
|
||||
font-size: 13px;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.auth-shell__aside-extra-list li::marker {
|
||||
color: #38bdf8;
|
||||
}
|
||||
|
||||
.auth-shell__aside-copy {
|
||||
max-width: 360px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.auth-shell__aside-title.ant-typography {
|
||||
margin: 0 0 16px;
|
||||
color: #ffffff;
|
||||
font-size: 36px;
|
||||
line-height: 1.18;
|
||||
letter-spacing: -0.8px;
|
||||
}
|
||||
|
||||
.auth-shell__aside-description.ant-typography {
|
||||
margin: 0;
|
||||
color: rgba(226, 232, 240, 0.9);
|
||||
font-size: 15px;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.auth-shell__highlights {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.auth-shell__highlight-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 16px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.auth-shell__highlight-item .anticon {
|
||||
margin-top: 4px;
|
||||
color: #7dd3fc;
|
||||
}
|
||||
|
||||
.auth-shell__highlight-item .ant-typography {
|
||||
color: rgba(241, 245, 249, 0.94);
|
||||
}
|
||||
|
||||
.auth-shell__main {
|
||||
padding: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.auth-shell__panel {
|
||||
width: min(460px, 100%);
|
||||
}
|
||||
|
||||
.auth-shell__panel-header {
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.auth-shell__panel-title.ant-typography {
|
||||
margin: 0 0 8px;
|
||||
color: #10233b;
|
||||
font-size: 30px;
|
||||
line-height: 1.2;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
|
||||
.auth-shell__panel-subtitle.ant-typography {
|
||||
display: block;
|
||||
color: #64748b;
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.auth-shell__panel-content .ant-form-item {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.auth-shell__panel-content .ant-input-affix-wrapper,
|
||||
.auth-shell__panel-content .ant-input,
|
||||
.auth-shell__panel-content .ant-btn {
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.auth-shell__panel-content .ant-input-affix-wrapper,
|
||||
.auth-shell__panel-content .ant-input {
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.auth-shell__panel-content .ant-btn {
|
||||
min-height: 44px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.auth-shell__panel-footer {
|
||||
margin-top: 24px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid rgba(148, 163, 184, 0.18);
|
||||
}
|
||||
|
||||
.auth-form__inline {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.auth-form__captcha-button,
|
||||
.auth-form__code-button {
|
||||
flex: 0 0 136px;
|
||||
}
|
||||
|
||||
.auth-form__captcha-button {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.auth-form__captcha-button img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 42px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.auth-form__actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.auth-form__policy-hints {
|
||||
margin: -4px 0 20px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 14px;
|
||||
background: rgba(241, 245, 249, 0.78);
|
||||
border: 1px solid rgba(148, 163, 184, 0.18);
|
||||
}
|
||||
|
||||
.auth-form__policy-hints-title {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
color: #334155;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.auth-form__policy-list {
|
||||
margin: 0;
|
||||
padding-left: 18px;
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.auth-shell__footer-link {
|
||||
color: #1677ff;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.auth-shell__footer-link.ant-btn-link {
|
||||
padding: 0;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.auth-shell {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.auth-shell__surface {
|
||||
min-height: auto;
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.auth-shell__aside {
|
||||
padding: 36px 28px 28px;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.auth-shell__main {
|
||||
padding: 28px 24px 32px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 576px) {
|
||||
.auth-shell {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.auth-shell__aside {
|
||||
padding: 28px 20px 24px;
|
||||
}
|
||||
|
||||
.auth-shell__aside-title.ant-typography {
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.auth-shell__main {
|
||||
padding: 24px 16px 28px;
|
||||
}
|
||||
|
||||
.auth-shell__panel-title.ant-typography {
|
||||
font-size: 26px;
|
||||
}
|
||||
|
||||
.auth-form__inline {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.auth-form__captcha-button,
|
||||
.auth-form__code-button {
|
||||
flex-basis: auto;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
import {CheckCircleFilled, SafetyCertificateOutlined} from "@ant-design/icons";
|
||||
import {Typography} from "antd";
|
||||
import type {ReactNode} from "react";
|
||||
import "../auth-shell.less";
|
||||
|
||||
const {Paragraph, Text, Title} = Typography;
|
||||
|
||||
interface AuthShellProps {
|
||||
eyebrow: string;
|
||||
asideExtra?: ReactNode;
|
||||
asideTitle: string;
|
||||
asideDescription: string;
|
||||
asideHighlights: string[];
|
||||
title: string;
|
||||
subtitle: string;
|
||||
children: ReactNode;
|
||||
footer?: ReactNode;
|
||||
}
|
||||
|
||||
export default function AuthShell({
|
||||
eyebrow,
|
||||
asideExtra,
|
||||
asideTitle,
|
||||
asideDescription,
|
||||
asideHighlights,
|
||||
title,
|
||||
subtitle,
|
||||
children,
|
||||
footer
|
||||
}: AuthShellProps) {
|
||||
return (
|
||||
<div className="auth-shell">
|
||||
<div className="auth-shell__surface">
|
||||
<aside className="auth-shell__aside">
|
||||
<div className="auth-shell__aside-top">
|
||||
<div className="auth-shell__eyebrow">
|
||||
<SafetyCertificateOutlined aria-hidden="true"/>
|
||||
<span>{eyebrow}</span>
|
||||
</div>
|
||||
{asideExtra}
|
||||
</div>
|
||||
|
||||
<div className="auth-shell__aside-copy">
|
||||
<Title level={2} className="auth-shell__aside-title">
|
||||
{asideTitle}
|
||||
</Title>
|
||||
<Paragraph className="auth-shell__aside-description">
|
||||
{asideDescription}
|
||||
</Paragraph>
|
||||
</div>
|
||||
|
||||
<div className="auth-shell__highlights">
|
||||
{asideHighlights.map((item) => (
|
||||
<div key={item} className="auth-shell__highlight-item">
|
||||
<CheckCircleFilled aria-hidden="true"/>
|
||||
<Text>{item}</Text>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main className="auth-shell__main">
|
||||
<div className="auth-shell__panel">
|
||||
<div className="auth-shell__panel-header">
|
||||
<Title level={3} className="auth-shell__panel-title">
|
||||
{title}
|
||||
</Title>
|
||||
<Text type="secondary" className="auth-shell__panel-subtitle">
|
||||
{subtitle}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<div className="auth-shell__panel-content">{children}</div>
|
||||
|
||||
{footer ? <div className="auth-shell__panel-footer">{footer}</div> : null}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,238 @@
|
|||
import {Button, Form, Input, Typography, message} from "antd";
|
||||
import {LockOutlined, MailOutlined, ReloadOutlined, SafetyOutlined, UserOutlined} from "@ant-design/icons";
|
||||
import {useEffect, useMemo, useState} from "react";
|
||||
import {useNavigate} from "react-router-dom";
|
||||
import {getSystemParamValue} from "@/api";
|
||||
import {
|
||||
fetchCaptcha,
|
||||
fetchPublicPasswordPolicy,
|
||||
resetPasswordByRecovery,
|
||||
sendPasswordRecoveryCode,
|
||||
type CaptchaResponse,
|
||||
type PasswordPolicyPublic
|
||||
} from "@/api/auth";
|
||||
import {buildPasswordPolicyValidator, buildPolicyHints} from "@/utils/password";
|
||||
import AuthShell from "../components/AuthShell";
|
||||
|
||||
const {Text} = Typography;
|
||||
|
||||
type RecoveryFormValues = {
|
||||
username: string;
|
||||
captchaCode: string;
|
||||
code: string;
|
||||
newPassword: string;
|
||||
confirmPassword: string;
|
||||
};
|
||||
|
||||
const RECOVERY_HIGHLIGHTS = [
|
||||
"验证码仅发送到已绑定邮箱,避免外部恶意重置。",
|
||||
"重置完成后立即生效,旧密码将失效。",
|
||||
"新密码需要满足当前系统安全策略。"
|
||||
];
|
||||
|
||||
export default function ForgotPassword() {
|
||||
const navigate = useNavigate();
|
||||
const [form] = Form.useForm<RecoveryFormValues>();
|
||||
const [captcha, setCaptcha] = useState<CaptchaResponse | null>(null);
|
||||
const [policy, setPolicy] = useState<PasswordPolicyPublic | null>(null);
|
||||
const [countdown, setCountdown] = useState(0);
|
||||
const [sending, setSending] = useState(false);
|
||||
const [captchaEnabled, setCaptchaEnabled] = useState(true);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const policyHints = useMemo(() => buildPolicyHints(policy), [policy]);
|
||||
|
||||
const reloadCaptcha = async () => {
|
||||
const data = await fetchCaptcha();
|
||||
setCaptcha(data);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const init = async () => {
|
||||
const [policyData, captchaValue] = await Promise.all([
|
||||
fetchPublicPasswordPolicy(),
|
||||
getSystemParamValue("security.captcha.enabled", "true")
|
||||
]);
|
||||
|
||||
const enabled = captchaValue !== "false";
|
||||
setCaptchaEnabled(enabled);
|
||||
setPolicy(policyData);
|
||||
|
||||
if (enabled) {
|
||||
await reloadCaptcha();
|
||||
}
|
||||
};
|
||||
|
||||
void init();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (countdown <= 0) {
|
||||
return;
|
||||
}
|
||||
const timer = window.setTimeout(() => setCountdown((value) => value - 1), 1000);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [countdown]);
|
||||
|
||||
const handleSendCode = async () => {
|
||||
const fields = captchaEnabled ? ["username", "captchaCode"] : ["username"];
|
||||
const values = await form.validateFields(fields);
|
||||
|
||||
if (captchaEnabled && !captcha?.captchaId) {
|
||||
await reloadCaptcha();
|
||||
return;
|
||||
}
|
||||
|
||||
setSending(true);
|
||||
try {
|
||||
const resp = await sendPasswordRecoveryCode({
|
||||
username: values.username,
|
||||
captchaId: captcha?.captchaId,
|
||||
captchaCode: values.captchaCode,
|
||||
channel: "EMAIL"
|
||||
});
|
||||
message.success(resp.msg);
|
||||
if (resp.data) {
|
||||
setCountdown(60);
|
||||
}
|
||||
form.setFieldValue("captchaCode", "");
|
||||
if (captchaEnabled) {
|
||||
await reloadCaptcha();
|
||||
}
|
||||
} catch {
|
||||
if (captchaEnabled) {
|
||||
await reloadCaptcha();
|
||||
}
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (values: RecoveryFormValues) => {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await resetPasswordByRecovery({
|
||||
username: values.username,
|
||||
channel: "EMAIL",
|
||||
code: values.code,
|
||||
newPassword: values.newPassword
|
||||
});
|
||||
message.success("密码已重置,请重新登录");
|
||||
navigate("/login", {replace: true});
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthShell
|
||||
eyebrow="账户安全恢复"
|
||||
asideTitle="通过已绑定邮箱恢复访问权限"
|
||||
asideDescription="系统只会向当前账号已验证的邮箱发送一次性验证码。完成校验后即可立即设置新密码,整个流程不会暴露任何旧密码信息。"
|
||||
asideHighlights={RECOVERY_HIGHLIGHTS}
|
||||
title="找回密码"
|
||||
subtitle="输入账号并完成邮箱校验后,即可设置新的登录密码。"
|
||||
asideExtra={
|
||||
policyHints.length > 0 ? (
|
||||
<div className="auth-shell__aside-extra">
|
||||
<span className="auth-shell__aside-extra-title">密码规则</span>
|
||||
<ul className="auth-shell__aside-extra-list">
|
||||
{policyHints.map((hint) => (
|
||||
<li key={hint}>{hint}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
footer={
|
||||
<Text type="secondary">
|
||||
想起密码了?{" "}
|
||||
<Button type="link" className="auth-shell__footer-link" onClick={() => navigate("/login")}>
|
||||
返回登录
|
||||
</Button>
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
<Form form={form} layout="vertical" onFinish={handleSubmit} requiredMark={false} className="auth-form">
|
||||
<Form.Item label="用户名" name="username" rules={[{required: true, message: "请输入用户名"}]}>
|
||||
<Input size="large" prefix={<UserOutlined/>} autoComplete="username"/>
|
||||
</Form.Item>
|
||||
|
||||
{captchaEnabled ? (
|
||||
<Form.Item label="图形验证码" name="captchaCode" rules={[{required: true, message: "请输入图形验证码"}]}>
|
||||
<div className="auth-form__inline">
|
||||
<Input size="large" prefix={<SafetyOutlined/>} maxLength={6}/>
|
||||
<Button
|
||||
size="large"
|
||||
className="auth-form__captcha-button"
|
||||
onClick={() => void reloadCaptcha()}
|
||||
icon={!captcha ? <ReloadOutlined spin/> : undefined}
|
||||
>
|
||||
{captcha ? <img src={captcha.imageBase64} alt="验证码"/> : null}
|
||||
</Button>
|
||||
</div>
|
||||
</Form.Item>
|
||||
) : null}
|
||||
|
||||
<Form.Item label="邮箱验证码" required>
|
||||
<div className="auth-form__inline">
|
||||
<Form.Item name="code" noStyle rules={[{required: true, message: "请输入邮箱验证码"}]}>
|
||||
<Input size="large" prefix={<MailOutlined/>} maxLength={6}/>
|
||||
</Form.Item>
|
||||
<Button
|
||||
size="large"
|
||||
className="auth-form__code-button"
|
||||
onClick={() => void handleSendCode()}
|
||||
disabled={countdown > 0}
|
||||
loading={sending}
|
||||
>
|
||||
{countdown > 0 ? `${countdown}s 后重发` : "发送验证码"}
|
||||
</Button>
|
||||
</div>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="新密码"
|
||||
name="newPassword"
|
||||
validateFirst
|
||||
rules={[
|
||||
{required: true, message: "请输入新密码"},
|
||||
{
|
||||
validator: buildPasswordPolicyValidator(policy, () => form.getFieldValue("username"))
|
||||
}
|
||||
]}
|
||||
>
|
||||
<Input.Password size="large" prefix={<LockOutlined/>} autoComplete="new-password"/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="确认新密码"
|
||||
name="confirmPassword"
|
||||
dependencies={["newPassword"]}
|
||||
rules={[
|
||||
{required: true, message: "请再次输入新密码"},
|
||||
({getFieldValue}) => ({
|
||||
validator(_, value) {
|
||||
if (!value || getFieldValue("newPassword") === value) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return Promise.reject(new Error("两次输入的新密码不一致"));
|
||||
}
|
||||
})
|
||||
]}
|
||||
>
|
||||
<Input.Password size="large" prefix={<LockOutlined/>} autoComplete="new-password"/>
|
||||
</Form.Item>
|
||||
|
||||
<div className="auth-form__actions">
|
||||
<Button type="primary" htmlType="submit" block size="large" loading={submitting}>
|
||||
重置密码
|
||||
</Button>
|
||||
<Button block size="large" onClick={() => navigate("/login")}>
|
||||
返回登录
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</AuthShell>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,13 +1,14 @@
|
|||
import { Button, Checkbox, Form, Input, Typography, message } from "antd";
|
||||
import { LockOutlined, ReloadOutlined, SafetyOutlined, UserOutlined } from "@ant-design/icons";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import {Link as RouterLink} from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getCurrentUser, getOpenPlatformConfig, getSystemParamValue } from "@/api";
|
||||
import { fetchCaptcha, login, type CaptchaResponse } from "@/api/auth";
|
||||
import type { SysPlatformConfig } from "@/types";
|
||||
import "./index.less";
|
||||
|
||||
const { Title, Text, Link } = Typography;
|
||||
const {Title, Text} = Typography;
|
||||
|
||||
type LoginFormValues = {
|
||||
username: string;
|
||||
|
|
@ -101,9 +102,7 @@ export default function Login() {
|
|||
}
|
||||
|
||||
message.success(t("common.success"));
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
const redirect = searchParams.get("redirect");
|
||||
window.location.href = redirect || "/";
|
||||
window.location.href = "/";
|
||||
} catch {
|
||||
if (captchaEnabled) {
|
||||
await loadCaptcha();
|
||||
|
|
@ -224,7 +223,7 @@ export default function Login() {
|
|||
<Form.Item name="remember" valuePropName="checked" noStyle>
|
||||
<Checkbox>{t("login.rememberMe")}</Checkbox>
|
||||
</Form.Item>
|
||||
<Link className="forgot-password">{t("login.forgotPassword")}</Link>
|
||||
<RouterLink className="forgot-password" to="/forgot-password">{t("login.forgotPassword")}</RouterLink>
|
||||
</div>
|
||||
|
||||
<Form.Item>
|
||||
|
|
@ -234,12 +233,12 @@ export default function Login() {
|
|||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
{/*<div className="login-footer">*/}
|
||||
{/* <Text type="secondary">*/}
|
||||
{/* {t("login.demoAccount")} <Text strong className="tabular-nums">admin</Text> / {t("login.password")}{" "}*/}
|
||||
{/* <Text strong className="tabular-nums">123456</Text>*/}
|
||||
{/* </Text>*/}
|
||||
{/*</div>*/}
|
||||
<div className="login-footer">
|
||||
<Text type="secondary">
|
||||
{t("login.demoAccount")} <Text strong className="tabular-nums">admin</Text> / {t("login.password")}{" "}
|
||||
<Text strong className="tabular-nums">123456</Text>
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,10 +1,13 @@
|
|||
import { Button, Card, Form, Input, Layout, Typography, message } from "antd";
|
||||
import {Alert, Button, Form, Input, Typography, message} from "antd";
|
||||
import { LockOutlined, LogoutOutlined } from "@ant-design/icons";
|
||||
import { useState } from "react";
|
||||
import {useEffect, useMemo, useState} from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { getCurrentUser, updateMyPassword } from "@/api";
|
||||
import {fetchPublicPasswordPolicy, type PasswordPolicyPublic} from "@/api/auth";
|
||||
import {buildPasswordPolicyValidator, buildPolicyHints} from "@/utils/password";
|
||||
import AuthShell from "../components/AuthShell";
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
const {Text} = Typography;
|
||||
|
||||
type ResetPasswordFormValues = {
|
||||
oldPassword: string;
|
||||
|
|
@ -12,11 +15,35 @@ type ResetPasswordFormValues = {
|
|||
confirmPassword: string;
|
||||
};
|
||||
|
||||
const RESET_HIGHLIGHTS = [
|
||||
"首次登录必须完成密码更新,避免继续使用初始凭证。",
|
||||
"修改成功后保持当前登录态,无需再次验证。",
|
||||
"若不是本人操作,请立即退出并联系管理员。"
|
||||
];
|
||||
|
||||
export default function ResetPassword() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [policy, setPolicy] = useState<PasswordPolicyPublic | null>(null);
|
||||
const [username, setUsername] = useState<string>();
|
||||
const navigate = useNavigate();
|
||||
const [form] = Form.useForm<ResetPasswordFormValues>();
|
||||
|
||||
const policyHints = useMemo(() => buildPolicyHints(policy), [policy]);
|
||||
|
||||
useEffect(() => {
|
||||
const init = async () => {
|
||||
const [profileResult, policyResult] = await Promise.allSettled([getCurrentUser(), fetchPublicPasswordPolicy()]);
|
||||
|
||||
if (profileResult.status === "fulfilled") {
|
||||
setUsername(profileResult.value.username);
|
||||
}
|
||||
|
||||
setPolicy(policyResult.status === "fulfilled" ? policyResult.value : null);
|
||||
};
|
||||
|
||||
void init();
|
||||
}, []);
|
||||
|
||||
const goToLogin = () => {
|
||||
localStorage.removeItem("accessToken");
|
||||
localStorage.removeItem("refreshToken");
|
||||
|
|
@ -46,53 +73,79 @@ export default function ResetPassword() {
|
|||
};
|
||||
|
||||
return (
|
||||
<Layout style={{ minHeight: "100vh", background: "#f0f2f5", display: "flex", alignItems: "center", justifyContent: "center" }}>
|
||||
<Card style={{ width: 420, borderRadius: 8, boxShadow: "0 4px 12px rgba(0,0,0,0.1)" }}>
|
||||
<div className="text-center mb-6">
|
||||
<LockOutlined style={{ fontSize: 40, color: "#1890ff" }} />
|
||||
<Title level={3} style={{ marginTop: 16 }}>
|
||||
首次登录请修改密码
|
||||
</Title>
|
||||
<Text type="secondary">当前账号需要先更新初始密码,提交成功后会直接进入系统。</Text>
|
||||
</div>
|
||||
<AuthShell
|
||||
eyebrow="首次登录校验"
|
||||
asideTitle="先完成一次安全换密,再进入系统"
|
||||
asideDescription="初始密码只用于验证当前账号的合法持有者。设置新的个人密码后,系统会继续保持登录并同步最新的账户资料。"
|
||||
asideHighlights={RESET_HIGHLIGHTS}
|
||||
title="修改初始密码"
|
||||
subtitle="请输入当前密码并设置一个符合策略的新密码。"
|
||||
footer={<Text type="secondary">如需稍后处理,请先退出当前账号。</Text>}
|
||||
>
|
||||
<Alert
|
||||
showIcon
|
||||
type="info"
|
||||
message="密码更新后将直接进入系统首页"
|
||||
description="新密码提交成功后立即生效,旧密码会同时失效。"
|
||||
style={{marginBottom: 20, borderRadius: 14}}
|
||||
/>
|
||||
|
||||
<Form form={form} layout="vertical" onFinish={onFinish}>
|
||||
<Form.Item label="当前密码" name="oldPassword" rules={[{ required: true, message: "请输入当前密码" }]}>
|
||||
<Input.Password prefix={<LockOutlined />} />
|
||||
</Form.Item>
|
||||
<Form form={form} layout="vertical" onFinish={onFinish} requiredMark={false} className="auth-form">
|
||||
<Form.Item label="当前密码" name="oldPassword" rules={[{required: true, message: "请输入当前密码"}]}>
|
||||
<Input.Password size="large" prefix={<LockOutlined/>} autoComplete="current-password"/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="新密码" name="newPassword" rules={[{ required: true, min: 6, message: "新密码至少 6 位" }]}>
|
||||
<Input.Password prefix={<LockOutlined />} />
|
||||
</Form.Item>
|
||||
{policyHints.length > 0 ? (
|
||||
<div className="auth-form__policy-hints">
|
||||
<span className="auth-form__policy-hints-title">密码规则</span>
|
||||
<ul className="auth-form__policy-list">
|
||||
{policyHints.map((hint) => (
|
||||
<li key={hint}>{hint}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Form.Item
|
||||
label="确认新密码"
|
||||
name="confirmPassword"
|
||||
dependencies={["newPassword"]}
|
||||
rules={[
|
||||
{ required: true, message: "请再次输入新密码" },
|
||||
({ getFieldValue }) => ({
|
||||
validator(_, value) {
|
||||
if (!value || getFieldValue("newPassword") === value) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return Promise.reject(new Error("两次输入的新密码不一致"));
|
||||
<Form.Item
|
||||
label="新密码"
|
||||
name="newPassword"
|
||||
validateFirst
|
||||
rules={[
|
||||
{required: true, min: 6, message: "新密码至少 6 位"},
|
||||
{validator: buildPasswordPolicyValidator(policy, () => username)}
|
||||
]}
|
||||
>
|
||||
<Input.Password size="large" prefix={<LockOutlined/>} autoComplete="new-password"/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="确认新密码"
|
||||
name="confirmPassword"
|
||||
dependencies={["newPassword"]}
|
||||
rules={[
|
||||
{required: true, message: "请再次输入新密码"},
|
||||
({getFieldValue}) => ({
|
||||
validator(_, value) {
|
||||
if (!value || getFieldValue("newPassword") === value) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
})
|
||||
]}
|
||||
>
|
||||
<Input.Password prefix={<LockOutlined />} />
|
||||
</Form.Item>
|
||||
return Promise.reject(new Error("两次输入的新密码不一致"));
|
||||
}
|
||||
})
|
||||
]}
|
||||
>
|
||||
<Input.Password size="large" prefix={<LockOutlined/>} autoComplete="new-password"/>
|
||||
</Form.Item>
|
||||
|
||||
<Button type="primary" htmlType="submit" block size="large" loading={loading} style={{ marginTop: 8 }}>
|
||||
<div className="auth-form__actions">
|
||||
<Button type="primary" htmlType="submit" block size="large" loading={loading}>
|
||||
提交并保持登录
|
||||
</Button>
|
||||
|
||||
<Button type="link" block icon={<LogoutOutlined />} onClick={goToLogin} style={{ marginTop: 8 }}>
|
||||
<Button block size="large" icon={<LogoutOutlined/>} onClick={goToLogin}>
|
||||
退出登录
|
||||
</Button>
|
||||
</Form>
|
||||
</Card>
|
||||
</Layout>
|
||||
</div>
|
||||
</Form>
|
||||
</AuthShell>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,162 @@
|
|||
.profile-page {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.profile-side-stack {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.profile-summary-card,
|
||||
.profile-help-card,
|
||||
.profile-tabs-card {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.profile-summary-card__header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
|
||||
.profile-summary-card__avatar-trigger {
|
||||
width: fit-content;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.profile-summary-card__avatar.ant-avatar {
|
||||
background: linear-gradient(135deg, #1677ff 0%, #57a7ff 100%);
|
||||
box-shadow: 0 16px 28px rgba(22, 119, 255, 0.22);
|
||||
}
|
||||
|
||||
.profile-summary-card__identity {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.profile-summary-card__title.ant-typography {
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
|
||||
.profile-summary-card__tags {
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.profile-summary-card__meta {
|
||||
margin-top: 16px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid rgba(148, 163, 184, 0.16);
|
||||
}
|
||||
|
||||
.profile-summary-card__meta .ant-descriptions-view {
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.profile-summary-card__meta .ant-descriptions-item-label {
|
||||
width: 96px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.profile-help-card__title.ant-typography {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.profile-help-card__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
color: #475569;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.profile-help-card__alert {
|
||||
margin-top: 16px;
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.profile-tabs-card .ant-card-body {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.profile-tabs .ant-tabs-nav {
|
||||
margin: 0;
|
||||
padding: 0 24px;
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.16);
|
||||
background: linear-gradient(180deg, rgba(248, 250, 252, 0.95) 0%, rgba(255, 255, 255, 0.75) 100%);
|
||||
}
|
||||
|
||||
.profile-tabs .ant-tabs-content-holder {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.profile-form .ant-form-item {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.profile-upload-panel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 16px 18px;
|
||||
border-radius: 16px;
|
||||
border: 1px dashed rgba(59, 130, 246, 0.25);
|
||||
background: rgba(248, 250, 252, 0.9);
|
||||
}
|
||||
|
||||
.profile-upload-panel__hint {
|
||||
margin-top: 4px;
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.profile-security-alert,
|
||||
.profile-credential-panel__card {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.profile-security-alert {
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.profile-credential-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.profile-credential-panel__card {
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.profile-credential-panel__card .ant-card-body {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.profile-credential-panel__descriptions .ant-descriptions-view {
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.profile-tab-actions {
|
||||
margin: 8px 0 0;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.profile-tabs .ant-tabs-nav {
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.profile-tabs .ant-tabs-content-holder {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.profile-upload-panel {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,13 +1,42 @@
|
|||
import { Alert, Avatar, Button, Card, Col, Descriptions, Form, Input, Row, Space, Tabs, Tag, Typography, Upload, message } from "antd";
|
||||
import { KeyOutlined, LockOutlined, ReloadOutlined, SaveOutlined, SolutionOutlined, UploadOutlined, UserOutlined } from "@ant-design/icons";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
Avatar,
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
Descriptions,
|
||||
Form,
|
||||
Input,
|
||||
Row,
|
||||
Space,
|
||||
Tabs,
|
||||
Tag,
|
||||
Tooltip,
|
||||
Typography,
|
||||
Upload,
|
||||
message
|
||||
} from "antd";
|
||||
import {
|
||||
KeyOutlined,
|
||||
LockOutlined,
|
||||
QuestionCircleOutlined,
|
||||
ReloadOutlined,
|
||||
SaveOutlined,
|
||||
SolutionOutlined,
|
||||
UploadOutlined,
|
||||
UserOutlined
|
||||
} from "@ant-design/icons";
|
||||
import {useEffect, useMemo, useState} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {fetchPublicPasswordPolicy, type PasswordPolicyPublic} from "@/api/auth";
|
||||
import { generateMyBotCredential, getCurrentUser, getMyBotCredential, updateMyPassword, updateMyProfile, uploadPlatformAsset } from "@/api";
|
||||
import PageHeader from "@/components/shared/PageHeader";
|
||||
import type { BotCredential, UserProfile } from "@/types";
|
||||
import {buildPasswordPolicyValidator, buildPolicyHints} from "@/utils/password";
|
||||
import AvatarCropDialog, { type CropModalState } from "./AvatarCropDialog";
|
||||
import "./index.less";
|
||||
|
||||
const { Paragraph, Title, Text } = Typography;
|
||||
const {Paragraph, Text, Title} = Typography;
|
||||
|
||||
export default function Profile() {
|
||||
const { t } = useTranslation();
|
||||
|
|
@ -18,6 +47,7 @@ export default function Profile() {
|
|||
const [avatarUploading, setAvatarUploading] = useState(false);
|
||||
const [user, setUser] = useState<UserProfile | null>(null);
|
||||
const [credential, setCredential] = useState<BotCredential | null>(null);
|
||||
const [policy, setPolicy] = useState<PasswordPolicyPublic | null>(null);
|
||||
const [profileForm] = Form.useForm();
|
||||
const [pwdForm] = Form.useForm();
|
||||
const [cropState, setCropState] = useState<CropModalState>({
|
||||
|
|
@ -25,7 +55,7 @@ export default function Profile() {
|
|||
src: "",
|
||||
fileName: "",
|
||||
mimeType: "image/jpeg",
|
||||
targetSize: 300,
|
||||
targetSize: 300
|
||||
});
|
||||
|
||||
const loadUser = async () => {
|
||||
|
|
@ -34,8 +64,6 @@ export default function Profile() {
|
|||
const data = await getCurrentUser();
|
||||
setUser(data);
|
||||
sessionStorage.setItem("userProfile", JSON.stringify(data));
|
||||
localStorage.setItem("displayName", data.displayName || data.username || "");
|
||||
localStorage.setItem("username", data.username || localStorage.getItem("username") || "");
|
||||
window.dispatchEvent(new Event("user-profile-updated"));
|
||||
profileForm.setFieldsValue(data);
|
||||
} finally {
|
||||
|
|
@ -54,17 +82,26 @@ export default function Profile() {
|
|||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadUser();
|
||||
loadCredential();
|
||||
void loadUser();
|
||||
void loadCredential();
|
||||
void (async () => {
|
||||
try {
|
||||
setPolicy(await fetchPublicPasswordPolicy());
|
||||
} catch {
|
||||
setPolicy(null);
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
const policyHints = useMemo(() => buildPolicyHints(policy), [policy]);
|
||||
|
||||
const handleUpdateProfile = async () => {
|
||||
try {
|
||||
const values = await profileForm.validateFields();
|
||||
setSaving(true);
|
||||
await updateMyProfile(values);
|
||||
message.success(t("common.success"));
|
||||
loadUser();
|
||||
await loadUser();
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
|
|
@ -78,7 +115,7 @@ export default function Profile() {
|
|||
src: String(reader.result || ""),
|
||||
fileName: file.name,
|
||||
mimeType: file.type === "image/png" ? "image/png" : "image/jpeg",
|
||||
targetSize: 300,
|
||||
targetSize: 300
|
||||
});
|
||||
};
|
||||
reader.onerror = () => message.error(t("common.error"));
|
||||
|
|
@ -104,7 +141,10 @@ export default function Profile() {
|
|||
try {
|
||||
const values = await pwdForm.validateFields();
|
||||
setSaving(true);
|
||||
await updateMyPassword(values);
|
||||
await updateMyPassword({
|
||||
oldPassword: values.oldPassword,
|
||||
newPassword: values.newPassword
|
||||
});
|
||||
const profile = await getCurrentUser();
|
||||
setUser(profile);
|
||||
sessionStorage.setItem("userProfile", JSON.stringify(profile));
|
||||
|
|
@ -130,58 +170,152 @@ export default function Profile() {
|
|||
const renderValue = (value?: string) => value || "-";
|
||||
const avatarUrlValue = Form.useWatch("avatarUrl", profileForm) as string | undefined;
|
||||
const avatarUrl = avatarUrlValue?.trim() || undefined;
|
||||
const userStatus = user ? (user.status === 0 ? <Tag color="red">禁用</Tag> : <Tag color="green">启用</Tag>) : "-";
|
||||
|
||||
return (
|
||||
<div className="app-page app-page--contained" style={{ maxWidth: 1024, width: "100%", margin: "0 auto" }}>
|
||||
<div className="app-page app-page--contained profile-page">
|
||||
<PageHeader title={t("profile.title")} subtitle={t("profile.subtitle")} />
|
||||
|
||||
<Row gutter={24}>
|
||||
<Col xs={24} lg={8}>
|
||||
<Card className="app-page__content-card text-center" loading={loading}>
|
||||
<Upload accept="image/*" showUploadList={false} beforeUpload={handleAvatarUpload} disabled={avatarUploading}>
|
||||
<div style={{ cursor: "pointer", display: "inline-block" }}>
|
||||
<Avatar size={80} src={avatarUrl} icon={avatarUrl ? undefined : <UserOutlined />} style={{ backgroundColor: "#1677ff", marginBottom: 16 }} />
|
||||
<Row gutter={[24, 24]}>
|
||||
<Col xs={24} xl={8}>
|
||||
<Space direction="vertical" size={16} className="profile-side-stack">
|
||||
<Card className="app-page__panel-card profile-summary-card" loading={loading}>
|
||||
<div className="profile-summary-card__header">
|
||||
<Upload accept="image/*" showUploadList={false} beforeUpload={handleAvatarUpload}
|
||||
disabled={avatarUploading}>
|
||||
<div className="profile-summary-card__avatar-trigger">
|
||||
<Avatar
|
||||
size={92}
|
||||
src={avatarUrl}
|
||||
icon={avatarUrl ? undefined : <UserOutlined/>}
|
||||
className="profile-summary-card__avatar"
|
||||
/>
|
||||
</div>
|
||||
</Upload>
|
||||
|
||||
<div className="profile-summary-card__identity">
|
||||
<Title level={4} className="profile-summary-card__title">
|
||||
{user?.displayName || "-"}
|
||||
</Title>
|
||||
<Text type="secondary" className="tabular-nums">
|
||||
@{user?.username || "-"}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<Space wrap size={[8, 8]} className="profile-summary-card__tags">
|
||||
{user?.isPlatformAdmin ? <Tag color="gold">{t("users.platformAdmin")}</Tag> : null}
|
||||
{user?.isTenantAdmin ? <Tag color="blue">租户管理员</Tag> : null}
|
||||
{user && !user.isPlatformAdmin && !user.isTenantAdmin ?
|
||||
<Tag color="blue">{t("profile.standardUser")}</Tag> : null}
|
||||
{user && !user.isPlatformAdmin && user.hasPlatformAdminPrivilege ?
|
||||
<Tag color="purple">可切换平台管理员</Tag> : null}
|
||||
</Space>
|
||||
</div>
|
||||
</Upload>
|
||||
<Title level={5} style={{ margin: 0 }}>{user?.displayName}</Title>
|
||||
<Text type="secondary">@{user?.username}</Text>
|
||||
<div className="mt-4">
|
||||
{user?.isPlatformAdmin ? <Tag color="gold">{t("users.platformAdmin")}</Tag> : null}
|
||||
{user?.isTenantAdmin ? <Tag color="blue">租户管理员</Tag> : null}
|
||||
{!user?.isPlatformAdmin && !user?.isTenantAdmin ? <Tag color="blue">{t("profile.standardUser")}</Tag> : null}
|
||||
{!user?.isPlatformAdmin && user?.hasPlatformAdminPrivilege ? <Tag color="purple">可切换平台管理员</Tag> : null}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Descriptions
|
||||
column={1}
|
||||
size="small"
|
||||
className="profile-summary-card__meta"
|
||||
items={[
|
||||
{
|
||||
key: "email",
|
||||
label: t("users.email"),
|
||||
children: <span className="tabular-nums">{renderValue(user?.email)}</span>
|
||||
},
|
||||
{
|
||||
key: "phone",
|
||||
label: t("users.phone"),
|
||||
children: <span className="tabular-nums">{renderValue(user?.phone)}</span>
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
label: t("common.status"),
|
||||
children: userStatus
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card className="app-page__panel-card profile-help-card">
|
||||
<Title level={5} className="profile-help-card__title">
|
||||
安全提示
|
||||
</Title>
|
||||
<div className="profile-help-card__list">
|
||||
<div>头像上传后会立即填充到个人资料表单,保存后对外生效。</div>
|
||||
<div>密码修改成功后会刷新当前会话中的个人资料缓存。</div>
|
||||
<div>Bot 凭证重新生成后,旧密钥会立即失效。</div>
|
||||
</div>
|
||||
{user?.pwdResetRequired ? (
|
||||
<Alert
|
||||
showIcon
|
||||
type="warning"
|
||||
message="当前账号仍需要完成密码重置"
|
||||
description="请尽快在“安全设置”页签中更新密码。"
|
||||
className="profile-help-card__alert"
|
||||
/>
|
||||
) : null}
|
||||
</Card>
|
||||
</Space>
|
||||
</Col>
|
||||
|
||||
<Col xs={24} lg={16}>
|
||||
<Card className="app-page__content-card">
|
||||
<Col xs={24} xl={16}>
|
||||
<Card className="app-page__content-card profile-tabs-card">
|
||||
<Tabs
|
||||
defaultActiveKey="basic"
|
||||
className="profile-tabs"
|
||||
items={[
|
||||
{
|
||||
key: "basic",
|
||||
label: <span><SolutionOutlined /> {t("profile.basicInfo")}</span>,
|
||||
label: (
|
||||
<span>
|
||||
<SolutionOutlined/> {t("profile.basicInfo")}
|
||||
</span>
|
||||
),
|
||||
children: (
|
||||
<Form form={profileForm} layout="vertical" onFinish={handleUpdateProfile} style={{ marginTop: 16 }}>
|
||||
<Form.Item label={t("users.displayName")} name="displayName" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("users.email")} name="email">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("users.phone")} name="phone">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("profile.avatarUrl")} name="avatarUrl">
|
||||
<Input placeholder={t("profile.avatarUrlPlaceholder")} />
|
||||
</Form.Item>
|
||||
<Upload accept="image/*" showUploadList={false} beforeUpload={handleAvatarUpload}>
|
||||
<Button icon={<UploadOutlined />} loading={avatarUploading} style={{ marginBottom: 16 }}>
|
||||
{t("profile.uploadAvatar")}
|
||||
</Button>
|
||||
</Upload>
|
||||
<div className="app-page__page-actions" style={{ margin: "8px 0 0" }}>
|
||||
<Form form={profileForm} layout="vertical" onFinish={handleUpdateProfile} className="profile-form">
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item label={t("users.displayName")} name="displayName" rules={[{required: true}]}>
|
||||
<Input/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item label={t("users.email")} name="email"
|
||||
rules={[{required: true, message: "请输入邮箱地址"}, {
|
||||
type: "email",
|
||||
message: "请输入正确的邮箱格式"
|
||||
}]}>
|
||||
<Input/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item label={t("users.phone")} name="phone">
|
||||
<Input/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item label={t("profile.avatarUrl")} name="avatarUrl">
|
||||
<Input placeholder={t("profile.avatarUrlPlaceholder")}/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<div className="profile-upload-panel">
|
||||
<div>
|
||||
<Text strong>{t("profile.uploadAvatar")}</Text>
|
||||
<div className="profile-upload-panel__hint">建议上传方形图片,裁剪后会生成统一头像尺寸。</div>
|
||||
</div>
|
||||
<Upload accept="image/*" showUploadList={false} beforeUpload={handleAvatarUpload}>
|
||||
<Button icon={<UploadOutlined/>} loading={avatarUploading}>
|
||||
{t("profile.uploadAvatar")}
|
||||
</Button>
|
||||
</Upload>
|
||||
</div>
|
||||
|
||||
<div className="app-page__page-actions profile-tab-actions">
|
||||
<Button type="primary" icon={<SaveOutlined />} loading={saving} onClick={() => profileForm.submit()}>
|
||||
{t("profile.saveChanges")}
|
||||
</Button>
|
||||
|
|
@ -191,13 +325,55 @@ export default function Profile() {
|
|||
},
|
||||
{
|
||||
key: "password",
|
||||
label: <span><LockOutlined /> {t("profile.security")}</span>,
|
||||
label: (
|
||||
<span>
|
||||
<LockOutlined/> {t("profile.security")}
|
||||
</span>
|
||||
),
|
||||
children: (
|
||||
<Form form={pwdForm} layout="vertical" onFinish={handleUpdatePassword} style={{ marginTop: 16 }}>
|
||||
<Form form={pwdForm} layout="vertical" onFinish={handleUpdatePassword} className="profile-form">
|
||||
<Alert
|
||||
showIcon
|
||||
type="info"
|
||||
message="密码更新后立即生效"
|
||||
description="建议使用与其他系统不同的新密码,避免复用。"
|
||||
className="profile-security-alert"
|
||||
/>
|
||||
|
||||
<Form.Item label={t("profile.currentPassword")} name="oldPassword" rules={[{ required: true }]}>
|
||||
<Input.Password />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("profile.newPassword")} name="newPassword" rules={[{ required: true, min: 6 }]}>
|
||||
<Form.Item
|
||||
label={
|
||||
<Space size={4}>
|
||||
<span>{t("profile.newPassword")}</span>
|
||||
<Tooltip
|
||||
title={
|
||||
policyHints.length > 0 ? (
|
||||
<ul style={{paddingLeft: 18, margin: 0, fontSize: 13, lineHeight: 1.6}}>
|
||||
{policyHints.map((hint) => (
|
||||
<li key={hint}>{hint}</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
t("profile.passwordRules")
|
||||
)
|
||||
}
|
||||
overlayStyle={{maxWidth: 300}}
|
||||
>
|
||||
<QuestionCircleOutlined style={{color: "rgba(0,0,0,0.45)", cursor: "help"}}/>
|
||||
</Tooltip>
|
||||
</Space>
|
||||
}
|
||||
name="newPassword"
|
||||
validateFirst
|
||||
rules={[
|
||||
{required: true},
|
||||
{
|
||||
validator: buildPasswordPolicyValidator(policy, () => user?.username)
|
||||
}
|
||||
]}
|
||||
>
|
||||
<Input.Password />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
|
|
@ -218,7 +394,7 @@ export default function Profile() {
|
|||
>
|
||||
<Input.Password />
|
||||
</Form.Item>
|
||||
<div className="app-page__page-actions" style={{ margin: "8px 0 0" }}>
|
||||
<div className="app-page__page-actions profile-tab-actions">
|
||||
<Button type="primary" danger loading={saving} onClick={() => pwdForm.submit()}>
|
||||
{t("profile.updatePassword")}
|
||||
</Button>
|
||||
|
|
@ -228,27 +404,32 @@ export default function Profile() {
|
|||
},
|
||||
{
|
||||
key: "bot-credential",
|
||||
label: <span><KeyOutlined /> {t("profile.botCredentialTab")}</span>,
|
||||
label: (
|
||||
<span>
|
||||
<KeyOutlined/> {t("profile.botCredentialTab")}
|
||||
</span>
|
||||
),
|
||||
children: (
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<Space direction="vertical" size={16} style={{ width: "100%" }}>
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message={t("profile.botCredentialHint")}
|
||||
description={t("profile.botCredentialHintDesc")}
|
||||
/>
|
||||
<div className="profile-credential-panel">
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message={t("profile.botCredentialHint")}
|
||||
description={t("profile.botCredentialHintDesc")}
|
||||
/>
|
||||
|
||||
<Card bordered={false} loading={credentialLoading} className="profile-credential-panel__card">
|
||||
<Descriptions
|
||||
bordered
|
||||
size="middle"
|
||||
column={1} items={[
|
||||
column={1}
|
||||
className="profile-credential-panel__descriptions"
|
||||
items={[
|
||||
{
|
||||
key: "bind-status",
|
||||
label: t("profile.botBindStatus"),
|
||||
children: credential?.bound
|
||||
? <Tag color="success">{t("profile.botBound")}</Tag>
|
||||
: <Tag>{t("profile.botUnbound")}</Tag>
|
||||
children: credential?.bound ? <Tag color="success">{t("profile.botBound")}</Tag> :
|
||||
<Tag>{t("profile.botUnbound")}</Tag>
|
||||
},
|
||||
{
|
||||
key: "bot-id",
|
||||
|
|
@ -257,7 +438,9 @@ export default function Profile() {
|
|||
<Paragraph copyable={{ text: credential.botId }} style={{ marginBottom: 0 }}>
|
||||
{credential.botId}
|
||||
</Paragraph>
|
||||
) : "-"
|
||||
) : (
|
||||
"-"
|
||||
)
|
||||
},
|
||||
{
|
||||
key: "bot-secret",
|
||||
|
|
@ -266,34 +449,34 @@ export default function Profile() {
|
|||
<Paragraph copyable={{ text: credential.botSecret }} style={{ marginBottom: 0 }}>
|
||||
{credential.botSecret}
|
||||
</Paragraph>
|
||||
) : t("profile.botSecretHidden")
|
||||
) : (
|
||||
t("profile.botSecretHidden")
|
||||
)
|
||||
},
|
||||
{
|
||||
key: "last-access-time",
|
||||
label: t("profile.botLastAccessTime"),
|
||||
children: renderValue(credential?.lastAccessTime)
|
||||
children: <span className="tabular-nums">{renderValue(credential?.lastAccessTime)}</span>
|
||||
},
|
||||
{
|
||||
key: "last-access-ip",
|
||||
label: t("profile.botLastAccessIp"),
|
||||
children: renderValue(credential?.lastAccessIp)
|
||||
children: <span className="tabular-nums">{renderValue(credential?.lastAccessIp)}</span>
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<div className="app-page__page-actions" style={{ margin: "8px 0 0" }}>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={credential?.bound ? <ReloadOutlined /> : <KeyOutlined />}
|
||||
loading={credentialSaving}
|
||||
onClick={handleGenerateCredential}
|
||||
>
|
||||
{credential?.bound
|
||||
? t("profile.regenerateBotCredential")
|
||||
: t("profile.generateBotCredential")}
|
||||
</Button>
|
||||
</div>
|
||||
</Space>
|
||||
<div className="app-page__page-actions profile-tab-actions">
|
||||
<Button
|
||||
type="primary"
|
||||
icon={credential?.bound ? <ReloadOutlined/> : <KeyOutlined/>}
|
||||
loading={credentialSaving}
|
||||
onClick={handleGenerateCredential}
|
||||
>
|
||||
{credential?.bound ? t("profile.regenerateBotCredential") : t("profile.generateBotCredential")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -311,4 +494,3 @@ export default function Profile() {
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ const Login = lazy(() => import("@/pages/auth/login"));
|
|||
const ResetPassword = lazy(() => import("@/pages/auth/reset-password"));
|
||||
const MeetingPreview = lazy(() => import("@/pages/business/MeetingPreview"));
|
||||
const PublicDeviceMeetingCreate = lazy(() => import("@/pages/business/PublicDeviceMeetingCreate"));
|
||||
|
||||
const ForgotPassword = lazy(() => import("@/pages/auth/forgot-password"));
|
||||
function RouteFallback() {
|
||||
let platformName = "iMeeting";
|
||||
try {
|
||||
|
|
@ -71,6 +71,7 @@ export default function AppRoutes() {
|
|||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/reset-password" element={<ResetPassword />} />
|
||||
<Route path="/forgot-password" element={<ForgotPassword/>}/>
|
||||
<Route
|
||||
path="/meetings/:id/preview"
|
||||
element={<MeetingPreview />}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,125 @@
|
|||
import type {PasswordPolicyPublic} from "@/api/auth";
|
||||
|
||||
const DEFAULT_SPECIAL_CHARS = "!@#$%^&*()_+-=[]{}|;:',.<>/?`~\"\\";
|
||||
type UsernameResolver = string | undefined | (() => string | undefined);
|
||||
|
||||
/**
|
||||
* 根据密码策略生成可读的规则提示文本,用于问号 Tooltip 展示。
|
||||
*/
|
||||
export function buildPolicyHints(policy: PasswordPolicyPublic | null): string[] {
|
||||
if (!policy || policy.enabled === false) {
|
||||
return ["密码长度至少 6 位"];
|
||||
}
|
||||
const hints: string[] = [];
|
||||
hints.push(`长度 ${policy.minLength}-${policy.maxLength} 位`);
|
||||
if (policy.requireUppercase) hints.push("包含大写字母");
|
||||
if (policy.requireLowercase) hints.push("包含小写字母");
|
||||
if (policy.requireDigit) hints.push("包含数字");
|
||||
if (policy.requireSpecialChar) {
|
||||
const chars = policy.specialCharSet?.trim() || DEFAULT_SPECIAL_CHARS;
|
||||
hints.push(`包含特殊字符(如 ${chars})`);
|
||||
}
|
||||
if (policy.forbidUsernameContain) hints.push("不能包含登录名");
|
||||
if (policy.forbidSequentialChars) hints.push("不能包含 3 位及以上连续字符(如 abc、123)");
|
||||
if (policy.forbidRepeatedChars) hints.push("不能包含 3 位及以上重复字符(如 aaa)");
|
||||
if (policy.customRuleMessage) hints.push(policy.customRuleMessage);
|
||||
return hints;
|
||||
}
|
||||
|
||||
function hasSequentialChars(value: string): boolean {
|
||||
for (let i = 0; i + 2 < value.length; i += 1) {
|
||||
const a = value.charCodeAt(i);
|
||||
const b = value.charCodeAt(i + 1);
|
||||
const c = value.charCodeAt(i + 2);
|
||||
if (b - a === 1 && c - b === 1) return true; // 递增:abc / 123
|
||||
if (a - b === 1 && b - c === 1) return true; // 递减:cba / 321
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function hasRepeatedChars(value: string): boolean {
|
||||
for (let i = 0; i + 2 < value.length; i += 1) {
|
||||
if (value[i] === value[i + 1] && value[i + 1] === value[i + 2]) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function resolveUsername(username: UsernameResolver): string | undefined {
|
||||
return typeof username === "function" ? username() : username;
|
||||
}
|
||||
|
||||
function containsConfiguredSpecialChar(password: string, specialCharSet: string): boolean {
|
||||
for (const char of password) {
|
||||
if (specialCharSet.includes(char)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按密码策略校验密码,返回第一条未满足的提示;全部满足返回 null。
|
||||
* 用于表单的实时校验(边输入边提示)。
|
||||
*/
|
||||
export function validatePasswordAgainstPolicy(
|
||||
policy: PasswordPolicyPublic | null,
|
||||
password: string,
|
||||
username?: string
|
||||
): string | null {
|
||||
if (!password) {
|
||||
return null; // 空值交由 required 规则处理
|
||||
}
|
||||
|
||||
if (!policy || policy.enabled === false) {
|
||||
return password.length >= 6 ? null : "密码长度至少 6 位";
|
||||
}
|
||||
|
||||
if (password.length < policy.minLength) {
|
||||
return `密码长度不能少于 ${policy.minLength} 位`;
|
||||
}
|
||||
if (password.length > policy.maxLength) {
|
||||
return `密码长度不能超过 ${policy.maxLength} 位`;
|
||||
}
|
||||
if (policy.requireUppercase && !/[A-Z]/.test(password)) {
|
||||
return "密码需包含大写字母";
|
||||
}
|
||||
if (policy.requireLowercase && !/[a-z]/.test(password)) {
|
||||
return "密码需包含小写字母";
|
||||
}
|
||||
if (policy.requireDigit && !/[0-9]/.test(password)) {
|
||||
return "密码需包含数字";
|
||||
}
|
||||
if (policy.requireSpecialChar) {
|
||||
const chars = policy.specialCharSet?.trim() || DEFAULT_SPECIAL_CHARS;
|
||||
if (!containsConfiguredSpecialChar(password, chars)) {
|
||||
return "密码需包含特殊字符";
|
||||
}
|
||||
}
|
||||
if (policy.forbidUsernameContain && username) {
|
||||
const name = username.trim().toLowerCase();
|
||||
if (name && password.toLowerCase().includes(name)) {
|
||||
return "密码不能包含登录名";
|
||||
}
|
||||
}
|
||||
if (policy.forbidSequentialChars && hasSequentialChars(password)) {
|
||||
return "密码不能包含 3 位及以上连续字符";
|
||||
}
|
||||
if (policy.forbidRepeatedChars && hasRepeatedChars(password)) {
|
||||
return "密码不能包含 3 位及以上重复字符";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function buildPasswordPolicyValidator(
|
||||
policy: PasswordPolicyPublic | null,
|
||||
username?: UsernameResolver
|
||||
) {
|
||||
return async (_: unknown, value: string) => {
|
||||
const error = validatePasswordAgainstPolicy(policy, value, resolveUsername(username));
|
||||
if (error) {
|
||||
throw new Error(error);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@ import ProtectedRoute from "@/routes/ProtectedRoute";
|
|||
import { hasAccessToken } from "@/utils/auth";
|
||||
|
||||
const LoginPage = lazy(() => import("@/pages/login"));
|
||||
const ForgotPasswordPage = lazy(() => import("@/pages/forgot-password"));
|
||||
const MeetingsPage = lazy(() => import("@/pages/meetings"));
|
||||
const MeetingDetailPage = lazy(() => import("@/pages/meeting-detail"));
|
||||
const MeetingPreviewPage = lazy(() => import("@/pages/meeting-preview"));
|
||||
|
|
@ -24,6 +25,7 @@ export default function App() {
|
|||
<Routes>
|
||||
<Route path="/" element={<HomeRedirect />} />
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/forgot-password" element={<ForgotPasswordPage/>}/>
|
||||
<Route path="/meetings/:id/preview" element={<MeetingPreviewPage />} />
|
||||
|
||||
<Route
|
||||
|
|
|
|||
|
|
@ -8,12 +8,56 @@ export interface LoginPayload {
|
|||
captchaCode?: string;
|
||||
}
|
||||
|
||||
export interface PasswordPolicyPublic {
|
||||
enabled: boolean;
|
||||
minLength: number;
|
||||
maxLength: number;
|
||||
requireUppercase: boolean;
|
||||
requireLowercase: boolean;
|
||||
requireDigit: boolean;
|
||||
requireSpecialChar: boolean;
|
||||
specialCharSet?: string;
|
||||
forbidUsernameContain: boolean;
|
||||
forbidSequentialChars: boolean;
|
||||
forbidRepeatedChars: boolean;
|
||||
customRuleMessage?: string;
|
||||
}
|
||||
|
||||
export interface PasswordRecoverySendCodePayload {
|
||||
username: string;
|
||||
captchaId?: string;
|
||||
captchaCode?: string;
|
||||
channel: "EMAIL";
|
||||
}
|
||||
|
||||
export interface PasswordRecoveryResetPayload {
|
||||
username: string;
|
||||
channel: "EMAIL";
|
||||
code: string;
|
||||
newPassword: string;
|
||||
}
|
||||
|
||||
export async function fetchCaptcha() {
|
||||
const resp = await http.get("/sys/auth/captcha");
|
||||
return resp.data.data as CaptchaResponse;
|
||||
}
|
||||
|
||||
export async function fetchPublicPasswordPolicy() {
|
||||
const resp = await http.get("/sys/auth/password-policy/public");
|
||||
return resp.data.data as PasswordPolicyPublic;
|
||||
}
|
||||
|
||||
export async function login(payload: LoginPayload) {
|
||||
const resp = await http.post("/sys/auth/login", payload);
|
||||
return resp.data.data as TokenResponse;
|
||||
}
|
||||
|
||||
export async function sendPasswordRecoveryCode(payload: PasswordRecoverySendCodePayload) {
|
||||
const resp = await http.post("/sys/auth/password-recovery/send-code", payload);
|
||||
return resp.data as { code: string; msg: string; data: boolean };
|
||||
}
|
||||
|
||||
export async function resetPasswordByRecovery(payload: PasswordRecoveryResetPayload) {
|
||||
const resp = await http.post("/sys/auth/password-recovery/reset", payload);
|
||||
return resp.data.data as boolean;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,7 +14,15 @@ declare module "axios" {
|
|||
}
|
||||
}
|
||||
|
||||
const AUTH_WHITELIST = ["/sys/auth/login", "/sys/auth/refresh", "/sys/auth/captcha", "/sys/auth/device-code"];
|
||||
const AUTH_WHITELIST = [
|
||||
"/sys/auth/login",
|
||||
"/sys/auth/refresh",
|
||||
"/sys/auth/captcha",
|
||||
"/sys/auth/device-code",
|
||||
"/sys/auth/password-policy/public",
|
||||
"/sys/auth/password-recovery/send-code",
|
||||
"/sys/auth/password-recovery/reset",
|
||||
];
|
||||
const SUCCESS_CODE = "200";
|
||||
const REFRESH_AHEAD_MS = 60 * 1000;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { LockOutlined, ReloadOutlined, UserOutlined } from "@ant-design/icons";
|
||||
import { App, Button, Card, Form, Input, Typography } from "antd";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import {Link, useNavigate, useSearchParams} from "react-router-dom";
|
||||
|
||||
import { fetchCaptcha, login } from "@/api/auth";
|
||||
import { getCurrentUser } from "@/api/user";
|
||||
|
|
@ -148,6 +148,12 @@ export default function LoginPage() {
|
|||
</Button>
|
||||
</Form>
|
||||
|
||||
<div className="login-card__actions">
|
||||
<Link to="/forgot-password" className="login-card__link">
|
||||
忘记密码
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{platformConfig?.icpInfo || platformConfig?.copyrightInfo ? (
|
||||
<div className="login-card__footer">
|
||||
{platformConfig?.icpInfo ? <div>{platformConfig.icpInfo}</div> : null}
|
||||
|
|
|
|||
|
|
@ -162,6 +162,12 @@ button {
|
|||
gap: 24px;
|
||||
}
|
||||
|
||||
.login-page--compact {
|
||||
gap: 16px;
|
||||
padding-top: 24px;
|
||||
padding-bottom: 24px;
|
||||
}
|
||||
|
||||
.login-page__hero {
|
||||
padding: 8px 4px;
|
||||
}
|
||||
|
|
@ -181,6 +187,10 @@ button {
|
|||
padding: 24px;
|
||||
}
|
||||
|
||||
.login-card--compact .ant-card-body {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.login-card__brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
@ -233,6 +243,36 @@ button {
|
|||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.login-card__actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.login-card__link {
|
||||
color: var(--h5-primary);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.captcha-preview--compact {
|
||||
margin-top: -8px;
|
||||
margin-bottom: 14px;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.login-inline-field {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 92px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.login-form__hint-list {
|
||||
color: var(--h5-text-subtle);
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.meeting-card-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
@ -542,6 +582,11 @@ button {
|
|||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.login-page {
|
||||
padding-left: 14px;
|
||||
padding-right: 14px;
|
||||
}
|
||||
|
||||
.page-header__title {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
|
@ -558,4 +603,8 @@ button {
|
|||
.inline-actions .ant-btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.login-inline-field {
|
||||
grid-template-columns: minmax(0, 1fr) 84px;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
{"root":["./src/app.tsx","./src/main.tsx","./src/api/auth.ts","./src/api/http.ts","./src/api/meeting.ts","./src/api/platform.ts","./src/api/user.ts","./src/components/bottomnav.tsx","./src/components/loadingscreen.tsx","./src/components/meetingcontent.tsx","./src/components/pageheader.tsx","./src/components/platformconfigprovider.tsx","./src/components/preview/meetingpreviewview.tsx","./src/components/preview/meetinganalysis.ts","./src/hooks/usepagetitle.ts","./src/layouts/mainlayout.tsx","./src/pages/about/index.tsx","./src/pages/login/index.tsx","./src/pages/meeting-detail/index.tsx","./src/pages/meeting-preview/index.tsx","./src/pages/meetings/index.tsx","./src/pages/password/index.tsx","./src/pages/profile/index.tsx","./src/pages/scan-confirm/index.tsx","./src/routes/protectedroute.tsx","./src/types/index.ts","./src/types/platform.ts","./src/utils/auth.ts","./src/utils/meeting.ts"],"version":"5.9.3"}
|
||||
{"root":["./src/app.tsx","./src/main.tsx","./src/api/auth.ts","./src/api/http.ts","./src/api/meeting.ts","./src/api/platform.ts","./src/api/user.ts","./src/components/bottomnav.tsx","./src/components/loadingscreen.tsx","./src/components/pageheader.tsx","./src/components/platformconfigprovider.tsx","./src/components/preview/meetingpreviewview.tsx","./src/components/preview/meetinganalysis.ts","./src/hooks/usepagetitle.ts","./src/layouts/mainlayout.tsx","./src/pages/forgot-password/index.tsx","./src/pages/login/index.tsx","./src/pages/meeting-detail/index.tsx","./src/pages/meeting-preview/index.tsx","./src/pages/meetings/index.tsx","./src/pages/password/index.tsx","./src/pages/profile/index.tsx","./src/pages/scan-confirm/index.tsx","./src/routes/protectedroute.tsx","./src/types/index.ts","./src/types/platform.ts","./src/utils/auth.ts","./src/utils/meeting.ts","./src/utils/password.ts"],"version":"5.9.3"}
|
||||
Loading…
Reference in New Issue