Files
memind/src/components/SubscribeModal.tsx
T
john 9b4a25799f Add smart ACK provider for WeChat MP replies
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>
2026-06-26 15:19:03 +08:00

288 lines
11 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 { 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,
);
}