9b4a25799f
Replace fixed ackText with a rule-based AckProvider that picks response templates by message type and intent (translate, summary, rewrite, poster, ppt, mindmap, code, search, schedule). Pure sync, zero I/O, auto-falls back to config.ackText on any error. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
288 lines
11 KiB
TypeScript
288 lines
11 KiB
TypeScript
import { useCallback, useEffect, useState } from 'react';
|
||
import { createPortal } from 'react-dom';
|
||
import { getAvailablePlans, purchaseSubscription, setAutoRenew } from '../api/client';
|
||
import type { ActiveSubscription, PlanDefinition } from '../types';
|
||
|
||
// Plan tier order (matches PLAN_ORDER in billing-subscription.mjs)
|
||
const PLAN_ORDER: Record<string, number> = { free: 0, lite: 1, standard: 2, pro: 3 };
|
||
|
||
function formatYuan(cents: number) {
|
||
return `¥${(cents / 100).toFixed(cents % 100 === 0 ? 0 : 2)}`;
|
||
}
|
||
|
||
function formatTokens(tokens: number) {
|
||
if (tokens === 0) return '不限';
|
||
if (tokens >= 10_000) return `${(tokens / 10_000).toFixed(0)} 万`;
|
||
if (tokens >= 1_000) return `${(tokens / 1_000).toFixed(0)}k`;
|
||
return tokens.toLocaleString('zh-CN');
|
||
}
|
||
|
||
function callsApprox(tokens: number) {
|
||
if (tokens === 0) return '不限';
|
||
return `约 ${Math.floor(tokens / 3_000).toLocaleString()} 次`;
|
||
}
|
||
|
||
function imageCallsApprox(images: number) {
|
||
if (images === 0) return '不限';
|
||
return `约 ${images.toLocaleString()} 次`;
|
||
}
|
||
|
||
const MODEL_TIER_LABELS: Record<string, string> = {
|
||
basic: '基础模型',
|
||
standard: '标准模型',
|
||
premium: '旗舰模型',
|
||
};
|
||
|
||
const PLAN_HIGHLIGHT: Record<string, string> = {
|
||
lite: '',
|
||
standard: '推荐',
|
||
pro: '旗舰',
|
||
};
|
||
|
||
type SubscribeModalProps = {
|
||
open: boolean;
|
||
onClose: () => void;
|
||
onSuccess: (subscription: ActiveSubscription, balanceCents: number) => void;
|
||
onRechargeNeeded: (shortfallCents: number) => void;
|
||
};
|
||
|
||
export function SubscribeModal({ open, onClose, onSuccess, onRechargeNeeded }: SubscribeModalProps) {
|
||
const [plans, setPlans] = useState<PlanDefinition[]>([]);
|
||
const [currentSub, setCurrentSub] = useState<ActiveSubscription | null>(null);
|
||
const [balanceCents, setBalanceCents] = useState(0);
|
||
const [loading, setLoading] = useState(false);
|
||
const [purchasing, setPurchasing] = useState<string | null>(null);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||
const [shortfall, setShortfall] = useState<{ planType: string; shortfallCents: number } | null>(null);
|
||
const [autoRenew, setAutoRenewState] = useState(false);
|
||
const [togglingAutoRenew, setTogglingAutoRenew] = useState(false);
|
||
|
||
const reset = useCallback(() => {
|
||
setPurchasing(null);
|
||
setError(null);
|
||
setSuccessMessage(null);
|
||
setShortfall(null);
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (!open) { reset(); return; }
|
||
setLoading(true);
|
||
setError(null);
|
||
void getAvailablePlans()
|
||
.then((data) => {
|
||
setPlans(data.plans);
|
||
setCurrentSub(data.subscription);
|
||
setBalanceCents(data.balanceCents);
|
||
setAutoRenewState(data.subscription?.autoRenew ?? false);
|
||
})
|
||
.catch((err) => setError(err instanceof Error ? err.message : '无法加载套餐列表'))
|
||
.finally(() => setLoading(false));
|
||
}, [open, reset]);
|
||
|
||
const handlePurchase = async (planType: string) => {
|
||
setPurchasing(planType);
|
||
setError(null);
|
||
setShortfall(null);
|
||
setSuccessMessage(null);
|
||
try {
|
||
const result = await purchaseSubscription(planType, autoRenew);
|
||
const plan = plans.find((p) => p.key === planType);
|
||
const renewNote = autoRenew ? ',已开启自动续费' : '';
|
||
setSuccessMessage(`已开通 ${plan?.name ?? planType}${renewNote},余额剩余 ${formatYuan(result.balanceCents)}`);
|
||
setCurrentSub(result.subscription);
|
||
setBalanceCents(result.balanceCents);
|
||
setAutoRenewState(result.subscription.autoRenew);
|
||
window.setTimeout(() => onSuccess(result.subscription, result.balanceCents), 1000);
|
||
} catch (err: unknown) {
|
||
type E = { status?: number; code?: string; message?: string; details?: Record<string, unknown> };
|
||
const e = err as E;
|
||
if (e.status === 402 || e.code === 'INSUFFICIENT_BALANCE') {
|
||
const plan = plans.find((p) => p.key === planType);
|
||
const sf = Math.max(0, (plan?.priceCents ?? 0) - balanceCents);
|
||
setShortfall({ planType, shortfallCents: sf });
|
||
} else {
|
||
setError(e.message ?? '订阅失败,请稍后重试');
|
||
}
|
||
} finally {
|
||
setPurchasing(null);
|
||
}
|
||
};
|
||
|
||
const handleToggleAutoRenew = async (enabled: boolean) => {
|
||
setTogglingAutoRenew(true);
|
||
try {
|
||
await setAutoRenew(enabled);
|
||
setAutoRenewState(enabled);
|
||
setCurrentSub((s) => s ? { ...s, autoRenew: enabled } : s);
|
||
} catch (err: unknown) {
|
||
const e = err as { message?: string };
|
||
setError(e.message ?? '设置失败');
|
||
} finally {
|
||
setTogglingAutoRenew(false);
|
||
}
|
||
};
|
||
|
||
if (!open) return null;
|
||
|
||
return createPortal(
|
||
<div className="recharge-backdrop" role="presentation" onClick={onClose}>
|
||
<div
|
||
className="subscribe-dialog"
|
||
role="dialog"
|
||
aria-modal="true"
|
||
aria-labelledby="subscribe-title"
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
{/* Header */}
|
||
<div className="subscribe-dialog-head">
|
||
<div>
|
||
<p className="recharge-eyebrow">升级套餐</p>
|
||
<h3 id="subscribe-title">选择适合你的方案</h3>
|
||
</div>
|
||
<button type="button" className="recharge-close" onClick={onClose} aria-label="关闭">✕</button>
|
||
</div>
|
||
|
||
{/* Balance row */}
|
||
<div className="subscribe-balance-row">
|
||
<span>当前余额</span>
|
||
<strong>{formatYuan(balanceCents)}</strong>
|
||
{currentSub && currentSub.planType !== 'free' && (
|
||
<span className="subscribe-current-plan-tag">
|
||
当前:{currentSub.planType} · 至 {new Date(currentSub.expiresAt).toLocaleDateString('zh-CN')}
|
||
</span>
|
||
)}
|
||
</div>
|
||
|
||
{/* Plan grid */}
|
||
{loading ? (
|
||
<p className="recharge-muted" style={{ padding: '24px 0', textAlign: 'center' }}>加载套餐中…</p>
|
||
) : (
|
||
<div className="subscribe-plan-grid">
|
||
{plans.map((plan) => {
|
||
const canAfford = balanceCents >= plan.priceCents;
|
||
const isActive = currentSub?.planType === plan.key;
|
||
const badge = PLAN_HIGHLIGHT[plan.key];
|
||
const isBuying = purchasing === plan.key;
|
||
const currentOrder = PLAN_ORDER[currentSub?.planType ?? 'free'] ?? 0;
|
||
const planOrder = PLAN_ORDER[plan.key] ?? 0;
|
||
const isDowngrade = !!currentSub && planOrder < currentOrder;
|
||
const isUpgrade = !!currentSub && !isActive && planOrder > currentOrder;
|
||
|
||
return (
|
||
<div
|
||
key={plan.key}
|
||
className={[
|
||
'subscribe-plan-card',
|
||
isActive ? 'subscribe-plan-card--active' : '',
|
||
badge === '推荐' ? 'subscribe-plan-card--highlight' : '',
|
||
isDowngrade ? 'subscribe-plan-card--locked' : '',
|
||
].filter(Boolean).join(' ')}
|
||
>
|
||
{badge && <span className="subscribe-plan-badge-top">{badge}</span>}
|
||
|
||
<div className="subscribe-plan-card-head">
|
||
<span className="subscribe-plan-name">{plan.name}</span>
|
||
<div className="subscribe-plan-price">
|
||
<span className="subscribe-plan-price-num">{formatYuan(plan.priceCents)}</span>
|
||
<span className="subscribe-plan-price-unit">/月</span>
|
||
</div>
|
||
</div>
|
||
|
||
<ul className="subscribe-plan-features">
|
||
<li>
|
||
<span className="subscribe-feat-label">对话</span>
|
||
<span className="subscribe-feat-val">{callsApprox(plan.periodTokens)}</span>
|
||
</li>
|
||
<li>
|
||
<span className="subscribe-feat-label">图片</span>
|
||
<span className="subscribe-feat-val">{imageCallsApprox(plan.periodImages)}</span>
|
||
</li>
|
||
<li>
|
||
<span className="subscribe-feat-label">Token</span>
|
||
<span className="subscribe-feat-val">{formatTokens(plan.periodTokens)}</span>
|
||
</li>
|
||
<li>
|
||
<span className="subscribe-feat-label">模型</span>
|
||
<span className="subscribe-feat-val">{MODEL_TIER_LABELS[plan.modelTier] ?? plan.modelTier}</span>
|
||
</li>
|
||
{plan.overageRate < 1 && (
|
||
<li>
|
||
<span className="subscribe-feat-label">超量</span>
|
||
<span className="subscribe-feat-val">{Math.round(plan.overageRate * 10)} 折</span>
|
||
</li>
|
||
)}
|
||
</ul>
|
||
|
||
{isActive ? (
|
||
<button className="subscribe-plan-btn subscribe-plan-btn--current" disabled>当前套餐</button>
|
||
) : isDowngrade ? (
|
||
<button className="subscribe-plan-btn subscribe-plan-btn--locked" disabled>到期后可选</button>
|
||
) : (
|
||
<button
|
||
className={`subscribe-plan-btn ${canAfford ? 'subscribe-plan-btn--buy' : 'subscribe-plan-btn--short'}`}
|
||
disabled={!!purchasing}
|
||
onClick={() => void handlePurchase(plan.key)}
|
||
>
|
||
{isBuying
|
||
? '处理中…'
|
||
: canAfford
|
||
? isUpgrade ? `升级 ${formatYuan(plan.priceCents)}` : `订阅 ${formatYuan(plan.priceCents)}`
|
||
: `差额 ${formatYuan(plan.priceCents - balanceCents)}`}
|
||
</button>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
|
||
{/* Feedback messages */}
|
||
{shortfall && !successMessage && (
|
||
<div className="subscribe-shortfall">
|
||
<span>余额不足,还差 <strong>{formatYuan(shortfall.shortfallCents)}</strong> 才能订阅该套餐</span>
|
||
<button
|
||
className="recharge-pay-btn"
|
||
onClick={() => { onClose(); onRechargeNeeded(shortfall.shortfallCents); }}
|
||
>
|
||
去充值差额
|
||
</button>
|
||
</div>
|
||
)}
|
||
{error && <p className="recharge-error">{error}</p>}
|
||
{successMessage && <p className="recharge-success">✓ {successMessage}</p>}
|
||
|
||
{/* Auto-renew toggle */}
|
||
<div className="subscribe-autorenew-row">
|
||
<label className="subscribe-autorenew-label">
|
||
<input
|
||
type="checkbox"
|
||
checked={autoRenew}
|
||
disabled={togglingAutoRenew}
|
||
onChange={(e) => {
|
||
if (currentSub && currentSub.planType !== 'free') {
|
||
void handleToggleAutoRenew(e.target.checked);
|
||
} else {
|
||
setAutoRenewState(e.target.checked);
|
||
}
|
||
}}
|
||
/>
|
||
<span>自动续费</span>
|
||
</label>
|
||
<span className="subscribe-autorenew-hint">
|
||
{currentSub && currentSub.planType !== 'free'
|
||
? autoRenew
|
||
? '到期自动扣费续订,余额不足时将不续订'
|
||
: '到期后自动降回免费版'
|
||
: autoRenew
|
||
? '订阅后将自动续费'
|
||
: '订阅后每月需手动续订'}
|
||
</span>
|
||
</div>
|
||
<p className="subscribe-legal">余额直接扣除 · 订阅立即生效</p>
|
||
</div>
|
||
</div>,
|
||
document.body,
|
||
);
|
||
}
|