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:
2026-06-29 18:41:07 +08:00
parent e0d91335c7
commit 337d6c1205
187 changed files with 5252 additions and 1702 deletions

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

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

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

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