diff --git a/.github/workflows/ghcr-build.yml b/.github/workflows/ghcr-build.yml new file mode 100644 index 000000000000..92d027f18ff7 --- /dev/null +++ b/.github/workflows/ghcr-build.yml @@ -0,0 +1,56 @@ +name: Publish GHCR image + +on: + push: + branches: + - main + workflow_dispatch: + +jobs: + build: + name: Build and push image + runs-on: ubuntu-latest + + permissions: + contents: read + packages: write + + steps: + - name: Check out + uses: actions/checkout@v4 + + - name: Normalize GHCR repository + run: echo "GHCR_REPOSITORY=${GITHUB_REPOSITORY,,}" >> "$GITHUB_ENV" + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ghcr.io/${{ env.GHCR_REPOSITORY }} + tags: | + type=raw,value=latest,enable={{is_default_branch}} + type=ref,event=branch + type=sha,prefix=sha- + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: . + platforms: linux/amd64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + provenance: false + sbom: false diff --git a/router/api-router.go b/router/api-router.go index 83f9259b2132..0dbf97102d02 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -32,6 +32,7 @@ func SetApiRouter(router *gin.Engine) { //apiRouter.GET("/midjourney", controller.GetMidjourney) apiRouter.GET("/home_page_content", controller.GetHomePageContent) apiRouter.GET("/pricing", middleware.HeaderNavModuleAuth("pricing"), controller.GetPricing) + apiRouter.GET("/subscription/plans", controller.GetSubscriptionPlans) perfMetricsRoute := apiRouter.Group("/perf-metrics") perfMetricsRoute.Use(middleware.HeaderNavModulePublicOrUserAuth("pricing")) { @@ -151,7 +152,6 @@ func SetApiRouter(router *gin.Engine) { subscriptionRoute := apiRouter.Group("/subscription") subscriptionRoute.Use(middleware.UserAuth()) { - subscriptionRoute.GET("/plans", controller.GetSubscriptionPlans) subscriptionRoute.GET("/self", controller.GetSubscriptionSelf) subscriptionRoute.PUT("/self/preference", controller.UpdateSubscriptionPreference) subscriptionRoute.POST("/balance/pay", middleware.CriticalRateLimit(), controller.SubscriptionRequestBalancePay) diff --git a/web/default/src/components/config-drawer.tsx b/web/default/src/components/config-drawer.tsx index 5eda5152e405..4802b715e3ae 100644 --- a/web/default/src/components/config-drawer.tsx +++ b/web/default/src/components/config-drawer.tsx @@ -55,7 +55,6 @@ import { useTheme } from '@/context/theme-provider' import { type ContentLayout, THEME_PRESETS, - type ThemeFont, type ThemePreset, type ThemeRadius, type ThemeScale, @@ -107,7 +106,6 @@ export function ConfigDrawer() {
- @@ -306,90 +304,6 @@ function PresetConfig() { ) } -/** - * Font options shown in the theme drawer. - * - * Each option renders a live "Aa" preview in the font it represents. - * `Auto` deliberately leaves `fontFamily` undefined so the preview inherits - * the currently active body font — that way the user sees what `Auto` will - * actually look like for the active preset (Anthropic → serif glyphs, - * everything else → sans glyphs) without us having to duplicate the - * preset-default mapping in the UI. - */ -const FONT_OPTIONS: { - value: ThemeFont - label: string - // CSS font-family applied to the "Aa" preview. `undefined` = inherit - // from the current theme (used by the `default` option). - preview?: string -}[] = [ - { value: 'default', label: 'Auto', preview: undefined }, - { value: 'sans', label: 'Sans', preview: 'var(--font-sans)' }, - { value: 'serif', label: 'Serif', preview: 'var(--font-serif)' }, -] - -function FontConfig() { - const { t } = useTranslation() - const { defaults, customization, setFont } = useThemeCustomization() - return ( -
- setFont(defaults.font)} - /> - setFont(v as ThemeFont)} - className='grid w-full grid-cols-3 gap-4' - aria-label={t('Select body font')} - > - {FONT_OPTIONS.map((option) => ( - -
-
-
{option.label}
-
- ))} -
-
- ) -} - const RADIUS_OPTIONS: { value: ThemeRadius label: string @@ -492,7 +406,6 @@ function ScaleConfig() { { value: 'sm', label: t('Compact'), rows: 4, rowGap: '3px' }, { value: 'default', label: t('Default'), rows: 3, rowGap: '6px' }, { value: 'lg', label: t('Comfortable'), rows: 2, rowGap: '10px' }, - { value: 'xl', label: t('Super Large'), rows: 1, rowGap: '14px' }, ] return (
@@ -504,7 +417,7 @@ function ScaleConfig() { setScale(v as ThemeScale)} - className='grid w-full grid-cols-4 gap-3' + className='grid w-full grid-cols-3 gap-4' aria-label={t('Select interface density')} > {scaleOptions.map((option) => ( diff --git a/web/default/src/components/content-language-select.tsx b/web/default/src/components/content-language-select.tsx new file mode 100644 index 000000000000..92f0128f8f0f --- /dev/null +++ b/web/default/src/components/content-language-select.tsx @@ -0,0 +1,73 @@ +/* +Copyright (C) 2023-2026 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ +import { useTranslation } from 'react-i18next' + +import { FormLabel } from '@/components/ui/form' +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { + INTERFACE_LANGUAGE_OPTIONS, + type InterfaceLanguageCode, +} from '@/i18n/languages' + +export type EditableContentLocale = 'default' | InterfaceLanguageCode + +interface ContentLanguageSelectProps { + value: EditableContentLocale + onValueChange: (value: EditableContentLocale) => void +} + +export function ContentLanguageSelect({ + value, + onValueChange, +}: ContentLanguageSelectProps) { + const { t } = useTranslation() + + return ( +
+ {t('Translation')} + +
+ ) +} diff --git a/web/default/src/components/data-table/layout/card-row-content.tsx b/web/default/src/components/data-table/layout/card-row-content.tsx index aadaf746d4b4..94edc7e052f9 100644 --- a/web/default/src/components/data-table/layout/card-row-content.tsx +++ b/web/default/src/components/data-table/layout/card-row-content.tsx @@ -110,7 +110,7 @@ function CompactContent({ row }: { row: Row }) { return (
{label && ( -
+
{label}
)} @@ -180,7 +180,7 @@ function FallbackContent({ row }: { row: Row }) { key={cell.id} className='flex items-start justify-between gap-2 overflow-hidden' > - + {label}
diff --git a/web/default/src/components/layout/components/authenticated-layout.tsx b/web/default/src/components/layout/components/authenticated-layout.tsx index 237031a2a3c9..cc01d0404c97 100644 --- a/web/default/src/components/layout/components/authenticated-layout.tsx +++ b/web/default/src/components/layout/components/authenticated-layout.tsx @@ -39,7 +39,7 @@ export function AuthenticatedLayout(props: AuthenticatedLayoutProps) { - +
- + {title} diff --git a/web/default/src/components/layout/components/public-header.tsx b/web/default/src/components/layout/components/public-header.tsx index 43a7e9c91a41..205aadc00d90 100644 --- a/web/default/src/components/layout/components/public-header.tsx +++ b/web/default/src/components/layout/components/public-header.tsx @@ -209,7 +209,7 @@ export function PublicHeader(props: PublicHeaderProps) { /> )}
- + {loading ? : displaySiteName} @@ -229,7 +229,7 @@ export function PublicHeader(props: PublicHeaderProps) { tabIndex={link.disabled ? -1 : undefined} onClick={(event) => handleNavLinkClick(event, link)} className={cn( - 'text-muted-foreground hover:text-foreground rounded-lg px-3 py-1.5 text-[13px] font-medium transition-colors duration-200', + 'text-muted-foreground hover:text-foreground rounded-lg px-3 py-1.5 text-sm font-medium transition-colors duration-200', link.disabled && 'pointer-events-none opacity-50' )} > @@ -244,7 +244,7 @@ export function PublicHeader(props: PublicHeaderProps) { disabled={link.disabled} onClick={(event) => handleNavLinkClick(event, link)} className={cn( - 'rounded-lg px-3 py-1.5 text-[13px] font-medium transition-colors duration-200', + 'rounded-lg px-3 py-1.5 text-sm font-medium transition-colors duration-200', isActive ? 'text-foreground' : 'text-muted-foreground hover:text-foreground', @@ -287,7 +287,7 @@ export function PublicHeader(props: PublicHeaderProps) { ) : ( - ) - } - return ( - - ) - } + const { systemName } = useSystemConfig() return ( -
+
{/* Radial gradient background */}
@@ -99,147 +52,66 @@ export function Hero(props: HeroProps) { className='absolute inset-0 -z-10 bg-[linear-gradient(to_right,var(--border)_1px,transparent_1px),linear-gradient(to_bottom,var(--border)_1px,transparent_1px)] [mask-image:radial-gradient(ellipse_60%_50%_at_50%_30%,black_20%,transparent_100%)] bg-[size:4rem_4rem] opacity-[0.08]' /> -
- {/* Left Column: Title, description, action buttons and application support */} -
- {/* Top Pill Badge */} -
- - - - - {t('AI Application Infrastructure Foundation')} -
- -

- {t('Unified API Gateway for')} -
- - {t('Vast Range of AI Models')} - -

-

- {t( - 'Access a vast selection of models via a standard, unified API protocol. Power AI applications, manage digital assets, and connect the Future.' - )} -

- -
- {props.isAuthenticated ? ( - <> - - {renderDocsButton()} - - ) : ( - <> - - - {renderDocsButton()} - - )} -
- - {/* Supported Apps (参考图二样式,进行卡片化和信息扩充设计,增加视觉高度) */} -
-
- - {t('Supported Applications')} - -

- {t( - 'Supports one-click configuration and perfectly adapts to NewAPI multi-protocol configuration.' - )} -

-
- + {t('Get Started')} + + + + + )}
+
- {/* Right Column: Hero Terminal API Demo */} -
- -
+
+
+
) } diff --git a/web/default/src/features/home/components/sections/how-it-works.tsx b/web/default/src/features/home/components/sections/how-it-works.tsx index 2959fd8785c1..b4f7fb285f27 100644 --- a/web/default/src/features/home/components/sections/how-it-works.tsx +++ b/web/default/src/features/home/components/sections/how-it-works.tsx @@ -18,7 +18,6 @@ For commercial licensing, please contact support@quantumnous.com */ import { Settings, Zap, BarChart3 } from 'lucide-react' import { useTranslation } from 'react-i18next' - import { AnimateInView } from '@/components/animate-in-view' export function HowItWorks() { diff --git a/web/default/src/features/home/components/sections/model-pricing.tsx b/web/default/src/features/home/components/sections/model-pricing.tsx new file mode 100644 index 000000000000..d37f30e25b3c --- /dev/null +++ b/web/default/src/features/home/components/sections/model-pricing.tsx @@ -0,0 +1,67 @@ +/* +Copyright (C) 2023-2026 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ +import { useTranslation } from 'react-i18next' +import { AnimateInView } from '@/components/animate-in-view' +import { modelPricingConfig } from '../../model-pricing-config' + +interface ModelPricingProps { + className?: string +} + +export function ModelPricing(_props: ModelPricingProps) { + const { t } = useTranslation() + + return ( +
+
+ +

+ {t('Model Pricing')} +

+

+ {t('Mainstream model prices at a glance')} +

+

+ {t('Official price, current price, and discount are dynamically calculated from backend pricing configuration.')} +

+
+ +
+
+ {t('Model')} + {t('Cache Hit')} +
+
+ {modelPricingConfig.map((item) => ( +
+ {item.name} + + {item.cacheHit || '-'} + +
+ ))} +
+
+
+
+ ) +} diff --git a/web/default/src/features/home/hooks/use-home-page-content.ts b/web/default/src/features/home/hooks/use-home-page-content.ts index be6f9e5d96a7..fb40c31fbd0a 100644 --- a/web/default/src/features/home/hooks/use-home-page-content.ts +++ b/web/default/src/features/home/hooks/use-home-page-content.ts @@ -16,12 +16,9 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import i18next from 'i18next' import { useEffect, useState } from 'react' +import i18next from 'i18next' import { toast } from 'sonner' - -import { isHttpUrl } from '@/lib/content-format' - import { getHomePageContent } from '../api' import type { HomePageContentResult } from '../types' @@ -78,7 +75,13 @@ export function useHomePageContent(): HomePageContentResult { } }, []) - const isUrl = isHttpUrl(content) + let isUrl = false + try { + const url = new URL(content) + isUrl = url.protocol === 'http:' || url.protocol === 'https:' + } catch { + // not a URL + } return { content, isLoaded, isUrl } } diff --git a/web/default/src/features/home/index.tsx b/web/default/src/features/home/index.tsx index abfc9a291730..6ae435a7d7ea 100644 --- a/web/default/src/features/home/index.tsx +++ b/web/default/src/features/home/index.tsx @@ -16,49 +16,430 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import { useCallback, useEffect, useRef } from 'react' +import { useQuery } from '@tanstack/react-query' +import { Link } from '@tanstack/react-router' +import { + BadgeCheck, + Cable, + Check, + Copy, + EyeOff, + Gauge, + Play, + ShieldCheck, +} from 'lucide-react' +import { useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' +import { toast } from 'sonner' +import { IconGithub } from '@/assets/brand-icons' import { PublicLayout } from '@/components/layout' import { Footer } from '@/components/layout/components/footer' -import { RichContent } from '@/components/rich-content' -import { useTheme } from '@/context/theme-provider' -import { isLikelyHtml } from '@/lib/content-format' -import { useAuthStore } from '@/stores/auth-store' +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from '@/components/ui/accordion' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { + Card, + CardContent, + CardFooter, + CardHeader, + CardTitle, +} from '@/components/ui/card' +import { Input } from '@/components/ui/input' +import { Markdown } from '@/components/ui/markdown' +import { Separator } from '@/components/ui/separator' +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' +import { getPricing } from '@/features/pricing/api' +import { QUOTA_TYPE_VALUES } from '@/features/pricing/constants' +import type { PricingModel } from '@/features/pricing/types' +import { getPublicPlans } from '@/features/subscriptions/api' +import { formatDuration, formatResetPeriod } from '@/features/subscriptions/lib' +import { api } from '@/lib/api' +import { formatSubscriptionPlanPrice, getCurrencyDisplay } from '@/lib/currency' +import { formatQuota } from '@/lib/format' +import { + getLocalizedField, + type ContentTranslations, +} from '@/lib/localized-content' +import { cn } from '@/lib/utils' +import { useSystemConfigStore } from '@/stores/system-config-store' -import { CTA, Features, Hero, HowItWorks, Stats } from './components' -import { useHomePageContent } from './hooks' +import { modelPricingConfig, pricingHeaderConfig } from './model-pricing-config' + +interface ModelPricingRow { + name: string + inputPrice: string + outputPrice: string + officialInput: string + officialOutput: string + discount: string +} + +interface HomePricingResponse { + data?: PricingModel[] + group_ratio?: Record + usable_group?: Record +} + +interface HomeStatusResponse { + data?: { + faq?: HomeFAQItem[] + faq_enabled?: boolean + server_address?: string + serverAddress?: string + } +} + +interface HomeFAQItem { + id?: number + question: string + answer: string + translations?: ContentTranslations +} + +const TRUST_SIGNALS = [ + { label: 'Officially funded accounts', icon: BadgeCheck }, + { label: 'Direct official access', icon: Cable }, + { label: 'Stable high concurrency', icon: Gauge }, + { label: 'No model downgrades or substitutions', icon: ShieldCheck }, + { label: 'Zero retention', icon: EyeOff }, +] as const + +function hasNumber(value: number | null | undefined): value is number { + return typeof value === 'number' && Number.isFinite(value) +} + +function truncateDecimal(value: number, digits: number): number { + const sign = value < 0 ? '-' : '' + const normalized = Math.abs(value).toFixed(digits + 8) + const [integerPart, fractionPart = ''] = normalized.split('.') + const truncatedFraction = fractionPart.slice(0, digits).replace(/0+$/, '') + return Number( + `${sign}${integerPart}${truncatedFraction ? `.${truncatedFraction}` : ''}` + ) +} + +function formatTruncatedCurrency( + value: number, + symbol: string, + currencyCode?: string +): string { + const truncatedValue = truncateDecimal(value, 4) + if (currencyCode) { + return new Intl.NumberFormat(undefined, { + style: 'currency', + currency: currencyCode, + currencyDisplay: 'narrowSymbol', + minimumFractionDigits: 0, + maximumFractionDigits: 4, + }).format(truncatedValue) + } + + const formattedNumber = new Intl.NumberFormat(undefined, { + minimumFractionDigits: 0, + maximumFractionDigits: 4, + }).format(truncatedValue) + return `${symbol}${formattedNumber}` +} + +function formatPrice(value: number | null | undefined): string { + if (!hasNumber(value) || value <= 0) return '-' + const { meta } = getCurrencyDisplay() + if (meta.kind === 'custom') { + return formatTruncatedCurrency(value * meta.exchangeRate, meta.symbol) + } + if (meta.kind === 'currency') { + return formatTruncatedCurrency( + value * meta.exchangeRate, + meta.symbol, + meta.currencyCode + ) + } + return formatTruncatedCurrency(value, '$', 'USD') +} + +function getModelUsableGroupRatios( + model: PricingModel, + groupRatios: Record, + usableGroups: Record +): number[] { + const groups = Array.isArray(model.enable_groups) ? model.enable_groups : [] + const usableRatios: number[] = [] + + for (const group of groups) { + if (!(group in usableGroups)) continue + const ratio = groupRatios[group] + if (hasNumber(ratio) && ratio > 0) { + usableRatios.push(ratio) + } + } + + return usableRatios.length > 0 ? usableRatios : [1] +} + +function getPriceRangeUSD( + model: PricingModel, + groupRatios: Record, + usableGroups: Record, + getValue: (base: number, pricingModel: PricingModel) => number +): { min: number; max: number } | null { + if (model.quota_type !== QUOTA_TYPE_VALUES.TOKEN) return null + const ranges = getModelUsableGroupRatios(model, groupRatios, usableGroups) + .map((ratio) => { + const base = model.model_ratio * 2 * ratio + return getValue(base, model) + }) + .filter((value) => Number.isFinite(value) && value > 0) + + if (ranges.length === 0) return null + + return { + min: Math.min(...ranges), + max: Math.max(...ranges), + } +} + +function getInputPriceRangeUSD( + model: PricingModel, + groupRatios: Record, + usableGroups: Record +): { min: number; max: number } | null { + return getPriceRangeUSD(model, groupRatios, usableGroups, (base) => base) +} + +function getOutputPriceRangeUSD( + model: PricingModel, + groupRatios: Record, + usableGroups: Record +): { min: number; max: number } | null { + return getPriceRangeUSD( + model, + groupRatios, + usableGroups, + (base, pricingModel) => base * pricingModel.completion_ratio + ) +} + +function getConfiguredInputPriceUSD(model: PricingModel): number | null { + if (model.quota_type !== QUOTA_TYPE_VALUES.TOKEN) return null + const value = model.model_ratio * 2 + return Number.isFinite(value) && value > 0 ? value : null +} + +function getConfiguredOutputPriceUSD(model: PricingModel): number | null { + const inputPrice = getConfiguredInputPriceUSD(model) + if (!hasNumber(inputPrice)) return null + const value = inputPrice * model.completion_ratio + return Number.isFinite(value) && value > 0 ? value : null +} + +function formatOfficialPrice(value: number | null): string { + if (!hasNumber(value) || value <= 0) return '-' + return formatTruncatedCurrency(value, '$', 'USD') +} + +function getDiscountPercent(actual: number, official: number): number | null { + if (!hasNumber(actual) || !hasNumber(official) || official <= 0) return null + return (1 - actual / official) * 100 +} + +function formatDiscountPercent(value: number | null): string { + if (!hasNumber(value)) return '-' + const rounded = Math.round(value) + if (rounded > 0) return `-${rounded}%` + if (rounded < 0) return `+${Math.abs(rounded)}%` + return '0%' +} export function Home() { - const { i18n, t } = useTranslation() - const iframeRef = useRef(null) - const { resolvedTheme } = useTheme() - const { auth } = useAuthStore() - const isAuthenticated = !!auth.user - const { content, isLoaded, isUrl } = useHomePageContent() - - const syncIframePreferences = useCallback(() => { - try { - iframeRef.current?.contentWindow?.postMessage( - { themeMode: resolvedTheme }, - '*' - ) - iframeRef.current?.contentWindow?.postMessage( - { lang: i18n.language }, - '*' + const { t, i18n } = useTranslation() + const { config } = useSystemConfigStore() + const [homePageContent, setHomePageContent] = useState('') + const [homePageContentLoaded, setHomePageContentLoaded] = useState(false) + const [showAllPricingModels, setShowAllPricingModels] = useState(false) + const isChinese = i18n.language.startsWith('zh') + const isDemoSiteMode = config.demoSiteEnabled || false + const { data: statusData } = useQuery({ + queryKey: ['home-status'], + queryFn: async () => { + const response = await api.get('/api/status') + return response.data + }, + staleTime: 5 * 60 * 1000, + }) + const serverAddress = + statusData?.data?.server_address || + statusData?.data?.serverAddress || + (typeof window !== 'undefined' ? window.location.origin : '') + const faqItems = useMemo(() => { + const items = + statusData?.data?.faq_enabled === false ? [] : statusData?.data?.faq || [] + return items.map((item) => ({ + ...item, + question: getLocalizedField(item, 'question', i18n.resolvedLanguage, t), + answer: getLocalizedField(item, 'answer', i18n.resolvedLanguage, t), + })) + }, [i18n.resolvedLanguage, statusData, t]) + const { data: pricingData } = useQuery({ + queryKey: ['home-pricing'], + queryFn: getPricing, + staleTime: 5 * 60 * 1000, + }) + const { data: subscriptionPlansData } = useQuery({ + queryKey: ['home-subscription-plans'], + queryFn: getPublicPlans, + staleTime: 5 * 60 * 1000, + }) + + const subscriptionPlans = useMemo(() => { + return (subscriptionPlansData?.data || []) + .filter((item) => item.plan?.enabled) + .map((item) => ({ + ...item, + plan: { + ...item.plan, + title: getLocalizedField( + item.plan, + 'title', + i18n.resolvedLanguage, + t + ), + subtitle: getLocalizedField( + item.plan, + 'subtitle', + i18n.resolvedLanguage, + t + ), + }, + })) + .sort( + (a, b) => + Number(b.plan?.sort_order || 0) - Number(a.plan?.sort_order || 0) ) + }, [i18n.resolvedLanguage, subscriptionPlansData, t]) + + const subscriptionPlanGroups = useMemo( + () => + [ + { + value: 'credit', + labelKey: 'Credit Plans', + plans: subscriptionPlans.filter( + (item) => item.plan.quota_reset_period === 'never' + ), + }, + { + value: 'reset', + labelKey: 'Reset Plans', + plans: subscriptionPlans.filter( + (item) => item.plan.quota_reset_period !== 'never' + ), + }, + ].filter((group) => group.plans.length > 0), + [subscriptionPlans] + ) + + const modelPricingRows = useMemo(() => { + const pricingModels = pricingData?.data || [] + const groupRatios = pricingData?.group_ratio || {} + const usableGroups = pricingData?.usable_group || {} + const modelMap = new Map( + pricingModels.map((model) => [model.model_name, model]) + ) + + return modelPricingConfig + .map((configItem) => { + const model = modelMap.get(configItem.name) + if (!model) { + if ( + !hasNumber(configItem.officialInputPrice) || + !hasNumber(configItem.officialOutputPrice) + ) { + return null + } + + return { + name: configItem.name, + inputPrice: '-', + outputPrice: '-', + officialInput: formatOfficialPrice(configItem.officialInputPrice), + officialOutput: formatOfficialPrice(configItem.officialOutputPrice), + discount: '-', + } + } + + if (model.quota_type !== QUOTA_TYPE_VALUES.TOKEN) { + return null + } + + const inputPriceRange = getInputPriceRangeUSD( + model, + groupRatios, + usableGroups + ) + const outputPriceRange = getOutputPriceRangeUSD( + model, + groupRatios, + usableGroups + ) + const configuredInputPrice = getConfiguredInputPriceUSD(model) + const configuredOutputPrice = getConfiguredOutputPriceUSD(model) + const officialInputPrice = + configItem.officialInputPrice ?? configuredInputPrice + const officialOutputPrice = + configItem.officialOutputPrice ?? configuredOutputPrice + const minimumDiscount = + inputPriceRange && hasNumber(officialInputPrice) + ? getDiscountPercent(inputPriceRange.min, officialInputPrice) + : null + + return { + name: configItem.name, + inputPrice: formatPrice(inputPriceRange?.min), + outputPrice: formatPrice(outputPriceRange?.min), + officialInput: formatOfficialPrice(officialInputPrice), + officialOutput: formatOfficialPrice(officialOutputPrice), + discount: formatDiscountPercent(minimumDiscount), + } + }) + .filter((item): item is ModelPricingRow => item !== null) + }, [pricingData]) + + const displayHomePageContent = async () => { + const cached = localStorage.getItem('home_page_content') || '' + setHomePageContent(cached) + try { + const res = await api.get('/api/home_page_content') + const { success, data } = res.data + if (success) { + setHomePageContent(data) + localStorage.setItem('home_page_content', data) + } + } catch (error) { + console.error('加载首页内容失败:', error) + } + setHomePageContentLoaded(true) + } + + const handleCopyBaseURL = async () => { + try { + await navigator.clipboard.writeText(serverAddress) + toast.success(t('Copied to clipboard')) } catch { - // Cross-origin frames may reject access while navigating. + toast.error(t('Copy failed')) } - }, [i18n.language, resolvedTheme]) + } useEffect(() => { - if (isUrl) { - syncIframePreferences() - } - }, [isUrl, syncIframePreferences]) + displayHomePageContent() + }, []) - if (!isLoaded) { + if (!homePageContentLoaded) { return (
@@ -68,57 +449,381 @@ export function Home() { ) } - if (content) { - if (isUrl) { - return ( - -