feat: 初始化船长·HeiXiu 桌面应用项目(错误是svg的问题)
Electron + React 19 + TypeScript + Ant Design 6 + Tailwind CSS 4 【核心架构】 - Electron 主进程(窗口管理、平台检测、图标适配) - Vite 构建链(双产物 dist/ + dist-electron/) - 共享层 shared/(类型、常量、工具函数) - IPC 通信框架 + 安全守卫 【主题系统】 - 蓝紫渐变主题(亮色/暗色双模式) - Ant Design ConfigProvider 适配(lightAlgorithm / darkAlgorithm) - Tailwind CSS 4 @theme 指令 + CSS 变量 - 设计令牌三处同步(tokens.ts / globals.css / antd-theme.ts) 【导航栏】 - 自定义 H5 导航栏替代系统菜单 - 个人版/企业版双布局 - 未登录拦截 + 事件总线驱动登录弹窗 【登录注册】 - Modal 弹层(Portal)+ Tabs 切换 - 登录/注册表单动态字段渲染 - 记住密码 / 自动登录 / 用户协议 【更新系统】 - 自定义 API 版本检查(替代 electron-updater generic provider) - 手动下载 + SHA512 校验 + spawn NSIS 安装 - 渲染进程更新状态机 Hook(useUpdater) - 检查更新按钮 + 下载进度展示 【构建工具链】 - build.mjs 构建总管(--win/--mac/--linux --personal/--enterprise --build/--clean/--sign/--upload/--publish) - scripts/ 自动化脚本(clean / generate-update-info / upload-oss / publish-release) - electron-builder NSIS 安装器(可选路径、多版本打包) - update-info.json 自动生成(fileSize/sha512/changelog/releaseDate) 【代码质量】 - ESLint + TypeScript strict + Prettier - husky + lint-staged(提交前自动检查) - commitlint(规范提交信息) - Playwright E2E + Vitest 单元测试框架 - rollup-plugin-visualizer 打包体积分析 【文档】 - README.md(快速开始 + 目录结构 + 命令速查) - UpdateA.md(发布手册:API/签名/发版流程/数据库) - CHANGELOG.md(更新日志) - .env.example(构建环境变量模板)
This commit is contained in:
92
src/pages/login/components/FormField.tsx
Normal file
92
src/pages/login/components/FormField.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
// ============================================================
|
||||
// FormField — 单个表单字段(数据驱动渲染)
|
||||
// 根据 FieldConfig 自动选择 Input / Input.Password 及附加操作按钮
|
||||
// ============================================================
|
||||
|
||||
import { useCallback } from 'react';
|
||||
import { Input, Button, Form } from 'antd';
|
||||
import type { FieldConfig } from '../types';
|
||||
|
||||
interface FormFieldProps {
|
||||
config: FieldConfig;
|
||||
value: string;
|
||||
onChange: (name: string, value: string) => void;
|
||||
errors: string[];
|
||||
/** 发送短信验证码回调 */
|
||||
onSendSms?: () => void;
|
||||
/** 验证码按钮冷却中 */
|
||||
smsCooldown?: boolean;
|
||||
/** 冷却剩余秒数 */
|
||||
smsSecondsLeft?: number;
|
||||
}
|
||||
|
||||
export function FormField({
|
||||
config,
|
||||
value,
|
||||
onChange,
|
||||
errors,
|
||||
onSendSms,
|
||||
smsCooldown,
|
||||
smsSecondsLeft,
|
||||
}: FormFieldProps) {
|
||||
const hasError = errors.length > 0;
|
||||
const isPassword = config.inputType === 'password';
|
||||
|
||||
const handleChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
onChange(config.name, e.target.value);
|
||||
},
|
||||
[config.name, onChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<Form.Item
|
||||
label={
|
||||
<span className="text-xs">
|
||||
{config.label}
|
||||
{config.optional && <span className="text-gray-400 ml-1">(选填)</span>}
|
||||
</span>
|
||||
}
|
||||
validateStatus={hasError ? 'error' : undefined}
|
||||
help={hasError ? errors[0] : undefined}
|
||||
className="!mb-3"
|
||||
>
|
||||
{isPassword ? (
|
||||
<Input.Password
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
placeholder={config.placeholder}
|
||||
size="middle"
|
||||
allowClear
|
||||
/>
|
||||
) : config.extraAction === 'send-sms' ? (
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
placeholder={config.placeholder}
|
||||
size="middle"
|
||||
className="flex-1"
|
||||
allowClear
|
||||
/>
|
||||
<Button
|
||||
size="middle"
|
||||
disabled={smsCooldown}
|
||||
onClick={onSendSms}
|
||||
className="shrink-0 text-xs"
|
||||
>
|
||||
{smsCooldown ? `${smsSecondsLeft}s` : '发送验证码'}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Input
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
placeholder={config.placeholder}
|
||||
size="middle"
|
||||
allowClear
|
||||
/>
|
||||
)}
|
||||
</Form.Item>
|
||||
);
|
||||
}
|
||||
216
src/pages/login/components/LoginForm.tsx
Normal file
216
src/pages/login/components/LoginForm.tsx
Normal file
@@ -0,0 +1,216 @@
|
||||
// ============================================================
|
||||
// LoginForm — 登录表单
|
||||
// ============================================================
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { Input, Button, Checkbox, Typography } from 'antd';
|
||||
import { UserOutlined, LockOutlined } from '@ant-design/icons';
|
||||
|
||||
import { loginFields } from '../config/login-fields';
|
||||
import { validateForm } from '../config/validators';
|
||||
import { LegalTextModal } from '@/components/common/LegalTextModal';
|
||||
import type { LoginFormValues } from '../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;
|
||||
}
|
||||
|
||||
export function LoginForm({
|
||||
initialUsername,
|
||||
initialPassword,
|
||||
initialRemember = false,
|
||||
initialAutoLogin = false,
|
||||
submitting = false,
|
||||
onSubmit,
|
||||
onRememberChange,
|
||||
onAutoLoginChange,
|
||||
}: 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(false);
|
||||
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');
|
||||
}}
|
||||
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={() => {
|
||||
/* TODO: 忘记密码流程 */
|
||||
}}
|
||||
>
|
||||
忘记密码?
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* 用户协议 */}
|
||||
<div>
|
||||
<Checkbox
|
||||
checked={agreed}
|
||||
onChange={(e) => {
|
||||
setAgreed(e.target.checked);
|
||||
clearError('_agreement');
|
||||
}}
|
||||
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)} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
258
src/pages/login/components/RegisterForm.tsx
Normal file
258
src/pages/login/components/RegisterForm.tsx
Normal file
@@ -0,0 +1,258 @@
|
||||
// ============================================================
|
||||
// RegisterForm — 注册表单(仅个人版)
|
||||
// ============================================================
|
||||
|
||||
import { useState, useCallback, useRef } from 'react';
|
||||
import { Input, Button, Checkbox, Typography } from 'antd';
|
||||
|
||||
import { LegalTextModal } from '@/components/common/LegalTextModal';
|
||||
import {
|
||||
UserOutlined,
|
||||
LockOutlined,
|
||||
PhoneOutlined,
|
||||
SafetyCertificateOutlined,
|
||||
LaptopOutlined,
|
||||
TagOutlined,
|
||||
SmileOutlined,
|
||||
GiftOutlined,
|
||||
} from '@ant-design/icons';
|
||||
|
||||
import { registerFields } from '../config/register-fields';
|
||||
import { validateForm } from '../config/validators';
|
||||
import type { RegisterFormValues } from '../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 crossValidators = {
|
||||
confirmPassword: (val: string) => {
|
||||
if (!val) return '请再次输入密码';
|
||||
if (val !== values.password) return '两次密码不一致';
|
||||
return null;
|
||||
},
|
||||
};
|
||||
|
||||
// 提交
|
||||
const handleSubmit = useCallback(() => {
|
||||
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;
|
||||
});
|
||||
}}
|
||||
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)} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
40
src/pages/login/config/login-fields.ts
Normal file
40
src/pages/login/config/login-fields.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
// ============================================================
|
||||
// 登录表单 — 字段配置(纯数据,驱动 UI 渲染)
|
||||
// 修改校验规则或字段属性只需改此文件,无需动 UI 代码
|
||||
// ============================================================
|
||||
|
||||
import type { FieldConfig } from '../types';
|
||||
|
||||
export const loginFields: FieldConfig[] = [
|
||||
{
|
||||
name: 'username',
|
||||
label: '用户名',
|
||||
placeholder: '请输入用户名',
|
||||
inputType: 'text',
|
||||
rules: [
|
||||
{ required: true, message: '请输入用户名' },
|
||||
{ minLen: 3, message: '用户名至少 3 位' },
|
||||
{ maxLen: 64, message: '用户名最多 64 位' },
|
||||
{ pattern: /^[a-zA-Z0-9\-_.]+$/, message: '仅允许字母、数字、-、_、.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'password',
|
||||
label: '密码',
|
||||
placeholder: '请输入密码',
|
||||
inputType: 'password',
|
||||
rules: [
|
||||
{ required: true, message: '请输入密码' },
|
||||
{ minLen: 6, message: '密码至少 6 位' },
|
||||
{ maxLen: 128, message: '密码最多 128 位' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/** 登录表单初始值 */
|
||||
export const loginInitialValues = {
|
||||
username: '',
|
||||
password: '',
|
||||
remember: false,
|
||||
autoLogin: false,
|
||||
};
|
||||
116
src/pages/login/config/register-fields.ts
Normal file
116
src/pages/login/config/register-fields.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
// ============================================================
|
||||
// 注册表单 — 字段配置(纯数据,驱动 UI 渲染)
|
||||
// 修改校验规则或字段属性只需改此文件,无需动 UI 代码
|
||||
// ============================================================
|
||||
|
||||
import type { FieldConfig } from '../types';
|
||||
|
||||
export const registerFields: FieldConfig[] = [
|
||||
{
|
||||
name: 'username',
|
||||
label: '用户名',
|
||||
placeholder: '3~64位,字母/数字/-/_/.',
|
||||
inputType: 'text',
|
||||
rules: [
|
||||
{ required: true, message: '请输入用户名' },
|
||||
{ minLen: 3, message: '用户名至少 3 位' },
|
||||
{ maxLen: 64, message: '用户名最多 64 位' },
|
||||
{ pattern: /^[a-zA-Z0-9\-_.]+$/, message: '仅允许字母、数字、-、_、.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'password',
|
||||
label: '密码',
|
||||
placeholder: '6~128位',
|
||||
inputType: 'password',
|
||||
rules: [
|
||||
{ required: true, message: '请输入密码' },
|
||||
{ minLen: 6, message: '密码至少 6 位' },
|
||||
{ maxLen: 128, message: '密码最多 128 位' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'confirmPassword',
|
||||
label: '确认密码',
|
||||
placeholder: '请再次输入密码',
|
||||
inputType: 'password',
|
||||
rules: [{ required: true, message: '请再次输入密码' }],
|
||||
},
|
||||
{
|
||||
name: 'phone',
|
||||
label: '手机号',
|
||||
placeholder: '请输入手机号',
|
||||
inputType: 'tel',
|
||||
rules: [
|
||||
{ required: true, message: '请输入手机号' },
|
||||
{ minLen: 5, message: '手机号至少 5 位' },
|
||||
{ maxLen: 32, message: '手机号最多 32 位' },
|
||||
{ pattern: /^\+?[0-9]+$/, message: '手机号格式不正确' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'sms_code',
|
||||
label: '短信验证码',
|
||||
placeholder: '请输入验证码',
|
||||
inputType: 'text',
|
||||
rules: [
|
||||
{ required: true, message: '请输入验证码' },
|
||||
{ minLen: 4, message: '验证码至少 4 位' },
|
||||
{ maxLen: 8, message: '验证码最多 8 位' },
|
||||
{ pattern: /^[0-9]+$/, message: '验证码仅允许数字' },
|
||||
],
|
||||
extraAction: 'send-sms',
|
||||
cooldown: 60,
|
||||
},
|
||||
{
|
||||
name: 'device_id',
|
||||
label: '设备标识',
|
||||
placeholder: '网卡MAC地址',
|
||||
inputType: 'text',
|
||||
rules: [
|
||||
{ required: true, message: '请输入设备标识' },
|
||||
{ minLen: 8, message: '设备标识至少 8 位' },
|
||||
{ maxLen: 128, message: '设备标识最多 128 位' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'device_label',
|
||||
label: '设备标签',
|
||||
placeholder: '如"办公电脑"(可选)',
|
||||
inputType: 'text',
|
||||
optional: true,
|
||||
rules: [{ maxLen: 64, message: '设备标签最多 64 位' }],
|
||||
},
|
||||
{
|
||||
name: 'display_name',
|
||||
label: '个人昵称',
|
||||
placeholder: '如何称呼您(可选)',
|
||||
inputType: 'text',
|
||||
optional: true,
|
||||
rules: [{ maxLen: 32, message: '昵称最多 32 位' }],
|
||||
},
|
||||
{
|
||||
name: 'referral_code',
|
||||
label: '邀请码',
|
||||
placeholder: '如有邀请码请填写(可选)',
|
||||
inputType: 'text',
|
||||
optional: true,
|
||||
rules: [
|
||||
{ maxLen: 32, message: '邀请码最多 32 位' },
|
||||
{ pattern: /^[a-zA-Z0-9]*$/, message: '仅允许字母和数字' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/** 注册表单初始值 */
|
||||
export const registerInitialValues = {
|
||||
username: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
phone: '',
|
||||
sms_code: '',
|
||||
device_id: '',
|
||||
device_label: '',
|
||||
display_name: '',
|
||||
referral_code: '',
|
||||
};
|
||||
136
src/pages/login/config/validators.ts
Normal file
136
src/pages/login/config/validators.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
// ============================================================
|
||||
// 校验函数 — 纯函数,与 UI 层解耦
|
||||
// 每个校验器返回 string(错误信息)或 null(通过)
|
||||
// ============================================================
|
||||
|
||||
type ValidateResult = string | null;
|
||||
|
||||
/** 用户名:3~64 位,仅允许字母、数字、-、_、. */
|
||||
export function validateUsername(value: string): ValidateResult {
|
||||
if (!value || !value.trim()) return '请输入用户名';
|
||||
if (value.length < 3) return '用户名至少 3 位';
|
||||
if (value.length > 64) return '用户名最多 64 位';
|
||||
if (!/^[a-zA-Z0-9\-_.]+$/.test(value)) return '仅允许字母、数字、-、_、.';
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 密码:6~128 位 */
|
||||
export function validatePassword(value: string): ValidateResult {
|
||||
if (!value) return '请输入密码';
|
||||
if (value.length < 6) return '密码至少 6 位';
|
||||
if (value.length > 128) return '密码最多 128 位';
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 确认密码:与密码一致 */
|
||||
export function validateConfirmPassword(value: string, password: string): ValidateResult {
|
||||
if (!value) return '请再次输入密码';
|
||||
if (value !== password) return '两次输入的密码不一致';
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 手机号:5~32 位数字(可含 + 前缀) */
|
||||
export function validatePhone(value: string): ValidateResult {
|
||||
if (!value) return '请输入手机号';
|
||||
if (value.length < 5) return '手机号至少 5 位';
|
||||
if (value.length > 32) return '手机号最多 32 位';
|
||||
if (!/^\+?[0-9]+$/.test(value)) return '手机号格式不正确';
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 短信验证码:4~8 位数字 */
|
||||
export function validateSmsCode(value: string): ValidateResult {
|
||||
if (!value) return '请输入验证码';
|
||||
if (value.length < 4) return '验证码至少 4 位';
|
||||
if (value.length > 8) return '验证码最多 8 位';
|
||||
if (!/^[0-9]+$/.test(value)) return '验证码仅允许数字';
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 设备 ID:8~128 位 */
|
||||
export function validateDeviceId(value: string): ValidateResult {
|
||||
if (!value) return '请输入设备标识';
|
||||
if (value.length < 8) return '设备标识至少 8 位';
|
||||
if (value.length > 128) return '设备标识最多 128 位';
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 可选字段:非空时不超过 maxLen */
|
||||
export function optionalMaxLen(maxLen: number, label: string) {
|
||||
return (value: string): ValidateResult => {
|
||||
if (!value) return null;
|
||||
if (value.length > maxLen) return `${label}最多 ${maxLen} 位`;
|
||||
return null;
|
||||
};
|
||||
}
|
||||
|
||||
/** 邀请码:可选,最多 32 位字母数字 */
|
||||
export function validateReferralCode(value: string): ValidateResult {
|
||||
if (!value) return null;
|
||||
if (value.length > 32) return '邀请码最多 32 位';
|
||||
if (!/^[a-zA-Z0-9]+$/.test(value)) return '仅允许字母和数字';
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 对一条字段配置执行所有规则校验
|
||||
* @returns 错误信息数组(空数组表示通过)
|
||||
*/
|
||||
export function validateField(value: string, rules: import('../types').FieldRule[]): string[] {
|
||||
const errors: string[] = [];
|
||||
for (const rule of rules) {
|
||||
// required
|
||||
if (rule.required && (!value || !value.trim())) {
|
||||
errors.push(rule.message || '此字段为必填');
|
||||
continue;
|
||||
}
|
||||
if (!value) continue; // 非必填且为空 → 跳过后续校验
|
||||
|
||||
// minLen
|
||||
if (rule.minLen && value.length < rule.minLen) {
|
||||
errors.push(rule.message || `至少 ${rule.minLen} 位`);
|
||||
}
|
||||
// maxLen
|
||||
if (rule.maxLen && value.length > rule.maxLen) {
|
||||
errors.push(rule.message || `最多 ${rule.maxLen} 位`);
|
||||
}
|
||||
// pattern
|
||||
if (rule.pattern && !rule.pattern.test(value)) {
|
||||
errors.push(rule.message || '格式不正确');
|
||||
}
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验整个表单
|
||||
* @returns 错误映射 { fieldName: errorMsg[] },空对象表示全部通过
|
||||
*/
|
||||
export function validateForm<T extends Record<string, string>>(
|
||||
values: T,
|
||||
fieldConfigs: import('../types').FieldConfig[],
|
||||
crossValidators?: Record<string, (value: string, allValues: T) => string | null>,
|
||||
): Record<string, string[]> {
|
||||
const errors: Record<string, string[]> = {};
|
||||
|
||||
for (const config of fieldConfigs) {
|
||||
const value = values[config.name] || '';
|
||||
const fieldErrors = validateField(value, config.rules);
|
||||
if (fieldErrors.length > 0) {
|
||||
errors[config.name] = fieldErrors;
|
||||
}
|
||||
}
|
||||
|
||||
// 跨字段校验(如确认密码)
|
||||
if (crossValidators) {
|
||||
for (const [name, fn] of Object.entries(crossValidators)) {
|
||||
const value = values[name] || '';
|
||||
const err = fn(value, values);
|
||||
if (err) {
|
||||
errors[name] = [...(errors[name] || []), err];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
131
src/pages/login/hooks/use-auth-state.ts
Normal file
131
src/pages/login/hooks/use-auth-state.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
// ============================================================
|
||||
// useAuthState — 记住密码 / 自动登录 状态管理
|
||||
// 持久化到 localStorage,下次打开页面自动回填
|
||||
// ============================================================
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
|
||||
const STORAGE_KEY = 'ele-heixiu-auth';
|
||||
|
||||
interface StoredAuth {
|
||||
/** 上次登录的用户名 */
|
||||
username: string;
|
||||
/** 加密后的密码(Base64 编码,非安全加密,仅方便回填) */
|
||||
password: string;
|
||||
/** 是否记住密码 */
|
||||
remember: boolean;
|
||||
/** 是否自动登录 */
|
||||
autoLogin: boolean;
|
||||
}
|
||||
|
||||
/** 简单 Base64 编解码(非安全加密,仅避免明文存储) */
|
||||
function encode(str: string): string {
|
||||
try {
|
||||
return btoa(decodeURI(encodeURIComponent(str)));
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
function decode(str: string): string {
|
||||
try {
|
||||
return decodeURIComponent(decodeURI(atob(str)));
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/** 从 localStorage 读取已保存的认证信息 */
|
||||
function readAuth(): StoredAuth | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return null;
|
||||
return JSON.parse(raw) as StoredAuth;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 写入 localStorage */
|
||||
function writeAuth(auth: StoredAuth): void {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(auth));
|
||||
} catch {
|
||||
/* 静默 */
|
||||
}
|
||||
}
|
||||
|
||||
/** 清除 localStorage */
|
||||
function clearAuth(): void {
|
||||
try {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
} catch {
|
||||
/* 静默 */
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Hook ----------
|
||||
|
||||
export function useAuthState() {
|
||||
// 初始化:从 localStorage 恢复
|
||||
const [username, setUsername] = useState(() => readAuth()?.username || '');
|
||||
const [password, setPassword] = useState(() => {
|
||||
const auth = readAuth();
|
||||
return auth?.remember ? decode(auth.password) : '';
|
||||
});
|
||||
const [remember, setRemember] = useState(() => readAuth()?.remember || false);
|
||||
const [autoLogin, setAutoLogin] = useState(() => readAuth()?.autoLogin || false);
|
||||
|
||||
// 勾选"记住密码"变化时持久化
|
||||
const handleRememberChange = useCallback((checked: boolean) => {
|
||||
setRemember(checked);
|
||||
if (!checked) {
|
||||
// 取消记住密码 → 同时取消自动登录
|
||||
setAutoLogin(false);
|
||||
clearAuth();
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 勾选"自动登录"变化时持久化
|
||||
const handleAutoLoginChange = useCallback((checked: boolean) => {
|
||||
setAutoLogin(checked);
|
||||
}, []);
|
||||
|
||||
// 登录成功后保存
|
||||
const saveAuth = useCallback(
|
||||
(user: string, pass: string) => {
|
||||
setUsername(user);
|
||||
if (remember) {
|
||||
setPassword(pass);
|
||||
writeAuth({
|
||||
username: user,
|
||||
password: encode(pass),
|
||||
remember,
|
||||
autoLogin,
|
||||
});
|
||||
}
|
||||
},
|
||||
[remember, autoLogin],
|
||||
);
|
||||
|
||||
// 清除保存的认证信息
|
||||
const clearSavedAuth = useCallback(() => {
|
||||
setUsername('');
|
||||
setPassword('');
|
||||
setRemember(false);
|
||||
setAutoLogin(false);
|
||||
clearAuth();
|
||||
}, []);
|
||||
|
||||
return {
|
||||
username,
|
||||
password,
|
||||
remember,
|
||||
autoLogin,
|
||||
setUsername,
|
||||
setPassword,
|
||||
handleRememberChange,
|
||||
handleAutoLoginChange,
|
||||
saveAuth,
|
||||
clearSavedAuth,
|
||||
};
|
||||
}
|
||||
149
src/pages/login/index.tsx
Normal file
149
src/pages/login/index.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
// ============================================================
|
||||
// LoginPage — 登录/注册 Modal 弹层
|
||||
//
|
||||
// 行为:
|
||||
// - 未登录时弹出,不可操作主页(Modal 遮罩)
|
||||
// - 可关闭(右上 X),关闭后主页可操作,但 API 401 时会再次弹出
|
||||
// - 登录成功 → 关闭 Modal,更新全局登录态
|
||||
// ============================================================
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { Modal, Tabs, Typography, App } from 'antd';
|
||||
|
||||
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 { useAuthState } from './hooks/use-auth-state';
|
||||
import type { LoginFormValues, RegisterFormValues } from './types';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
interface LoginPageProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function LoginPage({ open, onClose }: LoginPageProps) {
|
||||
const { edition, 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 authState = useAuthState();
|
||||
|
||||
// ========== 登录 ==========
|
||||
|
||||
const handleLogin = useCallback(
|
||||
async (values: LoginFormValues) => {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
// TODO: 接入登录 API
|
||||
console.log('[Login]', values);
|
||||
authState.saveAuth(values.username, values.password);
|
||||
message.success('登录成功(Demo)');
|
||||
login();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
message.error((err as Error).message || '登录失败');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
},
|
||||
[authState, message, login, onClose],
|
||||
);
|
||||
|
||||
// ========== 注册 ==========
|
||||
|
||||
const handleRegister = useCallback(
|
||||
async (values: RegisterFormValues) => {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
console.log('[Register]', values);
|
||||
message.success('注册成功(Demo),请登录');
|
||||
setActiveTab('login');
|
||||
} catch (err) {
|
||||
message.error((err as Error).message || '注册失败');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
},
|
||||
[message],
|
||||
);
|
||||
|
||||
const handleSendSms = useCallback(
|
||||
(phone: string) => {
|
||||
console.log('[SMS]', phone);
|
||||
message.info(`验证码已发送到 ${phone}(Demo)`);
|
||||
},
|
||||
[message],
|
||||
);
|
||||
|
||||
// ========== 渲染 ==========
|
||||
|
||||
const loginTab = (
|
||||
<LoginForm
|
||||
initialUsername={authState.username}
|
||||
initialPassword={authState.password}
|
||||
initialRemember={authState.remember}
|
||||
initialAutoLogin={authState.autoLogin}
|
||||
submitting={submitting}
|
||||
onSubmit={handleLogin}
|
||||
onRememberChange={authState.handleRememberChange}
|
||||
onAutoLoginChange={authState.handleAutoLoginChange}
|
||||
/>
|
||||
);
|
||||
|
||||
const registerTab = (
|
||||
<RegisterForm submitting={submitting} onSubmit={handleRegister} onSendSms={handleSendSms} />
|
||||
);
|
||||
|
||||
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>
|
||||
|
||||
{/* 个人版 Tab | 企业版仅登录 */}
|
||||
{isEnterprise ? (
|
||||
loginTab
|
||||
) : (
|
||||
<Tabs
|
||||
activeKey={activeTab}
|
||||
onChange={setActiveTab}
|
||||
centered
|
||||
size="small"
|
||||
items={[
|
||||
{ key: 'login', label: '登录', children: loginTab },
|
||||
{ key: 'register', label: '注册', children: registerTab },
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
52
src/pages/login/types.ts
Normal file
52
src/pages/login/types.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
// ============================================================
|
||||
// 登录/注册 — 类型定义
|
||||
// ============================================================
|
||||
|
||||
// ---------- 登录 ----------
|
||||
|
||||
export interface LoginFormValues {
|
||||
username: string;
|
||||
password: string;
|
||||
remember: boolean;
|
||||
autoLogin: boolean;
|
||||
}
|
||||
|
||||
// ---------- 注册(个人版) ----------
|
||||
|
||||
export interface RegisterFormValues {
|
||||
username: string;
|
||||
password: string;
|
||||
confirmPassword: string;
|
||||
phone: string;
|
||||
sms_code: string;
|
||||
device_id: string;
|
||||
device_label: string;
|
||||
display_name: string;
|
||||
referral_code: string;
|
||||
}
|
||||
|
||||
// ---------- 字段配置(数据驱动 UI 渲染) ----------
|
||||
|
||||
export interface FieldRule {
|
||||
required?: boolean;
|
||||
minLen?: number;
|
||||
maxLen?: number;
|
||||
pattern?: RegExp;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export type InputType = 'text' | 'password' | 'tel';
|
||||
|
||||
export interface FieldConfig {
|
||||
name: string;
|
||||
label: string;
|
||||
placeholder: string;
|
||||
inputType: InputType;
|
||||
rules: FieldRule[];
|
||||
/** 仅注册表单的选填字段 */
|
||||
optional?: boolean;
|
||||
/** 附带操作按钮标识 */
|
||||
extraAction?: 'send-sms';
|
||||
/** 冷却秒数(发送验证码按钮用) */
|
||||
cooldown?: number;
|
||||
}
|
||||
Reference in New Issue
Block a user