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:
2026-06-03 15:00:51 +08:00
commit 767bb8b297
95 changed files with 20814 additions and 0 deletions

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

View 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)} />}
</>
);
}

View 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)} />}
</>
);
}

View 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,
};

View 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: '',
};

View 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;
}
/** 设备 ID8~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;
}

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