feat: 官方等价对比改为轮播动效,展示所有匹配模型

- 新增 RotatingEquiv 组件,3秒间隔淡入淡出切换
- calcOfficialEquivList 返回所有匹配模型(含 Gemini 系列)
- Packages 页面和 7 个 Home 模板全部适配轮播

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
abingyydsandClaude Sonnet 4.6 committed 2026-03-25 17:10:31 +09:00
1 parent b34e81bb58
commit a6b962084d
49 files changed
+134 -82

No files matched your search

+34
View File
@@ -0,0 +1,34 @@
import React, { useState, useEffect } from 'react';
/**
* Rotating display of official price equivalents.
* Cycles through items with a fade up/down transition.
*
* @param {{ items: {label:string, equivDollars:number}[], text: (item) => string, className?: string, interval?: number }}
*/
export default function RotatingEquiv({ items, text, className = '', interval = 3000 }) {
const [idx, setIdx] = useState(0);
const [visible, setVisible] = useState(true);
useEffect(() => {
if (items.length <= 1) return;
const timer = setInterval(() => {
setVisible(false);
setTimeout(() => {
setIdx((i) => (i + 1) % items.length);
setVisible(true);
}, 300);
}, interval);
return () => clearInterval(timer);
}, [items.length, interval]);
if (!items || items.length === 0) return null;
return (
<span
className={`inline-block transition-all duration-300 ${visible ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-1'} ${className}`}
>
{text(items[idx])}
</span>
);
}
+8 -7
View File
@@ -4,7 +4,8 @@ import { useTranslation } from 'react-i18next';
import { useAuth } from '../context/AuthContext';
import { getSitePackages, getSiteModels, subscribePackage, getActiveSubscriptions, Q } from '../api';
import SpotlightCard from '../components/bits/SpotlightCard';
import { calcOfficialEquiv } from '../utils/officialEquiv';
import { calcOfficialEquivList } from '../utils/officialEquiv';
import RotatingEquiv from '../components/bits/RotatingEquiv';
import toast from 'react-hot-toast';
const resetLabelKeys = {
@@ -166,7 +167,7 @@ export default function Packages() {
const resetPeriod = pkg.quota_reset_period || 'never';
const isSubscription = resetPeriod !== 'never';
const quotaDollars = pkg.quota_amount > 0 ? pkg.quota_amount / Q : 0;
const equiv = calcOfficialEquiv(enabledModels, quotaDollars);
const equivList = calcOfficialEquivList(enabledModels, quotaDollars);
return (
<div
@@ -203,13 +204,13 @@ export default function Packages() {
</div>
{/* Official Equiv Banner */}
{equiv && equiv.equivDollars > quotaDollars && (
{equivList.length > 0 && (
<div className="bg-gradient-to-r from-amber-500/10 to-orange-500/10 border border-amber-500/20 rounded-xl p-3 mb-4">
<p className="text-xs text-page-warning font-medium">
🔥 {t('packages.officialEquiv', {
model: equiv.label,
amount: Math.round(equiv.equivDollars),
})}
🔥 <RotatingEquiv
items={equivList}
text={(item) => t('packages.officialEquiv', { model: item.label, amount: item.equivDollars })}
/>
</p>
</div>
)}
+5 -4
View File
@@ -4,7 +4,8 @@ import { useTranslation } from 'react-i18next';
import { useAuth } from '../../context/AuthContext';
import { useSite } from '../../context/SiteContext';
import { getSiteModels, getSitePackages, Q } from '../../api';
import { calcOfficialEquiv } from '../../utils/officialEquiv';
import { calcOfficialEquivList } from '../../utils/officialEquiv';
import RotatingEquiv from '../../components/bits/RotatingEquiv';
import CountUp from '../../components/bits/CountUp';
import FadeContent from '../../components/bits/FadeContent';
@@ -142,7 +143,7 @@ export default function ClaudeHome() {
<div className="grid md:grid-cols-3 gap-4 max-w-4xl">
{packages.filter(p => p.enabled).slice(0, 3).map((pkg, i) => {
const quotaDollars = pkg.quota_amount > 0 ? pkg.quota_amount / Q : 0;
const equiv = calcOfficialEquiv(enabledModels, quotaDollars);
const equiv = calcOfficialEquivList(enabledModels, quotaDollars);
return (
<div key={pkg.id} className={`rounded-xl p-6 flex flex-col border transition-all ${
i === 1 ? 'border-[#D97757]/30 bg-[#D97757]/[0.04]' : 'border-[#E8DDD0] hover:border-[#D9C5B2]'
@@ -157,8 +158,8 @@ export default function ClaudeHome() {
)}
{pkg.duration > 0 && <p className="text-xs text-[#8B7D6E] mt-1">{t('home.days', { count: pkg.duration })}</p>}
</div>
{equiv && equiv.equivDollars > quotaDollars && (
<p className="text-xs text-[#D97757] mt-2">🔥 {t('packages.officialEquiv', { model: equiv.label, amount: Math.round(equiv.equivDollars) })}</p>
{equiv.length > 0 && (
<p className="text-xs text-[#D97757] mt-2">🔥 <RotatingEquiv items={equiv} text={(item) => t('packages.officialEquiv', { model: item.label, amount: item.equivDollars })} /></p>
)}
<Link to={user ? '/packages' : '/register'} className={`mt-4 py-2.5 rounded-full font-medium text-sm text-center transition-colors ${
i === 1
+5 -4
View File
@@ -4,7 +4,8 @@ import { useTranslation } from 'react-i18next';
import { useAuth } from '../../context/AuthContext';
import { useSite } from '../../context/SiteContext';
import { getSiteModels, getSitePackages, Q } from '../../api';
import { calcOfficialEquiv } from '../../utils/officialEquiv';
import { calcOfficialEquivList } from '../../utils/officialEquiv';
import RotatingEquiv from '../../components/bits/RotatingEquiv';
import CountUp from '../../components/bits/CountUp';
import FadeContent from '../../components/bits/FadeContent';
@@ -155,7 +156,7 @@ export default function CleanHome() {
<div className="grid md:grid-cols-3 gap-4 max-w-4xl">
{packages.filter(p => p.enabled).slice(0, 3).map((pkg, i) => {
const quotaDollars = pkg.quota_amount > 0 ? pkg.quota_amount / Q : 0;
const equiv = calcOfficialEquiv(enabledModels, quotaDollars);
const equiv = calcOfficialEquivList(enabledModels, quotaDollars);
return (
<div key={pkg.id} className={`rounded-xl p-6 flex flex-col border transition-all ${
i === 1 ? 'border-blue-200 bg-blue-50/50 shadow-sm' : 'border-gray-100 hover:border-gray-200'
@@ -170,8 +171,8 @@ export default function CleanHome() {
)}
{pkg.duration > 0 && <p className="text-xs text-gray-500 mt-1">{t('home.days', { count: pkg.duration })}</p>}
</div>
{equiv && equiv.equivDollars > quotaDollars && (
<p className="text-xs text-amber-600 mt-2">🔥 {t('packages.officialEquiv', { model: equiv.label, amount: Math.round(equiv.equivDollars) })}</p>
{equiv.length > 0 && (
<p className="text-xs text-amber-600 mt-2">🔥 <RotatingEquiv items={equiv} text={(item) => t('packages.officialEquiv', { model: item.label, amount: item.equivDollars })} /></p>
)}
<Link to={user ? '/packages' : '/register'} className={`mt-4 py-2.5 rounded-lg font-medium text-sm text-center transition-colors ${
i === 1
+5 -4
View File
@@ -4,7 +4,8 @@ import { useTranslation } from 'react-i18next';
import { useAuth } from '../../context/AuthContext';
import { useSite } from '../../context/SiteContext';
import { getSiteModels, getSitePackages, Q } from '../../api';
import { calcOfficialEquiv } from '../../utils/officialEquiv';
import { calcOfficialEquivList } from '../../utils/officialEquiv';
import RotatingEquiv from '../../components/bits/RotatingEquiv';
import CountUp from '../../components/bits/CountUp';
import FadeContent from '../../components/bits/FadeContent';
@@ -154,7 +155,7 @@ export default function CorporateHome() {
<div className="grid md:grid-cols-3 gap-5 max-w-4xl">
{packages.filter(p => p.enabled).slice(0, 3).map((pkg, i) => {
const quotaDollars = pkg.quota_amount > 0 ? pkg.quota_amount / Q : 0;
const equiv = calcOfficialEquiv(enabledModels, quotaDollars);
const equiv = calcOfficialEquivList(enabledModels, quotaDollars);
return (
<div key={pkg.id} className={`rounded-xl p-6 flex flex-col border transition-all ${
i === 1 ? 'border-slate-900 shadow-sm' : 'border-slate-200 hover:border-slate-300'
@@ -169,8 +170,8 @@ export default function CorporateHome() {
)}
{pkg.duration > 0 && <p className="text-xs text-slate-500 mt-1">{t('home.days', { count: pkg.duration })}</p>}
</div>
{equiv && equiv.equivDollars > quotaDollars && (
<p className="text-xs text-amber-600 mt-2">🔥 {t('packages.officialEquiv', { model: equiv.label, amount: Math.round(equiv.equivDollars) })}</p>
{equiv.length > 0 && (
<p className="text-xs text-amber-600 mt-2">🔥 <RotatingEquiv items={equiv} text={(item) => t('packages.officialEquiv', { model: item.label, amount: item.equivDollars })} /></p>
)}
<Link to={user ? '/packages' : '/register'} className={`mt-4 py-2.5 rounded-lg font-medium text-sm text-center transition-colors ${
i === 1
+5 -4
View File
@@ -4,7 +4,8 @@ import { useTranslation } from 'react-i18next';
import { useAuth } from '../../context/AuthContext';
import { useSite } from '../../context/SiteContext';
import { getSiteModels, getSitePackages, Q } from '../../api';
import { calcOfficialEquiv } from '../../utils/officialEquiv';
import { calcOfficialEquivList } from '../../utils/officialEquiv';
import RotatingEquiv from '../../components/bits/RotatingEquiv';
import CountUp from '../../components/bits/CountUp';
import FadeContent from '../../components/bits/FadeContent';
@@ -145,7 +146,7 @@ export default function DarkHome() {
<div className="grid md:grid-cols-3 gap-4 max-w-4xl">
{packages.filter(p => p.enabled).slice(0, 3).map((pkg, i) => {
const quotaDollars = pkg.quota_amount > 0 ? pkg.quota_amount / Q : 0;
const equiv = calcOfficialEquiv(enabledModels, quotaDollars);
const equiv = calcOfficialEquivList(enabledModels, quotaDollars);
return (
<div key={pkg.id} className={`rounded-xl p-6 flex flex-col border transition-colors ${
i === 1 ? 'border-emerald-500/25 bg-emerald-500/[0.04]' : 'border-neutral-800/60 hover:border-neutral-700'
@@ -160,8 +161,8 @@ export default function DarkHome() {
)}
{pkg.duration > 0 && <p className="text-xs text-neutral-600 mt-1">{t('home.days', { count: pkg.duration })}</p>}
</div>
{equiv && equiv.equivDollars > quotaDollars && (
<p className="text-xs text-amber-300/80 mt-2">🔥 {t('packages.officialEquiv', { model: equiv.label, amount: Math.round(equiv.equivDollars) })}</p>
{equiv.length > 0 && (
<p className="text-xs text-amber-300/80 mt-2">🔥 <RotatingEquiv items={equiv} text={(item) => t('packages.officialEquiv', { model: item.label, amount: item.equivDollars })} /></p>
)}
<Link to={user ? '/packages' : '/register'} className={`mt-4 py-2.5 rounded-lg font-medium text-sm text-center transition-colors ${
i === 1
+5 -4
View File
@@ -4,7 +4,8 @@ import { useTranslation } from 'react-i18next';
import { useAuth } from '../../context/AuthContext';
import { useSite } from '../../context/SiteContext';
import { getSiteModels, getSitePackages, Q } from '../../api';
import { calcOfficialEquiv } from '../../utils/officialEquiv';
import { calcOfficialEquivList } from '../../utils/officialEquiv';
import RotatingEquiv from '../../components/bits/RotatingEquiv';
import CountUp from '../../components/bits/CountUp';
import FadeContent from '../../components/bits/FadeContent';
@@ -154,7 +155,7 @@ export default function DefaultHome() {
<div className="grid md:grid-cols-3 gap-4 max-w-4xl">
{packages.filter(p => p.enabled).slice(0, 3).map((pkg, i) => {
const quotaDollars = pkg.quota_amount > 0 ? pkg.quota_amount / Q : 0;
const equiv = calcOfficialEquiv(enabledModels, quotaDollars);
const equiv = calcOfficialEquivList(enabledModels, quotaDollars);
return (
<div key={pkg.id} className={`rounded-2xl p-6 flex flex-col border transition-colors ${
i === 1 ? 'bg-white/[0.04] border-white/[0.12]' : 'bg-white/[0.02] border-white/[0.06] hover:border-white/[0.1]'
@@ -169,8 +170,8 @@ export default function DefaultHome() {
)}
{pkg.duration > 0 && <p className="text-xs text-neutral-600 mt-1">{t('home.days', { count: pkg.duration })}</p>}
</div>
{equiv && equiv.equivDollars > quotaDollars && (
<p className="text-xs text-amber-300/80 mt-2">🔥 {t('packages.officialEquiv', { model: equiv.label, amount: Math.round(equiv.equivDollars) })}</p>
{equiv.length > 0 && (
<p className="text-xs text-amber-300/80 mt-2">🔥 <RotatingEquiv items={equiv} text={(item) => t('packages.officialEquiv', { model: item.label, amount: item.equivDollars })} /></p>
)}
<Link to={user ? '/packages' : '/register'} className={`mt-4 py-2.5 rounded-xl font-medium text-sm text-center transition-colors ${
i === 1
+5 -4
View File
@@ -4,7 +4,8 @@ import { useTranslation } from 'react-i18next';
import { useAuth } from '../../context/AuthContext';
import { useSite } from '../../context/SiteContext';
import { getSiteModels, getSitePackages, Q } from '../../api';
import { calcOfficialEquiv } from '../../utils/officialEquiv';
import { calcOfficialEquivList } from '../../utils/officialEquiv';
import RotatingEquiv from '../../components/bits/RotatingEquiv';
import CountUp from '../../components/bits/CountUp';
import FadeContent from '../../components/bits/FadeContent';
@@ -140,7 +141,7 @@ export default function MinimalHome() {
<div className="grid md:grid-cols-3 gap-4 max-w-4xl">
{packages.filter(p => p.enabled).slice(0, 3).map((pkg) => {
const quotaDollars = pkg.quota_amount > 0 ? pkg.quota_amount / Q : 0;
const equiv = calcOfficialEquiv(enabledModels, quotaDollars);
const equiv = calcOfficialEquivList(enabledModels, quotaDollars);
return (
<div key={pkg.id} className="rounded-xl p-6 flex flex-col border border-neutral-800/60 hover:border-neutral-700 transition-colors">
<h3 className="text-base font-semibold text-white">{pkg.name}</h3>
@@ -152,8 +153,8 @@ export default function MinimalHome() {
)}
{pkg.duration > 0 && <p className="text-xs text-neutral-600 mt-1">{t('home.days', { count: pkg.duration })}</p>}
</div>
{equiv && equiv.equivDollars > quotaDollars && (
<p className="text-xs text-amber-300/80 mt-2">🔥 {t('packages.officialEquiv', { model: equiv.label, amount: Math.round(equiv.equivDollars) })}</p>
{equiv.length > 0 && (
<p className="text-xs text-amber-300/80 mt-2">🔥 <RotatingEquiv items={equiv} text={(item) => t('packages.officialEquiv', { model: item.label, amount: item.equivDollars })} /></p>
)}
<Link to={user ? '/packages' : '/register'} className="mt-4 py-2.5 rounded-lg bg-neutral-800 text-white font-medium text-sm text-center hover:bg-neutral-700 transition-colors">
{user ? t('home.subscribe') : t('home.getStarted')}
+5 -4
View File
@@ -4,7 +4,8 @@ import { useTranslation } from 'react-i18next';
import { useAuth } from '../../context/AuthContext';
import { useSite } from '../../context/SiteContext';
import { getSiteModels, getSitePackages, Q } from '../../api';
import { calcOfficialEquiv } from '../../utils/officialEquiv';
import { calcOfficialEquivList } from '../../utils/officialEquiv';
import RotatingEquiv from '../../components/bits/RotatingEquiv';
import CountUp from '../../components/bits/CountUp';
import FadeContent from '../../components/bits/FadeContent';
@@ -160,7 +161,7 @@ export default function StarterHome() {
<div className="grid md:grid-cols-3 gap-4 max-w-4xl">
{packages.filter(p => p.enabled).slice(0, 3).map((pkg, i) => {
const quotaDollars = pkg.quota_amount > 0 ? pkg.quota_amount / Q : 0;
const equiv = calcOfficialEquiv(enabledModels, quotaDollars);
const equiv = calcOfficialEquivList(enabledModels, quotaDollars);
return (
<div key={pkg.id} className={`rounded-xl p-6 flex flex-col border transition-colors ${
i === 1 ? 'border-brand-500/30 bg-brand-500/[0.04]' : 'border-neutral-800/60 hover:border-neutral-700'
@@ -175,8 +176,8 @@ export default function StarterHome() {
)}
{pkg.duration > 0 && <p className="text-xs text-neutral-600 mt-1">{t('home.days', { count: pkg.duration })}</p>}
</div>
{equiv && equiv.equivDollars > quotaDollars && (
<p className="text-xs text-amber-300/80 mt-2">🔥 {t('packages.officialEquiv', { model: equiv.label, amount: Math.round(equiv.equivDollars) })}</p>
{equiv.length > 0 && (
<p className="text-xs text-amber-300/80 mt-2">🔥 <RotatingEquiv items={equiv} text={(item) => t('packages.officialEquiv', { model: item.label, amount: item.equivDollars })} /></p>
)}
<Link to={user ? '/packages' : '/register'} className={`mt-4 py-2.5 rounded-lg font-medium text-sm text-center transition-colors ${
i === 1
+26 -16
View File
@@ -2,6 +2,7 @@ import { Q } from '../api';
// Official prices (input $/1M tokens) for reference models
const OFFICIAL_PRICES = [
// Claude
{ pattern: 'claude-4-sonnet', label: 'Claude Sonnet 4', inputPerMtok: 3 },
{ pattern: 'claude-sonnet-4', label: 'Claude Sonnet 4', inputPerMtok: 3 },
{ pattern: 'claude-4.5-sonnet', label: 'Claude 4.5 Sonnet', inputPerMtok: 3 },
@@ -13,26 +14,31 @@ const OFFICIAL_PRICES = [
{ pattern: 'claude-4.6-opus', label: 'Claude Opus 4.6', inputPerMtok: 5 },
{ pattern: 'claude-3.5-haiku', label: 'Claude 3.5 Haiku', inputPerMtok: 0.8 },
{ pattern: 'claude-3-5-haiku', label: 'Claude 3.5 Haiku', inputPerMtok: 0.8 },
// OpenAI
{ pattern: 'gpt-5.1', label: 'GPT-5.1', inputPerMtok: 1.25 },
{ pattern: 'gpt-5.2', label: 'GPT-5.2', inputPerMtok: 1.75 },
{ pattern: 'gpt-5.3', label: 'GPT-5.3', inputPerMtok: 1.75 },
{ pattern: 'gpt-5.4', label: 'GPT-5.4', inputPerMtok: 2.5 },
{ pattern: 'gpt-4o', label: 'GPT-4o', inputPerMtok: 2.5 },
{ pattern: 'gpt-4.1', label: 'GPT-4.1', inputPerMtok: 2 },
// Gemini
{ pattern: 'gemini-2.5-pro', label: 'Gemini 2.5 Pro', inputPerMtok: 1.25 },
{ pattern: 'gemini-2.5-flash', label: 'Gemini 2.5 Flash', inputPerMtok: 0.15 },
{ pattern: 'gemini-2.0-flash', label: 'Gemini 2.0 Flash', inputPerMtok: 0.1 },
{ pattern: 'gemini-1.5-pro', label: 'Gemini 1.5 Pro', inputPerMtok: 1.25 },
];
/**
* Find the best official equivalent for a given quota.
* Picks the model with the highest ratio (biggest discount advantage).
*
* @param {Array} models - site models with input_price ($/1K tokens)
* @param {number} quotaDollars - package quota in USD
* @returns {{ label: string, equivDollars: number } | null}
* Find ALL official equivalents for a given quota.
* Returns array of { label, equivDollars } sorted by equivDollars desc.
* Only includes results where equivDollars > quotaDollars.
* Deduplicates by label (keeps best ratio per model label).
*/
export function calcOfficialEquiv(models, quotaDollars) {
if (!models || models.length === 0 || quotaDollars <= 0) return null;
export function calcOfficialEquivList(models, quotaDollars) {
if (!models || models.length === 0 || quotaDollars <= 0) return [];
let bestResult = null;
// label -> best equivDollars
const bestByLabel = {};
for (const m of models) {
const siteInputPrice = Number(m.input_price);
@@ -46,21 +52,25 @@ export function calcOfficialEquiv(models, quotaDollars) {
const ratio = official.inputPerMtok / sitePricePerMtok;
const equivDollars = quotaDollars * ratio;
if (!bestResult || equivDollars > bestResult.equivDollars) {
bestResult = { label: official.label, equivDollars, ratio };
if (equivDollars > quotaDollars) {
if (!bestByLabel[official.label] || equivDollars > bestByLabel[official.label]) {
bestByLabel[official.label] = equivDollars;
}
}
break;
}
}
}
return bestResult;
return Object.entries(bestByLabel)
.map(([label, equivDollars]) => ({ label, equivDollars: Math.round(equivDollars) }))
.sort((a, b) => b.equivDollars - a.equivDollars);
}
/**
* Convenience: compute equiv from raw quota units + models.
* Legacy single-result API (kept for compatibility).
*/
export function calcPkgEquiv(models, quotaAmount) {
const quotaDollars = quotaAmount > 0 ? quotaAmount / Q : 0;
return calcOfficialEquiv(models, quotaDollars);
export function calcOfficialEquiv(models, quotaDollars) {
const list = calcOfficialEquivList(models, quotaDollars);
return list.length > 0 ? list[0] : null;
}