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
chenhao 2026-06-18 13:43:27 +08:00
parent 7233f13598
commit ee1e75eda2
28 changed files with 1893 additions and 285 deletions

View File

@ -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>

View File

@ -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);
}
}

View File

@ -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;
}

View File

@ -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

View File

@ -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;
}

View File

@ -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;

View File

@ -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;

View File

@ -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;
}

View File

@ -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;

View File

@ -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",

View File

@ -259,6 +259,7 @@
"security": "安全设置",
"currentPassword": "当前密码",
"newPassword": "新密码",
"passwordRules": "密码规则",
"confirmNewPassword": "确认新密码",
"saveChanges": "保存修改",
"updatePassword": "更新密码",

View File

@ -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;
}
}

View File

@ -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>
);
}

View File

@ -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%;
}
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>

View File

@ -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>
);
}

View File

@ -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;
}
}

View File

@ -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>
);
}

View File

@ -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 />}

View File

@ -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);
}
};
}

View File

@ -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

View File

@ -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;
}

View File

@ -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;

View File

@ -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}

View File

@ -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;
}
}

View File

@ -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"}