feat: 忘记密码/修改密码 + 设置页结构重组 + barrel cleanup + LoginPage 版本统一

新增:
  - ForgotPasswordModal — 手机号+短信验证码重置密码 (AuthAPI.resetPassword)
  - ChangePasswordModal — 当前密码验证后修改密码 (AuthAPI.changePassword)
  - useSmsCooldown Hook — 通用短信发送冷却逻辑,供注册/忘记密码复用
  - PasswordSetting — 设置页账号安全区块,双选项:修改密码 / 短信验证码重置

  修改:
  - LoginForm \"忘记密码?\" 链接接入 ForgotPasswordModal(替换 TODO)
  - LoginPage 移除 edition 业务版本 UI 区分,登录/注册 Tab 全局统一
  - 设置页 Section 组件移入 blocks/ 子目录(git mv 保留历史)
  - auth.ts sendSms URL 修正: /sms/send → /send-sms

  清理:
  - settings/index.ts 移除 4 个未使用 re-export,精简为仅 SettingsPage
  - navbar/index.ts 移除未使用 re-export,精简为仅 NavBar

  文档:
  - WORKLOG.md 新增 2026-06-08 工作日志
  - ARCHITECTURE.md / PROJECT.md 更新设置页目录结构与新组件
  - TODO.md 忘记密码流程标记已完成 + 短信人机验证列入后期优化
This commit is contained in:
2026-06-08 11:57:35 +08:00
parent 952a723448
commit 7032d1c3f2
22 changed files with 1089 additions and 515 deletions

View File

@@ -0,0 +1,199 @@
// ============================================================
// ChangePasswordModal — 修改密码弹窗(已登录用户)
//
// 场景:已登录用户通过旧密码验证后设置新密码
// 复用:可在设置页"修改密码"入口使用,也可在其他位置独立调用
//
// API
// - AuthAPI.changePassword({ current_password, new_password })
// ============================================================
import { useState, useCallback } from 'react';
import { Modal, Input, Button, Typography, App } from 'antd';
import { LockOutlined, KeyOutlined, SafetyOutlined } from '@ant-design/icons';
import { useTheme } from '@/hooks/use-theme.ts';
import { AuthAPI } from '@/services/modules';
import { validatePassword, validateConfirmPassword } from '../config/validators';
const { Text, Title } = Typography;
interface ChangePasswordModalProps {
open: boolean;
onClose: () => void;
}
export function ChangePasswordModal({ open, onClose }: ChangePasswordModalProps) {
const { isDark } = useTheme();
const { message } = App.useApp();
// ---- 表单字段 ----
const [currentPassword, setCurrentPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
// ---- 错误信息 ----
const [errors, setErrors] = useState<Record<string, string>>({});
// ---- 提交状态 ----
const [submitting, setSubmitting] = useState(false);
// ---- 清除单个字段错误 ----
const clearError = useCallback((name: string) => {
setErrors((p) => {
if (!(name in p)) return p;
const n = { ...p };
delete n[name];
return n;
});
}, []);
// ---- 提交 ----
const handleSubmit = useCallback(async () => {
const newErrors: Record<string, string> = {};
if (!currentPassword) {
newErrors.currentPassword = '请输入当前密码';
}
const pwdErr = validatePassword(newPassword);
if (pwdErr) newErrors.newPassword = pwdErr;
const confirmErr = validateConfirmPassword(confirmPassword, newPassword);
if (confirmErr) newErrors.confirmPassword = confirmErr;
if (Object.keys(newErrors).length > 0) {
setErrors(newErrors);
return;
}
setErrors({});
setSubmitting(true);
try {
await AuthAPI.changePassword({
current_password: currentPassword,
new_password: newPassword,
});
message.success('密码修改成功');
// 重置表单
setCurrentPassword('');
setNewPassword('');
setConfirmPassword('');
onClose();
} catch (err) {
message.error((err as Error).message || '密码修改失败');
} finally {
setSubmitting(false);
}
}, [currentPassword, newPassword, confirmPassword, onClose, message]);
// ---- 关闭时重置错误 ----
const handleClose = useCallback(() => {
setErrors({});
onClose();
}, [onClose]);
return (
<Modal
open={open}
onCancel={handleClose}
footer={null}
width={440}
centered
destroyOnHidden={true}
mask={{ closable: false }}
styles={{
body: {
padding: '24px 32px 16px',
background: isDark ? '#1A1730' : '#FFFFFF',
},
}}
>
{/* 标题 */}
<div className="text-center mb-5">
<Title level={5} className="!mb-1 gradient-primary-text">
</Title>
<Text type="secondary" className="text-xs">
</Text>
</div>
{/* 表单 */}
<div className="space-y-4">
{/* 当前密码 */}
<div>
<Input.Password
size="large"
prefix={<SafetyOutlined className="opacity-40" />}
placeholder="当前密码"
value={currentPassword}
onChange={(e) => {
setCurrentPassword(e.target.value);
clearError('currentPassword');
}}
status={errors.currentPassword ? 'error' : undefined}
allowClear
/>
{errors.currentPassword && (
<Text type="danger" className="text-xs ml-1">
{errors.currentPassword}
</Text>
)}
</div>
{/* 新密码 */}
<div>
<Input.Password
size="large"
prefix={<LockOutlined className="opacity-40" />}
placeholder="新密码6~128位"
value={newPassword}
onChange={(e) => {
setNewPassword(e.target.value);
clearError('newPassword');
}}
status={errors.newPassword ? 'error' : undefined}
allowClear
/>
{errors.newPassword && (
<Text type="danger" className="text-xs ml-1">
{errors.newPassword}
</Text>
)}
</div>
{/* 确认新密码 */}
<div>
<Input.Password
size="large"
prefix={<KeyOutlined className="opacity-40" />}
placeholder="请再次输入新密码"
value={confirmPassword}
onChange={(e) => {
setConfirmPassword(e.target.value);
clearError('confirmPassword');
}}
status={errors.confirmPassword ? 'error' : undefined}
allowClear
/>
{errors.confirmPassword && (
<Text type="danger" className="text-xs ml-1">
{errors.confirmPassword}
</Text>
)}
</div>
{/* 提交按钮 */}
<Button
type="primary"
block
size="large"
loading={submitting}
onClick={handleSubmit}
className="h-11! text-base! font-medium! mt-2!"
>
</Button>
</div>
</Modal>
);
}

View File

@@ -0,0 +1,265 @@
// ============================================================
// ForgotPasswordModal — 忘记密码弹窗
//
// 场景:未登录用户通过手机号 + 短信验证码重置密码
// 复用:可在登录页"忘记密码?"入口使用,也可在其他位置独立调用
//
// API
// - AuthAPI.sendSms({ phone }) 发送短信验证码
// - AuthAPI.resetPassword({ phone, sms_code, new_password }) 重置密码
// ============================================================
import { useState, useCallback } from 'react';
import { Modal, Input, Button, Typography, App } from 'antd';
import {
PhoneOutlined,
SafetyCertificateOutlined,
LockOutlined,
KeyOutlined,
} from '@ant-design/icons';
import { useTheme } from '@/hooks/use-theme.ts';
import { AuthAPI } from '@/services/modules';
import {
validatePhone,
validateSmsCode,
validatePassword,
validateConfirmPassword,
} from '../config/validators';
import { useSmsCooldown } from '../hooks/use-sms-cooldown';
const { Text, Title } = Typography;
interface ForgotPasswordModalProps {
open: boolean;
onClose: () => void;
}
export function ForgotPasswordModal({ open, onClose }: ForgotPasswordModalProps) {
const { isDark } = useTheme();
const { message } = App.useApp();
// ---- 表单字段 ----
const [phone, setPhone] = useState('');
const [smsCode, setSmsCode] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
// ---- 错误信息 ----
const [errors, setErrors] = useState<Record<string, string>>({});
// ---- 提交状态 ----
const [submitting, setSubmitting] = useState(false);
// ---- 短信冷却 ----
const { cooldown, secondsLeft, startCooldown } = useSmsCooldown(60);
// ---- 清除单个字段错误 ----
const clearError = useCallback((name: string) => {
setErrors((p) => {
if (!(name in p)) return p;
const n = { ...p };
delete n[name];
return n;
});
}, []);
// ---- 发送验证码 ----
const handleSendSms = useCallback(() => {
const err = validatePhone(phone);
if (err) {
setErrors((p) => ({ ...p, phone: err }));
return;
}
clearError('phone');
startCooldown();
AuthAPI.sendSms({ phone })
.then(() => message.success('验证码已发送'))
.catch((e) => message.error((e as Error).message || '验证码发送失败'));
}, [phone, startCooldown, clearError, message]);
// ---- 提交重置 ----
const handleSubmit = useCallback(async () => {
// 逐字段校验
const newErrors: Record<string, string> = {};
const phoneErr = validatePhone(phone);
if (phoneErr) newErrors.phone = phoneErr;
const smsErr = validateSmsCode(smsCode);
if (smsErr) newErrors.smsCode = smsErr;
const pwdErr = validatePassword(newPassword);
if (pwdErr) newErrors.newPassword = pwdErr;
const confirmErr = validateConfirmPassword(confirmPassword, newPassword);
if (confirmErr) newErrors.confirmPassword = confirmErr;
if (Object.keys(newErrors).length > 0) {
setErrors(newErrors);
return;
}
setErrors({});
setSubmitting(true);
try {
await AuthAPI.resetPassword({
phone,
sms_code: smsCode,
new_password: newPassword,
});
message.success('密码重置成功,请使用新密码登录');
// 重置表单状态
setPhone('');
setSmsCode('');
setNewPassword('');
setConfirmPassword('');
onClose();
} catch (err) {
message.error((err as Error).message || '密码重置失败');
} finally {
setSubmitting(false);
}
}, [phone, smsCode, newPassword, confirmPassword, onClose, message]);
// ---- 关闭时重置 ----
const handleClose = useCallback(() => {
setErrors({});
onClose();
}, [onClose]);
return (
<Modal
open={open}
onCancel={handleClose}
footer={null}
width={440}
centered
destroyOnHidden={true}
mask={{ closable: false }}
styles={{
body: {
padding: '24px 32px 16px',
background: isDark ? '#1A1730' : '#FFFFFF',
},
}}
>
{/* 标题 */}
<div className="text-center mb-5">
<Title level={5} className="!mb-1 gradient-primary-text">
</Title>
<Text type="secondary" className="text-xs">
</Text>
</div>
{/* 表单 */}
<div className="space-y-4">
{/* 手机号 */}
<div>
<Input
size="large"
prefix={<PhoneOutlined className="opacity-40" />}
placeholder="请输入手机号"
value={phone}
onChange={(e) => {
setPhone(e.target.value);
clearError('phone');
}}
status={errors.phone ? 'error' : undefined}
allowClear
/>
{errors.phone && (
<Text type="danger" className="text-xs ml-1">
{errors.phone}
</Text>
)}
</div>
{/* 短信验证码 */}
<div>
<div className="flex gap-2">
<Input
size="large"
prefix={<SafetyCertificateOutlined className="opacity-40" />}
placeholder="请输入验证码"
value={smsCode}
onChange={(e) => {
setSmsCode(e.target.value);
clearError('smsCode');
}}
status={errors.smsCode ? 'error' : undefined}
className="flex-1"
allowClear
/>
<Button
size="large"
type="primary"
disabled={cooldown}
onClick={handleSendSms}
className="text-xs! shrink-0 px-3!"
>
{cooldown ? `${secondsLeft}s` : '发送验证码'}
</Button>
</div>
{errors.smsCode && (
<Text type="danger" className="text-xs ml-1">
{errors.smsCode}
</Text>
)}
</div>
{/* 新密码 */}
<div>
<Input.Password
size="large"
prefix={<LockOutlined className="opacity-40" />}
placeholder="新密码6~128位"
value={newPassword}
onChange={(e) => {
setNewPassword(e.target.value);
clearError('newPassword');
}}
status={errors.newPassword ? 'error' : undefined}
allowClear
/>
{errors.newPassword && (
<Text type="danger" className="text-xs ml-1">
{errors.newPassword}
</Text>
)}
</div>
{/* 确认新密码 */}
<div>
<Input.Password
size="large"
prefix={<KeyOutlined className="opacity-40" />}
placeholder="请再次输入新密码"
value={confirmPassword}
onChange={(e) => {
setConfirmPassword(e.target.value);
clearError('confirmPassword');
}}
status={errors.confirmPassword ? 'error' : undefined}
allowClear
/>
{errors.confirmPassword && (
<Text type="danger" className="text-xs ml-1">
{errors.confirmPassword}
</Text>
)}
</div>
{/* 提交按钮 */}
<Button
type="primary"
block
size="large"
loading={submitting}
onClick={handleSubmit}
className="h-11! text-base! font-medium! mt-2!"
>
</Button>
</div>
</Modal>
);
}

View File

@@ -22,6 +22,7 @@ interface LoginFormProps {
onSubmit: (values: LoginFormValues) => void;
onRememberChange: (checked: boolean) => void;
onAutoLoginChange: (checked: boolean) => void;
onForgotPassword?: () => void;
}
export function LoginForm({
@@ -33,6 +34,7 @@ export function LoginForm({
onSubmit,
onRememberChange,
onAutoLoginChange,
onForgotPassword,
}: LoginFormProps) {
const [username, setUsername] = useState(initialUsername || '');
const [password, setPassword] = useState(initialPassword || '');
@@ -147,12 +149,7 @@ export function LoginForm({
</Checkbox>
</div>
<a
className="text-xs"
onClick={() => {
/* TODO: 忘记密码流程 */
}}
>
<a className="text-xs" onClick={() => onForgotPassword?.()}>
?
</a>
</div>

View File

@@ -0,0 +1,57 @@
// ============================================================
// useSmsCooldown — 短信验证码发送冷却 Hook
//
// 从 RegisterForm 提取,供所有需要短信验证码的场景复用:
// - 注册RegisterForm
// - 忘记密码ForgotPasswordModal
// ============================================================
import { useState, useCallback, useRef, useEffect } from 'react';
export interface SmsCooldownState {
/** 是否在冷却中 */
cooldown: boolean;
/** 冷却剩余秒数 */
secondsLeft: number;
/** 开始冷却 */
startCooldown: () => void;
}
export function useSmsCooldown(cooldownSeconds: number = 60): SmsCooldownState {
const [cooldown, setCooldown] = useState(false);
const [secondsLeft, setSecondsLeft] = useState(0);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const startCooldown = useCallback(() => {
// 清理之前的计时器(防止重复点击导致多个计时器叠加)
if (timerRef.current) {
clearInterval(timerRef.current);
}
setCooldown(true);
setSecondsLeft(cooldownSeconds);
timerRef.current = setInterval(() => {
setSecondsLeft((prev) => {
if (prev <= 1) {
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
setCooldown(false);
return 0;
}
return prev - 1;
});
}, 1000);
}, [cooldownSeconds]);
// 组件卸载时清理计时器
useEffect(() => {
return () => {
if (timerRef.current) {
clearInterval(timerRef.current);
}
};
}, []);
return { cooldown, secondsLeft, startCooldown };
}

View File

@@ -14,8 +14,9 @@ import { useAppContext } from '@/contexts/app-context.ts';
import { useTheme } from '@/hooks/use-theme.ts';
import { LoginForm } from './components/LoginForm';
import { RegisterForm } from './components/RegisterForm';
import { ForgotPasswordModal } from './components/ForgotPasswordModal';
import { useAuthState } from './hooks/use-auth-state';
import {AuthAPI, LoginResponseBody} from '@/services/modules';
import { AuthAPI, LoginResponseBody } from '@/services/modules';
import { getDeviceId } from '@/utils/device';
import type { LoginFormValues, RegisterFormValues } from './types';
@@ -27,13 +28,13 @@ interface LoginPageProps {
}
export function LoginPage({ open, onClose }: LoginPageProps) {
const { edition, login } = useAppContext();
const { login } = useAppContext();
const { isDark } = useTheme();
const { message } = App.useApp();
const isEnterprise = edition === 'enterprise';
const [activeTab, setActiveTab] = useState<string>('login');
const [submitting, setSubmitting] = useState(false);
const [forgotOpen, setForgotOpen] = useState(false);
const authState = useAuthState();
@@ -43,7 +44,7 @@ export function LoginPage({ open, onClose }: LoginPageProps) {
async (values: LoginFormValues) => {
setSubmitting(true);
try {
const auth:LoginResponseBody = await AuthAPI.login({
const auth: LoginResponseBody = await AuthAPI.login({
username: values.username,
password: values.password,
device_id: getDeviceId(),
@@ -117,6 +118,7 @@ export function LoginPage({ open, onClose }: LoginPageProps) {
onSubmit={handleLogin}
onRememberChange={authState.handleRememberChange}
onAutoLoginChange={authState.handleAutoLoginChange}
onForgotPassword={() => setForgotOpen(true)}
/>
);
@@ -125,37 +127,34 @@ export function LoginPage({ open, onClose }: LoginPageProps) {
);
return (
<Modal
open={open}
onCancel={onClose}
footer={null}
width={440}
centered
destroyOnHidden={false}
mask={{
closable: false,
}}
styles={{
body: {
padding: '24px 32px 16px',
background: isDark ? '#1A1730' : '#FFFFFF',
},
}}
>
{/* 标题 */}
<div className="text-center mb-5">
<Text strong className="text-lg gradient-primary-text block">
· HeiXiu
</Text>
<Text type="secondary" className="text-xs">
{isEnterprise ? '企业版 — 员工账号登录' : '个人版'}
</Text>
</div>
<>
<Modal
open={open}
onCancel={onClose}
footer={null}
width={440}
centered
destroyOnHidden={false}
mask={{
closable: false,
}}
styles={{
body: {
padding: '24px 32px 16px',
background: isDark ? '#1A1730' : '#FFFFFF',
},
}}
>
{/* 标题 */}
<div className="text-center mb-5">
<Text strong className="text-lg gradient-primary-text block">
· HeiXiu
</Text>
<Text type="secondary" className="text-xs">
使·HeiXiu
</Text>
</div>
{/* 个人版 Tab | 企业版仅登录 */}
{isEnterprise ? (
loginTab
) : (
<Tabs
activeKey={activeTab}
onChange={setActiveTab}
@@ -166,7 +165,10 @@ export function LoginPage({ open, onClose }: LoginPageProps) {
{ key: 'register', label: '注册', children: registerTab },
]}
/>
)}
</Modal>
</Modal>
{/* 忘记密码弹窗 */}
<ForgotPasswordModal open={forgotOpen} onClose={() => setForgotOpen(false)} />
</>
);
}

View File

@@ -13,10 +13,11 @@
import { Modal } from 'antd';
import { SettingOutlined } from '@ant-design/icons';
import { useAppContext } from '@/contexts/app-context';
import { ThemeSetting } from './ThemeSetting';
import { StoragePathSetting } from './StoragePathSetting';
import { SystemInfoSetting } from './SystemInfoSetting';
import { UpdateSetting } from './UpdateSetting';
import { ThemeSetting } from './blocks/ThemeSetting';
import { StoragePathSetting } from './blocks/StoragePathSetting';
import { SystemInfoSetting } from './blocks/SystemInfoSetting';
import { UpdateSetting } from './blocks/UpdateSetting';
import { PasswordSetting } from './blocks/PasswordSetting';
interface SettingsPageProps {
open: boolean;
@@ -24,31 +25,32 @@ interface SettingsPageProps {
}
export function SettingsPage({ open, onClose }: SettingsPageProps) {
const { edition } = useAppContext();
const { edition } = useAppContext();
return (
<Modal
open={open}
width={480}
onCancel={onClose}
destroyOnHidden={true}
mask={{ closable: false }}
keyboard={false}
footer={null}
title={
<span className="flex items-center gap-2">
<SettingOutlined />
</span>
}
styles={{ body: { padding: 0, maxHeight: '70vh', overflow: 'auto' } }}
>
<div className="flex flex-col gap-4 p-4">
<ThemeSetting />
<StoragePathSetting edition={edition} />
<SystemInfoSetting />
<UpdateSetting />
</div>
</Modal>
);
return (
<Modal
open={open}
width={480}
onCancel={onClose}
destroyOnHidden={true}
mask={{ closable: false }}
keyboard={false}
footer={null}
title={
<span className="flex items-center gap-2">
<SettingOutlined />
</span>
}
styles={{ body: { padding: 0, maxHeight: '70vh', overflow: 'auto' } }}
>
<div className="flex flex-col gap-4 p-4">
<ThemeSetting />
<StoragePathSetting edition={edition} />
<SystemInfoSetting />
<PasswordSetting />
<UpdateSetting />
</div>
</Modal>
);
}

View File

@@ -0,0 +1,52 @@
// ============================================================
// PasswordSetting — 账号安全区块(密码操作入口)
//
// 提供两种密码操作,用户自选:
// 1. 修改密码 — 通过当前密码验证后设置新密码
// 2. 忘记密码 — 通过手机短信验证码重置密码
// ============================================================
import { useState } from 'react';
import { Card, Button, Typography } from 'antd';
import { LockOutlined, PhoneOutlined } from '@ant-design/icons';
import { ChangePasswordModal } from '@/pages/login/components/ChangePasswordModal';
import { ForgotPasswordModal } from '@/pages/login/components/ForgotPasswordModal';
const { Text } = Typography;
export function PasswordSetting() {
const [changePwdOpen, setChangePwdOpen] = useState(false);
const [forgotPwdOpen, setForgotPwdOpen] = useState(false);
return (
<>
<Card size="small" title="账号安全" className="rounded-md!">
<div className="flex flex-col gap-3">
{/* 修改密码:通过旧密码 */}
<div className="flex items-center justify-between">
<Text type="secondary" className="text-sm">
</Text>
<Button icon={<LockOutlined />} onClick={() => setChangePwdOpen(true)}>
</Button>
</div>
{/* 忘记密码:通过手机验证码重置 */}
<div className="flex items-center justify-between">
<Text type="secondary" className="text-sm">
</Text>
<Button icon={<PhoneOutlined />} onClick={() => setForgotPwdOpen(true)}>
</Button>
</div>
</div>
</Card>
<ChangePasswordModal open={changePwdOpen} onClose={() => setChangePwdOpen(false)} />
<ForgotPasswordModal open={forgotPwdOpen} onClose={() => setForgotPwdOpen(false)} />
</>
);
}

View File

@@ -159,8 +159,14 @@ function PathRow({
}
export function StoragePathSetting({ edition }: StoragePathSettingProps) {
const { outputPath, teamRepoPath, setOutputPath, setTeamRepoPath, clearOutputPath, clearTeamRepoPath } =
useSettings();
const {
outputPath,
teamRepoPath,
setOutputPath,
setTeamRepoPath,
clearOutputPath,
clearTeamRepoPath,
} = useSettings();
const isEnterprise = edition === 'enterprise';

View File

@@ -65,15 +65,8 @@ function renderChangelog(markdown: string) {
}
export function UpdateSetting() {
const {
status,
info,
progress,
error,
checkForUpdates,
downloadUpdate,
installUpdate,
} = useUpdater();
const { status, info, progress, error, checkForUpdates, downloadUpdate, installUpdate } =
useUpdater();
const [detailOpen, setDetailOpen] = useState(false);
@@ -210,15 +203,15 @@ export function UpdateSetting() {
title={`更新详情 — v${info?.version || ''}`}
open={detailOpen}
onCancel={() => setDetailOpen(false)}
footer={
<Button onClick={() => setDetailOpen(false)}></Button>
}
footer={<Button onClick={() => setDetailOpen(false)}></Button>}
width={520}
>
<div className="max-h-96 overflow-y-auto py-2">
{info?.releaseNotes
? renderChangelog(info.releaseNotes)
: <Text type="secondary"></Text>}
{info?.releaseNotes ? (
renderChangelog(info.releaseNotes)
) : (
<Text type="secondary"></Text>
)}
</div>
</Modal>
</Card>

View File

@@ -3,7 +3,3 @@
// ============================================================
export { SettingsPage } from './SettingsPage';
export { ThemeSetting } from './ThemeSetting';
export { StoragePathSetting } from './StoragePathSetting';
export { SystemInfoSetting } from './SystemInfoSetting';
export { UpdateSetting } from './UpdateSetting';