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:
640
src/modules/account/views/vip/VipPage.tsx
Normal file
640
src/modules/account/views/vip/VipPage.tsx
Normal file
@@ -0,0 +1,640 @@
|
||||
// ============================================================
|
||||
// VipPage — VIP 会员开通页面
|
||||
//
|
||||
// 通过 PageDispatcher 调度(Modal 容器),导航栏"开会员/激活"按钮触发。
|
||||
// 四部分布局:
|
||||
// 激活码 — Space.Compact 输入框 + 激活按钮
|
||||
// 套餐选择 — Radio.Group 卡片式单选,API 获取套餐列表
|
||||
// 支付协议 — Checkbox + LegalTextModal(用户服务协议) + 确认开通
|
||||
// 权益说明 — 选中套餐的大图展示
|
||||
// ============================================================
|
||||
|
||||
import {useState, useCallback, useEffect, useRef} from 'react';
|
||||
import { logger } from '@/shared/utils/bus';
|
||||
import {
|
||||
Radio,
|
||||
Button,
|
||||
Checkbox,
|
||||
Input,
|
||||
Image,
|
||||
Typography,
|
||||
App,
|
||||
Space,
|
||||
Divider,
|
||||
theme as antTheme,
|
||||
Spin,
|
||||
Result,
|
||||
} from 'antd';
|
||||
import {CrownOutlined, GiftOutlined, DollarOutlined} from '@ant-design/icons';
|
||||
|
||||
import {useTheme} from '@/app/providers/ThemeProvider';
|
||||
import {LegalTextModal} from '@/shared/components/LegalTextModal';
|
||||
import {
|
||||
VipAPI,
|
||||
VipOrderStatus,
|
||||
type VipLevelsItem,
|
||||
type VipOrderStatusValue,
|
||||
} from '@/modules/account/controllers/vip-api';
|
||||
import {centToYuan} from '@/shared/utils/display';
|
||||
import {EnumResolver} from '@/shared/utils/enum-resolver';
|
||||
import {emit, EVENTS} from '@/shared/utils/bus';
|
||||
import {RequestError, RequestErrorType} from '@/shared/services/http';
|
||||
|
||||
const {Text} = Typography;
|
||||
|
||||
// ---------- 常量 ----------
|
||||
|
||||
/** 订单状态轮询间隔(毫秒) */
|
||||
const POLL_INTERVAL_MS = 3000;
|
||||
|
||||
/** 页面阶段 */
|
||||
type VipPhase = 'form' | 'activate' | VipOrderStatusValue;
|
||||
|
||||
/** 状态 → Result status 映射 */
|
||||
function getResultStatus(status: VipOrderStatusValue): 'success' | 'info' | 'error' {
|
||||
if (status === VipOrderStatus.Paid) return 'success';
|
||||
if (status === VipOrderStatus.Pending) return 'info';
|
||||
return 'error';
|
||||
}
|
||||
|
||||
// ---------- 组件 ----------
|
||||
|
||||
export function VipPage() {
|
||||
const {isDark} = useTheme();
|
||||
const {message} = App.useApp();
|
||||
const {token} = antTheme.useToken();
|
||||
|
||||
// ======== 套餐列表 ========
|
||||
|
||||
const [levels, setLevels] = useState<VipLevelsItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [fetchError, setFetchError] = useState<string | null>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const mountedRef = useRef(true);
|
||||
|
||||
// ======== 表单状态 ========
|
||||
|
||||
const [selectedLevelId, setSelectedLevelId] = useState<number | null>(null);
|
||||
const [agreedToTerms, setAgreedToTerms] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const [legalOpen, setLegalOpen] = useState(false);
|
||||
|
||||
// ======== 激活码 ========
|
||||
|
||||
const [activateCode, setActivateCode] = useState('');
|
||||
const [activating, setActivating] = useState(false);
|
||||
|
||||
// ======== 支付订单状态 ========
|
||||
|
||||
const [phase, setPhase] = useState<VipPhase>('form');
|
||||
const [vipOrderId, setVipOrderId] = useState<string | null>(null);
|
||||
const [vipOrderStatus, setVipOrderStatus] = useState<VipOrderStatusValue | null>(null);
|
||||
const [vipPayUrl, setVipPayUrl] = useState<string | null>(null);
|
||||
|
||||
// ======== 工具函数 ========
|
||||
|
||||
const clearError = useCallback((name: string) => {
|
||||
setErrors((prev) => {
|
||||
if (!(name in prev)) return prev;
|
||||
const next = {...prev};
|
||||
delete next[name];
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// ======== 获取套餐列表 ========
|
||||
|
||||
const fetchLevels = useCallback(async () => {
|
||||
abortRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
|
||||
setLoading(true);
|
||||
setFetchError(null);
|
||||
try {
|
||||
const data = await VipAPI.getVipLevels(controller.signal);
|
||||
if (mountedRef.current) {
|
||||
setLevels(data.filter((l) => l.is_active && l.image));
|
||||
}
|
||||
} catch (err) {
|
||||
// axios 拦截器将取消错误转为 RequestError(CANCELLED),非原始 AbortError
|
||||
if (err instanceof RequestError && err.type === RequestErrorType.CANCELLED) return;
|
||||
logger.warn('ui', '加载VIP套餐列表失败', err instanceof Error ? err : new Error(String(err)));
|
||||
if (mountedRef.current) {
|
||||
setFetchError((err as Error)?.message || '加载套餐列表失败');
|
||||
}
|
||||
} finally {
|
||||
if (mountedRef.current) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
void fetchLevels();
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
abortRef.current?.abort();
|
||||
};
|
||||
}, [fetchLevels]);
|
||||
|
||||
// ======== 校验 ========
|
||||
|
||||
const validate = useCallback((): Record<string, string> => {
|
||||
const errs: Record<string, string> = {};
|
||||
|
||||
if (selectedLevelId === null) {
|
||||
errs.selectedLevel = '请选择会员套餐';
|
||||
}
|
||||
|
||||
if (!agreedToTerms) {
|
||||
errs.agreedToTerms = '请阅读并同意用户服务协议';
|
||||
}
|
||||
|
||||
return errs;
|
||||
}, [selectedLevelId, agreedToTerms]);
|
||||
|
||||
// ======== 提交 ========
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
const validationErrors = validate();
|
||||
if (Object.keys(validationErrors).length > 0) {
|
||||
setErrors(validationErrors);
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const res = await VipAPI.createVipOrder({
|
||||
vip_level_id: selectedLevelId!,
|
||||
payment_channel: 'alipay',
|
||||
});
|
||||
|
||||
// 保存订单信息,进入待支付阶段
|
||||
setVipOrderId(res.id);
|
||||
setVipOrderStatus(VipOrderStatus.Pending);
|
||||
setVipPayUrl(res.pay_url);
|
||||
setPhase(VipOrderStatus.Pending);
|
||||
|
||||
window.open(res.pay_url ?? '', '_blank');
|
||||
void message.success('订单已创建,请在新页面中完成支付');
|
||||
} catch (err: unknown) {
|
||||
logger.warn('ui', 'VIP创建订单失败', err instanceof Error ? err : new Error(String(err)));
|
||||
message.error((err as Error)?.message || '创建订单失败,请重试');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}, [selectedLevelId, validate, message]);
|
||||
|
||||
// ======== 激活码提交 ========
|
||||
|
||||
const handleActivate = useCallback(async () => {
|
||||
const code = activateCode.trim();
|
||||
if (!code) {
|
||||
message.warning('请输入激活码');
|
||||
return;
|
||||
}
|
||||
|
||||
setActivating(true);
|
||||
try {
|
||||
await VipAPI.activateVipCode({code});
|
||||
void message.success('激活成功!VIP 权益已生效');
|
||||
emit(EVENTS.CLOSE_PAGE);
|
||||
} catch (err: unknown) {
|
||||
logger.warn('ui', 'VIP激活失败', err instanceof Error ? err : new Error(String(err)));
|
||||
message.error((err as Error)?.message || '激活失败,请检查激活码是否正确');
|
||||
} finally {
|
||||
setActivating(false);
|
||||
}
|
||||
}, [activateCode, message]);
|
||||
|
||||
// ======== 操作回调 ========
|
||||
|
||||
// ======== 订单状态轮询 ========
|
||||
|
||||
useEffect(() => {
|
||||
if (phase !== VipOrderStatus.Pending || !vipOrderId) return;
|
||||
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
let cancelled = false;
|
||||
|
||||
const poll = async () => {
|
||||
try {
|
||||
const order = await VipAPI.getVipOrder(vipOrderId);
|
||||
if (cancelled) return;
|
||||
|
||||
const newStatus = order.status;
|
||||
setVipOrderStatus(newStatus);
|
||||
|
||||
if (newStatus === VipOrderStatus.Pending) {
|
||||
timer = setTimeout(poll, POLL_INTERVAL_MS);
|
||||
} else {
|
||||
setPhase(newStatus);
|
||||
if (newStatus === VipOrderStatus.Paid) {
|
||||
void message.success('支付成功!VIP 权益已生效');
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) timer = setTimeout(poll, POLL_INTERVAL_MS * 2);
|
||||
}
|
||||
};
|
||||
|
||||
timer = setTimeout(poll, POLL_INTERVAL_MS);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [phase, vipOrderId, message]);
|
||||
|
||||
// ======== 页面锁定(待支付时禁止关闭容器) ========
|
||||
|
||||
useEffect(() => {
|
||||
const locked = phase === VipOrderStatus.Pending;
|
||||
emit(EVENTS.PAGE_LOCK, { locked });
|
||||
return () => {
|
||||
if (locked) emit(EVENTS.PAGE_LOCK, { locked: false });
|
||||
};
|
||||
}, [phase]);
|
||||
|
||||
// ======== 操作回调 ========
|
||||
|
||||
/** 重新下单:回到表单,保留套餐选择 */
|
||||
const handleRetry = useCallback(() => {
|
||||
setPhase('form');
|
||||
setVipOrderId(null);
|
||||
setVipOrderStatus(null);
|
||||
setVipPayUrl(null);
|
||||
}, []);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
emit(EVENTS.CLOSE_PAGE);
|
||||
}, []);
|
||||
|
||||
// ================================================================
|
||||
// 渲染:加载态
|
||||
// ================================================================
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{display: 'flex', justifyContent: 'center', padding: '48px 0'}}>
|
||||
<Spin size="medium">
|
||||
<div style={{padding: 24, textAlign: 'center'}}>
|
||||
<Text type="secondary" style={{fontSize: 13}}>加载套餐列表…</Text>
|
||||
</div>
|
||||
</Spin>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// 渲染:错误态
|
||||
// ================================================================
|
||||
|
||||
if (fetchError) {
|
||||
return (
|
||||
<div style={{padding: '8px 0'}}>
|
||||
<Result
|
||||
status="error"
|
||||
title="套餐加载失败"
|
||||
subTitle={fetchError}
|
||||
extra={
|
||||
<Button type="primary" size="small" onClick={fetchLevels}>
|
||||
重试
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// 渲染:支付状态视图(非 form / activate 阶段)
|
||||
// ================================================================
|
||||
|
||||
if (phase !== 'form' && phase !== 'activate') {
|
||||
const statusText = vipOrderStatus ? EnumResolver.vip.orderStatus.label(vipOrderStatus) : '';
|
||||
const isPending = phase === VipOrderStatus.Pending;
|
||||
const isPaid = phase === VipOrderStatus.Paid;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ padding: '16px 0' }}>
|
||||
<Result
|
||||
status={vipOrderStatus ? getResultStatus(vipOrderStatus) : 'info'}
|
||||
title={
|
||||
isPaid
|
||||
? '支付成功'
|
||||
: isPending
|
||||
? `等待支付(${statusText})`
|
||||
: statusText
|
||||
}
|
||||
subTitle={
|
||||
isPaid
|
||||
? 'VIP 会员权益已生效'
|
||||
: isPending
|
||||
? '请在新打开的浏览器页面中完成支付'
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{/* ---- 订单信息卡片 ---- */}
|
||||
{isPending && (
|
||||
<div
|
||||
style={{
|
||||
background: isDark ? '#2E2A5A' : '#F5F3FF',
|
||||
padding: '12px 16px',
|
||||
borderRadius: 8,
|
||||
marginBottom: 16,
|
||||
textAlign: 'left',
|
||||
maxWidth: 360,
|
||||
margin: '0 auto 16px',
|
||||
}}
|
||||
>
|
||||
<Space orientation="vertical" size={6} style={{ width: '100%' }}>
|
||||
<Text style={{ fontSize: 12 }} type="secondary">
|
||||
订单编号:<Text copyable style={{ fontSize: 12 }}>{vipOrderId}</Text>
|
||||
</Text>
|
||||
<Text style={{ fontSize: 13 }}>
|
||||
<DollarOutlined style={{ marginRight: 6, color: token.colorPrimary }} />
|
||||
支付金额:
|
||||
<Text strong>
|
||||
¥{(() => {
|
||||
const lvl = levels.find((l) => l.id === selectedLevelId);
|
||||
return lvl ? centToYuan(lvl.price_cent) : '—';
|
||||
})()}
|
||||
</Text>
|
||||
</Text>
|
||||
<Text style={{ fontSize: 13 }}>
|
||||
<CrownOutlined style={{ marginRight: 6, color: token.colorSuccess }} />
|
||||
套餐:
|
||||
<Text strong>{levels.find((l) => l.id === selectedLevelId)?.name ?? '—'}</Text>
|
||||
</Text>
|
||||
</Space>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ---- 操作按钮 ---- */}
|
||||
<Space size={12}>
|
||||
{isPending && vipPayUrl && (
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<DollarOutlined />}
|
||||
onClick={() => window.open(vipPayUrl, '_blank')}
|
||||
>
|
||||
打开支付页面
|
||||
</Button>
|
||||
)}
|
||||
{!isPending && !isPaid && (
|
||||
<Button type="primary" onClick={handleRetry}>
|
||||
重新下单
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={handleClose}>
|
||||
{isPaid ? '完成' : '关闭'}
|
||||
</Button>
|
||||
</Space>
|
||||
</Result>
|
||||
</div>
|
||||
|
||||
{/* ---- 协议弹窗(待支付阶段仍可查看) ---- */}
|
||||
<LegalTextModal
|
||||
open={legalOpen}
|
||||
type="service"
|
||||
onClose={() => setLegalOpen(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// 渲染:正常
|
||||
// ================================================================
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{padding: '4px 0'}}>
|
||||
<div>
|
||||
<Text type="secondary" style={{fontSize: 13, marginBottom: 10, display: 'block'}}>
|
||||
<GiftOutlined style={{marginRight: 4}} />
|
||||
使用激活码
|
||||
</Text>
|
||||
<Space.Compact style={{width: '100%'}}>
|
||||
<Input
|
||||
value={activateCode}
|
||||
onChange={(e) => setActivateCode(e.target.value)}
|
||||
placeholder="输入 VIP 激活码"
|
||||
allowClear
|
||||
onPressEnter={() => void handleActivate()}
|
||||
style={{flex: 1}}
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<GiftOutlined />}
|
||||
loading={activating}
|
||||
onClick={() => void handleActivate()}
|
||||
disabled={!activateCode.trim()}
|
||||
>
|
||||
激活
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
</div>
|
||||
<Divider style={{margin: '20px 0 16px'}} />
|
||||
<Text type="secondary" style={{fontSize: 13, marginBottom: 12, display: 'block'}}>
|
||||
选择会员套餐
|
||||
</Text>
|
||||
|
||||
{/* 双列布局:左列套餐选择 | 右列权益图库 */}
|
||||
<div style={{display: 'flex', gap: 12, marginBottom: 16}}>
|
||||
{/* ---- 左列:套餐选择 ---- */}
|
||||
<div style={{flex: '0 0 52%', minWidth: 0}}>
|
||||
<Radio.Group
|
||||
value={selectedLevelId}
|
||||
onChange={(e) => {
|
||||
setSelectedLevelId(e.target.value);
|
||||
clearError('selectedLevel');
|
||||
}}
|
||||
style={{width: '100%'}}
|
||||
>
|
||||
<Space orientation="vertical" style={{width: '100%'}} size={6}>
|
||||
{levels.map((level) => (
|
||||
<Radio
|
||||
key={level.id}
|
||||
value={level.id}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
padding: '10px 12px',
|
||||
borderRadius: 8,
|
||||
border: `1px solid ${
|
||||
selectedLevelId === level.id
|
||||
? token.colorPrimary
|
||||
: isDark
|
||||
? '#2E2A5A'
|
||||
: '#E5E7EB'
|
||||
}`,
|
||||
background:
|
||||
selectedLevelId === level.id
|
||||
? isDark
|
||||
? '#2E2A5A'
|
||||
: '#F5F3FF'
|
||||
: isDark
|
||||
? '#1A1730'
|
||||
: '#FFFFFF',
|
||||
width: '100%',
|
||||
margin: 0,
|
||||
transition: 'border-color 0.2s, background 0.2s',
|
||||
}}
|
||||
>
|
||||
<div style={{flex: 1, marginLeft: 8, minWidth: 0}}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'baseline',
|
||||
marginBottom: 4,
|
||||
}}
|
||||
>
|
||||
<Text strong style={{fontSize: 13}}>
|
||||
{level.name}
|
||||
</Text>
|
||||
<Text
|
||||
strong
|
||||
style={{
|
||||
fontSize: 14,
|
||||
color: token.colorPrimary,
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
¥{centToYuan(level.price_cent)}
|
||||
</Text>
|
||||
</div>
|
||||
<div style={{display: 'flex', gap: 8, flexWrap: 'wrap'}}>
|
||||
<Text type="secondary" style={{fontSize: 11}}>
|
||||
{level.duration_days}天
|
||||
</Text>
|
||||
{level.gift_balance_cent > 0 && (
|
||||
<Text type="secondary" style={{fontSize: 11}}>
|
||||
赠{centToYuan(level.gift_balance_cent)}积分
|
||||
</Text>
|
||||
)}
|
||||
<Text type="secondary" style={{fontSize: 11}}>
|
||||
{EnumResolver.vip.targetAccountType.label(level.target_account_type)}
|
||||
</Text>
|
||||
</div>
|
||||
{level.description && (
|
||||
<Text type="secondary" style={{fontSize: 10, display: 'block', marginTop: 2}}>
|
||||
{level.description}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</Radio>
|
||||
))}
|
||||
</Space>
|
||||
</Radio.Group>
|
||||
|
||||
{errors.selectedLevel && (
|
||||
<Text type="danger" style={{fontSize: 12, marginTop: 4, display: 'block'}}>
|
||||
{errors.selectedLevel}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ---- 右列:权益图库 ---- */}
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 6,
|
||||
}}
|
||||
>
|
||||
<Text type="secondary" style={{fontSize: 11, textAlign: 'center'}}>
|
||||
权益说明(点击放大)
|
||||
</Text>
|
||||
{levels.map((level) => (
|
||||
<div
|
||||
key={level.id}
|
||||
style={{
|
||||
borderRadius: 6,
|
||||
overflow: 'hidden',
|
||||
border: `2px solid ${
|
||||
selectedLevelId === level.id
|
||||
? token.colorPrimary
|
||||
: 'transparent'
|
||||
}`,
|
||||
opacity: selectedLevelId === null || selectedLevelId === level.id ? 1 : 0.45,
|
||||
transition: 'border-color 0.2s, opacity 0.2s',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
src={level.image!}
|
||||
alt={`${level.name} 权益`}
|
||||
width="100%"
|
||||
style={{display: 'block'}}
|
||||
preview={{mask: '查看大图'}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ---- Part 2:支付协议 + 确认开通 ---- */}
|
||||
<div style={{marginBottom: 16}}>
|
||||
<Checkbox
|
||||
checked={agreedToTerms}
|
||||
onChange={(e) => {
|
||||
setAgreedToTerms(e.target.checked);
|
||||
clearError('agreedToTerms');
|
||||
}}
|
||||
>
|
||||
<Text style={{fontSize: 12}}>
|
||||
我已阅读并同意
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={() => setLegalOpen(true)}
|
||||
style={{
|
||||
padding: '0 2px',
|
||||
fontSize: 12,
|
||||
height: 'auto',
|
||||
lineHeight: 'inherit',
|
||||
}}
|
||||
>
|
||||
《用户服务协议》
|
||||
</Button>
|
||||
</Text>
|
||||
</Checkbox>
|
||||
{errors.agreedToTerms && (
|
||||
<Text type="danger" style={{fontSize: 12, marginTop: 4, display: 'block'}}>
|
||||
{errors.agreedToTerms}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div style={{display: 'flex', justifyContent: 'flex-end', gap: 8}}>
|
||||
<Button onClick={handleClose}>取消</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<CrownOutlined />}
|
||||
loading={submitting}
|
||||
onClick={() => void handleSubmit()}
|
||||
disabled={selectedLevelId === null}
|
||||
>
|
||||
确认开通
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ---- 协议弹窗 ---- */}
|
||||
<LegalTextModal
|
||||
open={legalOpen}
|
||||
type="service"
|
||||
onClose={() => setLegalOpen(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user