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,
);
}