feat: 构建系统重构 + VCHSM 架构迁移 + 右键菜单修复 + 尺寸参数统一
## 构建、打包与分发系统重构 (build/) - 构建系统收敛至 build/ 目录:build.mjs + electron-builder.js + 6 个脚本 - electron-builder 配置从 package.json 提取为 build/electron-builder.js - 新增 --edition (personal/enterprise) 和 --channel (stable/beta/alpha) 参数 - OSS 路径按渠道隔离 - 新增 pre-build-check.mjs:Git/CHANGELOG/版本/环境变量校验 - 新增 bump-version.mjs:版本号管理 + CHANGELOG 自动生成 - updater.ts 发送 channel/edition/deviceFingerprint - 新增 src/shared/utils/rollout.ts 灰度测试工具 - 新增 10 条 NPM 脚本 ## VCHSM 五层架构迁移 - 7 个业务模块 + ~500 处导入路径同步 ## 修复 - MediaCardContextMenu 改用共享 ContextMenu 组件 - ComboBox 尺寸参数显示统一 (size-format.ts) ## 文档 - UpdateA.md / build/README.md / README.md / .env.example 更新 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
191
src/modules/auth/views/components/ChangePasswordModal.tsx
Normal file
191
src/modules/auth/views/components/ChangePasswordModal.tsx
Normal file
@@ -0,0 +1,191 @@
|
||||
// ============================================================
|
||||
// ChangePasswordModal — 修改密码弹窗(已登录用户)
|
||||
//
|
||||
// 场景:已登录用户通过旧密码验证后设置新密码
|
||||
// 复用:可在设置页"修改密码"入口使用,也可在其他位置独立调用
|
||||
//
|
||||
// API:
|
||||
// - AuthAPI.changePassword({ current_password, new_password })
|
||||
// ============================================================
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { logger } from '@/shared/utils/bus';
|
||||
import { Modal, Input, Button, Typography, App } from 'antd';
|
||||
import { LockOutlined, KeyOutlined, SafetyOutlined } from '@ant-design/icons';
|
||||
|
||||
import { useTheme } from '@/app/providers/ThemeProvider';
|
||||
import { AuthAPI } from '@/modules/auth/controllers/auth-api';
|
||||
import { validatePassword, validateConfirmPassword, runFieldChecks } from '../../settings/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 () => {
|
||||
if (runFieldChecks(setErrors,
|
||||
{ field: 'currentPassword', error: !currentPassword ? '请输入当前密码' : null },
|
||||
{ field: 'newPassword', error: validatePassword(newPassword) },
|
||||
{ field: 'confirmPassword', error: validateConfirmPassword(confirmPassword, newPassword) },
|
||||
)) return;
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await AuthAPI.changePassword({
|
||||
current_password: currentPassword,
|
||||
new_password: newPassword,
|
||||
});
|
||||
message.success('密码修改成功');
|
||||
// 重置表单
|
||||
setCurrentPassword('');
|
||||
setNewPassword('');
|
||||
setConfirmPassword('');
|
||||
onClose();
|
||||
} catch (err) {
|
||||
logger.warn('auth', '密码修改失败', err instanceof Error ? err : undefined);
|
||||
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>
|
||||
);
|
||||
}
|
||||
257
src/modules/auth/views/components/ForgotPasswordModal.tsx
Normal file
257
src/modules/auth/views/components/ForgotPasswordModal.tsx
Normal file
@@ -0,0 +1,257 @@
|
||||
// ============================================================
|
||||
// ForgotPasswordModal — 忘记密码弹窗
|
||||
//
|
||||
// 场景:未登录用户通过手机号 + 短信验证码重置密码
|
||||
// 复用:可在登录页"忘记密码?"入口使用,也可在其他位置独立调用
|
||||
//
|
||||
// API:
|
||||
// - AuthAPI.sendSms({ phone }) 发送短信验证码
|
||||
// - AuthAPI.resetPassword({ phone, sms_code, new_password }) 重置密码
|
||||
// ============================================================
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { logger } from '@/shared/utils/bus';
|
||||
import { Modal, Input, Button, Typography, App } from 'antd';
|
||||
import {
|
||||
PhoneOutlined,
|
||||
SafetyCertificateOutlined,
|
||||
LockOutlined,
|
||||
KeyOutlined,
|
||||
} from '@ant-design/icons';
|
||||
|
||||
import { useTheme } from '@/app/providers/ThemeProvider';
|
||||
import { AuthAPI } from '@/modules/auth/controllers/auth-api';
|
||||
import {
|
||||
validatePhone,
|
||||
validateSmsCode,
|
||||
validatePassword,
|
||||
validateConfirmPassword,
|
||||
runFieldChecks,
|
||||
} from '../../settings/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) => { logger.warn('auth', '忘记密码-验证码发送失败', e instanceof Error ? e : undefined); message.error((e as Error).message || '验证码发送失败'); });
|
||||
}, [phone, startCooldown, clearError, message]);
|
||||
|
||||
// ---- 提交重置 ----
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (runFieldChecks(setErrors,
|
||||
{ field: 'phone', error: validatePhone(phone) },
|
||||
{ field: 'smsCode', error: validateSmsCode(smsCode) },
|
||||
{ field: 'newPassword', error: validatePassword(newPassword) },
|
||||
{ field: 'confirmPassword', error: validateConfirmPassword(confirmPassword, newPassword) },
|
||||
)) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await AuthAPI.resetPassword({
|
||||
phone,
|
||||
sms_code: smsCode,
|
||||
new_password: newPassword,
|
||||
});
|
||||
message.success('密码重置成功,请使用新密码登录');
|
||||
// 重置表单状态
|
||||
setPhone('');
|
||||
setSmsCode('');
|
||||
setNewPassword('');
|
||||
setConfirmPassword('');
|
||||
onClose();
|
||||
} catch (err) {
|
||||
logger.warn('auth', '密码重置失败', err instanceof Error ? err : undefined);
|
||||
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>
|
||||
);
|
||||
}
|
||||
220
src/modules/auth/views/components/LoginForm.tsx
Normal file
220
src/modules/auth/views/components/LoginForm.tsx
Normal file
@@ -0,0 +1,220 @@
|
||||
// ============================================================
|
||||
// LoginForm — 登录表单
|
||||
// ============================================================
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { Input, Button, Checkbox, Typography } from 'antd';
|
||||
import { UserOutlined, LockOutlined, EyeTwoTone, EyeInvisibleOutlined } from '@ant-design/icons';
|
||||
|
||||
import { loginFields } from '../../settings/login-fields';
|
||||
import { validateForm } from '../../settings/validators';
|
||||
import { LegalTextModal } from '@/shared/components/LegalTextModal';
|
||||
import type { LoginFormValues } from '../../model/types';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
interface LoginFormProps {
|
||||
initialUsername?: string;
|
||||
initialPassword?: string;
|
||||
initialRemember?: boolean;
|
||||
initialAutoLogin?: boolean;
|
||||
submitting?: boolean;
|
||||
onSubmit: (values: LoginFormValues) => void;
|
||||
onRememberChange: (checked: boolean) => void;
|
||||
onAutoLoginChange: (checked: boolean) => void;
|
||||
onForgotPassword?: () => void;
|
||||
}
|
||||
|
||||
export function LoginForm({
|
||||
initialUsername,
|
||||
initialPassword,
|
||||
initialRemember = false,
|
||||
initialAutoLogin = false,
|
||||
submitting = false,
|
||||
onSubmit,
|
||||
onRememberChange,
|
||||
onAutoLoginChange,
|
||||
onForgotPassword,
|
||||
}: LoginFormProps) {
|
||||
const [username, setUsername] = useState(initialUsername || '');
|
||||
const [password, setPassword] = useState(initialPassword || '');
|
||||
const [remember, setRemember] = useState(initialRemember);
|
||||
const [autoLogin, setAutoLogin] = useState(initialAutoLogin);
|
||||
const [agreed, setAgreed] = useState(true);
|
||||
const [errors, setErrors] = useState<Record<string, string[]>>({});
|
||||
|
||||
// 协议弹窗
|
||||
const [legalType, setLegalType] = useState<'service' | 'points' | null>(null);
|
||||
|
||||
const clearError = (name: string) =>
|
||||
setErrors((p) => {
|
||||
const n = { ...p };
|
||||
delete n[name];
|
||||
return n;
|
||||
});
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
const values = { username, password };
|
||||
const fieldErrors = validateForm(values, loginFields);
|
||||
if (Object.keys(fieldErrors).length > 0) {
|
||||
setErrors(fieldErrors);
|
||||
return;
|
||||
}
|
||||
if (!agreed) {
|
||||
setErrors({ _agreement: ['请先阅读并同意用户协议'] });
|
||||
return;
|
||||
}
|
||||
setErrors({});
|
||||
onSubmit({ username, password, remember, autoLogin: remember && autoLogin });
|
||||
}, [username, password, remember, autoLogin, agreed, onSubmit]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-5">
|
||||
{/* 用户名 */}
|
||||
<div>
|
||||
<Input
|
||||
size="large"
|
||||
prefix={<UserOutlined className="opacity-40" />}
|
||||
placeholder="用户名"
|
||||
value={username}
|
||||
onChange={(e) => {
|
||||
setUsername(e.target.value);
|
||||
clearError('username');
|
||||
}}
|
||||
status={errors.username ? 'error' : undefined}
|
||||
allowClear
|
||||
/>
|
||||
{errors.username && (
|
||||
<Text type="danger" className="text-xs ml-1">
|
||||
{errors.username[0]}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 密码 */}
|
||||
<div>
|
||||
<Input.Password
|
||||
size="large"
|
||||
prefix={<LockOutlined className="opacity-40" />}
|
||||
placeholder="密码"
|
||||
value={password}
|
||||
onChange={(e) => {
|
||||
setPassword(e.target.value);
|
||||
clearError('password');
|
||||
}}
|
||||
iconRender={(visible) => (visible ? <EyeTwoTone /> : <EyeInvisibleOutlined />)}
|
||||
onPressEnter={handleSubmit}
|
||||
status={errors.password ? 'error' : undefined}
|
||||
allowClear
|
||||
/>
|
||||
{errors.password && (
|
||||
<Text type="danger" className="text-xs ml-1">
|
||||
{errors.password[0]}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 记住密码 / 自动登录 / 忘记密码 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1">
|
||||
<Checkbox
|
||||
checked={remember}
|
||||
onChange={(e) => {
|
||||
const checked = e.target.checked;
|
||||
setRemember(checked);
|
||||
onRememberChange(checked);
|
||||
// 取消记住密码 → 同时取消自动登录
|
||||
if (!checked) {
|
||||
setAutoLogin(false);
|
||||
onAutoLoginChange(false);
|
||||
}
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
记住密码
|
||||
</Checkbox>
|
||||
<Checkbox
|
||||
checked={autoLogin}
|
||||
onChange={(e) => {
|
||||
const checked = e.target.checked;
|
||||
setAutoLogin(checked);
|
||||
onAutoLoginChange(checked);
|
||||
// 勾选自动登录 → 同时勾选记住密码
|
||||
if (checked && !remember) {
|
||||
setRemember(true);
|
||||
onRememberChange(true);
|
||||
}
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
自动登录
|
||||
</Checkbox>
|
||||
</div>
|
||||
<a className="text-xs" onClick={() => onForgotPassword?.()}>
|
||||
忘记密码?
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* 用户协议 */}
|
||||
<div>
|
||||
<Checkbox
|
||||
checked={agreed}
|
||||
onChange={(e) => {
|
||||
setAgreed(e.target.checked);
|
||||
clearError('_agreement');
|
||||
}}
|
||||
onKeyDown={(event)=>{
|
||||
if (event.key === 'Enter' || event.keyCode === 13) {
|
||||
setAgreed(!agreed);
|
||||
}
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
已阅读并同意
|
||||
<a
|
||||
className="text-xs mx-0.5"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setLegalType('service');
|
||||
}}
|
||||
>
|
||||
《用户服务协议》
|
||||
</a>
|
||||
和
|
||||
<a
|
||||
className="text-xs mx-0.5"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setLegalType('points');
|
||||
}}
|
||||
>
|
||||
《积分充值服务协议》
|
||||
</a>
|
||||
</Checkbox>
|
||||
{errors._agreement && (
|
||||
<Text type="danger" className="text-xs block mt-1">
|
||||
{errors._agreement[0]}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 登录按钮 */}
|
||||
<Button
|
||||
type="primary"
|
||||
block
|
||||
size="large"
|
||||
icon={<UserOutlined />}
|
||||
loading={submitting}
|
||||
onClick={handleSubmit}
|
||||
className="h-11! text-base! font-medium! mt-2!"
|
||||
>
|
||||
登录
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 协议弹窗 */}
|
||||
{legalType && <LegalTextModal open type={legalType} onClose={() => setLegalType(null)} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
262
src/modules/auth/views/components/RegisterForm.tsx
Normal file
262
src/modules/auth/views/components/RegisterForm.tsx
Normal file
@@ -0,0 +1,262 @@
|
||||
// ============================================================
|
||||
// RegisterForm — 注册表单(仅个人版)
|
||||
// ============================================================
|
||||
|
||||
import React, { useState, useCallback, useRef } from 'react';
|
||||
import { Input, Button, Checkbox, Typography } from 'antd';
|
||||
|
||||
import { LegalTextModal } from '@/shared/components/LegalTextModal';
|
||||
import {
|
||||
UserOutlined,
|
||||
LockOutlined,
|
||||
PhoneOutlined,
|
||||
SafetyCertificateOutlined,
|
||||
LaptopOutlined,
|
||||
TagOutlined,
|
||||
SmileOutlined,
|
||||
GiftOutlined,
|
||||
} from '@ant-design/icons';
|
||||
|
||||
import { registerFields } from '../../settings/register-fields';
|
||||
import { validateForm } from '../../settings/validators';
|
||||
import type { RegisterFormValues } from '../../model/types';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
interface RegisterFormProps {
|
||||
submitting?: boolean;
|
||||
onSubmit: (values: RegisterFormValues) => void;
|
||||
onSendSms?: (phone: string) => void;
|
||||
}
|
||||
|
||||
/** 字段名 → 图标映射 */
|
||||
const iconMap: Record<string, React.ReactNode> = {
|
||||
username: <UserOutlined className="opacity-40" />,
|
||||
password: <LockOutlined className="opacity-40" />,
|
||||
confirmPassword: <LockOutlined className="opacity-40" />,
|
||||
phone: <PhoneOutlined className="opacity-40" />,
|
||||
sms_code: <SafetyCertificateOutlined className="opacity-40" />,
|
||||
device_id: <LaptopOutlined className="opacity-40" />,
|
||||
device_label: <TagOutlined className="opacity-40" />,
|
||||
display_name: <SmileOutlined className="opacity-40" />,
|
||||
referral_code: <GiftOutlined className="opacity-40" />,
|
||||
};
|
||||
|
||||
export function RegisterForm({ submitting = false, onSubmit, onSendSms }: RegisterFormProps) {
|
||||
const [values, setValues] = useState<Record<string, string>>({});
|
||||
const [agreed, setAgreed] = useState(false);
|
||||
const [errors, setErrors] = useState<Record<string, string[]>>({});
|
||||
const [legalType, setLegalType] = useState<'service' | 'points' | null>(null);
|
||||
|
||||
// 验证码冷却
|
||||
const [smsCooldown, setSmsCooldown] = useState(false);
|
||||
const [smsSeconds, setSmsSeconds] = useState(0);
|
||||
const smsTimer = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
// 字段值变更
|
||||
const handleChange = useCallback((name: string, value: string) => {
|
||||
setValues((p) => ({ ...p, [name]: value }));
|
||||
setErrors((p) => {
|
||||
const n = { ...p };
|
||||
delete n[name];
|
||||
return n;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 发送验证码
|
||||
const handleSendSms = useCallback(() => {
|
||||
const phone = values.phone || '';
|
||||
const phoneConfig = registerFields.find((f) => f.name === 'phone')!;
|
||||
const phoneErrors = validateForm({ phone }, [phoneConfig]);
|
||||
if (Object.keys(phoneErrors).length > 0) {
|
||||
setErrors((p) => ({ ...p, phone: phoneErrors.phone }));
|
||||
return;
|
||||
}
|
||||
setSmsCooldown(true);
|
||||
setSmsSeconds(60);
|
||||
smsTimer.current = setInterval(() => {
|
||||
setSmsSeconds((prev) => {
|
||||
if (prev <= 1) {
|
||||
clearInterval(smsTimer.current!);
|
||||
setSmsCooldown(false);
|
||||
return 0;
|
||||
}
|
||||
return prev - 1;
|
||||
});
|
||||
}, 1000);
|
||||
onSendSms?.(phone);
|
||||
}, [values.phone, onSendSms]);
|
||||
|
||||
// 提交
|
||||
const handleSubmit = useCallback(() => {
|
||||
// 跨字段校验(内联避免每次渲染创建新引用致使用 useCallback 失效)
|
||||
const crossValidators = {
|
||||
confirmPassword: (val: string) => {
|
||||
if (!val) return '请再次输入密码';
|
||||
if (val !== values.password) return '两次密码不一致';
|
||||
return null;
|
||||
},
|
||||
};
|
||||
const fieldErrors = validateForm(values, registerFields, crossValidators);
|
||||
if (Object.keys(fieldErrors).length > 0) {
|
||||
setErrors(fieldErrors);
|
||||
return;
|
||||
}
|
||||
if (!agreed) {
|
||||
setErrors({ _agreement: ['请先阅读并同意用户协议'] });
|
||||
return;
|
||||
}
|
||||
setErrors({});
|
||||
onSubmit(values as unknown as RegisterFormValues);
|
||||
}, [values, agreed, onSubmit]);
|
||||
|
||||
// 渲染单个字段
|
||||
const renderField = (field: (typeof registerFields)[number]) => {
|
||||
const val = values[field.name] || '';
|
||||
const err = errors[field.name];
|
||||
const isPassword = field.inputType === 'password';
|
||||
const hasSms = field.extraAction === 'send-sms';
|
||||
|
||||
return (
|
||||
<div key={field.name}>
|
||||
{hasSms ? (
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
size="large"
|
||||
prefix={iconMap[field.name]}
|
||||
placeholder={field.placeholder}
|
||||
value={val}
|
||||
onChange={(e) => handleChange(field.name, e.target.value)}
|
||||
status={err ? 'error' : undefined}
|
||||
className="flex-1"
|
||||
allowClear
|
||||
/>
|
||||
<Button
|
||||
size="large"
|
||||
type="primary"
|
||||
disabled={smsCooldown}
|
||||
onClick={handleSendSms}
|
||||
className="text-xs! shrink-0 px-3!"
|
||||
>
|
||||
{smsCooldown ? `${smsSeconds}s` : '发送验证码'}
|
||||
</Button>
|
||||
</div>
|
||||
) : isPassword ? (
|
||||
<Input.Password
|
||||
size="large"
|
||||
prefix={iconMap[field.name]}
|
||||
placeholder={field.placeholder}
|
||||
value={val}
|
||||
onChange={(e) => handleChange(field.name, e.target.value)}
|
||||
status={err ? 'error' : undefined}
|
||||
allowClear
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
size="large"
|
||||
prefix={iconMap[field.name]}
|
||||
placeholder={field.placeholder}
|
||||
value={val}
|
||||
onChange={(e) => handleChange(field.name, e.target.value)}
|
||||
status={err ? 'error' : undefined}
|
||||
allowClear
|
||||
/>
|
||||
)}
|
||||
{err && (
|
||||
<Text type="danger" className="text-xs ml-1">
|
||||
{err[0]}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const requiredFields = registerFields.filter((f) => !f.optional);
|
||||
const optionalFields = registerFields.filter((f) => f.optional);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-4">
|
||||
{/* 必填字段 */}
|
||||
{requiredFields.map(renderField)}
|
||||
|
||||
{/* 选填字段 */}
|
||||
{optionalFields.length > 0 && (
|
||||
<>
|
||||
<Text type="secondary" className="text-xs block mt-5! mb-1!">
|
||||
—— 以下为选填信息 ——
|
||||
</Text>
|
||||
{optionalFields.map(renderField)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 邀请码说明 */}
|
||||
<Text type="secondary" className="text-xs! block mt-1!">
|
||||
填写邀请码可触发邀请佣金逻辑,审核通过后获得奖励
|
||||
</Text>
|
||||
|
||||
{/* 用户协议 */}
|
||||
<div className="mt-5!">
|
||||
<Checkbox
|
||||
checked={agreed}
|
||||
onChange={(e) => {
|
||||
setAgreed(e.target.checked);
|
||||
setErrors((p) => {
|
||||
const n = { ...p };
|
||||
delete n._agreement;
|
||||
return n;
|
||||
});
|
||||
}}
|
||||
onKeyDown={(e)=>{
|
||||
if(e.key==="Enter" || e.keyCode == 13) {
|
||||
setAgreed(!agreed);
|
||||
}
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
已阅读并同意
|
||||
<a
|
||||
className="text-xs mx-0.5"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setLegalType('service');
|
||||
}}
|
||||
>
|
||||
《用户服务协议》
|
||||
</a>
|
||||
和
|
||||
<a
|
||||
className="text-xs mx-0.5"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setLegalType('points');
|
||||
}}
|
||||
>
|
||||
《积分充值服务协议》
|
||||
</a>
|
||||
</Checkbox>
|
||||
{errors._agreement && (
|
||||
<Text type="danger" className="text-xs block mt-1">
|
||||
{errors._agreement[0]}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 注册按钮 */}
|
||||
<Button
|
||||
type="primary"
|
||||
block
|
||||
size="large"
|
||||
loading={submitting}
|
||||
onClick={handleSubmit}
|
||||
className="h-11! text-base! font-medium! mt-2!"
|
||||
>
|
||||
注册
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 协议弹窗 */}
|
||||
{legalType && <LegalTextModal open type={legalType} onClose={() => setLegalType(null)} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user