new-api/web/src/helpers/render.jsx

3328 lines
100 KiB
React
Raw Normal View History

/*
Copyright (C) 2025 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 <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import i18next from 'i18next';
🚀 feat: Introduce full Model & Vendor Management suite (backend + frontend) and UI refinements Backend • Add `model/model_meta.go` and `model/vendor_meta.go` defining Model & Vendor entities with CRUD helpers, soft-delete and time stamps • Create corresponding controllers `controller/model_meta.go`, `controller/vendor_meta.go` and register routes in `router/api-router.go` • Auto-migrate new tables in DB startup logic Frontend • Build complete “Model Management” module under `/console/models` - New pages, tables, filters, actions, hooks (`useModelsData`) and dynamic vendor tabs - Modals `EditModelModal.jsx` & unified `EditVendorModal.jsx`; latter now uses default confirm/cancel footer and mobile-friendly modal sizing (`full-width` / `small`) via `useIsMobile` • Update sidebar (`SiderBar.js`) and routing (`App.js`) to surface the feature • Add helper updates (`render.js`) incl. `stringToColor`, dynamic LobeHub icon retrieval, and tag color palettes Table UX improvements • Replace separate status column with inline Enable / Disable buttons in operation column (matching channel table style) • Limit visible tags to max 3; overflow represented as “+x” tag with padded `Popover` showing remaining tags • Color all tags deterministically using `stringToColor` for consistent theming • Change vendor column tag color to white for better contrast Misc • Minor layout tweaks, compact-mode toggle relocation, lint fixes and TypeScript/ESLint clean-up These changes collectively deliver end-to-end model & vendor administration while unifying visual language across management tables.
2025-07-31 22:28:09 +08:00
import { Modal, Tag, Typography, Avatar } from '@douyinfe/semi-ui';
import { copy, showSuccess } from './utils';
import { MOBILE_BREAKPOINT } from '../hooks/common/useIsMobile';
import { visit } from 'unist-util-visit';
🚀 feat: Introduce full Model & Vendor Management suite (backend + frontend) and UI refinements Backend • Add `model/model_meta.go` and `model/vendor_meta.go` defining Model & Vendor entities with CRUD helpers, soft-delete and time stamps • Create corresponding controllers `controller/model_meta.go`, `controller/vendor_meta.go` and register routes in `router/api-router.go` • Auto-migrate new tables in DB startup logic Frontend • Build complete “Model Management” module under `/console/models` - New pages, tables, filters, actions, hooks (`useModelsData`) and dynamic vendor tabs - Modals `EditModelModal.jsx` & unified `EditVendorModal.jsx`; latter now uses default confirm/cancel footer and mobile-friendly modal sizing (`full-width` / `small`) via `useIsMobile` • Update sidebar (`SiderBar.js`) and routing (`App.js`) to surface the feature • Add helper updates (`render.js`) incl. `stringToColor`, dynamic LobeHub icon retrieval, and tag color palettes Table UX improvements • Replace separate status column with inline Enable / Disable buttons in operation column (matching channel table style) • Limit visible tags to max 3; overflow represented as “+x” tag with padded `Popover` showing remaining tags • Color all tags deterministically using `stringToColor` for consistent theming • Change vendor column tag color to white for better contrast Misc • Minor layout tweaks, compact-mode toggle relocation, lint fixes and TypeScript/ESLint clean-up These changes collectively deliver end-to-end model & vendor administration while unifying visual language across management tables.
2025-07-31 22:28:09 +08:00
import * as LobeIcons from '@lobehub/icons';
import {
OpenAI,
Claude,
Gemini,
Moonshot,
Zhipu,
Qwen,
DeepSeek,
Minimax,
Wenxin,
Spark,
Midjourney,
Hunyuan,
Cohere,
Cloudflare,
Ai360,
Yi,
Jina,
Mistral,
XAI,
Ollama,
Doubao,
Suno,
Xinference,
OpenRouter,
Dify,
Coze,
SiliconCloud,
2025-06-11 23:46:59 +08:00
FastGPT,
Kling,
Jimeng,
2025-10-10 16:12:15 +08:00
Perplexity,
2025-11-08 01:24:45 +08:00
Replicate,
} from '@lobehub/icons';
import {
LayoutDashboard,
TerminalSquare,
MessageSquare,
Key,
BarChart3,
Image as ImageIcon,
CheckSquare,
CreditCard,
Layers,
Gift,
User,
Settings,
2025-06-11 23:46:59 +08:00
CircleUser,
Package,
Server,
✨ feat: add subscription billing system (#2808) * ci: create docker automation * ✨ feat: add subscription billing system with admin management and user purchase flow Implement a new subscription-based billing model alongside existing metered/per-request billing: Backend: - Add subscription plan models (SubscriptionPlan, SubscriptionPlanItem, UserSubscription, etc.) - Implement CRUD APIs for subscription plan management (admin only) - Add user subscription queries with support for multiple active/expired subscriptions - Integrate payment gateways (Stripe, Creem, Epay) for subscription purchases - Implement pre-consume and post-consume billing logic for subscription quota tracking - Add billing preference settings (subscription_first, wallet_first, etc.) - Enhance usage logs with subscription deduction details Frontend - Admin: - Add subscription management page with table view and drawer-based edit form - Match UI/UX style with existing admin pages (redemption codes, users) - Support enabling/disabling plans, configuring payment IDs, and model quotas - Add user subscription binding modal in user management Frontend - Wallet: - Add subscription plans card with current subscription status display - Show all subscriptions (active and expired) with remaining days/usage percentage - Display purchasable plans with pricing cards following SaaS best practices - Extract purchase modal to separate component matching payment confirm modal style - Add skeleton loading states with active animation - Implement billing preference selector in card header - Handle payment gateway availability based on admin configuration Frontend - Usage Logs: - Display subscription deduction details in log entries - Show step-by-step breakdown of subscription usage (pre-consumed, delta, final, remaining) - Add subscription deduction tag for subscription-covered requests * ✨ feat(admin): add user subscription management and refine UI/pagination Add admin APIs to list/create/invalidate/delete user subscriptions Add model helpers to fetch all user subscriptions (incl. expired) and support cancel/hard-delete Wire new admin routes for user subscription operations Replace “Bind subscription plan” entry with a dedicated User Subscriptions SideSheet in Users table Use CardTable with responsive layout and working client-side pagination inside the SideSheet Improve subscription purchase modal empty-gateway state with a Banner notice * ✨ feat(admin): streamline subscription plan benefits editor with bulk actions Restore the avatar/icon header for the “Model Benefits” section Replace scattered controls with a compact toolbar-style workflow Support multi-select add with a default quota for new items Add row selection with bulk apply-to-selected / apply-to-all quota updates Enable delete-selected to manage benefits faster and reduce mistakes * ✨ fix(subscription): finalize payments, log billing, and clean up dead code Complete subscription orders by creating a matching top-up record and writing billing logs Add Epay return handler to verify and finalize browser callbacks Require Stripe/Creem webhook configuration before starting subscription payments Show subscription purchases in topup history with clearer labels/methods Remove unused subscription helper, legacy Creem webhook struct, and unused topup fields Simplify subscription self API payload to active/all lists only * 🎨 style: format all code with gofmt and lint:fix Apply consistent code formatting across the entire codebase using gofmt and lint:fix tools. This ensures adherence to Go community standards and improves code readability and maintainability. Changes include: - Run gofmt on all .go files to standardize formatting - Apply lint:fix to automatically resolve linting issues - Fix code style inconsistencies and formatting violations No functional changes were made in this commit. * ✨ feat(subscription): add quota reset periods and admin configuration - Add reset period fields on subscription plans and user items - Apply automatic quota resets during pre-consume based on plan schedule - Expose reset-period configuration in the admin plan editor - Display reset cadence in subscription cards and purchase modal - Validate custom reset seconds on plan create/update * ✨ feat(subscription): harden subscription billing with resets, idempotency, and production-grade stability Add plan-level quota reset periods and display/reset cadence in admin/UI Enforce natural reset alignment with background reset task and cleanup job Make subscription pre-consume/refund idempotent with request-scoped records and retries Use database time for consistent resets across multi-instance deployments Harden payment callbacks with locking and idempotent order completion Record subscription purchases in topup history and billing logs Optimize subscription queries and add critical composite indexes * ✨ feat(subscription): cache plan lookups and stabilize pre-consume Introduce hybrid caches for subscription plans, items, and plan info with explicit invalidation on admin updates. Streamline pre-consume transactions to reduce redundant queries while preserving idempotency and reset logic. * 🐛 fix(subscription): avoid pre-consume lookup noise Use a RowsAffected check for the idempotency lookup so missing records no longer surface as "record not found" errors while preserving behavior. * 🔧 ci: Change workflow trigger to sub branch Update the Docker image workflow to run on pushes to the sub branch instead of main. * 💸 chore: Align subscription pricing display with global currency settings Unify subscription price rendering to use the site-wide currency symbol/rate on the wallet and admin views. Make subscription plan currency read-only in the editor and force USD on create/update to avoid drift. Use global currency display type when creating Creem checkout payloads. * 🔧 chore: Unify subscription plan status toggle with PATCH endpoint Replace separate enable/disable flows with a single PATCH API that updates the enabled flag. Update frontend hooks and table actions to call the unified endpoint and keep UI behavior consistent. Introduce a minimal admin controller handler and route for the status update. * ✨ feat: Add subscription limits and UI tags consistency Add per-plan purchase limits with backend enforcement and UI disable states. Expose limit configuration in admin plan editor and show limits in plan tables/cards. Refine subscription UI tags with unified badge style and streamlined “My Subscriptions” layout. * 🎨 style: tag color to white * 🚀 refactor: Simplify subscription quota to total amount model Remove per-model subscription items and switch to a single total quota per plan and user subscription. Update billing, reset, and logging flows to operate on total quota, and refactor admin/user UI to configure and display total quota consistently. * 🚀 chore: Remove duplicate subscription usage percentage display Keep the usage percentage shown only in the total quota line to avoid redundant “已用 0%” text while preserving remaining days in the summary. * ✨ feat: Add subscription upgrade group with auto downgrade * ✨ feat: Update subscription purchase modal display Show total quota as currency with tooltip for raw quota, hide reset cycle when never, and display upgrade group when configured to match card display rules. * ✨ feat: Extract quota conversion helpers to shared utils Move quota display/conversion helpers into web/src/helpers/quota.js and update the subscription plan editor to import and use the shared utilities instead of inline functions. * ✨ chore: Add upgrade group guidance in subscription editor Add explanatory helper text under the upgrade group field to clarify automatic group upgrades, rollback conditions, and the expected delay before downgrading takes effect. * 🔧 chore: remove unused Creem settings state Drop the unused originInputs state and redundant updates to keep the Creem settings form state minimal and easier to maintain. * 🚀 chore: Remove useless action * ✨ Add full i18n coverage for subscription-related UI across locales * ✨ feat: harden subscription billing and improve UI consistency Improve subscription payment safety and data integrity by handling user/URL lookup failures, fixing Stripe subscription mode, persisting quota reset fields, and correcting subscription delta accounting and DB timestamp casting. Refine the UI with stricter custom duration validation, accurate currency rounding, conditional Epay labeling, rollback on preference update failure, and shared subscription formatting helpers plus clearer component naming. * 🔧 fix: make epay webhook and return flow subscription-aware Ensure Epay webhook acknowledges success only after order completion, returning fail on processing errors to allow retries. Redirect subscription payment returns to the subscription page instead of top-up for correct user flow. * 🚦 fix: guard epay return success on order completion Redirect subscription return flow to failure when order completion fails, preventing false success states after payment verification. * 🔧 fix: normalize epay error handling and webhook retries Standardize SubscriptionRequestEpay error responses via ApiErrorMsg for a consistent schema. Return "fail" on non-success trade statuses in the epay webhook to preserve retry behavior. * 🧾 fix: persist epay orders before purchase Create the subscription order before initiating epay payment and expire it if the provider call fails, preventing orphaned transactions and improving reconciliation. * 🔧 fix: harden epay callbacks and billing fallbacks Use POST and form parsing for epay notify/return routes, persist epay orders before provider calls with expiry on failure, and ensure notify handlers retry correctly. Restrict subscription-first fallback to insufficient-subscription errors and log refund failures after retries to avoid silent quota drift. * 🔧 fix: harden billing flow and sidebar settings Add missing strings import for subscription fallback checks, log failed subscription refunds after retries, and extend sidebar module settings with a subscription management toggle plus translations. * 🛡️ fix: fail fast on epay form parse errors Handle ParseForm errors in epay notify/return handlers by returning fail or redirecting to failure, avoiding unsafe fallback to query parameters. * ✨ fix: refine Japanese subscription status labels Adjust Japanese UI wording for active-count labels to read more naturally and consistently. * ✅ fix: standardize epay success response schema Return subscription epay pay success responses via ApiSuccess to include the consistent success field and align with error schema.
2026-02-03 17:40:43 +08:00
CalendarClock,
} from 'lucide-react';
import {
SiAtlassian,
SiAuth0,
SiAuthentik,
SiBitbucket,
SiDiscord,
SiDropbox,
SiFacebook,
SiGitea,
SiGithub,
SiGitlab,
SiGoogle,
SiKeycloak,
SiLinkedin,
SiNextcloud,
SiNotion,
SiOkta,
SiOpenid,
SiReddit,
SiSlack,
SiTelegram,
SiTwitch,
SiWechat,
SiX,
} from 'react-icons/si';
// 获取侧边栏Lucide图标组件
export function getLucideIcon(key, selected = false) {
const size = 16;
const strokeWidth = 2;
const SELECTED_COLOR = 'var(--semi-color-primary)';
const iconColor = selected ? SELECTED_COLOR : 'currentColor';
const commonProps = {
size,
strokeWidth,
className: `transition-colors duration-200 ${selected ? 'transition-transform duration-200 scale-105' : ''}`,
};
// 根据不同的key返回不同的图标
switch (key) {
case 'detail':
return <LayoutDashboard {...commonProps} color={iconColor} />;
case 'playground':
return <TerminalSquare {...commonProps} color={iconColor} />;
case 'chat':
return <MessageSquare {...commonProps} color={iconColor} />;
case 'token':
return <Key {...commonProps} color={iconColor} />;
case 'log':
return <BarChart3 {...commonProps} color={iconColor} />;
case 'midjourney':
return <ImageIcon {...commonProps} color={iconColor} />;
case 'task':
return <CheckSquare {...commonProps} color={iconColor} />;
case 'topup':
return <CreditCard {...commonProps} color={iconColor} />;
case 'channel':
return <Layers {...commonProps} color={iconColor} />;
case 'redemption':
return <Gift {...commonProps} color={iconColor} />;
case 'user':
case 'personal':
return <User {...commonProps} color={iconColor} />;
🚀 feat: Introduce full Model & Vendor Management suite (backend + frontend) and UI refinements Backend • Add `model/model_meta.go` and `model/vendor_meta.go` defining Model & Vendor entities with CRUD helpers, soft-delete and time stamps • Create corresponding controllers `controller/model_meta.go`, `controller/vendor_meta.go` and register routes in `router/api-router.go` • Auto-migrate new tables in DB startup logic Frontend • Build complete “Model Management” module under `/console/models` - New pages, tables, filters, actions, hooks (`useModelsData`) and dynamic vendor tabs - Modals `EditModelModal.jsx` & unified `EditVendorModal.jsx`; latter now uses default confirm/cancel footer and mobile-friendly modal sizing (`full-width` / `small`) via `useIsMobile` • Update sidebar (`SiderBar.js`) and routing (`App.js`) to surface the feature • Add helper updates (`render.js`) incl. `stringToColor`, dynamic LobeHub icon retrieval, and tag color palettes Table UX improvements • Replace separate status column with inline Enable / Disable buttons in operation column (matching channel table style) • Limit visible tags to max 3; overflow represented as “+x” tag with padded `Popover` showing remaining tags • Color all tags deterministically using `stringToColor` for consistent theming • Change vendor column tag color to white for better contrast Misc • Minor layout tweaks, compact-mode toggle relocation, lint fixes and TypeScript/ESLint clean-up These changes collectively deliver end-to-end model & vendor administration while unifying visual language across management tables.
2025-07-31 22:28:09 +08:00
case 'models':
return <Package {...commonProps} color={iconColor} />;
case 'deployment':
return <Server {...commonProps} color={iconColor} />;
✨ feat: add subscription billing system (#2808) * ci: create docker automation * ✨ feat: add subscription billing system with admin management and user purchase flow Implement a new subscription-based billing model alongside existing metered/per-request billing: Backend: - Add subscription plan models (SubscriptionPlan, SubscriptionPlanItem, UserSubscription, etc.) - Implement CRUD APIs for subscription plan management (admin only) - Add user subscription queries with support for multiple active/expired subscriptions - Integrate payment gateways (Stripe, Creem, Epay) for subscription purchases - Implement pre-consume and post-consume billing logic for subscription quota tracking - Add billing preference settings (subscription_first, wallet_first, etc.) - Enhance usage logs with subscription deduction details Frontend - Admin: - Add subscription management page with table view and drawer-based edit form - Match UI/UX style with existing admin pages (redemption codes, users) - Support enabling/disabling plans, configuring payment IDs, and model quotas - Add user subscription binding modal in user management Frontend - Wallet: - Add subscription plans card with current subscription status display - Show all subscriptions (active and expired) with remaining days/usage percentage - Display purchasable plans with pricing cards following SaaS best practices - Extract purchase modal to separate component matching payment confirm modal style - Add skeleton loading states with active animation - Implement billing preference selector in card header - Handle payment gateway availability based on admin configuration Frontend - Usage Logs: - Display subscription deduction details in log entries - Show step-by-step breakdown of subscription usage (pre-consumed, delta, final, remaining) - Add subscription deduction tag for subscription-covered requests * ✨ feat(admin): add user subscription management and refine UI/pagination Add admin APIs to list/create/invalidate/delete user subscriptions Add model helpers to fetch all user subscriptions (incl. expired) and support cancel/hard-delete Wire new admin routes for user subscription operations Replace “Bind subscription plan” entry with a dedicated User Subscriptions SideSheet in Users table Use CardTable with responsive layout and working client-side pagination inside the SideSheet Improve subscription purchase modal empty-gateway state with a Banner notice * ✨ feat(admin): streamline subscription plan benefits editor with bulk actions Restore the avatar/icon header for the “Model Benefits” section Replace scattered controls with a compact toolbar-style workflow Support multi-select add with a default quota for new items Add row selection with bulk apply-to-selected / apply-to-all quota updates Enable delete-selected to manage benefits faster and reduce mistakes * ✨ fix(subscription): finalize payments, log billing, and clean up dead code Complete subscription orders by creating a matching top-up record and writing billing logs Add Epay return handler to verify and finalize browser callbacks Require Stripe/Creem webhook configuration before starting subscription payments Show subscription purchases in topup history with clearer labels/methods Remove unused subscription helper, legacy Creem webhook struct, and unused topup fields Simplify subscription self API payload to active/all lists only * 🎨 style: format all code with gofmt and lint:fix Apply consistent code formatting across the entire codebase using gofmt and lint:fix tools. This ensures adherence to Go community standards and improves code readability and maintainability. Changes include: - Run gofmt on all .go files to standardize formatting - Apply lint:fix to automatically resolve linting issues - Fix code style inconsistencies and formatting violations No functional changes were made in this commit. * ✨ feat(subscription): add quota reset periods and admin configuration - Add reset period fields on subscription plans and user items - Apply automatic quota resets during pre-consume based on plan schedule - Expose reset-period configuration in the admin plan editor - Display reset cadence in subscription cards and purchase modal - Validate custom reset seconds on plan create/update * ✨ feat(subscription): harden subscription billing with resets, idempotency, and production-grade stability Add plan-level quota reset periods and display/reset cadence in admin/UI Enforce natural reset alignment with background reset task and cleanup job Make subscription pre-consume/refund idempotent with request-scoped records and retries Use database time for consistent resets across multi-instance deployments Harden payment callbacks with locking and idempotent order completion Record subscription purchases in topup history and billing logs Optimize subscription queries and add critical composite indexes * ✨ feat(subscription): cache plan lookups and stabilize pre-consume Introduce hybrid caches for subscription plans, items, and plan info with explicit invalidation on admin updates. Streamline pre-consume transactions to reduce redundant queries while preserving idempotency and reset logic. * 🐛 fix(subscription): avoid pre-consume lookup noise Use a RowsAffected check for the idempotency lookup so missing records no longer surface as "record not found" errors while preserving behavior. * 🔧 ci: Change workflow trigger to sub branch Update the Docker image workflow to run on pushes to the sub branch instead of main. * 💸 chore: Align subscription pricing display with global currency settings Unify subscription price rendering to use the site-wide currency symbol/rate on the wallet and admin views. Make subscription plan currency read-only in the editor and force USD on create/update to avoid drift. Use global currency display type when creating Creem checkout payloads. * 🔧 chore: Unify subscription plan status toggle with PATCH endpoint Replace separate enable/disable flows with a single PATCH API that updates the enabled flag. Update frontend hooks and table actions to call the unified endpoint and keep UI behavior consistent. Introduce a minimal admin controller handler and route for the status update. * ✨ feat: Add subscription limits and UI tags consistency Add per-plan purchase limits with backend enforcement and UI disable states. Expose limit configuration in admin plan editor and show limits in plan tables/cards. Refine subscription UI tags with unified badge style and streamlined “My Subscriptions” layout. * 🎨 style: tag color to white * 🚀 refactor: Simplify subscription quota to total amount model Remove per-model subscription items and switch to a single total quota per plan and user subscription. Update billing, reset, and logging flows to operate on total quota, and refactor admin/user UI to configure and display total quota consistently. * 🚀 chore: Remove duplicate subscription usage percentage display Keep the usage percentage shown only in the total quota line to avoid redundant “已用 0%” text while preserving remaining days in the summary. * ✨ feat: Add subscription upgrade group with auto downgrade * ✨ feat: Update subscription purchase modal display Show total quota as currency with tooltip for raw quota, hide reset cycle when never, and display upgrade group when configured to match card display rules. * ✨ feat: Extract quota conversion helpers to shared utils Move quota display/conversion helpers into web/src/helpers/quota.js and update the subscription plan editor to import and use the shared utilities instead of inline functions. * ✨ chore: Add upgrade group guidance in subscription editor Add explanatory helper text under the upgrade group field to clarify automatic group upgrades, rollback conditions, and the expected delay before downgrading takes effect. * 🔧 chore: remove unused Creem settings state Drop the unused originInputs state and redundant updates to keep the Creem settings form state minimal and easier to maintain. * 🚀 chore: Remove useless action * ✨ Add full i18n coverage for subscription-related UI across locales * ✨ feat: harden subscription billing and improve UI consistency Improve subscription payment safety and data integrity by handling user/URL lookup failures, fixing Stripe subscription mode, persisting quota reset fields, and correcting subscription delta accounting and DB timestamp casting. Refine the UI with stricter custom duration validation, accurate currency rounding, conditional Epay labeling, rollback on preference update failure, and shared subscription formatting helpers plus clearer component naming. * 🔧 fix: make epay webhook and return flow subscription-aware Ensure Epay webhook acknowledges success only after order completion, returning fail on processing errors to allow retries. Redirect subscription payment returns to the subscription page instead of top-up for correct user flow. * 🚦 fix: guard epay return success on order completion Redirect subscription return flow to failure when order completion fails, preventing false success states after payment verification. * 🔧 fix: normalize epay error handling and webhook retries Standardize SubscriptionRequestEpay error responses via ApiErrorMsg for a consistent schema. Return "fail" on non-success trade statuses in the epay webhook to preserve retry behavior. * 🧾 fix: persist epay orders before purchase Create the subscription order before initiating epay payment and expire it if the provider call fails, preventing orphaned transactions and improving reconciliation. * 🔧 fix: harden epay callbacks and billing fallbacks Use POST and form parsing for epay notify/return routes, persist epay orders before provider calls with expiry on failure, and ensure notify handlers retry correctly. Restrict subscription-first fallback to insufficient-subscription errors and log refund failures after retries to avoid silent quota drift. * 🔧 fix: harden billing flow and sidebar settings Add missing strings import for subscription fallback checks, log failed subscription refunds after retries, and extend sidebar module settings with a subscription management toggle plus translations. * 🛡️ fix: fail fast on epay form parse errors Handle ParseForm errors in epay notify/return handlers by returning fail or redirecting to failure, avoiding unsafe fallback to query parameters. * ✨ fix: refine Japanese subscription status labels Adjust Japanese UI wording for active-count labels to read more naturally and consistently. * ✅ fix: standardize epay success response schema Return subscription epay pay success responses via ApiSuccess to include the consistent success field and align with error schema.
2026-02-03 17:40:43 +08:00
case 'subscription':
return <CalendarClock {...commonProps} color={iconColor} />;
case 'setting':
return <Settings {...commonProps} color={iconColor} />;
default:
return <CircleUser {...commonProps} color={iconColor} />;
}
}
// 获取模型分类
export const getModelCategories = (() => {
let categoriesCache = null;
let lastLocale = null;
return (t) => {
const currentLocale = i18next.language;
if (categoriesCache && lastLocale === currentLocale) {
return categoriesCache;
}
categoriesCache = {
all: {
label: t('全部模型'),
icon: null,
2025-06-07 12:26:23 +08:00
filter: () => true,
},
openai: {
label: 'OpenAI',
icon: <OpenAI />,
2025-06-07 12:26:23 +08:00
filter: (model) =>
model.model_name.toLowerCase().includes('gpt') ||
model.model_name.toLowerCase().includes('dall-e') ||
model.model_name.toLowerCase().includes('whisper') ||
2025-11-17 14:54:12 +08:00
model.model_name.toLowerCase().includes('tts-1') ||
model.model_name.toLowerCase().includes('text-embedding-3') ||
model.model_name.toLowerCase().includes('text-moderation') ||
model.model_name.toLowerCase().includes('babbage') ||
model.model_name.toLowerCase().includes('davinci') ||
model.model_name.toLowerCase().includes('curie') ||
model.model_name.toLowerCase().includes('ada') ||
model.model_name.toLowerCase().includes('o1') ||
model.model_name.toLowerCase().includes('o3') ||
model.model_name.toLowerCase().includes('o4'),
},
anthropic: {
label: 'Anthropic',
icon: <Claude.Color />,
2025-06-07 12:26:23 +08:00
filter: (model) => model.model_name.toLowerCase().includes('claude'),
},
gemini: {
label: 'Gemini',
icon: <Gemini.Color />,
filter: (model) =>
model.model_name.toLowerCase().includes('gemini') ||
2025-11-17 14:54:12 +08:00
model.model_name.toLowerCase().includes('gemma') ||
model.model_name.toLowerCase().includes('learnlm') ||
2025-11-17 14:54:12 +08:00
model.model_name.toLowerCase().startsWith('embedding-') ||
model.model_name.toLowerCase().includes('text-embedding-004') ||
model.model_name.toLowerCase().includes('imagen-4') ||
model.model_name.toLowerCase().includes('veo-') ||
model.model_name.toLowerCase().includes('aqa'),
},
moonshot: {
label: 'Moonshot',
icon: <Moonshot />,
filter: (model) =>
model.model_name.toLowerCase().includes('moonshot') ||
2025-11-17 14:54:12 +08:00
model.model_name.toLowerCase().includes('kimi'),
},
zhipu: {
label: t('智谱'),
icon: <Zhipu.Color />,
2025-06-07 12:26:23 +08:00
filter: (model) =>
model.model_name.toLowerCase().includes('chatglm') ||
model.model_name.toLowerCase().includes('glm-') ||
model.model_name.toLowerCase().includes('cogview') ||
2025-11-17 14:54:12 +08:00
model.model_name.toLowerCase().includes('cogvideo'),
},
qwen: {
label: t('通义千问'),
icon: <Qwen.Color />,
2025-06-07 12:26:23 +08:00
filter: (model) => model.model_name.toLowerCase().includes('qwen'),
},
deepseek: {
label: 'DeepSeek',
icon: <DeepSeek.Color />,
2025-06-07 12:26:23 +08:00
filter: (model) => model.model_name.toLowerCase().includes('deepseek'),
},
minimax: {
label: 'MiniMax',
icon: <Minimax.Color />,
filter: (model) =>
model.model_name.toLowerCase().includes('abab') ||
2025-11-17 14:54:12 +08:00
model.model_name.toLowerCase().includes('minimax'),
},
baidu: {
label: t('文心一言'),
icon: <Wenxin.Color />,
2025-06-07 12:26:23 +08:00
filter: (model) => model.model_name.toLowerCase().includes('ernie'),
},
xunfei: {
label: t('讯飞星火'),
icon: <Spark.Color />,
2025-06-07 12:26:23 +08:00
filter: (model) => model.model_name.toLowerCase().includes('spark'),
},
midjourney: {
label: 'Midjourney',
icon: <Midjourney />,
2025-06-07 12:26:23 +08:00
filter: (model) => model.model_name.toLowerCase().includes('mj_'),
},
tencent: {
label: t('腾讯混元'),
icon: <Hunyuan.Color />,
2025-06-07 12:26:23 +08:00
filter: (model) => model.model_name.toLowerCase().includes('hunyuan'),
},
cohere: {
label: 'Cohere',
icon: <Cohere.Color />,
filter: (model) =>
2025-11-17 14:54:12 +08:00
model.model_name.toLowerCase().includes('command') ||
model.model_name.toLowerCase().includes('c4ai-') ||
model.model_name.toLowerCase().includes('embed-'),
},
cloudflare: {
label: 'Cloudflare',
icon: <Cloudflare.Color />,
2025-06-07 12:26:23 +08:00
filter: (model) => model.model_name.toLowerCase().includes('@cf/'),
},
ai360: {
label: t('360智脑'),
icon: <Ai360.Color />,
2025-06-07 12:26:23 +08:00
filter: (model) => model.model_name.toLowerCase().includes('360'),
},
jina: {
label: 'Jina',
icon: <Jina />,
2025-06-07 12:26:23 +08:00
filter: (model) => model.model_name.toLowerCase().includes('jina'),
},
mistral: {
label: 'Mistral AI',
icon: <Mistral.Color />,
filter: (model) =>
2025-11-17 14:54:12 +08:00
model.model_name.toLowerCase().includes('mistral') ||
model.model_name.toLowerCase().includes('codestral') ||
model.model_name.toLowerCase().includes('pixtral') ||
model.model_name.toLowerCase().includes('voxtral') ||
model.model_name.toLowerCase().includes('magistral'),
},
xai: {
label: 'xAI',
icon: <XAI />,
2025-06-07 12:26:23 +08:00
filter: (model) => model.model_name.toLowerCase().includes('grok'),
},
llama: {
label: 'Llama',
icon: <Ollama />,
2025-06-07 12:26:23 +08:00
filter: (model) => model.model_name.toLowerCase().includes('llama'),
},
doubao: {
label: t('豆包'),
icon: <Doubao.Color />,
2025-06-07 12:26:23 +08:00
filter: (model) => model.model_name.toLowerCase().includes('doubao'),
},
2025-11-17 14:54:12 +08:00
yi: {
label: t('零一万物'),
icon: <Yi.Color />,
filter: (model) => model.model_name.toLowerCase().includes('yi'),
},
};
lastLocale = currentLocale;
return categoriesCache;
};
})();
/**
* 根据渠道类型返回对应的厂商图标
* @param {number} channelType - 渠道类型值
* @returns {JSX.Element|null} - 对应的厂商图标组件
*/
export function getChannelIcon(channelType) {
const iconSize = 14;
switch (channelType) {
case 1: // OpenAI
case 3: // Azure OpenAI
case 57: // Codex
return <OpenAI size={iconSize} />;
case 2: // Midjourney Proxy
case 5: // Midjourney Proxy Plus
return <Midjourney size={iconSize} />;
case 36: // Suno API
return <Suno size={iconSize} />;
case 4: // Ollama
return <Ollama size={iconSize} />;
case 14: // Anthropic Claude
case 33: // AWS Claude
return <Claude.Color size={iconSize} />;
case 41: // Vertex AI
return <Gemini.Color size={iconSize} />;
case 34: // Cohere
return <Cohere.Color size={iconSize} />;
case 39: // Cloudflare
return <Cloudflare.Color size={iconSize} />;
case 43: // DeepSeek
return <DeepSeek.Color size={iconSize} />;
case 15: // 百度文心千帆
case 46: // 百度文心千帆V2
return <Wenxin.Color size={iconSize} />;
case 17: // 阿里通义千问
return <Qwen.Color size={iconSize} />;
case 18: // 讯飞星火认知
return <Spark.Color size={iconSize} />;
case 16: // 智谱 ChatGLM
case 26: // 智谱 GLM-4V
return <Zhipu.Color size={iconSize} />;
case 24: // Google Gemini
case 11: // Google PaLM2
return <Gemini.Color size={iconSize} />;
case 47: // Xinference
return <Xinference.Color size={iconSize} />;
case 25: // Moonshot
return <Moonshot size={iconSize} />;
2025-10-10 16:12:15 +08:00
case 27: // Perplexity
return <Perplexity.Color size={iconSize} />;
case 20: // OpenRouter
return <OpenRouter size={iconSize} />;
case 19: // 360 智脑
return <Ai360.Color size={iconSize} />;
case 23: // 腾讯混元
return <Hunyuan.Color size={iconSize} />;
case 31: // 零一万物
return <Yi.Color size={iconSize} />;
case 35: // MiniMax
return <Minimax.Color size={iconSize} />;
case 37: // Dify
return <Dify.Color size={iconSize} />;
case 38: // Jina
return <Jina size={iconSize} />;
case 40: // SiliconCloud
return <SiliconCloud.Color size={iconSize} />;
case 42: // Mistral AI
return <Mistral.Color size={iconSize} />;
case 45: // 字节火山方舟、豆包通用
return <Doubao.Color size={iconSize} />;
case 48: // xAI
return <XAI size={iconSize} />;
case 49: // Coze
return <Coze size={iconSize} />;
case 50: // 可灵 Kling
return <Kling.Color size={iconSize} />;
case 51: // 即梦 Jimeng
return <Jimeng.Color size={iconSize} />;
2025-10-02 01:03:20 +08:00
case 54: // 豆包视频 Doubao Video
return <Doubao.Color size={iconSize} />;
2025-11-08 01:24:45 +08:00
case 56: // Replicate
return <Replicate size={iconSize} />;
case 8: // 自定义渠道
case 22: // 知识库FastGPT
return <FastGPT.Color size={iconSize} />;
case 21: // 知识库AI Proxy
case 44: // 嵌入模型MokaAI M3E
default:
return null; // 未知类型或自定义渠道不显示图标
}
}
🚀 feat: Introduce full Model & Vendor Management suite (backend + frontend) and UI refinements Backend • Add `model/model_meta.go` and `model/vendor_meta.go` defining Model & Vendor entities with CRUD helpers, soft-delete and time stamps • Create corresponding controllers `controller/model_meta.go`, `controller/vendor_meta.go` and register routes in `router/api-router.go` • Auto-migrate new tables in DB startup logic Frontend • Build complete “Model Management” module under `/console/models` - New pages, tables, filters, actions, hooks (`useModelsData`) and dynamic vendor tabs - Modals `EditModelModal.jsx` & unified `EditVendorModal.jsx`; latter now uses default confirm/cancel footer and mobile-friendly modal sizing (`full-width` / `small`) via `useIsMobile` • Update sidebar (`SiderBar.js`) and routing (`App.js`) to surface the feature • Add helper updates (`render.js`) incl. `stringToColor`, dynamic LobeHub icon retrieval, and tag color palettes Table UX improvements • Replace separate status column with inline Enable / Disable buttons in operation column (matching channel table style) • Limit visible tags to max 3; overflow represented as “+x” tag with padded `Popover` showing remaining tags • Color all tags deterministically using `stringToColor` for consistent theming • Change vendor column tag color to white for better contrast Misc • Minor layout tweaks, compact-mode toggle relocation, lint fixes and TypeScript/ESLint clean-up These changes collectively deliver end-to-end model & vendor administration while unifying visual language across management tables.
2025-07-31 22:28:09 +08:00
/**
* 根据图标名称动态获取 LobeHub 图标组件
* 支持
* - 基础"OpenAI""OpenAI.Color"
* - 额外属性点号链式"OpenAI.Avatar.type={'platform'}""OpenRouter.Avatar.shape={'square'}"
* - 继续兼容第二参数 size若字符串里有 size=以字符串为准
* @param {string} iconName - 图标名称/描述
🚀 feat: Introduce full Model & Vendor Management suite (backend + frontend) and UI refinements Backend • Add `model/model_meta.go` and `model/vendor_meta.go` defining Model & Vendor entities with CRUD helpers, soft-delete and time stamps • Create corresponding controllers `controller/model_meta.go`, `controller/vendor_meta.go` and register routes in `router/api-router.go` • Auto-migrate new tables in DB startup logic Frontend • Build complete “Model Management” module under `/console/models` - New pages, tables, filters, actions, hooks (`useModelsData`) and dynamic vendor tabs - Modals `EditModelModal.jsx` & unified `EditVendorModal.jsx`; latter now uses default confirm/cancel footer and mobile-friendly modal sizing (`full-width` / `small`) via `useIsMobile` • Update sidebar (`SiderBar.js`) and routing (`App.js`) to surface the feature • Add helper updates (`render.js`) incl. `stringToColor`, dynamic LobeHub icon retrieval, and tag color palettes Table UX improvements • Replace separate status column with inline Enable / Disable buttons in operation column (matching channel table style) • Limit visible tags to max 3; overflow represented as “+x” tag with padded `Popover` showing remaining tags • Color all tags deterministically using `stringToColor` for consistent theming • Change vendor column tag color to white for better contrast Misc • Minor layout tweaks, compact-mode toggle relocation, lint fixes and TypeScript/ESLint clean-up These changes collectively deliver end-to-end model & vendor administration while unifying visual language across management tables.
2025-07-31 22:28:09 +08:00
* @param {number} size - 图标大小默认为 14
* @returns {JSX.Element} - 对应的图标组件或 Avatar
*/
export function getLobeHubIcon(iconName, size = 14) {
if (typeof iconName === 'string') iconName = iconName.trim();
// 如果没有图标名称,返回 Avatar
if (!iconName) {
return <Avatar size='extra-extra-small'>?</Avatar>;
🚀 feat: Introduce full Model & Vendor Management suite (backend + frontend) and UI refinements Backend • Add `model/model_meta.go` and `model/vendor_meta.go` defining Model & Vendor entities with CRUD helpers, soft-delete and time stamps • Create corresponding controllers `controller/model_meta.go`, `controller/vendor_meta.go` and register routes in `router/api-router.go` • Auto-migrate new tables in DB startup logic Frontend • Build complete “Model Management” module under `/console/models` - New pages, tables, filters, actions, hooks (`useModelsData`) and dynamic vendor tabs - Modals `EditModelModal.jsx` & unified `EditVendorModal.jsx`; latter now uses default confirm/cancel footer and mobile-friendly modal sizing (`full-width` / `small`) via `useIsMobile` • Update sidebar (`SiderBar.js`) and routing (`App.js`) to surface the feature • Add helper updates (`render.js`) incl. `stringToColor`, dynamic LobeHub icon retrieval, and tag color palettes Table UX improvements • Replace separate status column with inline Enable / Disable buttons in operation column (matching channel table style) • Limit visible tags to max 3; overflow represented as “+x” tag with padded `Popover` showing remaining tags • Color all tags deterministically using `stringToColor` for consistent theming • Change vendor column tag color to white for better contrast Misc • Minor layout tweaks, compact-mode toggle relocation, lint fixes and TypeScript/ESLint clean-up These changes collectively deliver end-to-end model & vendor administration while unifying visual language across management tables.
2025-07-31 22:28:09 +08:00
}
// 解析组件路径与点号链式属性
const segments = String(iconName).split('.');
const baseKey = segments[0];
const BaseIcon = LobeIcons[baseKey];
🚀 feat: Introduce full Model & Vendor Management suite (backend + frontend) and UI refinements Backend • Add `model/model_meta.go` and `model/vendor_meta.go` defining Model & Vendor entities with CRUD helpers, soft-delete and time stamps • Create corresponding controllers `controller/model_meta.go`, `controller/vendor_meta.go` and register routes in `router/api-router.go` • Auto-migrate new tables in DB startup logic Frontend • Build complete “Model Management” module under `/console/models` - New pages, tables, filters, actions, hooks (`useModelsData`) and dynamic vendor tabs - Modals `EditModelModal.jsx` & unified `EditVendorModal.jsx`; latter now uses default confirm/cancel footer and mobile-friendly modal sizing (`full-width` / `small`) via `useIsMobile` • Update sidebar (`SiderBar.js`) and routing (`App.js`) to surface the feature • Add helper updates (`render.js`) incl. `stringToColor`, dynamic LobeHub icon retrieval, and tag color palettes Table UX improvements • Replace separate status column with inline Enable / Disable buttons in operation column (matching channel table style) • Limit visible tags to max 3; overflow represented as “+x” tag with padded `Popover` showing remaining tags • Color all tags deterministically using `stringToColor` for consistent theming • Change vendor column tag color to white for better contrast Misc • Minor layout tweaks, compact-mode toggle relocation, lint fixes and TypeScript/ESLint clean-up These changes collectively deliver end-to-end model & vendor administration while unifying visual language across management tables.
2025-07-31 22:28:09 +08:00
let IconComponent = undefined;
let propStartIndex = 1;
if (BaseIcon && segments.length > 1 && BaseIcon[segments[1]]) {
IconComponent = BaseIcon[segments[1]];
propStartIndex = 2;
🚀 feat: Introduce full Model & Vendor Management suite (backend + frontend) and UI refinements Backend • Add `model/model_meta.go` and `model/vendor_meta.go` defining Model & Vendor entities with CRUD helpers, soft-delete and time stamps • Create corresponding controllers `controller/model_meta.go`, `controller/vendor_meta.go` and register routes in `router/api-router.go` • Auto-migrate new tables in DB startup logic Frontend • Build complete “Model Management” module under `/console/models` - New pages, tables, filters, actions, hooks (`useModelsData`) and dynamic vendor tabs - Modals `EditModelModal.jsx` & unified `EditVendorModal.jsx`; latter now uses default confirm/cancel footer and mobile-friendly modal sizing (`full-width` / `small`) via `useIsMobile` • Update sidebar (`SiderBar.js`) and routing (`App.js`) to surface the feature • Add helper updates (`render.js`) incl. `stringToColor`, dynamic LobeHub icon retrieval, and tag color palettes Table UX improvements • Replace separate status column with inline Enable / Disable buttons in operation column (matching channel table style) • Limit visible tags to max 3; overflow represented as “+x” tag with padded `Popover` showing remaining tags • Color all tags deterministically using `stringToColor` for consistent theming • Change vendor column tag color to white for better contrast Misc • Minor layout tweaks, compact-mode toggle relocation, lint fixes and TypeScript/ESLint clean-up These changes collectively deliver end-to-end model & vendor administration while unifying visual language across management tables.
2025-07-31 22:28:09 +08:00
} else {
IconComponent = LobeIcons[baseKey];
propStartIndex = 1;
}
// 失败兜底
if (
!IconComponent ||
(typeof IconComponent !== 'function' && typeof IconComponent !== 'object')
) {
const firstLetter = String(iconName).charAt(0).toUpperCase();
return <Avatar size='extra-extra-small'>{firstLetter}</Avatar>;
🚀 feat: Introduce full Model & Vendor Management suite (backend + frontend) and UI refinements Backend • Add `model/model_meta.go` and `model/vendor_meta.go` defining Model & Vendor entities with CRUD helpers, soft-delete and time stamps • Create corresponding controllers `controller/model_meta.go`, `controller/vendor_meta.go` and register routes in `router/api-router.go` • Auto-migrate new tables in DB startup logic Frontend • Build complete “Model Management” module under `/console/models` - New pages, tables, filters, actions, hooks (`useModelsData`) and dynamic vendor tabs - Modals `EditModelModal.jsx` & unified `EditVendorModal.jsx`; latter now uses default confirm/cancel footer and mobile-friendly modal sizing (`full-width` / `small`) via `useIsMobile` • Update sidebar (`SiderBar.js`) and routing (`App.js`) to surface the feature • Add helper updates (`render.js`) incl. `stringToColor`, dynamic LobeHub icon retrieval, and tag color palettes Table UX improvements • Replace separate status column with inline Enable / Disable buttons in operation column (matching channel table style) • Limit visible tags to max 3; overflow represented as “+x” tag with padded `Popover` showing remaining tags • Color all tags deterministically using `stringToColor` for consistent theming • Change vendor column tag color to white for better contrast Misc • Minor layout tweaks, compact-mode toggle relocation, lint fixes and TypeScript/ESLint clean-up These changes collectively deliver end-to-end model & vendor administration while unifying visual language across management tables.
2025-07-31 22:28:09 +08:00
}
// 解析点号链式属性形如key={...}、key='...'、key="..."、key=123、key、key=true/false
const props = {};
const parseValue = (raw) => {
if (raw == null) return true;
let v = String(raw).trim();
// 去除一层花括号包裹
if (v.startsWith('{') && v.endsWith('}')) {
v = v.slice(1, -1).trim();
}
// 去除引号
if (
(v.startsWith('"') && v.endsWith('"')) ||
(v.startsWith("'") && v.endsWith("'"))
) {
return v.slice(1, -1);
}
// 布尔
if (v === 'true') return true;
if (v === 'false') return false;
// 数字
if (/^-?\d+(?:\.\d+)?$/.test(v)) return Number(v);
// 其他原样返回字符串
return v;
};
for (let i = propStartIndex; i < segments.length; i++) {
const seg = segments[i];
if (!seg) continue;
const eqIdx = seg.indexOf('=');
if (eqIdx === -1) {
props[seg.trim()] = true;
continue;
}
const key = seg.slice(0, eqIdx).trim();
const valRaw = seg.slice(eqIdx + 1).trim();
props[key] = parseValue(valRaw);
🚀 feat: Introduce full Model & Vendor Management suite (backend + frontend) and UI refinements Backend • Add `model/model_meta.go` and `model/vendor_meta.go` defining Model & Vendor entities with CRUD helpers, soft-delete and time stamps • Create corresponding controllers `controller/model_meta.go`, `controller/vendor_meta.go` and register routes in `router/api-router.go` • Auto-migrate new tables in DB startup logic Frontend • Build complete “Model Management” module under `/console/models` - New pages, tables, filters, actions, hooks (`useModelsData`) and dynamic vendor tabs - Modals `EditModelModal.jsx` & unified `EditVendorModal.jsx`; latter now uses default confirm/cancel footer and mobile-friendly modal sizing (`full-width` / `small`) via `useIsMobile` • Update sidebar (`SiderBar.js`) and routing (`App.js`) to surface the feature • Add helper updates (`render.js`) incl. `stringToColor`, dynamic LobeHub icon retrieval, and tag color palettes Table UX improvements • Replace separate status column with inline Enable / Disable buttons in operation column (matching channel table style) • Limit visible tags to max 3; overflow represented as “+x” tag with padded `Popover` showing remaining tags • Color all tags deterministically using `stringToColor` for consistent theming • Change vendor column tag color to white for better contrast Misc • Minor layout tweaks, compact-mode toggle relocation, lint fixes and TypeScript/ESLint clean-up These changes collectively deliver end-to-end model & vendor administration while unifying visual language across management tables.
2025-07-31 22:28:09 +08:00
}
// 兼容第二参数 size若字符串中未显式指定 size则使用函数入参
if (props.size == null && size != null) props.size = size;
return <IconComponent {...props} />;
🚀 feat: Introduce full Model & Vendor Management suite (backend + frontend) and UI refinements Backend • Add `model/model_meta.go` and `model/vendor_meta.go` defining Model & Vendor entities with CRUD helpers, soft-delete and time stamps • Create corresponding controllers `controller/model_meta.go`, `controller/vendor_meta.go` and register routes in `router/api-router.go` • Auto-migrate new tables in DB startup logic Frontend • Build complete “Model Management” module under `/console/models` - New pages, tables, filters, actions, hooks (`useModelsData`) and dynamic vendor tabs - Modals `EditModelModal.jsx` & unified `EditVendorModal.jsx`; latter now uses default confirm/cancel footer and mobile-friendly modal sizing (`full-width` / `small`) via `useIsMobile` • Update sidebar (`SiderBar.js`) and routing (`App.js`) to surface the feature • Add helper updates (`render.js`) incl. `stringToColor`, dynamic LobeHub icon retrieval, and tag color palettes Table UX improvements • Replace separate status column with inline Enable / Disable buttons in operation column (matching channel table style) • Limit visible tags to max 3; overflow represented as “+x” tag with padded `Popover` showing remaining tags • Color all tags deterministically using `stringToColor` for consistent theming • Change vendor column tag color to white for better contrast Misc • Minor layout tweaks, compact-mode toggle relocation, lint fixes and TypeScript/ESLint clean-up These changes collectively deliver end-to-end model & vendor administration while unifying visual language across management tables.
2025-07-31 22:28:09 +08:00
}
const oauthProviderIconMap = {
github: SiGithub,
gitlab: SiGitlab,
gitea: SiGitea,
google: SiGoogle,
discord: SiDiscord,
facebook: SiFacebook,
linkedin: SiLinkedin,
x: SiX,
twitter: SiX,
slack: SiSlack,
telegram: SiTelegram,
wechat: SiWechat,
keycloak: SiKeycloak,
nextcloud: SiNextcloud,
authentik: SiAuthentik,
openid: SiOpenid,
okta: SiOkta,
auth0: SiAuth0,
atlassian: SiAtlassian,
bitbucket: SiBitbucket,
notion: SiNotion,
twitch: SiTwitch,
reddit: SiReddit,
dropbox: SiDropbox,
};
function isHttpUrl(value) {
return /^https?:\/\//i.test(value || '');
}
function isSimpleEmoji(value) {
if (!value) return false;
const trimmed = String(value).trim();
return trimmed.length > 0 && trimmed.length <= 4 && !isHttpUrl(trimmed);
}
function normalizeOAuthIconKey(raw) {
return raw
.trim()
.toLowerCase()
.replace(/^ri:/, '')
.replace(/^react-icons:/, '')
.replace(/^si:/, '');
}
/**
* Render custom OAuth provider icon with react-icons or URL/emoji fallback.
* Supported formats:
* - react-icons simple key: github / gitlab / google / keycloak
* - prefixed key: ri:github / si:github
* - full URL image: https://example.com/logo.png
* - emoji: 🐱
*/
export function getOAuthProviderIcon(iconName, size = 20) {
const raw = String(iconName || '').trim();
const iconSize = Number(size) > 0 ? Number(size) : 20;
if (!raw) {
return <Layers size={iconSize} color='var(--semi-color-text-2)' />;
}
if (isHttpUrl(raw)) {
return (
<img
src={raw}
alt='provider icon'
width={iconSize}
height={iconSize}
style={{ borderRadius: 4, objectFit: 'cover' }}
/>
);
}
if (isSimpleEmoji(raw)) {
return (
<span
style={{
width: iconSize,
height: iconSize,
lineHeight: `${iconSize}px`,
textAlign: 'center',
display: 'inline-block',
fontSize: Math.max(Math.floor(iconSize * 0.8), 14),
}}
>
{raw}
</span>
);
}
const key = normalizeOAuthIconKey(raw);
const IconComp = oauthProviderIconMap[key];
if (IconComp) {
return <IconComp size={iconSize} />;
}
return (
<Avatar size='extra-extra-small'>{raw.charAt(0).toUpperCase()}</Avatar>
);
}
// 颜色列表
const colors = [
'amber',
'blue',
'cyan',
'green',
'grey',
'indigo',
'light-blue',
'lime',
'orange',
'pink',
'purple',
'red',
'teal',
'violet',
'yellow',
];
// 基础10色色板 (N ≤ 10)
const baseColors = [
'#1664FF', // 主色
'#1AC6FF',
'#FF8A00',
'#3CC780',
'#7442D4',
'#FFC400',
'#304D77',
'#B48DEB',
'#009488',
'#FF7DDA',
];
// 扩展20色色板 (10 < N ≤ 20)
const extendedColors = [
'#1664FF',
'#B2CFFF',
'#1AC6FF',
'#94EFFF',
'#FF8A00',
'#FFCE7A',
'#3CC780',
'#B9EDCD',
'#7442D4',
'#DDC5FA',
'#FFC400',
'#FAE878',
'#304D77',
'#8B959E',
'#B48DEB',
'#EFE3FF',
'#009488',
'#59BAA8',
'#FF7DDA',
'#FFCFEE',
];
// 模型颜色映射
export const modelColorMap = {
'dall-e': 'rgb(147,112,219)', // 深紫色
// 'dall-e-2': 'rgb(147,112,219)', // 介于紫色和蓝色之间的色调
'dall-e-3': 'rgb(153,50,204)', // 介于紫罗兰和洋红之间的色调
'gpt-3.5-turbo': 'rgb(184,227,167)', // 浅绿色
// 'gpt-3.5-turbo-0301': 'rgb(131,220,131)', // 亮绿色
'gpt-3.5-turbo-0613': 'rgb(60,179,113)', // 海洋绿
'gpt-3.5-turbo-1106': 'rgb(32,178,170)', // 浅海洋绿
'gpt-3.5-turbo-16k': 'rgb(149,252,206)', // 淡橙色
'gpt-3.5-turbo-16k-0613': 'rgb(119,255,214)', // 淡桃
'gpt-3.5-turbo-instruct': 'rgb(175,238,238)', // 粉蓝色
'gpt-4': 'rgb(135,206,235)', // 天蓝色
// 'gpt-4-0314': 'rgb(70,130,180)', // 钢蓝色
'gpt-4-0613': 'rgb(100,149,237)', // 矢车菊蓝
'gpt-4-1106-preview': 'rgb(30,144,255)', // 道奇蓝
'gpt-4-0125-preview': 'rgb(2,177,236)', // 深天蓝
'gpt-4-turbo-preview': 'rgb(2,177,255)', // 深天蓝
'gpt-4-32k': 'rgb(104,111,238)', // 中紫色
// 'gpt-4-32k-0314': 'rgb(90,105,205)', // 暗灰蓝色
'gpt-4-32k-0613': 'rgb(61,71,139)', // 暗蓝灰色
'gpt-4-all': 'rgb(65,105,225)', // 皇家蓝
'gpt-4-gizmo-*': 'rgb(0,0,255)', // 纯蓝色
'gpt-4-vision-preview': 'rgb(25,25,112)', // 午夜蓝
'text-ada-001': 'rgb(255,192,203)', // 粉红色
'text-babbage-001': 'rgb(255,160,122)', // 浅珊瑚色
'text-curie-001': 'rgb(219,112,147)', // 苍紫罗兰色
// 'text-davinci-002': 'rgb(199,21,133)', // 中紫罗兰红色
'text-davinci-003': 'rgb(219,112,147)', // 苍紫罗兰色与Curie相同表示同一个系列
'text-davinci-edit-001': 'rgb(255,105,180)', // 热粉色
'text-embedding-ada-002': 'rgb(255,182,193)', // 浅粉红
'text-embedding-v1': 'rgb(255,174,185)', // 浅粉红色(略有区别)
'text-moderation-latest': 'rgb(255,130,171)', // 强粉色
'text-moderation-stable': 'rgb(255,160,122)', // 浅珊瑚色与Babbage相同表示同一类功能
'tts-1': 'rgb(255,140,0)', // 深橙色
'tts-1-1106': 'rgb(255,165,0)', // 橙色
'tts-1-hd': 'rgb(255,215,0)', // 金色
'tts-1-hd-1106': 'rgb(255,223,0)', // 金黄色(略有区别)
'whisper-1': 'rgb(245,245,220)', // 米色
'claude-3-opus-20240229': 'rgb(255,132,31)', // 橙红色
'claude-3-sonnet-20240229': 'rgb(253,135,93)', // 橙色
'claude-3-haiku-20240307': 'rgb(255,175,146)', // 浅橙色
};
export function modelToColor(modelName) {
// 1. 如果模型在预定义的 modelColorMap 中,使用预定义颜色
if (modelColorMap[modelName]) {
return modelColorMap[modelName];
}
// 2. 生成一个稳定的数字作为索引
let hash = 0;
for (let i = 0; i < modelName.length; i++) {
hash = (hash << 5) - hash + modelName.charCodeAt(i);
hash = hash & hash; // Convert to 32-bit integer
}
hash = Math.abs(hash);
// 3. 根据模型名称长度选择不同的色板
const colorPalette = modelName.length > 10 ? extendedColors : baseColors;
// 4. 使用hash值选择颜色
const index = hash % colorPalette.length;
return colorPalette[index];
}
export function stringToColor(str) {
let sum = 0;
for (let i = 0; i < str.length; i++) {
sum += str.charCodeAt(i);
}
let i = sum % colors.length;
return colors[i];
}
// 渲染带有模型图标的标签
export function renderModelTag(modelName, options = {}) {
2025-06-07 12:26:23 +08:00
const {
color,
size = 'default',
2025-06-07 12:26:23 +08:00
shape = 'circle',
onClick,
suffixIcon,
} = options;
const categories = getModelCategories(i18next.t);
let icon = null;
for (const [key, category] of Object.entries(categories)) {
if (key !== 'all' && category.filter({ model_name: modelName })) {
icon = category.icon;
break;
}
}
return (
<Tag
color={color || stringToColor(modelName)}
prefixIcon={icon}
suffixIcon={suffixIcon}
size={size}
shape={shape}
onClick={onClick}
>
{modelName}
</Tag>
);
}
export function renderText(text, limit) {
2024-03-23 21:24:39 +08:00
if (text.length > limit) {
return text.slice(0, limit - 3) + '...';
}
return text;
}
/**
* Render group tags based on the input group string
* @param {string} group - The input group string
* @returns {JSX.Element} - The rendered group tags
*/
export function renderGroup(group) {
2024-03-23 21:24:39 +08:00
if (group === '') {
return (
<Tag key='default' color='white' shape='circle'>
{i18next.t('用户分组')}
</Tag>
);
2024-03-23 21:24:39 +08:00
}
const tagColors = {
vip: 'yellow',
pro: 'yellow',
svip: 'red',
premium: 'red',
};
const groups = group.split(',').sort();
2024-03-23 21:24:39 +08:00
return (
<span key={group}>
{groups.map((group) => (
<Tag
color={tagColors[group] || stringToColor(group)}
key={group}
2025-05-23 00:24:08 +08:00
shape='circle'
onClick={async (event) => {
event.stopPropagation();
if (await copy(group)) {
showSuccess(i18next.t('已复制:') + group);
} else {
2025-04-04 12:00:38 +08:00
Modal.error({
title: i18next.t('无法复制到剪贴板,请手动复制'),
2025-04-04 12:00:38 +08:00
content: group,
});
}
}}
>
{group}
</Tag>
))}
</span>
2024-03-23 21:24:39 +08:00
);
}
export function renderRatio(ratio) {
let color = 'green';
if (ratio > 5) {
color = 'red';
} else if (ratio > 3) {
color = 'orange';
} else if (ratio > 1) {
color = 'blue';
}
2025-04-04 12:00:38 +08:00
return (
<Tag color={color}>
{ratio}x {i18next.t('倍率')}
</Tag>
);
}
2025-04-04 12:00:38 +08:00
const measureTextWidth = (
text,
style = {
fontSize: '14px',
fontFamily:
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
},
containerWidth,
) => {
const span = document.createElement('span');
2025-04-04 12:00:38 +08:00
span.style.visibility = 'hidden';
span.style.position = 'absolute';
span.style.whiteSpace = 'nowrap';
span.style.fontSize = style.fontSize;
span.style.fontFamily = style.fontFamily;
2025-04-04 12:00:38 +08:00
span.textContent = text;
2025-04-04 12:00:38 +08:00
document.body.appendChild(span);
const width = span.offsetWidth;
2025-04-04 12:00:38 +08:00
document.body.removeChild(span);
2025-04-04 12:00:38 +08:00
return width;
};
export function truncateText(text, maxWidth = 200) {
const isMobileScreen = window.matchMedia(
`(max-width: ${MOBILE_BREAKPOINT - 1}px)`,
).matches;
if (!isMobileScreen) {
return text;
}
if (!text) return text;
2025-04-04 12:00:38 +08:00
try {
// Handle percentage-based maxWidth
let actualMaxWidth = maxWidth;
if (typeof maxWidth === 'string' && maxWidth.endsWith('%')) {
const percentage = parseFloat(maxWidth) / 100;
// Use window width as fallback container width
actualMaxWidth = window.innerWidth * percentage;
}
2025-04-04 12:00:38 +08:00
const width = measureTextWidth(text);
if (width <= actualMaxWidth) return text;
2025-04-04 12:00:38 +08:00
let left = 0;
let right = text.length;
let result = text;
2025-04-04 12:00:38 +08:00
while (left <= right) {
const mid = Math.floor((left + right) / 2);
const truncated = text.slice(0, mid) + '...';
const currentWidth = measureTextWidth(truncated);
2025-04-04 12:00:38 +08:00
if (currentWidth <= actualMaxWidth) {
result = truncated;
left = mid + 1;
} else {
right = mid - 1;
}
}
2025-04-04 12:00:38 +08:00
return result;
} catch (error) {
2025-04-04 12:00:38 +08:00
console.warn(
'Text measurement failed, falling back to character count',
error,
);
if (text.length > 20) {
return text.slice(0, 17) + '...';
}
return text;
}
}
export const renderGroupOption = (item) => {
const {
disabled,
selected,
label,
value,
focused,
className,
style,
onMouseEnter,
onClick,
empty,
emptyContent,
...rest
} = item;
2025-04-04 12:00:38 +08:00
const baseStyle = {
2025-04-04 12:00:38 +08:00
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '8px 16px',
cursor: disabled ? 'not-allowed' : 'pointer',
backgroundColor: focused ? 'var(--semi-color-fill-0)' : 'transparent',
opacity: disabled ? 0.5 : 1,
...(selected && {
backgroundColor: 'var(--semi-color-primary-light-default)',
}),
'&:hover': {
2025-04-04 12:00:38 +08:00
backgroundColor: !disabled && 'var(--semi-color-fill-1)',
},
};
const handleClick = () => {
if (!disabled && onClick) {
onClick();
}
};
const handleMouseEnter = (e) => {
if (!disabled && onMouseEnter) {
onMouseEnter(e);
}
};
2025-04-04 12:00:38 +08:00
return (
2025-04-04 12:00:38 +08:00
<div
style={baseStyle}
onClick={handleClick}
onMouseEnter={handleMouseEnter}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
<Typography.Text strong type={disabled ? 'tertiary' : undefined}>
{value}
</Typography.Text>
2025-04-04 12:00:38 +08:00
<Typography.Text type='secondary' size='small'>
{label}
</Typography.Text>
</div>
{item.ratio && renderRatio(item.ratio)}
</div>
);
};
export function renderNumber(num) {
2024-03-23 21:24:39 +08:00
if (num >= 1000000000) {
return (num / 1000000000).toFixed(1) + 'B';
} else if (num >= 1000000) {
return (num / 1000000).toFixed(1) + 'M';
} else if (num >= 10000) {
return (num / 1000).toFixed(1) + 'k';
} else {
return num;
}
2024-01-08 11:32:27 +08:00
}
export function renderQuotaNumberWithDigit(num, digits = 2) {
if (typeof num !== 'number' || isNaN(num)) {
return 0;
}
💱 feat(settings): introduce site-wide quota display type (USD/CNY/TOKENS/CUSTOM) Replace the legacy boolean “DisplayInCurrencyEnabled” with an injected, type-safe configuration `general_setting.quota_display_type`, and wire it through the backend and frontend. Backend - Add `QuotaDisplayType` to `operation_setting.GeneralSetting` with injected registration via `config.GlobalConfig.Register("general_setting", ...)`. Helpers: `IsCurrencyDisplay()`, `IsCNYDisplay()`, `GetQuotaDisplayType()`. - Expose `quota_display_type` in `/api/status` and keep legacy `display_in_currency` for backward compatibility. - Logger: update `LogQuota` and `FormatQuota` to support USD/CNY/TOKENS. When CNY is selected, convert using `operation_setting.USDExchangeRate`. - Controllers: - `billing`: compute subscription/usage amounts based on the selected type (USD: divide by `QuotaPerUnit`; CNY: USD→CNY; TOKENS: keep raw tokens). - `topup` / `topup_stripe`: treat inputs as “amount” for USD/CNY and as token-count for TOKENS; adjust min topup and pay money accordingly. - `misc`: include `quota_display_type` in status payload. - Compatibility: in `model/option.UpdateOption`, map updates to `DisplayInCurrencyEnabled` → `general_setting.quota_display_type` (true→USD, false→TOKENS). Keep exporting the legacy key in `OptionMap`. Frontend - Settings: replace the “display in currency” switch with a Select (`general_setting.quota_display_type`) offering USD / CNY / Tokens. Provide fallback mapping from legacy `DisplayInCurrencyEnabled`. - Persist `quota_display_type` to localStorage (keep `display_in_currency` for legacy components). - Rendering helpers: base all quota/price rendering on `quota_display_type`; use `usd_exchange_rate` for CNY symbol/values. - Pricing page: default view currency follows site display type (USD/CNY), while TOKENS mode still allows per-view currency toggling when needed. Notes - No database migrations required. - Legacy clients remain functional via compatibility fields.
2025-09-29 23:23:31 +08:00
const quotaDisplayType = localStorage.getItem('quota_display_type') || 'USD';
2024-03-23 21:24:39 +08:00
num = num.toFixed(digits);
💱 feat(settings): introduce site-wide quota display type (USD/CNY/TOKENS/CUSTOM) Replace the legacy boolean “DisplayInCurrencyEnabled” with an injected, type-safe configuration `general_setting.quota_display_type`, and wire it through the backend and frontend. Backend - Add `QuotaDisplayType` to `operation_setting.GeneralSetting` with injected registration via `config.GlobalConfig.Register("general_setting", ...)`. Helpers: `IsCurrencyDisplay()`, `IsCNYDisplay()`, `GetQuotaDisplayType()`. - Expose `quota_display_type` in `/api/status` and keep legacy `display_in_currency` for backward compatibility. - Logger: update `LogQuota` and `FormatQuota` to support USD/CNY/TOKENS. When CNY is selected, convert using `operation_setting.USDExchangeRate`. - Controllers: - `billing`: compute subscription/usage amounts based on the selected type (USD: divide by `QuotaPerUnit`; CNY: USD→CNY; TOKENS: keep raw tokens). - `topup` / `topup_stripe`: treat inputs as “amount” for USD/CNY and as token-count for TOKENS; adjust min topup and pay money accordingly. - `misc`: include `quota_display_type` in status payload. - Compatibility: in `model/option.UpdateOption`, map updates to `DisplayInCurrencyEnabled` → `general_setting.quota_display_type` (true→USD, false→TOKENS). Keep exporting the legacy key in `OptionMap`. Frontend - Settings: replace the “display in currency” switch with a Select (`general_setting.quota_display_type`) offering USD / CNY / Tokens. Provide fallback mapping from legacy `DisplayInCurrencyEnabled`. - Persist `quota_display_type` to localStorage (keep `display_in_currency` for legacy components). - Rendering helpers: base all quota/price rendering on `quota_display_type`; use `usd_exchange_rate` for CNY symbol/values. - Pricing page: default view currency follows site display type (USD/CNY), while TOKENS mode still allows per-view currency toggling when needed. Notes - No database migrations required. - Legacy clients remain functional via compatibility fields.
2025-09-29 23:23:31 +08:00
if (quotaDisplayType === 'CNY') {
return '¥' + num;
} else if (quotaDisplayType === 'USD') {
2024-03-23 21:24:39 +08:00
return '$' + num;
💱 feat(settings): introduce site-wide quota display type (USD/CNY/TOKENS/CUSTOM) Replace the legacy boolean “DisplayInCurrencyEnabled” with an injected, type-safe configuration `general_setting.quota_display_type`, and wire it through the backend and frontend. Backend - Add `QuotaDisplayType` to `operation_setting.GeneralSetting` with injected registration via `config.GlobalConfig.Register("general_setting", ...)`. Helpers: `IsCurrencyDisplay()`, `IsCNYDisplay()`, `GetQuotaDisplayType()`. - Expose `quota_display_type` in `/api/status` and keep legacy `display_in_currency` for backward compatibility. - Logger: update `LogQuota` and `FormatQuota` to support USD/CNY/TOKENS. When CNY is selected, convert using `operation_setting.USDExchangeRate`. - Controllers: - `billing`: compute subscription/usage amounts based on the selected type (USD: divide by `QuotaPerUnit`; CNY: USD→CNY; TOKENS: keep raw tokens). - `topup` / `topup_stripe`: treat inputs as “amount” for USD/CNY and as token-count for TOKENS; adjust min topup and pay money accordingly. - `misc`: include `quota_display_type` in status payload. - Compatibility: in `model/option.UpdateOption`, map updates to `DisplayInCurrencyEnabled` → `general_setting.quota_display_type` (true→USD, false→TOKENS). Keep exporting the legacy key in `OptionMap`. Frontend - Settings: replace the “display in currency” switch with a Select (`general_setting.quota_display_type`) offering USD / CNY / Tokens. Provide fallback mapping from legacy `DisplayInCurrencyEnabled`. - Persist `quota_display_type` to localStorage (keep `display_in_currency` for legacy components). - Rendering helpers: base all quota/price rendering on `quota_display_type`; use `usd_exchange_rate` for CNY symbol/values. - Pricing page: default view currency follows site display type (USD/CNY), while TOKENS mode still allows per-view currency toggling when needed. Notes - No database migrations required. - Legacy clients remain functional via compatibility fields.
2025-09-29 23:23:31 +08:00
} else if (quotaDisplayType === 'CUSTOM') {
const statusStr = localStorage.getItem('status');
let symbol = '¤';
try {
if (statusStr) {
const s = JSON.parse(statusStr);
symbol = s?.custom_currency_symbol || symbol;
}
} catch (e) {}
return symbol + num;
} else {
return num;
2024-03-23 21:24:39 +08:00
}
2023-06-20 20:09:17 +08:00
}
2023-12-11 20:42:51 +08:00
export function renderNumberWithPoint(num) {
2025-04-04 12:00:38 +08:00
if (num === undefined) return '';
2024-03-23 21:24:39 +08:00
num = num.toFixed(2);
if (num >= 100000) {
// Convert number to string to manipulate it
let numStr = num.toString();
// Find the position of the decimal point
let decimalPointIndex = numStr.indexOf('.');
let wholePart = numStr;
let decimalPart = '';
// If there is a decimal point, split the number into whole and decimal parts
if (decimalPointIndex !== -1) {
wholePart = numStr.slice(0, decimalPointIndex);
decimalPart = numStr.slice(decimalPointIndex);
}
2023-12-11 20:42:51 +08:00
2024-03-23 21:24:39 +08:00
// Take the first two and last two digits of the whole number part
let shortenedWholePart = wholePart.slice(0, 2) + '..' + wholePart.slice(-2);
2024-01-08 11:32:27 +08:00
2024-03-23 21:24:39 +08:00
// Return the formatted number
return shortenedWholePart + decimalPart;
}
2024-01-08 11:32:27 +08:00
2024-03-23 21:24:39 +08:00
// If the number is less than 100,000, return it unmodified
return num;
2023-12-11 20:42:51 +08:00
}
2023-11-21 16:35:51 +08:00
export function getQuotaPerUnit() {
2024-03-23 21:24:39 +08:00
let quotaPerUnit = localStorage.getItem('quota_per_unit');
quotaPerUnit = parseFloat(quotaPerUnit);
return quotaPerUnit;
2023-11-21 16:35:51 +08:00
}
export function renderUnitWithQuota(quota) {
let quotaPerUnit = localStorage.getItem('quota_per_unit');
quotaPerUnit = parseFloat(quotaPerUnit);
quota = parseFloat(quota);
return quotaPerUnit * quota;
}
2024-01-07 18:31:14 +08:00
export function getQuotaWithUnit(quota, digits = 6) {
2024-03-23 21:24:39 +08:00
let quotaPerUnit = localStorage.getItem('quota_per_unit');
quotaPerUnit = parseFloat(quotaPerUnit);
return (quota / quotaPerUnit).toFixed(digits);
2024-01-07 18:31:14 +08:00
}
export function renderQuotaWithAmount(amount) {
💱 feat(settings): introduce site-wide quota display type (USD/CNY/TOKENS/CUSTOM) Replace the legacy boolean “DisplayInCurrencyEnabled” with an injected, type-safe configuration `general_setting.quota_display_type`, and wire it through the backend and frontend. Backend - Add `QuotaDisplayType` to `operation_setting.GeneralSetting` with injected registration via `config.GlobalConfig.Register("general_setting", ...)`. Helpers: `IsCurrencyDisplay()`, `IsCNYDisplay()`, `GetQuotaDisplayType()`. - Expose `quota_display_type` in `/api/status` and keep legacy `display_in_currency` for backward compatibility. - Logger: update `LogQuota` and `FormatQuota` to support USD/CNY/TOKENS. When CNY is selected, convert using `operation_setting.USDExchangeRate`. - Controllers: - `billing`: compute subscription/usage amounts based on the selected type (USD: divide by `QuotaPerUnit`; CNY: USD→CNY; TOKENS: keep raw tokens). - `topup` / `topup_stripe`: treat inputs as “amount” for USD/CNY and as token-count for TOKENS; adjust min topup and pay money accordingly. - `misc`: include `quota_display_type` in status payload. - Compatibility: in `model/option.UpdateOption`, map updates to `DisplayInCurrencyEnabled` → `general_setting.quota_display_type` (true→USD, false→TOKENS). Keep exporting the legacy key in `OptionMap`. Frontend - Settings: replace the “display in currency” switch with a Select (`general_setting.quota_display_type`) offering USD / CNY / Tokens. Provide fallback mapping from legacy `DisplayInCurrencyEnabled`. - Persist `quota_display_type` to localStorage (keep `display_in_currency` for legacy components). - Rendering helpers: base all quota/price rendering on `quota_display_type`; use `usd_exchange_rate` for CNY symbol/values. - Pricing page: default view currency follows site display type (USD/CNY), while TOKENS mode still allows per-view currency toggling when needed. Notes - No database migrations required. - Legacy clients remain functional via compatibility fields.
2025-09-29 23:23:31 +08:00
const quotaDisplayType = localStorage.getItem('quota_display_type') || 'USD';
if (quotaDisplayType === 'TOKENS') {
2025-06-09 01:16:35 +08:00
return renderNumber(renderUnitWithQuota(amount));
}
const numericAmount = Number(amount);
const formattedAmount = Number.isFinite(numericAmount)
? numericAmount.toFixed(2)
: amount;
💱 feat(settings): introduce site-wide quota display type (USD/CNY/TOKENS/CUSTOM) Replace the legacy boolean “DisplayInCurrencyEnabled” with an injected, type-safe configuration `general_setting.quota_display_type`, and wire it through the backend and frontend. Backend - Add `QuotaDisplayType` to `operation_setting.GeneralSetting` with injected registration via `config.GlobalConfig.Register("general_setting", ...)`. Helpers: `IsCurrencyDisplay()`, `IsCNYDisplay()`, `GetQuotaDisplayType()`. - Expose `quota_display_type` in `/api/status` and keep legacy `display_in_currency` for backward compatibility. - Logger: update `LogQuota` and `FormatQuota` to support USD/CNY/TOKENS. When CNY is selected, convert using `operation_setting.USDExchangeRate`. - Controllers: - `billing`: compute subscription/usage amounts based on the selected type (USD: divide by `QuotaPerUnit`; CNY: USD→CNY; TOKENS: keep raw tokens). - `topup` / `topup_stripe`: treat inputs as “amount” for USD/CNY and as token-count for TOKENS; adjust min topup and pay money accordingly. - `misc`: include `quota_display_type` in status payload. - Compatibility: in `model/option.UpdateOption`, map updates to `DisplayInCurrencyEnabled` → `general_setting.quota_display_type` (true→USD, false→TOKENS). Keep exporting the legacy key in `OptionMap`. Frontend - Settings: replace the “display in currency” switch with a Select (`general_setting.quota_display_type`) offering USD / CNY / Tokens. Provide fallback mapping from legacy `DisplayInCurrencyEnabled`. - Persist `quota_display_type` to localStorage (keep `display_in_currency` for legacy components). - Rendering helpers: base all quota/price rendering on `quota_display_type`; use `usd_exchange_rate` for CNY symbol/values. - Pricing page: default view currency follows site display type (USD/CNY), while TOKENS mode still allows per-view currency toggling when needed. Notes - No database migrations required. - Legacy clients remain functional via compatibility fields.
2025-09-29 23:23:31 +08:00
if (quotaDisplayType === 'CNY') {
return '¥' + formattedAmount;
💱 feat(settings): introduce site-wide quota display type (USD/CNY/TOKENS/CUSTOM) Replace the legacy boolean “DisplayInCurrencyEnabled” with an injected, type-safe configuration `general_setting.quota_display_type`, and wire it through the backend and frontend. Backend - Add `QuotaDisplayType` to `operation_setting.GeneralSetting` with injected registration via `config.GlobalConfig.Register("general_setting", ...)`. Helpers: `IsCurrencyDisplay()`, `IsCNYDisplay()`, `GetQuotaDisplayType()`. - Expose `quota_display_type` in `/api/status` and keep legacy `display_in_currency` for backward compatibility. - Logger: update `LogQuota` and `FormatQuota` to support USD/CNY/TOKENS. When CNY is selected, convert using `operation_setting.USDExchangeRate`. - Controllers: - `billing`: compute subscription/usage amounts based on the selected type (USD: divide by `QuotaPerUnit`; CNY: USD→CNY; TOKENS: keep raw tokens). - `topup` / `topup_stripe`: treat inputs as “amount” for USD/CNY and as token-count for TOKENS; adjust min topup and pay money accordingly. - `misc`: include `quota_display_type` in status payload. - Compatibility: in `model/option.UpdateOption`, map updates to `DisplayInCurrencyEnabled` → `general_setting.quota_display_type` (true→USD, false→TOKENS). Keep exporting the legacy key in `OptionMap`. Frontend - Settings: replace the “display in currency” switch with a Select (`general_setting.quota_display_type`) offering USD / CNY / Tokens. Provide fallback mapping from legacy `DisplayInCurrencyEnabled`. - Persist `quota_display_type` to localStorage (keep `display_in_currency` for legacy components). - Rendering helpers: base all quota/price rendering on `quota_display_type`; use `usd_exchange_rate` for CNY symbol/values. - Pricing page: default view currency follows site display type (USD/CNY), while TOKENS mode still allows per-view currency toggling when needed. Notes - No database migrations required. - Legacy clients remain functional via compatibility fields.
2025-09-29 23:23:31 +08:00
} else if (quotaDisplayType === 'CUSTOM') {
const statusStr = localStorage.getItem('status');
let symbol = '¤';
try {
if (statusStr) {
const s = JSON.parse(statusStr);
symbol = s?.custom_currency_symbol || symbol;
}
} catch (e) {}
return symbol + formattedAmount;
💱 feat(settings): introduce site-wide quota display type (USD/CNY/TOKENS/CUSTOM) Replace the legacy boolean “DisplayInCurrencyEnabled” with an injected, type-safe configuration `general_setting.quota_display_type`, and wire it through the backend and frontend. Backend - Add `QuotaDisplayType` to `operation_setting.GeneralSetting` with injected registration via `config.GlobalConfig.Register("general_setting", ...)`. Helpers: `IsCurrencyDisplay()`, `IsCNYDisplay()`, `GetQuotaDisplayType()`. - Expose `quota_display_type` in `/api/status` and keep legacy `display_in_currency` for backward compatibility. - Logger: update `LogQuota` and `FormatQuota` to support USD/CNY/TOKENS. When CNY is selected, convert using `operation_setting.USDExchangeRate`. - Controllers: - `billing`: compute subscription/usage amounts based on the selected type (USD: divide by `QuotaPerUnit`; CNY: USD→CNY; TOKENS: keep raw tokens). - `topup` / `topup_stripe`: treat inputs as “amount” for USD/CNY and as token-count for TOKENS; adjust min topup and pay money accordingly. - `misc`: include `quota_display_type` in status payload. - Compatibility: in `model/option.UpdateOption`, map updates to `DisplayInCurrencyEnabled` → `general_setting.quota_display_type` (true→USD, false→TOKENS). Keep exporting the legacy key in `OptionMap`. Frontend - Settings: replace the “display in currency” switch with a Select (`general_setting.quota_display_type`) offering USD / CNY / Tokens. Provide fallback mapping from legacy `DisplayInCurrencyEnabled`. - Persist `quota_display_type` to localStorage (keep `display_in_currency` for legacy components). - Rendering helpers: base all quota/price rendering on `quota_display_type`; use `usd_exchange_rate` for CNY symbol/values. - Pricing page: default view currency follows site display type (USD/CNY), while TOKENS mode still allows per-view currency toggling when needed. Notes - No database migrations required. - Legacy clients remain functional via compatibility fields.
2025-09-29 23:23:31 +08:00
}
return '$' + formattedAmount;
}
/**
* 获取当前货币配置信息
* @returns {Object} - { symbol, rate, type }
*/
export function getCurrencyConfig() {
const quotaDisplayType = localStorage.getItem('quota_display_type') || 'USD';
const statusStr = localStorage.getItem('status');
✨ feat: Add topup billing history with admin manual completion Implement comprehensive topup billing system with user history viewing and admin management capabilities. ## Features Added ### Frontend - Add topup history modal with paginated billing records - Display order details: trade number, payment method, amount, money, status, create time - Implement empty state with proper illustrations - Add payment method column with localized display (Stripe, Alipay, WeChat) - Add admin manual completion feature for pending orders - Add Coins icon for recharge amount display - Integrate "Bills" button in RechargeCard header - Optimize code quality by using shared utility functions (isAdmin) - Extract constants for status and payment method mappings - Use React.useMemo for performance optimization ### Backend - Create GET `/api/user/topup/self` endpoint for user topup history with pagination - Create POST `/api/user/topup/complete` endpoint for admin manual order completion - Add `payment_method` field to TopUp model for tracking payment types - Implement `GetUserTopUps` method with proper pagination and ordering - Implement `ManualCompleteTopUp` with transaction safety and row-level locking - Add application-level mutex locks to prevent concurrent order processing - Record payment method in Epay and Stripe payment flows - Ensure idempotency and data consistency with proper error handling ### Internationalization - Add i18n keys for Chinese (zh), English (en), and French (fr) - Support for billing-related UI text and status messages ## Technical Improvements - Use database transactions with FOR UPDATE row-level locking - Implement sync.Map-based mutex for order-level concurrency control - Proper error handling and user-friendly toast notifications - Follow existing codebase patterns for empty states and modals - Maintain code quality with extracted render functions and constants ## Files Changed - Backend: controller/topup.go, controller/topup_stripe.go, model/topup.go, router/api-router.go - Frontend: web/src/components/topup/modals/TopupHistoryModal.jsx (new), web/src/components/topup/RechargeCard.jsx, web/src/components/topup/index.jsx - i18n: web/src/i18n/locales/{zh,en,fr}.json
2025-10-07 00:22:45 +08:00
let symbol = '$';
let rate = 1;
✨ feat: Add topup billing history with admin manual completion Implement comprehensive topup billing system with user history viewing and admin management capabilities. ## Features Added ### Frontend - Add topup history modal with paginated billing records - Display order details: trade number, payment method, amount, money, status, create time - Implement empty state with proper illustrations - Add payment method column with localized display (Stripe, Alipay, WeChat) - Add admin manual completion feature for pending orders - Add Coins icon for recharge amount display - Integrate "Bills" button in RechargeCard header - Optimize code quality by using shared utility functions (isAdmin) - Extract constants for status and payment method mappings - Use React.useMemo for performance optimization ### Backend - Create GET `/api/user/topup/self` endpoint for user topup history with pagination - Create POST `/api/user/topup/complete` endpoint for admin manual order completion - Add `payment_method` field to TopUp model for tracking payment types - Implement `GetUserTopUps` method with proper pagination and ordering - Implement `ManualCompleteTopUp` with transaction safety and row-level locking - Add application-level mutex locks to prevent concurrent order processing - Record payment method in Epay and Stripe payment flows - Ensure idempotency and data consistency with proper error handling ### Internationalization - Add i18n keys for Chinese (zh), English (en), and French (fr) - Support for billing-related UI text and status messages ## Technical Improvements - Use database transactions with FOR UPDATE row-level locking - Implement sync.Map-based mutex for order-level concurrency control - Proper error handling and user-friendly toast notifications - Follow existing codebase patterns for empty states and modals - Maintain code quality with extracted render functions and constants ## Files Changed - Backend: controller/topup.go, controller/topup_stripe.go, model/topup.go, router/api-router.go - Frontend: web/src/components/topup/modals/TopupHistoryModal.jsx (new), web/src/components/topup/RechargeCard.jsx, web/src/components/topup/index.jsx - i18n: web/src/i18n/locales/{zh,en,fr}.json
2025-10-07 00:22:45 +08:00
if (quotaDisplayType === 'CNY') {
symbol = '¥';
try {
if (statusStr) {
const s = JSON.parse(statusStr);
rate = s?.usd_exchange_rate || 7;
}
} catch (e) {}
} else if (quotaDisplayType === 'CUSTOM') {
try {
if (statusStr) {
const s = JSON.parse(statusStr);
symbol = s?.custom_currency_symbol || '¤';
rate = s?.custom_currency_exchange_rate || 1;
}
} catch (e) {}
}
✨ feat: Add topup billing history with admin manual completion Implement comprehensive topup billing system with user history viewing and admin management capabilities. ## Features Added ### Frontend - Add topup history modal with paginated billing records - Display order details: trade number, payment method, amount, money, status, create time - Implement empty state with proper illustrations - Add payment method column with localized display (Stripe, Alipay, WeChat) - Add admin manual completion feature for pending orders - Add Coins icon for recharge amount display - Integrate "Bills" button in RechargeCard header - Optimize code quality by using shared utility functions (isAdmin) - Extract constants for status and payment method mappings - Use React.useMemo for performance optimization ### Backend - Create GET `/api/user/topup/self` endpoint for user topup history with pagination - Create POST `/api/user/topup/complete` endpoint for admin manual order completion - Add `payment_method` field to TopUp model for tracking payment types - Implement `GetUserTopUps` method with proper pagination and ordering - Implement `ManualCompleteTopUp` with transaction safety and row-level locking - Add application-level mutex locks to prevent concurrent order processing - Record payment method in Epay and Stripe payment flows - Ensure idempotency and data consistency with proper error handling ### Internationalization - Add i18n keys for Chinese (zh), English (en), and French (fr) - Support for billing-related UI text and status messages ## Technical Improvements - Use database transactions with FOR UPDATE row-level locking - Implement sync.Map-based mutex for order-level concurrency control - Proper error handling and user-friendly toast notifications - Follow existing codebase patterns for empty states and modals - Maintain code quality with extracted render functions and constants ## Files Changed - Backend: controller/topup.go, controller/topup_stripe.go, model/topup.go, router/api-router.go - Frontend: web/src/components/topup/modals/TopupHistoryModal.jsx (new), web/src/components/topup/RechargeCard.jsx, web/src/components/topup/index.jsx - i18n: web/src/i18n/locales/{zh,en,fr}.json
2025-10-07 00:22:45 +08:00
return { symbol, rate, type: quotaDisplayType };
}
/**
* 将美元金额转换为当前选择的货币
* @param {number} usdAmount - 美元金额
* @param {number} digits - 小数位数
* @returns {string} - 格式化后的货币字符串
*/
export function convertUSDToCurrency(usdAmount, digits = 2) {
const { symbol, rate } = getCurrencyConfig();
const convertedAmount = usdAmount * rate;
return symbol + convertedAmount.toFixed(digits);
}
2023-06-20 20:09:17 +08:00
export function renderQuota(quota, digits = 2) {
2024-03-23 21:24:39 +08:00
let quotaPerUnit = localStorage.getItem('quota_per_unit');
💱 feat(settings): introduce site-wide quota display type (USD/CNY/TOKENS/CUSTOM) Replace the legacy boolean “DisplayInCurrencyEnabled” with an injected, type-safe configuration `general_setting.quota_display_type`, and wire it through the backend and frontend. Backend - Add `QuotaDisplayType` to `operation_setting.GeneralSetting` with injected registration via `config.GlobalConfig.Register("general_setting", ...)`. Helpers: `IsCurrencyDisplay()`, `IsCNYDisplay()`, `GetQuotaDisplayType()`. - Expose `quota_display_type` in `/api/status` and keep legacy `display_in_currency` for backward compatibility. - Logger: update `LogQuota` and `FormatQuota` to support USD/CNY/TOKENS. When CNY is selected, convert using `operation_setting.USDExchangeRate`. - Controllers: - `billing`: compute subscription/usage amounts based on the selected type (USD: divide by `QuotaPerUnit`; CNY: USD→CNY; TOKENS: keep raw tokens). - `topup` / `topup_stripe`: treat inputs as “amount” for USD/CNY and as token-count for TOKENS; adjust min topup and pay money accordingly. - `misc`: include `quota_display_type` in status payload. - Compatibility: in `model/option.UpdateOption`, map updates to `DisplayInCurrencyEnabled` → `general_setting.quota_display_type` (true→USD, false→TOKENS). Keep exporting the legacy key in `OptionMap`. Frontend - Settings: replace the “display in currency” switch with a Select (`general_setting.quota_display_type`) offering USD / CNY / Tokens. Provide fallback mapping from legacy `DisplayInCurrencyEnabled`. - Persist `quota_display_type` to localStorage (keep `display_in_currency` for legacy components). - Rendering helpers: base all quota/price rendering on `quota_display_type`; use `usd_exchange_rate` for CNY symbol/values. - Pricing page: default view currency follows site display type (USD/CNY), while TOKENS mode still allows per-view currency toggling when needed. Notes - No database migrations required. - Legacy clients remain functional via compatibility fields.
2025-09-29 23:23:31 +08:00
const quotaDisplayType = localStorage.getItem('quota_display_type') || 'USD';
2024-03-23 21:24:39 +08:00
quotaPerUnit = parseFloat(quotaPerUnit);
💱 feat(settings): introduce site-wide quota display type (USD/CNY/TOKENS/CUSTOM) Replace the legacy boolean “DisplayInCurrencyEnabled” with an injected, type-safe configuration `general_setting.quota_display_type`, and wire it through the backend and frontend. Backend - Add `QuotaDisplayType` to `operation_setting.GeneralSetting` with injected registration via `config.GlobalConfig.Register("general_setting", ...)`. Helpers: `IsCurrencyDisplay()`, `IsCNYDisplay()`, `GetQuotaDisplayType()`. - Expose `quota_display_type` in `/api/status` and keep legacy `display_in_currency` for backward compatibility. - Logger: update `LogQuota` and `FormatQuota` to support USD/CNY/TOKENS. When CNY is selected, convert using `operation_setting.USDExchangeRate`. - Controllers: - `billing`: compute subscription/usage amounts based on the selected type (USD: divide by `QuotaPerUnit`; CNY: USD→CNY; TOKENS: keep raw tokens). - `topup` / `topup_stripe`: treat inputs as “amount” for USD/CNY and as token-count for TOKENS; adjust min topup and pay money accordingly. - `misc`: include `quota_display_type` in status payload. - Compatibility: in `model/option.UpdateOption`, map updates to `DisplayInCurrencyEnabled` → `general_setting.quota_display_type` (true→USD, false→TOKENS). Keep exporting the legacy key in `OptionMap`. Frontend - Settings: replace the “display in currency” switch with a Select (`general_setting.quota_display_type`) offering USD / CNY / Tokens. Provide fallback mapping from legacy `DisplayInCurrencyEnabled`. - Persist `quota_display_type` to localStorage (keep `display_in_currency` for legacy components). - Rendering helpers: base all quota/price rendering on `quota_display_type`; use `usd_exchange_rate` for CNY symbol/values. - Pricing page: default view currency follows site display type (USD/CNY), while TOKENS mode still allows per-view currency toggling when needed. Notes - No database migrations required. - Legacy clients remain functional via compatibility fields.
2025-09-29 23:23:31 +08:00
if (quotaDisplayType === 'TOKENS') {
return renderNumber(quota);
}
const resultUSD = quota / quotaPerUnit;
let symbol = '$';
let value = resultUSD;
if (quotaDisplayType === 'CNY') {
const statusStr = localStorage.getItem('status');
let usdRate = 1;
try {
if (statusStr) {
const s = JSON.parse(statusStr);
usdRate = s?.usd_exchange_rate || 1;
}
} catch (e) {}
value = resultUSD * usdRate;
symbol = '¥';
} else if (quotaDisplayType === 'CUSTOM') {
const statusStr = localStorage.getItem('status');
let symbolCustom = '¤';
let rate = 1;
try {
if (statusStr) {
const s = JSON.parse(statusStr);
symbolCustom = s?.custom_currency_symbol || symbolCustom;
rate = s?.custom_currency_exchange_rate || rate;
}
} catch (e) {}
value = resultUSD * rate;
symbol = symbolCustom;
}
const fixedResult = value.toFixed(digits);
if (parseFloat(fixedResult) === 0 && quota > 0 && value > 0) {
const minValue = Math.pow(10, -digits);
return symbol + minValue.toFixed(digits);
2024-03-23 21:24:39 +08:00
}
💱 feat(settings): introduce site-wide quota display type (USD/CNY/TOKENS/CUSTOM) Replace the legacy boolean “DisplayInCurrencyEnabled” with an injected, type-safe configuration `general_setting.quota_display_type`, and wire it through the backend and frontend. Backend - Add `QuotaDisplayType` to `operation_setting.GeneralSetting` with injected registration via `config.GlobalConfig.Register("general_setting", ...)`. Helpers: `IsCurrencyDisplay()`, `IsCNYDisplay()`, `GetQuotaDisplayType()`. - Expose `quota_display_type` in `/api/status` and keep legacy `display_in_currency` for backward compatibility. - Logger: update `LogQuota` and `FormatQuota` to support USD/CNY/TOKENS. When CNY is selected, convert using `operation_setting.USDExchangeRate`. - Controllers: - `billing`: compute subscription/usage amounts based on the selected type (USD: divide by `QuotaPerUnit`; CNY: USD→CNY; TOKENS: keep raw tokens). - `topup` / `topup_stripe`: treat inputs as “amount” for USD/CNY and as token-count for TOKENS; adjust min topup and pay money accordingly. - `misc`: include `quota_display_type` in status payload. - Compatibility: in `model/option.UpdateOption`, map updates to `DisplayInCurrencyEnabled` → `general_setting.quota_display_type` (true→USD, false→TOKENS). Keep exporting the legacy key in `OptionMap`. Frontend - Settings: replace the “display in currency” switch with a Select (`general_setting.quota_display_type`) offering USD / CNY / Tokens. Provide fallback mapping from legacy `DisplayInCurrencyEnabled`. - Persist `quota_display_type` to localStorage (keep `display_in_currency` for legacy components). - Rendering helpers: base all quota/price rendering on `quota_display_type`; use `usd_exchange_rate` for CNY symbol/values. - Pricing page: default view currency follows site display type (USD/CNY), while TOKENS mode still allows per-view currency toggling when needed. Notes - No database migrations required. - Legacy clients remain functional via compatibility fields.
2025-09-29 23:23:31 +08:00
return symbol + fixedResult;
}
2025-06-11 23:46:59 +08:00
function isValidGroupRatio(ratio) {
return Number.isFinite(ratio) && ratio !== -1;
}
/**
* Helper function to get effective ratio and label
* @param {number} groupRatio - The default group ratio
* @param {number} user_group_ratio - The user-specific group ratio
* @returns {Object} - Object containing { ratio, label, useUserGroupRatio }
*/
function getEffectiveRatio(groupRatio, user_group_ratio) {
const useUserGroupRatio = isValidGroupRatio(user_group_ratio);
const ratioLabel = useUserGroupRatio
? i18next.t('专属倍率')
: i18next.t('分组倍率');
const effectiveRatio = useUserGroupRatio ? user_group_ratio : groupRatio;
return {
ratio: effectiveRatio,
label: ratioLabel,
useUserGroupRatio: useUserGroupRatio,
};
2025-06-11 23:46:59 +08:00
}
function getQuotaDisplayType() {
return localStorage.getItem('quota_display_type') || 'USD';
}
function resolveBillingDisplayMode(displayMode, modelPrice = -1) {
if (modelPrice !== -1) {
return 'price';
}
if (getQuotaDisplayType() === 'TOKENS') {
return 'ratio';
}
return displayMode === 'ratio' ? 'ratio' : 'price';
}
function isPriceDisplayMode(displayMode, modelPrice = -1) {
return resolveBillingDisplayMode(displayMode, modelPrice) === 'price';
}
function shouldUseRatioBillingProcess(modelPrice = -1) {
return modelPrice === -1 && getQuotaDisplayType() === 'TOKENS';
}
function formatCompactDisplayPrice(usdAmount, digits = 6) {
const { symbol, rate } = getCurrencyConfig();
const amount = Number((usdAmount * rate).toFixed(digits));
return `${symbol}${amount}`;
}
function appendPricePart(parts, condition, key, vars) {
if (!condition) {
return;
}
parts.push(i18next.t(key, vars));
}
function joinBillingSummary(parts) {
return parts.filter(Boolean).join('');
}
function getGroupRatioText(groupRatio, user_group_ratio) {
const { ratio, label } = getEffectiveRatio(groupRatio, user_group_ratio);
return i18next.t('{{ratioType}} {{ratio}}x', {
ratioType: label,
ratio,
});
}
function formatRatioValue(value, digits = 6) {
const num = Number(value);
if (!Number.isFinite(num)) {
return 0;
}
return Number(num.toFixed(digits));
}
function renderDisplayAmountFromUsd(usdAmount, digits = 6) {
return renderQuotaWithAmount(Number(Number(usdAmount || 0).toFixed(digits)));
}
function formatBillingDisplayPrice(usdAmount, rate, digits = 6) {
return (usdAmount * rate).toFixed(digits);
}
function buildBillingText(key, vars) {
return i18next.t(key, vars);
}
function buildBillingPriceText(
key,
{ symbol, usdAmount, rate, amountKey = 'price', digits = 6, ...vars },
) {
return buildBillingText(key, {
symbol,
[amountKey]: formatBillingDisplayPrice(usdAmount, rate, digits),
...vars,
});
}
function renderBillingArticle(lines, { showReferenceNote = true } = {}) {
const articleLines = lines.filter(Boolean);
if (showReferenceNote) {
articleLines.push(buildBillingText('仅供参考,以实际扣费为准'));
}
return (
<article>
{articleLines.map((line, index) => (
<p key={index}>{line}</p>
))}
</article>
);
}
// Shared core for simple price rendering (used by OpenAI-like and Claude-like variants)
function renderPriceSimpleCore({
modelRatio,
modelPrice = -1,
groupRatio,
user_group_ratio,
cacheTokens = 0,
cacheRatio = 1.0,
cacheCreationTokens = 0,
cacheCreationRatio = 1.0,
cacheCreationTokens5m = 0,
cacheCreationRatio5m = 1.0,
cacheCreationTokens1h = 0,
cacheCreationRatio1h = 1.0,
image = false,
imageRatio = 1.0,
isSystemPromptOverride = false,
displayMode = 'price',
outputMode = 'text',
}) {
const { ratio: effectiveGroupRatio, label: ratioLabel } = getEffectiveRatio(
groupRatio,
user_group_ratio,
);
const finalGroupRatio = effectiveGroupRatio;
const { symbol, rate } = getCurrencyConfig();
const hasSplitCacheCreation =
cacheCreationTokens5m > 0 || cacheCreationTokens1h > 0;
const shouldShowLegacyCacheCreation =
!hasSplitCacheCreation && cacheCreationTokens !== 0;
const shouldShowCache = cacheTokens !== 0;
const shouldShowCacheCreation5m =
hasSplitCacheCreation && cacheCreationTokens5m > 0;
const shouldShowCacheCreation1h =
hasSplitCacheCreation && cacheCreationTokens1h > 0;
if (outputMode === 'segments') {
const segments = [
{
tone: 'primary',
text: getGroupRatioText(groupRatio, user_group_ratio),
},
];
if (modelPrice !== -1) {
segments.push({
tone: 'secondary',
text: isPriceDisplayMode(displayMode, modelPrice)
? i18next.t('模型价格 {{price}}', {
price: formatCompactDisplayPrice(modelPrice),
})
: i18next.t('按次'),
});
} else if (isPriceDisplayMode(displayMode, modelPrice)) {
segments.push({
tone: 'secondary',
text: i18next.t('输入 {{price}} / 1M tokens', {
price: formatCompactDisplayPrice(modelRatio * 2.0),
}),
});
if (shouldShowCache) {
segments.push({
tone: 'secondary',
text: i18next.t('缓存读 {{price}} / 1M tokens', {
price: formatCompactDisplayPrice(modelRatio * 2.0 * cacheRatio),
}),
});
}
if (hasSplitCacheCreation && shouldShowCacheCreation5m) {
segments.push({
tone: 'secondary',
text: i18next.t('5m缓存创建 {{price}} / 1M tokens', {
price: formatCompactDisplayPrice(
modelRatio * 2.0 * cacheCreationRatio5m,
),
}),
});
}
if (hasSplitCacheCreation && shouldShowCacheCreation1h) {
segments.push({
tone: 'secondary',
text: i18next.t('1h缓存创建 {{price}} / 1M tokens', {
price: formatCompactDisplayPrice(
modelRatio * 2.0 * cacheCreationRatio1h,
),
}),
});
}
if (!hasSplitCacheCreation && shouldShowLegacyCacheCreation) {
segments.push({
tone: 'secondary',
text: i18next.t('缓存创建 {{price}} / 1M tokens', {
price: formatCompactDisplayPrice(
modelRatio * 2.0 * cacheCreationRatio,
),
}),
});
}
if (image) {
segments.push({
tone: 'secondary',
text: i18next.t('图片输入 {{price}} / 1M tokens', {
price: formatCompactDisplayPrice(modelRatio * 2.0 * imageRatio),
}),
});
}
} else {
segments.push({
tone: 'secondary',
text: i18next.t('模型: {{ratio}}', {
ratio: modelRatio,
}),
});
if (shouldShowCache) {
segments.push({
tone: 'secondary',
text: i18next.t('缓存: {{cacheRatio}}', {
cacheRatio: cacheRatio,
}),
});
}
if (hasSplitCacheCreation) {
if (shouldShowCacheCreation5m && shouldShowCacheCreation1h) {
segments.push({
tone: 'secondary',
text: i18next.t(
'缓存创建: 5m {{cacheCreationRatio5m}} / 1h {{cacheCreationRatio1h}}',
{
cacheCreationRatio5m: cacheCreationRatio5m,
cacheCreationRatio1h: cacheCreationRatio1h,
},
),
});
} else if (shouldShowCacheCreation5m) {
segments.push({
tone: 'secondary',
text: i18next.t('缓存创建: 5m {{cacheCreationRatio5m}}', {
cacheCreationRatio5m: cacheCreationRatio5m,
}),
});
} else if (shouldShowCacheCreation1h) {
segments.push({
tone: 'secondary',
text: i18next.t('缓存创建: 1h {{cacheCreationRatio1h}}', {
cacheCreationRatio1h: cacheCreationRatio1h,
}),
});
}
} else if (shouldShowLegacyCacheCreation) {
segments.push({
tone: 'secondary',
text: i18next.t('缓存创建: {{cacheCreationRatio}}', {
cacheCreationRatio: cacheCreationRatio,
}),
});
}
if (image) {
segments.push({
tone: 'secondary',
text: i18next.t('图片输入: {{imageRatio}}', {
imageRatio: imageRatio,
}),
});
}
}
if (isSystemPromptOverride) {
segments.push({
tone: 'primary',
text: i18next.t('系统提示覆盖'),
});
}
return segments;
}
if (modelPrice !== -1) {
if (isPriceDisplayMode(displayMode, modelPrice)) {
return joinBillingSummary([
i18next.t('模型价格:{{symbol}}{{price}}', {
symbol: symbol,
price: (modelPrice * rate).toFixed(6),
}),
getGroupRatioText(groupRatio, user_group_ratio),
]);
}
const displayPrice = (modelPrice * rate).toFixed(6);
return i18next.t('价格:{{symbol}}{{price}} * {{ratioType}}{{ratio}}', {
symbol: symbol,
price: displayPrice,
ratioType: ratioLabel,
ratio: finalGroupRatio,
});
}
if (isPriceDisplayMode(displayMode, modelPrice)) {
const parts = [];
if (modelPrice !== -1) {
parts.push(
i18next.t('模型价格 {{price}}', {
price: formatCompactDisplayPrice(modelPrice),
}),
);
parts.push(getGroupRatioText(groupRatio, user_group_ratio));
return joinBillingSummary(parts);
}
parts.push(
i18next.t('输入 {{price}} / 1M tokens', {
price: formatCompactDisplayPrice(modelRatio * 2.0),
}),
);
if (shouldShowCache) {
parts.push(
i18next.t('缓存读 {{price}} / 1M tokens', {
price: formatCompactDisplayPrice(modelRatio * 2.0 * cacheRatio),
}),
);
}
if (hasSplitCacheCreation && shouldShowCacheCreation5m) {
parts.push(
i18next.t('5m缓存创建 {{price}} / 1M tokens', {
price: formatCompactDisplayPrice(
modelRatio * 2.0 * cacheCreationRatio5m,
),
}),
);
}
if (hasSplitCacheCreation && shouldShowCacheCreation1h) {
parts.push(
i18next.t('1h缓存创建 {{price}} / 1M tokens', {
price: formatCompactDisplayPrice(
modelRatio * 2.0 * cacheCreationRatio1h,
),
}),
);
}
if (!hasSplitCacheCreation && shouldShowLegacyCacheCreation) {
parts.push(
i18next.t('缓存创建 {{price}} / 1M tokens', {
price: formatCompactDisplayPrice(
modelRatio * 2.0 * cacheCreationRatio,
),
}),
);
}
if (image) {
parts.push(
i18next.t('图片输入 {{price}} / 1M tokens', {
price: formatCompactDisplayPrice(modelRatio * 2.0 * imageRatio),
}),
);
}
parts.push(getGroupRatioText(groupRatio, user_group_ratio));
let result = joinBillingSummary(parts);
if (isSystemPromptOverride) {
result += '\n\r' + i18next.t('系统提示覆盖');
}
return result;
}
const parts = [];
// base: model ratio
parts.push(i18next.t('模型: {{ratio}}'));
// cache part (label differs when with image)
if (shouldShowCache) {
parts.push(i18next.t('缓存: {{cacheRatio}}'));
}
if (hasSplitCacheCreation) {
if (shouldShowCacheCreation5m && shouldShowCacheCreation1h) {
parts.push(
i18next.t(
'缓存创建: 5m {{cacheCreationRatio5m}} / 1h {{cacheCreationRatio1h}}',
),
);
} else if (shouldShowCacheCreation5m) {
parts.push(i18next.t('缓存创建: 5m {{cacheCreationRatio5m}}'));
} else if (shouldShowCacheCreation1h) {
parts.push(i18next.t('缓存创建: 1h {{cacheCreationRatio1h}}'));
}
} else if (shouldShowLegacyCacheCreation) {
parts.push(i18next.t('缓存创建: {{cacheCreationRatio}}'));
}
// image part
if (image) {
parts.push(i18next.t('图片输入: {{imageRatio}}'));
}
parts.push(`{{ratioType}}: {{groupRatio}}`);
let result = i18next.t(parts.join(' * '), {
ratio: modelRatio,
ratioType: ratioLabel,
groupRatio: finalGroupRatio,
cacheRatio: cacheRatio,
cacheCreationRatio: cacheCreationRatio,
cacheCreationRatio5m: cacheCreationRatio5m,
cacheCreationRatio1h: cacheCreationRatio1h,
imageRatio: imageRatio,
});
if (isSystemPromptOverride) {
result += '\n\r' + i18next.t('系统提示覆盖');
}
return result;
}
export function renderModelPrice(opts) {
const {
prompt_tokens: inputTokens = 0,
completion_tokens: completionTokens = 0,
model_ratio: modelRatio = 0,
model_price: modelPrice = -1,
completion_ratio: completionRatio,
group_ratio: _groupRatio,
user_group_ratio,
cache_tokens: cacheTokens = 0,
cache_ratio: cacheRatio = 1.0,
image = false,
image_ratio: imageRatio = 1.0,
image_output: imageOutputTokens = 0,
web_search: webSearch = false,
web_search_call_count: webSearchCallCount = 0,
web_search_price: webSearchPrice = 0,
file_search: fileSearch = false,
file_search_call_count: fileSearchCallCount = 0,
file_search_price: fileSearchPrice = 0,
audio_input_seperate_price: audioInputSeperatePrice = false,
audio_input_token_count: audioInputTokens = 0,
audio_input_price: audioInputPrice = 0,
image_generation_call: imageGenerationCall = false,
image_generation_call_price: imageGenerationCallPrice = 0,
displayMode = 'price',
} = opts;
const { ratio: effectiveGroupRatio, label: ratioLabel } = getEffectiveRatio(
_groupRatio,
user_group_ratio,
);
let groupRatio = effectiveGroupRatio;
✨ feat: Add topup billing history with admin manual completion Implement comprehensive topup billing system with user history viewing and admin management capabilities. ## Features Added ### Frontend - Add topup history modal with paginated billing records - Display order details: trade number, payment method, amount, money, status, create time - Implement empty state with proper illustrations - Add payment method column with localized display (Stripe, Alipay, WeChat) - Add admin manual completion feature for pending orders - Add Coins icon for recharge amount display - Integrate "Bills" button in RechargeCard header - Optimize code quality by using shared utility functions (isAdmin) - Extract constants for status and payment method mappings - Use React.useMemo for performance optimization ### Backend - Create GET `/api/user/topup/self` endpoint for user topup history with pagination - Create POST `/api/user/topup/complete` endpoint for admin manual order completion - Add `payment_method` field to TopUp model for tracking payment types - Implement `GetUserTopUps` method with proper pagination and ordering - Implement `ManualCompleteTopUp` with transaction safety and row-level locking - Add application-level mutex locks to prevent concurrent order processing - Record payment method in Epay and Stripe payment flows - Ensure idempotency and data consistency with proper error handling ### Internationalization - Add i18n keys for Chinese (zh), English (en), and French (fr) - Support for billing-related UI text and status messages ## Technical Improvements - Use database transactions with FOR UPDATE row-level locking - Implement sync.Map-based mutex for order-level concurrency control - Proper error handling and user-friendly toast notifications - Follow existing codebase patterns for empty states and modals - Maintain code quality with extracted render functions and constants ## Files Changed - Backend: controller/topup.go, controller/topup_stripe.go, model/topup.go, router/api-router.go - Frontend: web/src/components/topup/modals/TopupHistoryModal.jsx (new), web/src/components/topup/RechargeCard.jsx, web/src/components/topup/index.jsx - i18n: web/src/i18n/locales/{zh,en,fr}.json
2025-10-07 00:22:45 +08:00
const { symbol, rate } = getCurrencyConfig();
if (!shouldUseRatioBillingProcess(modelPrice)) {
if (modelPrice !== -1) {
return renderBillingArticle([
buildBillingPriceText('按次:{{symbol}}{{price}}', {
symbol,
usdAmount: modelPrice,
rate,
}),
buildBillingPriceText(
'按次 {{symbol}}{{price}} * {{ratioType}} {{ratio}} = {{symbol}}{{total}}',
{
symbol,
usdAmount: modelPrice,
rate,
ratioType: ratioLabel,
ratio: groupRatio,
amountKey: 'price',
total: formatBillingDisplayPrice(modelPrice * groupRatio, rate),
},
),
]);
}
if (completionRatio === undefined) {
completionRatio = 0;
}
const inputRatioPrice = modelRatio * 2.0;
const completionRatioPrice = modelRatio * 2.0 * completionRatio;
const cacheRatioPrice = modelRatio * 2.0 * cacheRatio;
const imageRatioPrice = modelRatio * 2.0 * imageRatio;
let effectiveInputTokens =
inputTokens - cacheTokens + cacheTokens * cacheRatio;
if (image && imageOutputTokens > 0) {
effectiveInputTokens =
inputTokens - imageOutputTokens + imageOutputTokens * imageRatio;
}
if (audioInputTokens > 0) {
effectiveInputTokens -= audioInputTokens;
}
const price =
(effectiveInputTokens / 1000000) * inputRatioPrice * groupRatio +
(audioInputTokens / 1000000) * audioInputPrice * groupRatio +
(completionTokens / 1000000) * completionRatioPrice * groupRatio +
(webSearchCallCount / 1000) * webSearchPrice * groupRatio +
(fileSearchCallCount / 1000) * fileSearchPrice * groupRatio +
imageGenerationCallPrice * groupRatio;
let inputDesc = '';
if (image && imageOutputTokens > 0) {
inputDesc = buildBillingPriceText(
'(输入 {{nonImageInput}} tokens + 图片输入 {{imageInput}} tokens / 1M tokens * {{symbol}}{{price}}',
{
nonImageInput: inputTokens - imageOutputTokens,
imageInput: imageOutputTokens,
symbol,
usdAmount: inputRatioPrice,
rate,
},
);
} else if (cacheTokens > 0) {
inputDesc = buildBillingText(
'(输入 {{nonCacheInput}} tokens / 1M tokens * {{symbol}}{{price}} + 缓存 {{cacheInput}} tokens / 1M tokens * {{symbol}}{{cachePrice}}',
{
nonCacheInput: inputTokens - cacheTokens,
cacheInput: cacheTokens,
symbol,
price: formatBillingDisplayPrice(inputRatioPrice, rate),
cachePrice: formatBillingDisplayPrice(cacheRatioPrice, rate),
},
);
} else if (audioInputSeperatePrice && audioInputTokens > 0) {
inputDesc = buildBillingText(
'(输入 {{nonAudioInput}} tokens / 1M tokens * {{symbol}}{{price}} + 音频输入 {{audioInput}} tokens / 1M tokens * {{symbol}}{{audioPrice}}',
{
nonAudioInput: inputTokens - audioInputTokens,
audioInput: audioInputTokens,
symbol,
price: formatBillingDisplayPrice(inputRatioPrice, rate),
audioPrice: formatBillingDisplayPrice(audioInputPrice, rate),
},
);
} else {
inputDesc = buildBillingPriceText(
'(输入 {{input}} tokens / 1M tokens * {{symbol}}{{price}}',
{
input: inputTokens,
symbol,
usdAmount: inputRatioPrice,
rate,
},
);
}
const outputDesc = buildBillingText(
'输出 {{completion}} tokens / 1M tokens * {{symbol}}{{compPrice}}) * {{ratioType}} {{ratio}}',
{
completion: completionTokens,
symbol,
compPrice: formatBillingDisplayPrice(completionRatioPrice, rate),
ratio: groupRatio,
ratioType: ratioLabel,
},
);
const extraServices = [
webSearch && webSearchCallCount > 0
? buildBillingPriceText(
' + Web搜索 {{count}}次 / 1K 次 * {{symbol}}{{price}} * {{ratioType}} {{ratio}}',
{
count: webSearchCallCount,
symbol,
usdAmount: webSearchPrice,
rate,
ratio: groupRatio,
ratioType: ratioLabel,
},
)
: '',
fileSearch && fileSearchCallCount > 0
? buildBillingPriceText(
' + 文件搜索 {{count}}次 / 1K 次 * {{symbol}}{{price}} * {{ratioType}} {{ratio}}',
{
count: fileSearchCallCount,
symbol,
usdAmount: fileSearchPrice,
rate,
ratio: groupRatio,
ratioType: ratioLabel,
},
)
: '',
imageGenerationCall && imageGenerationCallPrice > 0
? buildBillingPriceText(
' + 图片生成调用 {{symbol}}{{price}} / 1次 * {{ratioType}} {{ratio}}',
{
symbol,
usdAmount: imageGenerationCallPrice,
rate,
ratio: groupRatio,
ratioType: ratioLabel,
},
)
: '',
].join('');
const billingLines = [
buildBillingPriceText(
'输入价格:{{symbol}}{{price}} / 1M tokens{{audioPrice}}',
{
symbol,
usdAmount: inputRatioPrice,
rate,
audioPrice: audioInputSeperatePrice
? `${i18next.t('音频输入价格')} ${symbol}${formatBillingDisplayPrice(audioInputPrice, rate)} / 1M tokens`
: '',
},
),
buildBillingPriceText('输出价格:{{symbol}}{{total}} / 1M tokens', {
symbol,
usdAmount: completionRatioPrice,
rate,
amountKey: 'total',
}),
cacheTokens > 0
? buildBillingPriceText(
'缓存读取价格:{{symbol}}{{total}} / 1M tokens',
{
symbol,
usdAmount: inputRatioPrice * cacheRatio,
rate,
amountKey: 'total',
},
)
: null,
image && imageOutputTokens > 0
? buildBillingPriceText(
'图片输入价格:{{symbol}}{{total}} / 1M tokens',
{
symbol,
usdAmount: imageRatioPrice,
rate,
amountKey: 'total',
},
)
: null,
webSearch && webSearchCallCount > 0
? buildBillingPriceText('Web搜索价格{{symbol}}{{price}} / 1K 次', {
symbol,
usdAmount: webSearchPrice,
rate,
})
: null,
fileSearch && fileSearchCallCount > 0
? buildBillingPriceText('文件搜索价格:{{symbol}}{{price}} / 1K 次', {
symbol,
usdAmount: fileSearchPrice,
rate,
})
: null,
imageGenerationCall && imageGenerationCallPrice > 0
? buildBillingPriceText('图片生成调用:{{symbol}}{{price}} / 1次', {
symbol,
usdAmount: imageGenerationCallPrice,
rate,
})
: null,
buildBillingText(
'{{inputDesc}} + {{outputDesc}}{{extraServices}} = {{symbol}}{{total}}',
{
inputDesc,
outputDesc,
extraServices,
symbol,
total: formatBillingDisplayPrice(price, rate),
},
),
];
return renderBillingArticle(billingLines);
}
2024-05-12 15:35:57 +08:00
if (modelPrice !== -1) {
const displayPrice = (modelPrice * rate).toFixed(6);
const displayTotal = (modelPrice * groupRatio * rate).toFixed(6);
2025-04-04 12:00:38 +08:00
return i18next.t(
'按次:{{symbol}}{{price}} * {{ratioType}}{{ratio}} = {{symbol}}{{total}}',
2025-04-04 12:00:38 +08:00
{
symbol: symbol,
price: displayPrice,
2025-04-04 12:00:38 +08:00
ratio: groupRatio,
total: displayTotal,
2025-06-11 23:46:59 +08:00
ratioType: ratioLabel,
2025-04-04 12:00:38 +08:00
},
);
}
2025-04-04 12:00:38 +08:00
if (completionRatio === undefined) {
completionRatio = 0;
}
2025-04-04 12:00:38 +08:00
const modelRatioValue = formatRatioValue(modelRatio);
const completionRatioValue = formatRatioValue(completionRatio);
const cacheRatioValue = formatRatioValue(cacheRatio);
const imageRatioValue = formatRatioValue(imageRatio);
const inputRatioPrice = modelRatio * 2.0;
const completionRatioPrice = modelRatio * 2.0 * completionRatioValue;
const audioRatioValue =
audioInputSeperatePrice && audioInputPrice > 0
? formatRatioValue(audioInputPrice / inputRatioPrice)
: null;
const textInputTokens = Math.max(
inputTokens - cacheTokens - audioInputTokens,
0,
);
const imageInputTokens =
image && imageOutputTokens > 0 ? imageOutputTokens : 0;
const cacheInputTokens = cacheTokens;
const textInputAmount =
(textInputTokens / 1000000) * inputRatioPrice * groupRatio;
const cacheInputAmount =
(cacheInputTokens / 1000000) *
inputRatioPrice *
cacheRatioValue *
groupRatio;
const imageInputAmount =
(imageInputTokens / 1000000) *
inputRatioPrice *
imageRatioValue *
groupRatio;
const audioInputAmount =
(audioInputTokens / 1000000) * audioInputPrice * groupRatio;
const completionAmount =
(completionTokens / 1000000) * completionRatioPrice * groupRatio;
const webSearchAmount =
(webSearchCallCount / 1000) * webSearchPrice * groupRatio;
const fileSearchAmount =
(fileSearchCallCount / 1000) * fileSearchPrice * groupRatio;
const imageGenerationAmount = imageGenerationCallPrice * groupRatio;
const totalAmount =
textInputAmount +
cacheInputAmount +
imageInputAmount +
audioInputAmount +
completionAmount +
webSearchAmount +
fileSearchAmount +
imageGenerationAmount;
return renderBillingArticle([
[
buildBillingText('模型倍率 {{modelRatio}}', {
modelRatio: modelRatioValue,
}),
buildBillingText('补全倍率 {{completionRatio}}', {
completionRatio: completionRatioValue,
}),
cacheInputTokens > 0
? buildBillingText('缓存倍率 {{cacheRatio}}', {
cacheRatio: cacheRatioValue,
})
: null,
imageInputTokens > 0
? buildBillingText('图片倍率 {{imageRatio}}', {
imageRatio: imageRatioValue,
})
: null,
audioRatioValue !== null
? buildBillingText('音频倍率 {{audioRatio}}', {
audioRatio: audioRatioValue,
})
: null,
buildBillingText('{{ratioType}} {{ratio}}', {
ratioType: ratioLabel,
ratio: groupRatio,
}),
]
.filter(Boolean)
.join(''),
textInputTokens > 0
? buildBillingText(
'普通输入:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * {{ratioType}} {{ratio}} = {{amount}}',
{
tokens: textInputTokens,
modelRatio: modelRatioValue,
ratioType: ratioLabel,
ratio: groupRatio,
amount: renderDisplayAmountFromUsd(textInputAmount),
},
)
: null,
cacheInputTokens > 0
? buildBillingText(
'缓存输入:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 缓存倍率 {{cacheRatio}} * {{ratioType}} {{ratio}} = {{amount}}',
{
tokens: cacheInputTokens,
modelRatio: modelRatioValue,
cacheRatio: cacheRatioValue,
ratioType: ratioLabel,
ratio: groupRatio,
amount: renderDisplayAmountFromUsd(cacheInputAmount),
},
)
: null,
imageInputTokens > 0
? buildBillingText(
'图片输入:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 图片倍率 {{imageRatio}} * {{ratioType}} {{ratio}} = {{amount}}',
{
tokens: imageInputTokens,
modelRatio: modelRatioValue,
imageRatio: imageRatioValue,
ratioType: ratioLabel,
ratio: groupRatio,
amount: renderDisplayAmountFromUsd(imageInputAmount),
},
)
: null,
audioInputTokens > 0 && audioRatioValue !== null
? buildBillingText(
'音频输入:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 音频倍率 {{audioRatio}} * {{ratioType}} {{ratio}} = {{amount}}',
{
tokens: audioInputTokens,
modelRatio: modelRatioValue,
audioRatio: audioRatioValue,
ratioType: ratioLabel,
ratio: groupRatio,
amount: renderDisplayAmountFromUsd(audioInputAmount),
},
)
: null,
buildBillingText(
'输出:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 补全倍率 {{completionRatio}} * {{ratioType}} {{ratio}} = {{amount}}',
{
tokens: completionTokens,
modelRatio: modelRatioValue,
completionRatio: completionRatioValue,
ratioType: ratioLabel,
ratio: groupRatio,
amount: renderDisplayAmountFromUsd(completionAmount),
},
),
webSearch && webSearchCallCount > 0
? buildBillingText(
'Web 搜索:{{count}} / 1K * 单价 {{price}} * {{ratioType}} {{ratio}} = {{amount}}',
{
count: webSearchCallCount,
price: renderDisplayAmountFromUsd(webSearchPrice),
ratioType: ratioLabel,
ratio: groupRatio,
amount: renderDisplayAmountFromUsd(webSearchAmount),
},
)
: null,
fileSearch && fileSearchCallCount > 0
? buildBillingText(
'文件搜索:{{count}} / 1K * 单价 {{price}} * {{ratioType}} {{ratio}} = {{amount}}',
{
count: fileSearchCallCount,
price: renderDisplayAmountFromUsd(fileSearchPrice),
ratioType: ratioLabel,
ratio: groupRatio,
amount: renderDisplayAmountFromUsd(fileSearchAmount),
},
)
: null,
imageGenerationCall && imageGenerationCallPrice > 0
? buildBillingText(
'图片生成1 次 * 单价 {{price}} * {{ratioType}} {{ratio}} = {{amount}}',
{
price: renderDisplayAmountFromUsd(imageGenerationCallPrice),
ratioType: ratioLabel,
ratio: groupRatio,
amount: renderDisplayAmountFromUsd(imageGenerationAmount),
},
)
: null,
buildBillingText('合计:{{total}}', {
total: renderDisplayAmountFromUsd(totalAmount),
}),
]);
2024-05-12 15:35:57 +08:00
}
export function renderLogContent(opts) {
const {
model_ratio: modelRatio,
completion_ratio: completionRatio,
model_price: modelPrice = -1,
group_ratio: groupRatio,
user_group_ratio,
cache_ratio: cacheRatio = 1.0,
image = false,
image_ratio: imageRatio = 1.0,
web_search: webSearch = false,
web_search_call_count: webSearchCallCount = 0,
file_search: fileSearch = false,
file_search_call_count: fileSearchCallCount = 0,
displayMode = 'price',
} = opts;
const {
ratio,
label: ratioLabel,
useUserGroupRatio: useUserGroupRatio,
} = getEffectiveRatio(groupRatio, user_group_ratio);
✨ feat: Add topup billing history with admin manual completion Implement comprehensive topup billing system with user history viewing and admin management capabilities. ## Features Added ### Frontend - Add topup history modal with paginated billing records - Display order details: trade number, payment method, amount, money, status, create time - Implement empty state with proper illustrations - Add payment method column with localized display (Stripe, Alipay, WeChat) - Add admin manual completion feature for pending orders - Add Coins icon for recharge amount display - Integrate "Bills" button in RechargeCard header - Optimize code quality by using shared utility functions (isAdmin) - Extract constants for status and payment method mappings - Use React.useMemo for performance optimization ### Backend - Create GET `/api/user/topup/self` endpoint for user topup history with pagination - Create POST `/api/user/topup/complete` endpoint for admin manual order completion - Add `payment_method` field to TopUp model for tracking payment types - Implement `GetUserTopUps` method with proper pagination and ordering - Implement `ManualCompleteTopUp` with transaction safety and row-level locking - Add application-level mutex locks to prevent concurrent order processing - Record payment method in Epay and Stripe payment flows - Ensure idempotency and data consistency with proper error handling ### Internationalization - Add i18n keys for Chinese (zh), English (en), and French (fr) - Support for billing-related UI text and status messages ## Technical Improvements - Use database transactions with FOR UPDATE row-level locking - Implement sync.Map-based mutex for order-level concurrency control - Proper error handling and user-friendly toast notifications - Follow existing codebase patterns for empty states and modals - Maintain code quality with extracted render functions and constants ## Files Changed - Backend: controller/topup.go, controller/topup_stripe.go, model/topup.go, router/api-router.go - Frontend: web/src/components/topup/modals/TopupHistoryModal.jsx (new), web/src/components/topup/RechargeCard.jsx, web/src/components/topup/index.jsx - i18n: web/src/i18n/locales/{zh,en,fr}.json
2025-10-07 00:22:45 +08:00
// 获取货币配置
const { symbol, rate } = getCurrencyConfig();
if (isPriceDisplayMode(displayMode, modelPrice)) {
if (modelPrice !== -1) {
return joinBillingSummary([
i18next.t('模型价格 {{symbol}}{{price}} / 次', {
symbol,
price: (modelPrice * rate).toFixed(6),
}),
getGroupRatioText(groupRatio, user_group_ratio),
]);
}
const parts = [
i18next.t('输入价格 {{symbol}}{{price}} / 1M tokens', {
symbol,
price: (modelRatio * 2.0 * rate).toFixed(6),
}),
i18next.t('输出价格 {{symbol}}{{price}} / 1M tokens', {
symbol,
price: (modelRatio * 2.0 * completionRatio * rate).toFixed(6),
}),
];
appendPricePart(
parts,
cacheRatio !== 1.0,
'缓存读取价格 {{symbol}}{{price}} / 1M tokens',
{
symbol,
price: (modelRatio * 2.0 * cacheRatio * rate).toFixed(6),
},
);
appendPricePart(
parts,
image,
'图片输入价格 {{symbol}}{{price}} / 1M tokens',
{
symbol,
price: (modelRatio * 2.0 * imageRatio * rate).toFixed(6),
},
);
appendPricePart(
parts,
webSearch,
'Web 搜索调用 {{webSearchCallCount}} 次',
{
webSearchCallCount,
},
);
appendPricePart(
parts,
fileSearch,
'文件搜索调用 {{fileSearchCallCount}} 次',
{
fileSearchCallCount,
},
);
parts.push(getGroupRatioText(groupRatio, user_group_ratio));
return joinBillingSummary(parts);
}
if (modelPrice !== -1) {
return i18next.t('模型价格 {{symbol}}{{price}}{{ratioType}} {{ratio}}', {
symbol: symbol,
price: (modelPrice * rate).toFixed(6),
ratioType: ratioLabel,
ratio,
});
} else {
if (image) {
return i18next.t(
2025-08-05 22:56:27 +08:00
'模型倍率 {{modelRatio}},缓存倍率 {{cacheRatio}},输出倍率 {{completionRatio}},图片输入倍率 {{imageRatio}}{{ratioType}} {{ratio}}',
{
modelRatio: modelRatio,
2025-08-05 22:56:27 +08:00
cacheRatio: cacheRatio,
completionRatio: completionRatio,
imageRatio: imageRatio,
ratioType: ratioLabel,
ratio,
},
);
} else if (webSearch) {
return i18next.t(
2025-08-05 22:56:27 +08:00
'模型倍率 {{modelRatio}},缓存倍率 {{cacheRatio}},输出倍率 {{completionRatio}}{{ratioType}} {{ratio}}Web 搜索调用 {{webSearchCallCount}} 次',
{
modelRatio: modelRatio,
2025-08-05 22:56:27 +08:00
cacheRatio: cacheRatio,
completionRatio: completionRatio,
ratioType: ratioLabel,
ratio,
webSearchCallCount,
},
);
} else {
return i18next.t(
2025-08-05 22:56:27 +08:00
'模型倍率 {{modelRatio}},缓存倍率 {{cacheRatio}},输出倍率 {{completionRatio}}{{ratioType}} {{ratio}}',
{
modelRatio: modelRatio,
2025-08-05 22:56:27 +08:00
cacheRatio: cacheRatio,
completionRatio: completionRatio,
ratioType: ratioLabel,
ratio,
},
);
}
}
}
const TIER_VAR_KEYS = ['p', 'c', 'cr', 'cc', 'cc1h', 'img', 'ai', 'ao'];
const TIER_VAR_TO_FIELD = {
p: 'inputPrice', c: 'outputPrice',
cr: 'cacheReadPrice', cc: 'cacheCreatePrice', cc1h: 'cacheCreate1hPrice',
img: 'imagePrice', ai: 'audioInputPrice', ao: 'audioOutputPrice',
};
function parseTierBody(bodyStr) {
const coeffs = {};
const re = new RegExp(`\\b(${TIER_VAR_KEYS.join('|')})\\s*\\*\\s*([\\d.eE+-]+)`, 'g');
let m;
while ((m = re.exec(bodyStr)) !== null) {
if (!(m[1] in coeffs)) coeffs[m[1]] = Number(m[2]);
}
const tier = {};
for (const [varName, field] of Object.entries(TIER_VAR_TO_FIELD)) {
tier[field] = coeffs[varName] || 0;
}
return tier;
}
export function parseTiersFromExpr(exprStr) {
if (!exprStr) return [];
try {
const condGroup = `((?:(?:p|c)\\s*(?:<|<=|>|>=)\\s*[\\d.eE+]+)(?:\\s*&&\\s*(?:p|c)\\s*(?:<|<=|>|>=)\\s*[\\d.eE+]+)*)`;
const tierRe = new RegExp(`(?:${condGroup}\\s*\\?\\s*)?tier\\("([^"]*)",\\s*([^)]+)\\)`, 'g');
const tiers = [];
let m;
while ((m = tierRe.exec(exprStr)) !== null) {
const condStr = m[1] || '';
const conditions = [];
if (condStr) {
for (const cp of condStr.split(/\s*&&\s*/)) {
const cm = cp.trim().match(/^(p|c)\s*(<|<=|>|>=)\s*([\d.eE+]+)$/);
if (cm) conditions.push({ var: cm[1], op: cm[2], value: Number(cm[3]) });
}
}
const tier = parseTierBody(m[3]);
tier.label = m[2];
tier.conditions = conditions;
tiers.push(tier);
}
return tiers;
} catch {
return [];
}
}
export function renderTieredModelPrice(opts) {
const {
prompt_tokens: inputTokens = 0,
completion_tokens: completionTokens = 0,
expr_b64: exprB64,
matched_tier: matchedTier,
group_ratio: groupRatio,
cache_tokens: cacheTokens = 0,
cache_creation_tokens: cacheCreationTokens = 0,
cache_creation_tokens_5m: cacheCreationTokens5m = 0,
cache_creation_tokens_1h: cacheCreationTokens1h = 0,
} = opts;
let exprStr = '';
try { exprStr = atob(exprB64); } catch { /* ignore */ }
const tiers = parseTiersFromExpr(exprStr);
if (tiers.length === 0) {
return i18next.t('阶梯计费(表达式解析失败)');
}
const tier = tiers.find((t) => t.label === matchedTier) || tiers[0];
const { symbol, rate } = getCurrencyConfig();
const gr = groupRatio || 1;
const priceLines = [
['inputPrice', '输入价格'],
['outputPrice', '补全价格'],
['cacheReadPrice', '缓存读取价格'],
['cacheCreatePrice', '缓存创建价格'],
['cacheCreate1hPrice', '1h缓存创建价格'],
['imagePrice', '图片输入价格'],
['audioInputPrice', '音频输入价格'],
['audioOutputPrice', '音频输出价格'],
];
const lines = [
buildBillingText('命中档位:{{tier}}', { tier: matchedTier || tier.label }),
...priceLines
.filter(([field]) => tier[field] > 0)
.map(([field, label]) =>
buildBillingPriceText(`${label}{{symbol}}{{price}} / 1M tokens`, { symbol, usdAmount: tier[field], rate }),
),
];
return renderBillingArticle(lines);
}
export function renderTieredModelPriceSimple(opts) {
const {
expr_b64: exprB64,
matched_tier: matchedTier,
group_ratio: groupRatio,
user_group_ratio,
cache_tokens: cacheTokens = 0,
cache_creation_tokens_5m: cacheCreationTokens5m = 0,
cache_creation_tokens_1h: cacheCreationTokens1h = 0,
cache_creation_tokens: cacheCreationTokens = 0,
displayMode = 'price',
outputMode = 'segments',
} = opts;
let exprStr = '';
try { exprStr = atob(exprB64); } catch { /* ignore */ }
const tiers = parseTiersFromExpr(exprStr);
const tier = tiers.find((t) => t.label === matchedTier) || tiers[0];
if (outputMode === 'segments') {
const segments = [
{
tone: 'primary',
text: getGroupRatioText(groupRatio, user_group_ratio),
},
];
if (tier && isPriceDisplayMode(displayMode)) {
const priceSegments = [
['inputPrice', '输入'],
['outputPrice', '补全'],
['cacheReadPrice', '缓存读'],
['cacheCreatePrice', '缓存创建'],
['cacheCreate1hPrice', '1h缓存创建'],
['imagePrice', '图片输入'],
['audioInputPrice', '音频输入'],
['audioOutputPrice', '音频输出'],
];
for (const [field, label] of priceSegments) {
if (tier[field] > 0) {
segments.push({
tone: 'secondary',
text: i18next.t('{{label}} {{price}} / 1M tokens', {
label: i18next.t(label),
price: formatCompactDisplayPrice(tier[field]),
}),
});
}
}
}
return segments;
}
return [];
}
export function renderModelPriceSimple(opts) {
const {
model_ratio: modelRatio,
model_price: modelPrice = -1,
group_ratio: groupRatio,
user_group_ratio,
cache_tokens: cacheTokens = 0,
cache_ratio: cacheRatio = 1.0,
cache_creation_tokens: cacheCreationTokens = 0,
cache_creation_ratio: cacheCreationRatio = 1.0,
cache_creation_tokens_5m: cacheCreationTokens5m = 0,
cache_creation_ratio_5m: cacheCreationRatio5m = 1.0,
cache_creation_tokens_1h: cacheCreationTokens1h = 0,
cache_creation_ratio_1h: cacheCreationRatio1h = 1.0,
image = false,
image_ratio: imageRatio = 1.0,
is_system_prompt_overwritten: isSystemPromptOverride = false,
provider = 'openai',
displayMode = 'price',
outputMode = 'text',
} = opts;
return renderPriceSimpleCore({
modelRatio,
modelPrice,
groupRatio,
user_group_ratio,
cacheTokens,
cacheRatio,
cacheCreationTokens,
cacheCreationRatio,
cacheCreationTokens5m,
cacheCreationRatio5m,
cacheCreationTokens1h,
cacheCreationRatio1h,
image,
imageRatio,
isSystemPromptOverride,
displayMode,
outputMode,
});
}
export function renderAudioModelPrice(opts) {
const {
prompt_tokens: inputTokens = 0,
completion_tokens: completionTokens = 0,
model_ratio: modelRatio = 0,
model_price: modelPrice = -1,
completion_ratio: completionRatio,
audio_input: audioInputTokens = 0,
audio_output: audioCompletionTokens = 0,
audio_ratio: audioRatio,
audio_completion_ratio: audioCompletionRatio,
group_ratio: _groupRatio,
user_group_ratio,
cache_tokens: cacheTokens = 0,
cache_ratio: cacheRatio = 1.0,
displayMode = 'price',
} = opts;
const { ratio: effectiveGroupRatio, label: ratioLabel } = getEffectiveRatio(
_groupRatio,
user_group_ratio,
);
let groupRatio = effectiveGroupRatio;
✨ feat: Add topup billing history with admin manual completion Implement comprehensive topup billing system with user history viewing and admin management capabilities. ## Features Added ### Frontend - Add topup history modal with paginated billing records - Display order details: trade number, payment method, amount, money, status, create time - Implement empty state with proper illustrations - Add payment method column with localized display (Stripe, Alipay, WeChat) - Add admin manual completion feature for pending orders - Add Coins icon for recharge amount display - Integrate "Bills" button in RechargeCard header - Optimize code quality by using shared utility functions (isAdmin) - Extract constants for status and payment method mappings - Use React.useMemo for performance optimization ### Backend - Create GET `/api/user/topup/self` endpoint for user topup history with pagination - Create POST `/api/user/topup/complete` endpoint for admin manual order completion - Add `payment_method` field to TopUp model for tracking payment types - Implement `GetUserTopUps` method with proper pagination and ordering - Implement `ManualCompleteTopUp` with transaction safety and row-level locking - Add application-level mutex locks to prevent concurrent order processing - Record payment method in Epay and Stripe payment flows - Ensure idempotency and data consistency with proper error handling ### Internationalization - Add i18n keys for Chinese (zh), English (en), and French (fr) - Support for billing-related UI text and status messages ## Technical Improvements - Use database transactions with FOR UPDATE row-level locking - Implement sync.Map-based mutex for order-level concurrency control - Proper error handling and user-friendly toast notifications - Follow existing codebase patterns for empty states and modals - Maintain code quality with extracted render functions and constants ## Files Changed - Backend: controller/topup.go, controller/topup_stripe.go, model/topup.go, router/api-router.go - Frontend: web/src/components/topup/modals/TopupHistoryModal.jsx (new), web/src/components/topup/RechargeCard.jsx, web/src/components/topup/index.jsx - i18n: web/src/i18n/locales/{zh,en,fr}.json
2025-10-07 00:22:45 +08:00
// 获取货币配置
const { symbol, rate } = getCurrencyConfig();
✨ feat: Add topup billing history with admin manual completion Implement comprehensive topup billing system with user history viewing and admin management capabilities. ## Features Added ### Frontend - Add topup history modal with paginated billing records - Display order details: trade number, payment method, amount, money, status, create time - Implement empty state with proper illustrations - Add payment method column with localized display (Stripe, Alipay, WeChat) - Add admin manual completion feature for pending orders - Add Coins icon for recharge amount display - Integrate "Bills" button in RechargeCard header - Optimize code quality by using shared utility functions (isAdmin) - Extract constants for status and payment method mappings - Use React.useMemo for performance optimization ### Backend - Create GET `/api/user/topup/self` endpoint for user topup history with pagination - Create POST `/api/user/topup/complete` endpoint for admin manual order completion - Add `payment_method` field to TopUp model for tracking payment types - Implement `GetUserTopUps` method with proper pagination and ordering - Implement `ManualCompleteTopUp` with transaction safety and row-level locking - Add application-level mutex locks to prevent concurrent order processing - Record payment method in Epay and Stripe payment flows - Ensure idempotency and data consistency with proper error handling ### Internationalization - Add i18n keys for Chinese (zh), English (en), and French (fr) - Support for billing-related UI text and status messages ## Technical Improvements - Use database transactions with FOR UPDATE row-level locking - Implement sync.Map-based mutex for order-level concurrency control - Proper error handling and user-friendly toast notifications - Follow existing codebase patterns for empty states and modals - Maintain code quality with extracted render functions and constants ## Files Changed - Backend: controller/topup.go, controller/topup_stripe.go, model/topup.go, router/api-router.go - Frontend: web/src/components/topup/modals/TopupHistoryModal.jsx (new), web/src/components/topup/RechargeCard.jsx, web/src/components/topup/index.jsx - i18n: web/src/i18n/locales/{zh,en,fr}.json
2025-10-07 00:22:45 +08:00
if (!shouldUseRatioBillingProcess(modelPrice)) {
if (modelPrice !== -1) {
return renderBillingArticle([
buildBillingPriceText('模型价格:{{symbol}}{{price}} / 次', {
symbol,
usdAmount: modelPrice,
rate,
}),
buildBillingPriceText(
'模型价格 {{symbol}}{{price}} / 次 * {{ratioType}} {{ratio}} = {{symbol}}{{total}}',
{
symbol,
usdAmount: modelPrice,
rate,
ratioType: ratioLabel,
ratio: groupRatio,
total: formatBillingDisplayPrice(modelPrice * groupRatio, rate),
},
),
]);
}
if (completionRatio === undefined) {
completionRatio = 0;
}
audioRatio = parseFloat(audioRatio).toFixed(6);
const inputRatioPrice = modelRatio * 2.0;
const completionRatioPrice = modelRatio * 2.0 * completionRatio;
const textPrice =
((inputTokens - cacheTokens + cacheTokens * cacheRatio) / 1000000) *
inputRatioPrice *
groupRatio +
(completionTokens / 1000000) * completionRatioPrice * groupRatio;
const audioPrice =
(audioInputTokens / 1000000) * inputRatioPrice * audioRatio * groupRatio +
(audioCompletionTokens / 1000000) *
inputRatioPrice *
audioRatio *
audioCompletionRatio *
groupRatio;
const totalPrice = textPrice + audioPrice;
return renderBillingArticle([
buildBillingPriceText('输入价格:{{symbol}}{{price}} / 1M tokens', {
symbol,
usdAmount: inputRatioPrice,
rate,
}),
buildBillingPriceText('输出价格:{{symbol}}{{price}} / 1M tokens', {
symbol,
usdAmount: completionRatioPrice,
rate,
}),
cacheTokens > 0
? buildBillingPriceText(
'缓存读取价格:{{symbol}}{{price}} / 1M tokens',
{
symbol,
usdAmount: inputRatioPrice * cacheRatio,
rate,
},
)
: null,
buildBillingPriceText('音频输入价格:{{symbol}}{{price}} / 1M tokens', {
symbol,
usdAmount: inputRatioPrice * audioRatio,
rate,
}),
buildBillingPriceText('音频补全价格:{{symbol}}{{price}} / 1M tokens', {
symbol,
usdAmount: inputRatioPrice * audioRatio * audioCompletionRatio,
rate,
}),
buildBillingText(
'文字提示 {{input}} tokens / 1M tokens * {{symbol}}{{textInputPrice}} + 文字补全 {{completion}} tokens / 1M tokens * {{symbol}}{{textCompPrice}} + 音频提示 {{audioInput}} tokens / 1M tokens * {{symbol}}{{audioInputPrice}} + 音频补全 {{audioCompletion}} tokens / 1M tokens * {{symbol}}{{audioCompPrice}} * {{ratioType}} {{ratio}} = {{symbol}}{{total}}',
{
input: inputTokens,
completion: completionTokens,
audioInput: audioInputTokens,
audioCompletion: audioCompletionTokens,
textInputPrice: formatBillingDisplayPrice(inputRatioPrice, rate),
textCompPrice: formatBillingDisplayPrice(completionRatioPrice, rate),
audioInputPrice: formatBillingDisplayPrice(
audioRatio * inputRatioPrice,
rate,
),
audioCompPrice: formatBillingDisplayPrice(
audioRatio * audioCompletionRatio * inputRatioPrice,
rate,
),
ratioType: ratioLabel,
ratio: groupRatio,
symbol,
total: formatBillingDisplayPrice(totalPrice, rate),
},
),
]);
}
2024-11-07 16:12:09 +08:00
// 1 ratio = $0.002 / 1K tokens
if (modelPrice !== -1) {
2025-04-04 12:00:38 +08:00
return i18next.t(
'模型价格:{{symbol}}{{price}} * {{ratioType}}{{ratio}} = {{symbol}}{{total}}',
2025-04-04 12:00:38 +08:00
{
symbol: symbol,
price: (modelPrice * rate).toFixed(6),
2025-04-04 12:00:38 +08:00
ratio: groupRatio,
total: (modelPrice * groupRatio * rate).toFixed(6),
2025-06-11 23:46:59 +08:00
ratioType: ratioLabel,
2025-04-04 12:00:38 +08:00
},
);
}
2025-01-04 17:54:02 +08:00
if (completionRatio === undefined) {
completionRatio = 0;
}
2025-04-04 12:00:38 +08:00
const modelRatioValue = formatRatioValue(modelRatio);
const completionRatioValue = formatRatioValue(completionRatio);
const cacheRatioValue = formatRatioValue(cacheRatio);
const audioRatioValue = formatRatioValue(audioRatio);
const audioCompletionRatioValue = formatRatioValue(audioCompletionRatio);
const inputRatioPrice = modelRatio * 2.0;
const completionRatioPrice = modelRatio * 2.0 * completionRatioValue;
const effectiveInputTokens =
inputTokens - cacheTokens + cacheTokens * cacheRatioValue;
const textPrice =
(effectiveInputTokens / 1000000) * inputRatioPrice * groupRatio +
(completionTokens / 1000000) * completionRatioPrice * groupRatio;
const audioPrice =
(audioInputTokens / 1000000) *
inputRatioPrice *
audioRatioValue *
groupRatio +
(audioCompletionTokens / 1000000) *
inputRatioPrice *
audioRatioValue *
audioCompletionRatioValue *
groupRatio;
const totalPrice = textPrice + audioPrice;
2025-04-04 12:00:38 +08:00
return renderBillingArticle([
buildBillingText(
'模型倍率 {{modelRatio}},补全倍率 {{completionRatio}},音频倍率 {{audioRatio}},音频补全倍率 {{audioCompletionRatio}}{{cachePart}}{{ratioType}} {{ratio}}',
{
modelRatio: modelRatioValue,
completionRatio: completionRatioValue,
audioRatio: audioRatioValue,
audioCompletionRatio: audioCompletionRatioValue,
cachePart:
cacheTokens > 0
? `${i18next.t('缓存倍率')} ${cacheRatioValue}`
: '',
ratioType: ratioLabel,
ratio: groupRatio,
},
),
buildBillingText(
'普通输入:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * {{ratioType}} {{ratio}} = {{amount}}',
{
tokens: Math.max(inputTokens - cacheTokens, 0),
modelRatio: modelRatioValue,
ratioType: ratioLabel,
ratio: groupRatio,
amount: renderDisplayAmountFromUsd(
(Math.max(inputTokens - cacheTokens, 0) / 1000000) *
inputRatioPrice *
groupRatio,
),
},
),
cacheTokens > 0
? buildBillingText(
'缓存输入:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 缓存倍率 {{cacheRatio}} * {{ratioType}} {{ratio}} = {{amount}}',
{
tokens: cacheTokens,
modelRatio: modelRatioValue,
cacheRatio: cacheRatioValue,
ratioType: ratioLabel,
ratio: groupRatio,
amount: renderDisplayAmountFromUsd(
(cacheTokens / 1000000) *
inputRatioPrice *
cacheRatioValue *
groupRatio,
),
},
)
: null,
buildBillingText(
'文字输出:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 补全倍率 {{completionRatio}} * {{ratioType}} {{ratio}} = {{amount}}',
{
tokens: completionTokens,
modelRatio: modelRatioValue,
completionRatio: completionRatioValue,
ratioType: ratioLabel,
ratio: groupRatio,
amount: renderDisplayAmountFromUsd(
(completionTokens / 1000000) *
inputRatioPrice *
completionRatioValue *
groupRatio,
),
},
),
buildBillingText(
'音频输入:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 音频倍率 {{audioRatio}} * {{ratioType}} {{ratio}} = {{amount}}',
{
tokens: audioInputTokens,
modelRatio: modelRatioValue,
audioRatio: audioRatioValue,
ratioType: ratioLabel,
ratio: groupRatio,
amount: renderDisplayAmountFromUsd(
(audioInputTokens / 1000000) *
inputRatioPrice *
audioRatioValue *
groupRatio,
),
},
),
buildBillingText(
'音频输出:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 音频倍率 {{audioRatio}} * 音频补全倍率 {{audioCompletionRatio}} * {{ratioType}} {{ratio}} = {{amount}}',
{
tokens: audioCompletionTokens,
modelRatio: modelRatioValue,
audioRatio: audioRatioValue,
audioCompletionRatio: audioCompletionRatioValue,
ratioType: ratioLabel,
ratio: groupRatio,
amount: renderDisplayAmountFromUsd(
(audioCompletionTokens / 1000000) *
inputRatioPrice *
audioRatioValue *
audioCompletionRatioValue *
groupRatio,
),
},
),
buildBillingText(
'合计:文字部分 {{textTotal}} + 音频部分 {{audioTotal}} = {{total}}',
{
textTotal: renderDisplayAmountFromUsd(textPrice),
audioTotal: renderDisplayAmountFromUsd(audioPrice),
total: renderDisplayAmountFromUsd(totalPrice),
},
),
]);
2024-11-07 16:12:09 +08:00
}
export function renderQuotaWithPrompt(quota, digits) {
💱 feat(settings): introduce site-wide quota display type (USD/CNY/TOKENS/CUSTOM) Replace the legacy boolean “DisplayInCurrencyEnabled” with an injected, type-safe configuration `general_setting.quota_display_type`, and wire it through the backend and frontend. Backend - Add `QuotaDisplayType` to `operation_setting.GeneralSetting` with injected registration via `config.GlobalConfig.Register("general_setting", ...)`. Helpers: `IsCurrencyDisplay()`, `IsCNYDisplay()`, `GetQuotaDisplayType()`. - Expose `quota_display_type` in `/api/status` and keep legacy `display_in_currency` for backward compatibility. - Logger: update `LogQuota` and `FormatQuota` to support USD/CNY/TOKENS. When CNY is selected, convert using `operation_setting.USDExchangeRate`. - Controllers: - `billing`: compute subscription/usage amounts based on the selected type (USD: divide by `QuotaPerUnit`; CNY: USD→CNY; TOKENS: keep raw tokens). - `topup` / `topup_stripe`: treat inputs as “amount” for USD/CNY and as token-count for TOKENS; adjust min topup and pay money accordingly. - `misc`: include `quota_display_type` in status payload. - Compatibility: in `model/option.UpdateOption`, map updates to `DisplayInCurrencyEnabled` → `general_setting.quota_display_type` (true→USD, false→TOKENS). Keep exporting the legacy key in `OptionMap`. Frontend - Settings: replace the “display in currency” switch with a Select (`general_setting.quota_display_type`) offering USD / CNY / Tokens. Provide fallback mapping from legacy `DisplayInCurrencyEnabled`. - Persist `quota_display_type` to localStorage (keep `display_in_currency` for legacy components). - Rendering helpers: base all quota/price rendering on `quota_display_type`; use `usd_exchange_rate` for CNY symbol/values. - Pricing page: default view currency follows site display type (USD/CNY), while TOKENS mode still allows per-view currency toggling when needed. Notes - No database migrations required. - Legacy clients remain functional via compatibility fields.
2025-09-29 23:23:31 +08:00
const quotaDisplayType = localStorage.getItem('quota_display_type') || 'USD';
if (quotaDisplayType !== 'TOKENS') {
return i18next.t('等价金额:') + renderQuota(quota, digits);
2024-03-23 21:24:39 +08:00
}
return '';
2023-10-31 00:03:22 +08:00
}
export function renderClaudeModelPrice(opts) {
const {
prompt_tokens: inputTokens = 0,
completion_tokens: completionTokens = 0,
model_ratio: modelRatio = 0,
model_price: modelPrice = -1,
completion_ratio: completionRatio,
group_ratio: _groupRatio,
user_group_ratio,
cache_tokens: cacheTokens = 0,
cache_ratio: cacheRatio = 1.0,
cache_creation_tokens: cacheCreationTokens = 0,
cache_creation_ratio: cacheCreationRatio = 1.0,
cache_creation_tokens_5m: cacheCreationTokens5m = 0,
cache_creation_ratio_5m: cacheCreationRatio5m = 1.0,
cache_creation_tokens_1h: cacheCreationTokens1h = 0,
cache_creation_ratio_1h: cacheCreationRatio1h = 1.0,
displayMode = 'price',
} = opts;
const { ratio: effectiveGroupRatio, label: ratioLabel } = getEffectiveRatio(
_groupRatio,
user_group_ratio,
);
let groupRatio = effectiveGroupRatio;
✨ feat: Add topup billing history with admin manual completion Implement comprehensive topup billing system with user history viewing and admin management capabilities. ## Features Added ### Frontend - Add topup history modal with paginated billing records - Display order details: trade number, payment method, amount, money, status, create time - Implement empty state with proper illustrations - Add payment method column with localized display (Stripe, Alipay, WeChat) - Add admin manual completion feature for pending orders - Add Coins icon for recharge amount display - Integrate "Bills" button in RechargeCard header - Optimize code quality by using shared utility functions (isAdmin) - Extract constants for status and payment method mappings - Use React.useMemo for performance optimization ### Backend - Create GET `/api/user/topup/self` endpoint for user topup history with pagination - Create POST `/api/user/topup/complete` endpoint for admin manual order completion - Add `payment_method` field to TopUp model for tracking payment types - Implement `GetUserTopUps` method with proper pagination and ordering - Implement `ManualCompleteTopUp` with transaction safety and row-level locking - Add application-level mutex locks to prevent concurrent order processing - Record payment method in Epay and Stripe payment flows - Ensure idempotency and data consistency with proper error handling ### Internationalization - Add i18n keys for Chinese (zh), English (en), and French (fr) - Support for billing-related UI text and status messages ## Technical Improvements - Use database transactions with FOR UPDATE row-level locking - Implement sync.Map-based mutex for order-level concurrency control - Proper error handling and user-friendly toast notifications - Follow existing codebase patterns for empty states and modals - Maintain code quality with extracted render functions and constants ## Files Changed - Backend: controller/topup.go, controller/topup_stripe.go, model/topup.go, router/api-router.go - Frontend: web/src/components/topup/modals/TopupHistoryModal.jsx (new), web/src/components/topup/RechargeCard.jsx, web/src/components/topup/index.jsx - i18n: web/src/i18n/locales/{zh,en,fr}.json
2025-10-07 00:22:45 +08:00
// 获取货币配置
const { symbol, rate } = getCurrencyConfig();
2025-03-12 21:31:46 +08:00
if (!shouldUseRatioBillingProcess(modelPrice)) {
if (modelPrice !== -1) {
return renderBillingArticle([
buildBillingPriceText('模型价格:{{symbol}}{{price}} / 次', {
symbol,
usdAmount: modelPrice,
rate,
}),
buildBillingPriceText(
'模型价格 {{symbol}}{{price}} / 次 * {{ratioType}} {{ratio}} = {{symbol}}{{total}}',
{
symbol,
usdAmount: modelPrice,
rate,
ratioType: ratioLabel,
ratio: groupRatio,
total: formatBillingDisplayPrice(modelPrice * groupRatio, rate),
},
),
]);
}
if (completionRatio === undefined) {
completionRatio = 0;
}
const inputRatioPrice = modelRatio * 2.0;
const completionRatioPrice = modelRatio * 2.0 * completionRatio;
const cacheRatioPrice = modelRatio * 2.0 * cacheRatio;
const cacheCreationRatioPrice = modelRatio * 2.0 * cacheCreationRatio;
const cacheCreationRatioPrice5m = modelRatio * 2.0 * cacheCreationRatio5m;
const cacheCreationRatioPrice1h = modelRatio * 2.0 * cacheCreationRatio1h;
const hasSplitCacheCreation =
cacheCreationTokens5m > 0 || cacheCreationTokens1h > 0;
const legacyCacheCreationTokens = hasSplitCacheCreation
? 0
: cacheCreationTokens;
const effectiveInputTokens =
inputTokens +
cacheTokens * cacheRatio +
legacyCacheCreationTokens * cacheCreationRatio +
cacheCreationTokens5m * cacheCreationRatio5m +
cacheCreationTokens1h * cacheCreationRatio1h;
const price =
(effectiveInputTokens / 1000000) * inputRatioPrice * groupRatio +
(completionTokens / 1000000) * completionRatioPrice * groupRatio;
const inputUnitPrice = inputRatioPrice * rate;
const completionUnitPrice = completionRatioPrice * rate;
const cacheUnitPrice = cacheRatioPrice * rate;
const cacheCreationUnitPrice = cacheCreationRatioPrice * rate;
const cacheCreationUnitPrice5m = cacheCreationRatioPrice5m * rate;
const cacheCreationUnitPrice1h = cacheCreationRatioPrice1h * rate;
const cacheCreationUnitPriceTotal =
cacheCreationUnitPrice5m + cacheCreationUnitPrice1h;
const shouldShowCache = cacheTokens > 0;
const shouldShowLegacyCacheCreation =
!hasSplitCacheCreation && cacheCreationTokens > 0;
const shouldShowCacheCreation5m =
hasSplitCacheCreation && cacheCreationTokens5m > 0;
const shouldShowCacheCreation1h =
hasSplitCacheCreation && cacheCreationTokens1h > 0;
const breakdownSegments = [
i18next.t('提示 {{input}} tokens / 1M tokens * {{symbol}}{{price}}', {
input: inputTokens,
symbol,
price: inputUnitPrice.toFixed(6),
}),
];
if (shouldShowCache) {
breakdownSegments.push(
i18next.t('缓存 {{tokens}} tokens / 1M tokens * {{symbol}}{{price}}', {
tokens: cacheTokens,
symbol,
price: cacheUnitPrice.toFixed(6),
}),
);
}
if (shouldShowLegacyCacheCreation) {
breakdownSegments.push(
i18next.t(
'缓存创建 {{tokens}} tokens / 1M tokens * {{symbol}}{{price}}',
{
tokens: cacheCreationTokens,
symbol,
price: cacheCreationUnitPrice.toFixed(6),
},
),
);
}
if (shouldShowCacheCreation5m) {
breakdownSegments.push(
i18next.t(
'5m缓存创建 {{tokens}} tokens / 1M tokens * {{symbol}}{{price}}',
{
tokens: cacheCreationTokens5m,
symbol,
price: cacheCreationUnitPrice5m.toFixed(6),
},
),
);
}
if (shouldShowCacheCreation1h) {
breakdownSegments.push(
i18next.t(
'1h缓存创建 {{tokens}} tokens / 1M tokens * {{symbol}}{{price}}',
{
tokens: cacheCreationTokens1h,
symbol,
price: cacheCreationUnitPrice1h.toFixed(6),
},
),
);
}
breakdownSegments.push(
i18next.t(
'补全 {{completion}} tokens / 1M tokens * {{symbol}}{{price}}',
{
completion: completionTokens,
symbol,
price: completionUnitPrice.toFixed(6),
},
),
);
const breakdownText = breakdownSegments.join(' + ');
return renderBillingArticle([
buildBillingPriceText('输入价格:{{symbol}}{{price}} / 1M tokens', {
symbol,
usdAmount: inputRatioPrice,
rate,
}),
buildBillingPriceText('输出价格:{{symbol}}{{price}} / 1M tokens', {
symbol,
usdAmount: completionRatioPrice,
rate,
}),
cacheTokens > 0
? buildBillingPriceText(
'缓存读取价格:{{symbol}}{{price}} / 1M tokens',
{
symbol,
usdAmount: cacheRatioPrice,
rate,
},
)
: null,
!hasSplitCacheCreation && cacheCreationTokens > 0
? buildBillingPriceText(
'缓存创建价格:{{symbol}}{{price}} / 1M tokens',
{
symbol,
usdAmount: cacheCreationRatioPrice,
rate,
},
)
: null,
hasSplitCacheCreation && cacheCreationTokens5m > 0
? buildBillingPriceText(
'5m缓存创建价格{{symbol}}{{price}} / 1M tokens',
{
symbol,
usdAmount: cacheCreationRatioPrice5m,
rate,
},
)
: null,
hasSplitCacheCreation && cacheCreationTokens1h > 0
? buildBillingPriceText(
'1h缓存创建价格{{symbol}}{{price}} / 1M tokens',
{
symbol,
usdAmount: cacheCreationRatioPrice1h,
rate,
},
)
: null,
buildBillingText(
'{{breakdown}} * {{ratioType}} {{ratio}} = {{symbol}}{{total}}',
{
breakdown: breakdownText,
ratioType: ratioLabel,
ratio: groupRatio,
symbol,
total: formatBillingDisplayPrice(price, rate),
},
),
]);
}
2025-03-12 21:31:46 +08:00
if (modelPrice !== -1) {
2025-04-04 12:00:38 +08:00
return i18next.t(
'模型价格:{{symbol}}{{price}} * {{ratioType}}{{ratio}} = {{symbol}}{{total}}',
2025-04-04 12:00:38 +08:00
{
symbol: symbol,
price: (modelPrice * rate).toFixed(6),
2025-04-04 12:00:38 +08:00
ratioType: ratioLabel,
ratio: groupRatio,
total: (modelPrice * groupRatio * rate).toFixed(6),
2025-04-04 12:00:38 +08:00
},
);
}
if (completionRatio === undefined) {
completionRatio = 0;
}
const modelRatioValue = formatRatioValue(modelRatio);
const completionRatioValue = formatRatioValue(completionRatio);
const cacheRatioValue = formatRatioValue(cacheRatio);
const cacheCreationRatioValue = formatRatioValue(cacheCreationRatio);
const cacheCreationRatio5mValue = formatRatioValue(cacheCreationRatio5m);
const cacheCreationRatio1hValue = formatRatioValue(cacheCreationRatio1h);
const inputRatioPrice = modelRatio * 2.0;
const completionRatioPrice = modelRatio * 2.0 * completionRatioValue;
const hasSplitCacheCreation =
cacheCreationTokens5m > 0 || cacheCreationTokens1h > 0;
const shouldShowCache = cacheTokens > 0;
const shouldShowLegacyCacheCreation =
!hasSplitCacheCreation && cacheCreationTokens > 0;
const shouldShowCacheCreation5m =
hasSplitCacheCreation && cacheCreationTokens5m > 0;
const shouldShowCacheCreation1h =
hasSplitCacheCreation && cacheCreationTokens1h > 0;
const legacyCacheCreationTokens = hasSplitCacheCreation
? 0
: cacheCreationTokens;
const effectiveInputTokens =
inputTokens +
cacheTokens * cacheRatioValue +
legacyCacheCreationTokens * cacheCreationRatioValue +
cacheCreationTokens5m * cacheCreationRatio5mValue +
cacheCreationTokens1h * cacheCreationRatio1hValue;
const totalAmount =
(effectiveInputTokens / 1000000) * inputRatioPrice * groupRatio +
(completionTokens / 1000000) * completionRatioPrice * groupRatio;
return renderBillingArticle([
buildBillingText(
'模型倍率 {{modelRatio}},输出倍率 {{completionRatio}},缓存倍率 {{cacheRatio}}{{ratioType}} {{ratio}}',
{
modelRatio: modelRatioValue,
completionRatio: completionRatioValue,
cacheRatio: cacheRatioValue,
ratioType: ratioLabel,
ratio: groupRatio,
},
),
hasSplitCacheCreation
? buildBillingText(
'缓存创建倍率 5m {{cacheCreationRatio5m}} / 1h {{cacheCreationRatio1h}}',
{
cacheCreationRatio5m: cacheCreationRatio5mValue,
cacheCreationRatio1h: cacheCreationRatio1hValue,
},
)
: buildBillingText('缓存创建倍率 {{cacheCreationRatio}}', {
cacheCreationRatio: cacheCreationRatioValue,
}),
buildBillingText(
'普通输入:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * {{ratioType}} {{ratio}} = {{amount}}',
{
tokens: inputTokens,
modelRatio: modelRatioValue,
ratioType: ratioLabel,
ratio: groupRatio,
amount: renderDisplayAmountFromUsd(
(inputTokens / 1000000) * inputRatioPrice * groupRatio,
),
},
),
shouldShowCache
? buildBillingText(
'缓存读取:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 缓存倍率 {{cacheRatio}} * {{ratioType}} {{ratio}} = {{amount}}',
{
tokens: cacheTokens,
modelRatio: modelRatioValue,
cacheRatio: cacheRatioValue,
ratioType: ratioLabel,
ratio: groupRatio,
amount: renderDisplayAmountFromUsd(
(cacheTokens / 1000000) *
inputRatioPrice *
cacheRatioValue *
groupRatio,
),
},
)
: null,
shouldShowLegacyCacheCreation
? buildBillingText(
'缓存创建:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 缓存创建倍率 {{cacheCreationRatio}} * {{ratioType}} {{ratio}} = {{amount}}',
{
tokens: cacheCreationTokens,
modelRatio: modelRatioValue,
cacheCreationRatio: cacheCreationRatioValue,
ratioType: ratioLabel,
ratio: groupRatio,
amount: renderDisplayAmountFromUsd(
(cacheCreationTokens / 1000000) *
inputRatioPrice *
cacheCreationRatioValue *
groupRatio,
),
},
)
: null,
shouldShowCacheCreation5m
? buildBillingText(
'5m缓存创建{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 5m缓存创建倍率 {{cacheCreationRatio5m}} * {{ratioType}} {{ratio}} = {{amount}}',
{
tokens: cacheCreationTokens5m,
modelRatio: modelRatioValue,
cacheCreationRatio5m: cacheCreationRatio5mValue,
ratioType: ratioLabel,
ratio: groupRatio,
amount: renderDisplayAmountFromUsd(
(cacheCreationTokens5m / 1000000) *
inputRatioPrice *
cacheCreationRatio5mValue *
groupRatio,
),
},
)
: null,
shouldShowCacheCreation1h
? buildBillingText(
'1h缓存创建{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 1h缓存创建倍率 {{cacheCreationRatio1h}} * {{ratioType}} {{ratio}} = {{amount}}',
{
tokens: cacheCreationTokens1h,
modelRatio: modelRatioValue,
cacheCreationRatio1h: cacheCreationRatio1hValue,
ratioType: ratioLabel,
ratio: groupRatio,
amount: renderDisplayAmountFromUsd(
(cacheCreationTokens1h / 1000000) *
inputRatioPrice *
cacheCreationRatio1hValue *
groupRatio,
),
},
)
: null,
buildBillingText(
'补全 {{completion}} tokens * 输出倍率 {{completionRatio}}',
{
completion: completionTokens,
completionRatio: completionRatioValue,
},
),
buildBillingText(
'输出:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 输出倍率 {{completionRatio}} * {{ratioType}} {{ratio}} = {{amount}}',
{
tokens: completionTokens,
modelRatio: modelRatioValue,
completionRatio: completionRatioValue,
ratioType: ratioLabel,
ratio: groupRatio,
amount: renderDisplayAmountFromUsd(
(completionTokens / 1000000) *
inputRatioPrice *
completionRatioValue *
groupRatio,
),
},
),
buildBillingText('合计:{{total}}', {
total: renderDisplayAmountFromUsd(totalAmount),
}),
]);
2025-03-12 21:31:46 +08:00
}
export function renderClaudeLogContent(opts) {
const {
model_ratio: modelRatio,
completion_ratio: completionRatio,
model_price: modelPrice = -1,
group_ratio: _groupRatio,
user_group_ratio,
cache_ratio: cacheRatio = 1.0,
cache_creation_ratio: cacheCreationRatio = 1.0,
cache_creation_tokens_5m: cacheCreationTokens5m = 0,
cache_creation_ratio_5m: cacheCreationRatio5m = 1.0,
cache_creation_tokens_1h: cacheCreationTokens1h = 0,
cache_creation_ratio_1h: cacheCreationRatio1h = 1.0,
displayMode = 'price',
} = opts;
const { ratio: effectiveGroupRatio, label: ratioLabel } = getEffectiveRatio(
_groupRatio,
user_group_ratio,
);
let groupRatio = effectiveGroupRatio;
✨ feat: Add topup billing history with admin manual completion Implement comprehensive topup billing system with user history viewing and admin management capabilities. ## Features Added ### Frontend - Add topup history modal with paginated billing records - Display order details: trade number, payment method, amount, money, status, create time - Implement empty state with proper illustrations - Add payment method column with localized display (Stripe, Alipay, WeChat) - Add admin manual completion feature for pending orders - Add Coins icon for recharge amount display - Integrate "Bills" button in RechargeCard header - Optimize code quality by using shared utility functions (isAdmin) - Extract constants for status and payment method mappings - Use React.useMemo for performance optimization ### Backend - Create GET `/api/user/topup/self` endpoint for user topup history with pagination - Create POST `/api/user/topup/complete` endpoint for admin manual order completion - Add `payment_method` field to TopUp model for tracking payment types - Implement `GetUserTopUps` method with proper pagination and ordering - Implement `ManualCompleteTopUp` with transaction safety and row-level locking - Add application-level mutex locks to prevent concurrent order processing - Record payment method in Epay and Stripe payment flows - Ensure idempotency and data consistency with proper error handling ### Internationalization - Add i18n keys for Chinese (zh), English (en), and French (fr) - Support for billing-related UI text and status messages ## Technical Improvements - Use database transactions with FOR UPDATE row-level locking - Implement sync.Map-based mutex for order-level concurrency control - Proper error handling and user-friendly toast notifications - Follow existing codebase patterns for empty states and modals - Maintain code quality with extracted render functions and constants ## Files Changed - Backend: controller/topup.go, controller/topup_stripe.go, model/topup.go, router/api-router.go - Frontend: web/src/components/topup/modals/TopupHistoryModal.jsx (new), web/src/components/topup/RechargeCard.jsx, web/src/components/topup/index.jsx - i18n: web/src/i18n/locales/{zh,en,fr}.json
2025-10-07 00:22:45 +08:00
// 获取货币配置
const { symbol, rate } = getCurrencyConfig();
2025-03-12 21:31:46 +08:00
if (isPriceDisplayMode(displayMode, modelPrice)) {
if (modelPrice !== -1) {
return joinBillingSummary([
i18next.t('模型价格 {{symbol}}{{price}} / 次', {
symbol,
price: (modelPrice * rate).toFixed(6),
}),
getGroupRatioText(groupRatio, user_group_ratio),
]);
}
const parts = [
i18next.t('输入价格 {{symbol}}{{price}} / 1M tokens', {
symbol,
price: (modelRatio * 2.0 * rate).toFixed(6),
}),
i18next.t('输出价格 {{symbol}}{{price}} / 1M tokens', {
symbol,
price: (modelRatio * 2.0 * completionRatio * rate).toFixed(6),
}),
i18next.t('缓存读取价格 {{symbol}}{{price}} / 1M tokens', {
symbol,
price: (modelRatio * 2.0 * cacheRatio * rate).toFixed(6),
}),
];
const hasSplitCacheCreation =
cacheCreationTokens5m > 0 || cacheCreationTokens1h > 0;
appendPricePart(
parts,
hasSplitCacheCreation && cacheCreationTokens5m > 0,
'5m缓存创建价格 {{symbol}}{{price}} / 1M tokens',
{
symbol,
price: (modelRatio * 2.0 * cacheCreationRatio5m * rate).toFixed(6),
},
);
appendPricePart(
parts,
hasSplitCacheCreation && cacheCreationTokens1h > 0,
'1h缓存创建价格 {{symbol}}{{price}} / 1M tokens',
{
symbol,
price: (modelRatio * 2.0 * cacheCreationRatio1h * rate).toFixed(6),
},
);
appendPricePart(
parts,
!hasSplitCacheCreation,
'缓存创建价格 {{symbol}}{{price}} / 1M tokens',
{
symbol,
price: (modelRatio * 2.0 * cacheCreationRatio * rate).toFixed(6),
},
);
parts.push(getGroupRatioText(groupRatio, user_group_ratio));
return joinBillingSummary(parts);
}
2025-03-12 21:31:46 +08:00
if (modelPrice !== -1) {
return i18next.t('模型价格 {{symbol}}{{price}}{{ratioType}} {{ratio}}', {
symbol: symbol,
price: (modelPrice * rate).toFixed(6),
2025-03-12 21:31:46 +08:00
ratioType: ratioLabel,
2025-04-04 12:00:38 +08:00
ratio: groupRatio,
2025-03-12 21:31:46 +08:00
});
} else {
const hasSplitCacheCreation =
cacheCreationTokens5m > 0 || cacheCreationTokens1h > 0;
const shouldShowCacheCreation5m =
hasSplitCacheCreation && cacheCreationTokens5m > 0;
const shouldShowCacheCreation1h =
hasSplitCacheCreation && cacheCreationTokens1h > 0;
let cacheCreationPart = null;
if (hasSplitCacheCreation) {
if (shouldShowCacheCreation5m && shouldShowCacheCreation1h) {
cacheCreationPart = i18next.t(
'缓存创建倍率 5m {{cacheCreationRatio5m}} / 1h {{cacheCreationRatio1h}}',
{
cacheCreationRatio5m,
cacheCreationRatio1h,
},
);
} else if (shouldShowCacheCreation5m) {
cacheCreationPart = i18next.t(
'缓存创建倍率 5m {{cacheCreationRatio5m}}',
{
cacheCreationRatio5m,
},
);
} else if (shouldShowCacheCreation1h) {
cacheCreationPart = i18next.t(
'缓存创建倍率 1h {{cacheCreationRatio1h}}',
{
cacheCreationRatio1h,
},
);
}
}
if (!cacheCreationPart) {
cacheCreationPart = i18next.t('缓存创建倍率 {{cacheCreationRatio}}', {
cacheCreationRatio,
});
}
const parts = [
i18next.t('模型倍率 {{modelRatio}}', { modelRatio }),
i18next.t('输出倍率 {{completionRatio}}', { completionRatio }),
i18next.t('缓存倍率 {{cacheRatio}}', { cacheRatio }),
cacheCreationPart,
i18next.t('{{ratioType}} {{ratio}}', {
2025-04-04 12:00:38 +08:00
ratioType: ratioLabel,
ratio: groupRatio,
}),
];
return parts.join('');
2025-03-12 21:31:46 +08:00
}
}
// 已统一至 renderModelPriceSimple若仍有遗留引用请改为传入 provider='claude'
/**
* rehype 插件将段落等文本节点拆分为逐词 <span>并添加淡入动画 class
* 仅在流式渲染阶段使用避免已渲染文字重复动画
*/
export function rehypeSplitWordsIntoSpans(options = {}) {
const { previousContentLength = 0 } = options;
return (tree) => {
let currentCharCount = 0; // 当前已处理的字符数
visit(tree, 'element', (node) => {
if (
2025-06-07 12:26:23 +08:00
['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'li', 'strong'].includes(
node.tagName,
) &&
node.children
) {
const newChildren = [];
node.children.forEach((child) => {
if (child.type === 'text') {
try {
// 使用 Intl.Segmenter 精准拆分中英文及标点
2025-06-07 12:26:23 +08:00
const segmenter = new Intl.Segmenter('zh', {
granularity: 'word',
});
const segments = segmenter.segment(child.value);
Array.from(segments)
.map((seg) => seg.segment)
.filter(Boolean)
.forEach((word) => {
const wordStartPos = currentCharCount;
const wordEndPos = currentCharCount + word.length;
// 判断这个词是否是新增的(在 previousContentLength 之后)
const isNewContent = wordStartPos >= previousContentLength;
newChildren.push({
type: 'element',
tagName: 'span',
properties: {
className: isNewContent ? ['animate-fade-in'] : [],
},
children: [{ type: 'text', value: word }],
});
currentCharCount = wordEndPos;
});
} catch (_) {
// Fallback如果浏览器不支持 Segmenter
const textStartPos = currentCharCount;
const isNewContent = textStartPos >= previousContentLength;
if (isNewContent) {
// 新内容,添加动画
newChildren.push({
type: 'element',
tagName: 'span',
properties: {
className: ['animate-fade-in'],
},
children: [{ type: 'text', value: child.value }],
});
} else {
// 旧内容,不添加动画
newChildren.push(child);
}
currentCharCount += child.value.length;
}
} else {
newChildren.push(child);
}
});
node.children = newChildren;
}
});
};
2025-06-07 12:26:23 +08:00
}