Files
omniai-web/src/components/RechargeModal/RechargeModal.tsx
T

308 lines
12 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.
import { CheckCircleOutlined, CloseOutlined, CrownOutlined, RocketOutlined } from "@ant-design/icons";
import { useMemo, useState, type ReactNode } from "react";
import "../../styles/components/recharge-modal.css";
import { keyServerClient, type RechargeOrderResult } from "../../api/keyServerClient";
import { toast } from "../toast/toastStore";
type RechargeAudience = "personal" | "enterprise";
type PaymentMethod = "wechat" | "alipay" | "bank";
interface MembershipPlan {
id: string;
audience: RechargeAudience;
name: string;
subtitle: string;
period: string;
price: string;
grant: string;
comparisonLabel: string;
badge?: string;
icon: ReactNode;
benefits: string[];
}
const membershipPlans: MembershipPlan[] = [
{
id: "pro-month",
audience: "personal",
name: "专业版",
subtitle: "Pro",
period: "月付",
price: "299 元 / 月",
grant: "每月赠送 10000 积分,30 天有效",
comparisonLabel: "专业版基础权益",
icon: <CrownOutlined />,
benefits: ["通用大模型全解锁", "积分与 API 消耗 9 折", "并发提升到 3 个", "去水印、插队加速、专属客服"],
},
{
id: "pro-quarter",
audience: "personal",
name: "专业版",
subtitle: "Pro",
period: "季付",
price: "897 元 / 季",
grant: "连续 3 个月按月发放 Pro 积分",
comparisonLabel: "相比月付新增",
badge: "季度",
icon: <CrownOutlined />,
benefits: ["一次覆盖 3 个月使用周期", "每月延续 Pro 权益", "适合短期项目排期"],
},
{
id: "pro-year",
audience: "personal",
name: "专业版",
subtitle: "Pro",
period: "年付",
price: "1990 元 / 年",
grant: "全年合计 140000 积分,默认按月分摊",
comparisonLabel: "相比季付新增",
badge: "年费优惠",
icon: <CrownOutlined />,
benefits: ["折合 10 个月费用", "前 100 名额外赠 20000 积分", "适合全年持续高频使用"],
},
{
id: "enterprise-month",
audience: "enterprise",
name: "企业版",
subtitle: "Enterprise",
period: "月付",
price: "499 元 / 月",
grant: "每月赠送 2000 积分,30 天有效",
comparisonLabel: "企业版基础权益",
icon: <RocketOutlined />,
benefits: ["企业私有模型与高性能模型", "默认 10 并发,可申请提升", "积分与 API 消耗 8 折", "用量报表与正式 API 权限"],
},
{
id: "enterprise-quarter",
audience: "enterprise",
name: "企业版",
subtitle: "Enterprise",
period: "季付",
price: "1497 元 / 季",
grant: "连续 3 个月按月发放企业版积分",
comparisonLabel: "相比月付新增",
badge: "季度",
icon: <RocketOutlined />,
benefits: ["一次覆盖季度项目周期", "延续企业资源池与高并发", "适合阶段性团队投放"],
},
{
id: "enterprise-year",
audience: "enterprise",
name: "企业版",
subtitle: "Enterprise",
period: "年付",
price: "4990 元 / 年",
grant: "全年合计 340000 积分,默认按月分摊",
comparisonLabel: "相比季付新增",
badge: "企业年费",
icon: <RocketOutlined />,
benefits: ["折合 10 个月费用", "前 100 名额外赠 100000 积分", "支持对公充值与子账户额度分配"],
},
];
const defaultSelectedPlanIds: Record<RechargeAudience, string> = {
personal: "pro-month",
enterprise: "enterprise-month",
};
const rechargeRules = [
"充值比例:固定 1 元 = 100 积分,平台可限时活动额外赠送积分",
"有效期:充值积分到账起有效期 12 个月,系统按先进先出自动消耗",
"退费规则:充值积分到账后不支持退换、折现,仅限平台内消费",
];
const paymentMethods: Array<{ id: PaymentMethod; label: string; hint: string }> = [
{ id: "wechat", label: "微信支付", hint: "生成支付链接或二维码" },
{ id: "alipay", label: "支付宝", hint: "生成支付链接或二维码" },
{ id: "bank", label: "对公转账", hint: "企业客户可联系客服确认" },
];
export interface RechargeModalProps {
open: boolean;
onClose: () => void;
currentBalance?: number;
}
export function RechargeModal({ open, onClose, currentBalance }: RechargeModalProps) {
const [activeAudience, setActiveAudience] = useState<RechargeAudience>("personal");
const [selectedPlanIds, setSelectedPlanIds] = useState<Record<RechargeAudience, string>>(defaultSelectedPlanIds);
const [paymentMethod, setPaymentMethod] = useState<PaymentMethod>("wechat");
const [submitting, setSubmitting] = useState(false);
const [order, setOrder] = useState<RechargeOrderResult | null>(null);
const visiblePlans = useMemo(() => membershipPlans.filter((plan) => plan.audience === activeAudience), [activeAudience]);
const selectedPlanId = selectedPlanIds[activeAudience];
const selectedPlan = membershipPlans.find((plan) => plan.id === selectedPlanId) ?? visiblePlans[0];
const handlePlanSelect = (plan: MembershipPlan) => {
setSelectedPlanIds((current) => ({
...current,
[plan.audience]: plan.id,
}));
setOrder(null);
};
const handleCreateOrder = async () => {
if (!selectedPlan || submitting) return;
setSubmitting(true);
try {
const nextOrder = await keyServerClient.createRechargeOrder({ planId: selectedPlan.id, paymentMethod });
setOrder(nextOrder);
if (nextOrder.payUrl) {
window.open(nextOrder.payUrl, "_blank", "noopener,noreferrer");
}
toast.success("充值订单已创建");
} catch (error) {
const message = error instanceof Error ? error.message : "订单创建失败,请联系客服处理。";
toast.error(message);
setOrder({
orderId: `support-${Date.now()}`,
status: "manual-review",
message: "支付接口暂不可用,请通过页面联系方式联系客服完成充值。",
});
} finally {
setSubmitting(false);
}
};
if (!open) return null;
return (
<div className="recharge-modal" role="dialog" aria-modal="true" aria-labelledby="recharge-modal-title">
<button type="button" className="recharge-modal__backdrop" onClick={onClose} aria-label="关闭充值弹窗" />
<section className="recharge-modal__panel" aria-describedby="recharge-modal-desc">
<div className="recharge-modal__promo" role="note">
<strong></strong>
<span> 100 12 </span>
</div>
<header className="recharge-modal__header">
<div>
<span className="recharge-modal__eyebrow"></span>
<h2 id="recharge-modal-title"></h2>
<p id="recharge-modal-desc"></p>
</div>
{currentBalance !== undefined ? (
<span className="recharge-modal__balance">{(currentBalance / 100).toFixed(2)} </span>
) : null}
<button type="button" className="recharge-modal__close" onClick={onClose} aria-label="关闭">
<CloseOutlined />
</button>
</header>
<div className="recharge-modal__audience-tabs" role="tablist" aria-label="充值对象">
<button
type="button"
role="tab"
aria-selected={activeAudience === "personal"}
className={activeAudience === "personal" ? "is-active" : ""}
onClick={() => setActiveAudience("personal")}
>
</button>
<button
type="button"
role="tab"
aria-selected={activeAudience === "enterprise"}
className={activeAudience === "enterprise" ? "is-active" : ""}
onClick={() => setActiveAudience("enterprise")}
>
</button>
</div>
<div className="recharge-modal__grid" data-audience={activeAudience}>
{visiblePlans.map((plan) => {
const isSelected = plan.id === selectedPlanId;
return (
<article
key={plan.id}
className={`recharge-modal__card recharge-modal__card--${plan.id}${isSelected ? " is-selected" : ""}`}
>
{plan.badge ? <span className="recharge-modal__badge">{plan.badge}</span> : null}
<div className="recharge-modal__card-head">
<span className="recharge-modal__card-icon">{plan.icon}</span>
<div>
<h3>{plan.name}</h3>
<span>{plan.subtitle}</span>
</div>
</div>
<span className="recharge-modal__period">{plan.period}</span>
<div className="recharge-modal__price">
<strong>{plan.price}</strong>
</div>
<p className="recharge-modal__grant">{plan.grant}</p>
<span className="recharge-modal__diff-label">{plan.comparisonLabel}</span>
<ul className="recharge-modal__features">
{plan.benefits.map((benefit) => (
<li key={benefit}>
<CheckCircleOutlined />
<span>{benefit}</span>
</li>
))}
</ul>
<button
type="button"
className={`recharge-modal__buy${isSelected ? " is-selected" : ""}`}
aria-pressed={isSelected}
onClick={() => handlePlanSelect(plan)}
>
{isSelected ? "当前方案" : "选择方案"}
</button>
</article>
);
})}
</div>
<footer className="recharge-modal__rules">
<h3></h3>
<ol>
{rechargeRules.map((rule) => (
<li key={rule}>{rule}</li>
))}
</ol>
</footer>
<section className="recharge-modal__checkout" aria-label="支付方式">
<div>
<span className="recharge-modal__checkout-eyebrow"></span>
<h3>{selectedPlan.name} · {selectedPlan.period}</h3>
<p>{selectedPlan.price}{selectedPlan.grant}</p>
</div>
<div className="recharge-modal__payment-methods" role="radiogroup" aria-label="选择支付方式">
{paymentMethods.map((method) => (
<button
key={method.id}
type="button"
role="radio"
aria-checked={paymentMethod === method.id}
className={paymentMethod === method.id ? "is-active" : ""}
onClick={() => {
setPaymentMethod(method.id);
setOrder(null);
}}
>
<strong>{method.label}</strong>
<span>{method.hint}</span>
</button>
))}
</div>
<button type="button" className="recharge-modal__pay" onClick={() => void handleCreateOrder()} disabled={submitting}>
{submitting ? "创建订单中..." : "立即充值"}
</button>
{order ? (
<div className="recharge-modal__order" role="status">
<strong>{order.orderId}</strong>
<span>{order.status}</span>
{order.qrCodeUrl ? <img src={order.qrCodeUrl} alt="支付二维码" /> : null}
{order.payUrl ? <a href={order.payUrl} target="_blank" rel="noreferrer"></a> : null}
<p>{order.message || "支付完成后积分将自动入账,如长时间未到账请联系客服。"}</p>
</div>
) : null}
</section>
</section>
</div>
);
}