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>
This commit is contained in:
john
2026-06-26 15:19:03 +08:00
parent 9ed4fd48d7
commit 9b4a25799f
162 changed files with 17276 additions and 2054 deletions
+287
View File
@@ -0,0 +1,287 @@
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,
);
}