Files
ele-HeiXiu/src/modules/account/views/vip/VipPage.tsx
YoungestSongMo 337d6c1205 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>
2026-06-29 18:41:07 +08:00

641 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ============================================================
// 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)}
/>
</>
);
}