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/context/theme-customization-provider.tsx b/web/default/src/context/theme-customization-provider.tsx index 2d404f81506e..f6d3c6d3b4a5 100644 --- a/web/default/src/context/theme-customization-provider.tsx +++ b/web/default/src/context/theme-customization-provider.tsx @@ -30,14 +30,11 @@ import { CONTENT_LAYOUT_VALUES, type ContentLayout, DEFAULT_THEME_CUSTOMIZATION, - resolveThemeFont, THEME_COOKIE_KEYS, - THEME_FONT_VALUES, THEME_PRESET_VALUES, THEME_RADIUS_VALUES, THEME_SCALE_VALUES, type ThemeCustomization, - type ThemeFont, type ThemePreset, type ThemeRadius, type ThemeScale, @@ -69,7 +66,6 @@ type ThemeCustomizationContextType = { defaults: ThemeCustomization customization: ThemeCustomization setPreset: (preset: ThemePreset) => void - setFont: (font: ThemeFont) => void setRadius: (radius: ThemeRadius) => void setScale: (scale: ThemeScale) => void setContentLayout: (contentLayout: ContentLayout) => void @@ -84,7 +80,6 @@ const FALLBACK_CONTEXT: ThemeCustomizationContextType = { defaults: DEFAULT_THEME_CUSTOMIZATION, customization: DEFAULT_THEME_CUSTOMIZATION, setPreset: () => {}, - setFont: () => {}, setRadius: () => {}, setScale: () => {}, setContentLayout: () => {}, @@ -104,13 +99,6 @@ export function ThemeCustomizationProvider(props: { DEFAULT_THEME_CUSTOMIZATION.preset ) ) - const [font, _setFont] = useState(() => - readCookie( - THEME_COOKIE_KEYS.font, - THEME_FONT_VALUES, - DEFAULT_THEME_CUSTOMIZATION.font - ) - ) const [radius, _setRadius] = useState(() => readCookie( THEME_COOKIE_KEYS.radius, @@ -142,15 +130,10 @@ export function ThemeCustomizationProvider(props: { ) }, [preset]) - // Font is the one axis where we resolve before writing the attribute: - // the persisted preference may be `default`, but CSS works in terms of - // the concrete `sans`/`serif` choice that should drive the cascade. - // Resolving here (instead of in CSS via `:not()` selectors) keeps the - // stylesheet to one simple `[data-theme-font='serif']` selector and lets - // future presets opt into typography via `PRESET_DEFAULT_FONT` alone. useEffect(() => { - applyAttribute('data-theme-font', resolveThemeFont(font, preset)) - }, [font, preset]) + applyAttribute('data-theme-font', null) + removeCookie('theme_font') + }, []) useEffect(() => { applyAttribute( @@ -179,15 +162,6 @@ export function ThemeCustomizationProvider(props: { } }, []) - const setFont = useCallback((value: ThemeFont) => { - _setFont(value) - if (value === DEFAULT_THEME_CUSTOMIZATION.font) { - removeCookie(THEME_COOKIE_KEYS.font) - } else { - setCookie(THEME_COOKIE_KEYS.font, value, COOKIE_MAX_AGE) - } - }, []) - const setRadius = useCallback((value: ThemeRadius) => { _setRadius(value) if (value === DEFAULT_THEME_CUSTOMIZATION.radius) { @@ -217,18 +191,16 @@ export function ThemeCustomizationProvider(props: { const resetCustomization = useCallback(() => { setPreset(DEFAULT_THEME_CUSTOMIZATION.preset) - setFont(DEFAULT_THEME_CUSTOMIZATION.font) setRadius(DEFAULT_THEME_CUSTOMIZATION.radius) setScale(DEFAULT_THEME_CUSTOMIZATION.scale) setContentLayout(DEFAULT_THEME_CUSTOMIZATION.contentLayout) - }, [setPreset, setFont, setRadius, setScale, setContentLayout]) + }, [setPreset, setRadius, setScale, setContentLayout]) const value = useMemo( () => ({ defaults: DEFAULT_THEME_CUSTOMIZATION, - customization: { preset, font, radius, scale, contentLayout }, + customization: { preset, radius, scale, contentLayout }, setPreset, - setFont, setRadius, setScale, setContentLayout, @@ -236,12 +208,10 @@ export function ThemeCustomizationProvider(props: { }), [ preset, - font, radius, scale, contentLayout, setPreset, - setFont, setRadius, setScale, setContentLayout, diff --git a/web/default/src/features/home/api.ts b/web/default/src/features/home/api.ts index e15928ec865a..009de4e4b185 100644 --- a/web/default/src/features/home/api.ts +++ b/web/default/src/features/home/api.ts @@ -17,7 +17,6 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ import { api } from '@/lib/api' - import type { HomePageContentResponse } from './types' // ============================================================================ diff --git a/web/default/src/features/home/components/gateway-card.tsx b/web/default/src/features/home/components/gateway-card.tsx index df4c70c648e8..cfd35cf080c8 100644 --- a/web/default/src/features/home/components/gateway-card.tsx +++ b/web/default/src/features/home/components/gateway-card.tsx @@ -17,9 +17,7 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ import { useTranslation } from 'react-i18next' - import { Separator } from '@/components/ui/separator' - import { getGatewayFeatures } from '../constants' interface GatewayCardProps { diff --git a/web/default/src/features/home/components/hero-buttons.tsx b/web/default/src/features/home/components/hero-buttons.tsx index 7ee90475c7b2..90913db2d26c 100644 --- a/web/default/src/features/home/components/hero-buttons.tsx +++ b/web/default/src/features/home/components/hero-buttons.tsx @@ -19,7 +19,6 @@ For commercial licensing, please contact support@quantumnous.com import { Link } from '@tanstack/react-router' import { ArrowRight } from 'lucide-react' import { useTranslation } from 'react-i18next' - import { Button } from '@/components/ui/button' interface HeroButtonsProps { diff --git a/web/default/src/features/home/components/hero-terminal-demo.tsx b/web/default/src/features/home/components/hero-terminal-demo.tsx index 2b4651a20d17..92113ce7b13b 100644 --- a/web/default/src/features/home/components/hero-terminal-demo.tsx +++ b/web/default/src/features/home/components/hero-terminal-demo.tsx @@ -17,7 +17,6 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ import { useState, useEffect, useRef, type ReactNode } from 'react' - import { cn } from '@/lib/utils' type AccentTone = 'emerald' | 'amber' | 'blue' | 'violet' @@ -164,11 +163,7 @@ const API_DEMOS: ApiDemoConfig[] = [ const CYCLE_INTERVAL = 4500 const TRANSITION_MS = 220 -interface HeroTerminalDemoProps { - className?: string -} - -export function HeroTerminalDemo(props: HeroTerminalDemoProps) { +export function HeroTerminalDemo() { const [activeIndex, setActiveIndex] = useState(0) const [transitioning, setTransitioning] = useState(false) const intervalRef = useRef>(undefined) @@ -207,7 +202,7 @@ export function HeroTerminalDemo(props: HeroTerminalDemoProps) { const accent = ACCENT_CLASSES[demo.accent] return ( -
+
. For commercial licensing, please contact support@quantumnous.com */ import { cn } from '@/lib/utils' - import { IconCard } from './icon-card' interface ScrollingIconsProps { diff --git a/web/default/src/features/home/components/sections/cta.tsx b/web/default/src/features/home/components/sections/cta.tsx index cffb4ca64da5..929069a30815 100644 --- a/web/default/src/features/home/components/sections/cta.tsx +++ b/web/default/src/features/home/components/sections/cta.tsx @@ -19,9 +19,8 @@ For commercial licensing, please contact support@quantumnous.com import { Link } from '@tanstack/react-router' import { ArrowRight } from 'lucide-react' import { useTranslation } from 'react-i18next' - -import { AnimateInView } from '@/components/animate-in-view' import { Button } from '@/components/ui/button' +import { AnimateInView } from '@/components/animate-in-view' interface CTAProps { className?: string diff --git a/web/default/src/features/home/components/sections/features.tsx b/web/default/src/features/home/components/sections/features.tsx index 79ea703a1f23..005e56143f38 100644 --- a/web/default/src/features/home/components/sections/features.tsx +++ b/web/default/src/features/home/components/sections/features.tsx @@ -27,7 +27,6 @@ import { HeartHandshake, } from 'lucide-react' import { useTranslation } from 'react-i18next' - import { AnimateInView } from '@/components/animate-in-view' interface FeaturesProps { diff --git a/web/default/src/features/home/components/sections/hero.tsx b/web/default/src/features/home/components/sections/hero.tsx index 4a08151a59fb..1612671b9b12 100644 --- a/web/default/src/features/home/components/sections/hero.tsx +++ b/web/default/src/features/home/components/sections/hero.tsx @@ -16,14 +16,11 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import { CherryStudio } from '@lobehub/icons' import { Link } from '@tanstack/react-router' -import { ArrowRight, BookOpen } from 'lucide-react' +import { ArrowRight } from 'lucide-react' import { useTranslation } from 'react-i18next' - +import { useSystemConfig } from '@/hooks/use-system-config' import { Button } from '@/components/ui/button' -import { useStatus } from '@/hooks/use-status' - import { HeroTerminalDemo } from '../hero-terminal-demo' interface HeroProps { @@ -31,65 +28,21 @@ interface HeroProps { isAuthenticated?: boolean } -// Stylized three-dots indicator representing "More" -const MoreIcon = () => ( - - - - - -) - export function Hero(props: HeroProps) { const { t } = useTranslation() - const { status } = useStatus() - const docsUrl = - (status?.docs_link as string | undefined) || 'https://docs.newapi.pro' - - const renderDocsButton = () => { - const isExternal = docsUrl.startsWith('http') - if (isExternal) { - return ( - - ) - } - 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..0724958eb161 --- /dev/null +++ b/web/default/src/features/home/components/sections/model-pricing.tsx @@ -0,0 +1,83 @@ +/* +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, pricingCurrencyConfig } from '../../model-pricing-config' + +interface ModelPricingProps { + className?: string +} + +function formatPrice(value?: number): string { + if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) { + return '-' + } + const { symbol } = pricingCurrencyConfig + return `${symbol}${value.toFixed(value >= 1 ? 2 : 4).replace(/\.0+$/, '').replace(/(\.\d*?)0+$/, '$1')}` +} + +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('Official Input')} + {t('Official Output')} + {t('Cache Hit')} +
+
+ {modelPricingConfig.map((item) => ( +
+ {item.name} + + {formatPrice(item.officialInput)} + + + {formatPrice(item.officialOutput)} + + + {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..9a04227833bd 100644 --- a/web/default/src/features/home/index.tsx +++ b/web/default/src/features/home/index.tsx @@ -16,49 +16,429 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import { useCallback, useEffect, useRef } from 'react' +import { useEffect, useMemo, useState } from 'react' +import { GitHubLogoIcon } from '@radix-ui/react-icons' +import { useQuery } from '@tanstack/react-query' +import { Link } from '@tanstack/react-router' +import { Check, Copy, Play } from 'lucide-react' import { useTranslation } from 'react-i18next' - +import { toast } from 'sonner' +import { useSystemConfigStore } from '@/stores/system-config-store' +import { api } from '@/lib/api' +import { getCurrencyDisplay } from '@/lib/currency' +import { formatQuota } from '@/lib/format' +import { cn } from '@/lib/utils' +import { Alert, AlertDescription } from '@/components/ui/alert' +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 { Separator } from '@/components/ui/separator' 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 { 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 { + imageModelPricingConfig, + imagePricingHeaderConfig, + modelPricingConfig, + pricingHeaderConfig, + pricingNoticeConfig, +} from './model-pricing-config' + +interface ModelPricingRow { + name: string + inputPrice: string + outputPrice: string + officialInput: string + officialOutput: string + discount: string + cacheHit: string +} + +interface ImageModelPricingRow { + name: string + types: string + price: string +} + +interface HomePricingResponse { + data?: PricingModel[] + group_ratio?: Record + usable_group?: Record +} + +interface HomeStatusResponse { + data?: { + server_address?: string + serverAddress?: string + } +} + +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 formatSubscriptionPrice(amount: number | string): string { + const numeric = + typeof amount === 'number' ? amount : Number.parseFloat(String(amount)) + if (!Number.isFinite(numeric)) return '-' + return `¥${numeric.toFixed(2)}` +} + +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 + ) +} -import { CTA, Features, Hero, HowItWorks, Stats } from './components' -import { useHomePageContent } from './hooks' +function getImagePriceRangeUSD( + model: PricingModel, + groupRatios: Record, + usableGroups: Record, + multiplier: number +): { min: number; max: number } | null { + const groups = getModelUsableGroupRatios(model, groupRatios, usableGroups) + const ranges = groups + .map((ratio) => { + if (model.quota_type === QUOTA_TYPE_VALUES.REQUEST) { + return (model.model_price || 0) * ratio * multiplier + } + + const imageRatio = hasNumber(model.image_ratio) + ? Number(model.image_ratio) + : 1 + return model.model_ratio * 2 * ratio * imageRatio * multiplier + }) + .filter((value) => Number.isFinite(value) && value > 0) + + if (ranges.length === 0) return null + + return { + min: Math.min(...ranges), + max: Math.max(...ranges), + } +} + +function formatPriceRange(range: { min: number; max: number } | null): string { + if (!range) return '-' + if (Math.abs(range.min - range.max) < 0.000001) { + return formatPrice(range.min) + } + return `${formatPrice(range.min)}~${formatPrice(range.max)}` +} + +function formatPerRequestPriceRange( + range: { min: number; max: number } | null +): string { + const price = formatPriceRange(range) + return price === '-' ? price : `${price}/次` +} + +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%' +} + +function formatUnsignedDiscountPercent(value: number | null): string { + if (!hasNumber(value)) return '-' + return `${Math.abs(Math.round(value))}%` +} + +function formatDiscountRange( + inputRange: { min: number; max: number } | null, + officialInput: number | null | undefined +): string { + const values = [ + inputRange && hasNumber(officialInput) + ? getDiscountPercent(inputRange.min, officialInput) + : null, + inputRange && hasNumber(officialInput) + ? getDiscountPercent(inputRange.max, officialInput) + : null, + ].filter(hasNumber) + + if (values.length === 0) return '-' + + const minValue = Math.min(...values) + const maxValue = Math.max(...values) + + if (Math.abs(minValue - maxValue) < 0.001) { + return formatDiscountPercent(maxValue) + } + + const first = formatDiscountPercent(maxValue) + const second = formatUnsignedDiscountPercent(minValue) + return `${first}~${second}` +} + +function findPricingModel( + modelMap: Map, + names: string[] +): PricingModel | null { + for (const name of names) { + const model = modelMap.get(name) + if (model) return model + } + return null +} 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 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 { 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) + .sort( + (a, b) => + Number(b.plan?.sort_order || 0) - Number(a.plan?.sort_order || 0) ) + }, [subscriptionPlansData]) + + 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 || model.quota_type !== QUOTA_TYPE_VALUES.TOKEN) { + return null + } + + const inputPriceRange = getInputPriceRangeUSD( + model, + groupRatios, + usableGroups + ) + const outputPriceRange = getOutputPriceRangeUSD( + model, + groupRatios, + usableGroups + ) + + return { + name: configItem.name, + inputPrice: formatPriceRange(inputPriceRange), + outputPrice: formatPriceRange(outputPriceRange), + officialInput: formatPrice(configItem.officialInput), + officialOutput: formatPrice(configItem.officialOutput), + discount: formatDiscountRange( + inputPriceRange, + configItem.officialInput + ), + cacheHit: configItem.cacheHit || '-', + } + }) + .filter((item): item is ModelPricingRow => item !== null) + }, [pricingData]) + + const imageModelPricingRows = 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 imageModelPricingConfig.map((configItem) => { + const typeLabels = configItem.types.map((typeItem) => typeItem.type) + const model = findPricingModel(modelMap, [configItem.name]) + const priceRange = model + ? getImagePriceRangeUSD(model, groupRatios, usableGroups, 1) + : null + + return { + name: configItem.name, + types: typeLabels.join('、'), + price: formatPerRequestPriceRange(priceRange), + } + }) + }, [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('已复制到剪切板')) } catch { - // Cross-origin frames may reject access while navigating. + toast.error(t('复制失败')) } - }, [i18n.language, resolvedTheme]) + } useEffect(() => { - if (isUrl) { - syncIframePreferences() - } - }, [isUrl, syncIframePreferences]) + displayHomePageContent() + }, []) - if (!isLoaded) { + if (!homePageContentLoaded) { return (
@@ -68,57 +448,393 @@ export function Home() { ) } - if (content) { - if (isUrl) { - return ( - -