🚀 feat: launch v1.0 — next-generation frontend built from the ground up (#4265)

* feat: add parameter coverage for the operations: copy, trim_prefix, trim_suffix, ensure_prefix, ensure_suffix, trim_space, to_lower, to_upper, replace, and regex_replace

* fix: CrossGroupRetry default false

移除gorm:"default:false",避免每次 AutoMigrate时都执行ALTER TABLE `tokens` MODIFY COLUMN `cross_group_retry` boolean DEFAULT false
且bool默认false不影响原有功能

* feat: check-in feature integrates Turnstile security check

* feat: add support for Doubao /v1/responses (#2567)

* feat: add support for Doubao /v1/responses

* fix: fix model deployment style issues, lint problems, and i18n gaps. (#2556)

* fix: fix model deployment style issues, lint problems, and i18n gaps.

* fix: adjust the key not to be displayed on the frontend, tested via the backend.

* fix: adjust the sidebar configuration logic to use the default configuration items if they are not defined.

* feat: add plans directory to .gitignore

* fix: 修复 gemini 文件类型不支持 image/jpg

* fix: fix the proxyURL is empty, not using the default HTTP client configuration && the AWS calling side did not apply the relay timeout.

* fix: batch add key backend deduplication

* Merge pull request #2582 from seefs001/fix/tips

fix: add tips for model management and channel testing

* fix(gin): update request body size check to allow zero limit

* feat: add regex pattern to mask API keys in sensitive information

* fix(task): 修复使用 auto 分组时 Task Relay 不记录日志和不扣费的问题

问题描述:
- 使用 auto 分组的令牌调用 /v1/videos 等 Task 接口时,虽然任务能成功创建,
  但使用日志不显示记录,且不会扣费

根本原因:
- Distribute 中间件在选择渠道后,会将实际选中的分组存储在 ContextKeyAutoGroup 中
- 但 RelayTaskSubmit 函数没有从 context 中读取这个值来更新 info.UsingGroup
- 导致 info.UsingGroup 始终是 "auto" 而不是实际选中的分组(如 "sora2逆")
- 当 auto 分组的倍率配置为 0 时,quota 计算结果为 0
- 日志记录条件 "if quota != 0" 不满足,导致日志不记录、不扣费

修复方案:
- 在 RelayTaskSubmit 函数中计算分组倍率之前,添加从 ContextKeyAutoGroup
  获取实际分组的逻辑
- 使用安全的类型断言,避免潜在的 panic 风险

影响范围:
- 仅影响 Task Relay 流程(/v1/videos, /suno, /kling 等接口)
- 不影响使用具体分组令牌的调用
- 不影响其他 Relay 类型(chat/completions 等已有类似处理逻辑)

* 🚀 feat(web): port legacy v2 frontend changes into new UI (deployments, check-in, ollama) + align APIs

Bring over the key frontend functionality introduced in merge `efa3301` and integrate it cleanly into the new `web/src` architecture and design system.

- **Model deployments (io.net)**
  - Align frontend endpoints and payloads with backend deployment routes (`/api/deployments/*`)
  - Add missing deployment operations: details, logs (container-aware), update config, rename, extend duration
  - Improve create-deployment flow (proper request shape, name availability check, price estimation parity)

- **System settings**
  - Enhance io.net deployment settings: allow testing connection with an unsaved API key and add “how to get API key” guidance

- **Channels / Ollama**
  - Improve Ollama model management: live fetch via base_url with fallback to channel fetch, selection + apply flows, delete confirmation
  - Refactor for feature-layer consistency: extract Ollama parsing/normalization utilities into `features/channels/lib`

- **Quality**
  - Ensure TypeScript typecheck passes after refactor and new dialogs/components integration

* Merge pull request #2590 from xyfacai/fix/max-body-limit

fix: 设置默认max req body 为128MB

* docs: update readme

* i18n: add missing translations

* fix(gemini): fetch model list via native v1beta/models endpoint

Use the native Gemini Models API (/v1beta/models) instead of the OpenAI-compatible
path when listing models for Gemini channels, improving compatibility with
third-party Gemini-format providers that don't implement OpenAI routes.

- Add paginated model listing with timeout and optional proxy support
- Select an enabled key for multi-key Gemini channels

* refactor(gemini): 更新 GeminiModelsResponse 以使用 dto.GeminiModel 类型

* fix: remove Minimax from FETCHABLE channels

* fix(minimax): 添加 MiniMax-M2 系列模型到 ModelList

* feat: add doubao video 1.5

* 🤢 chore: remove useless file

* feat: /v1/chat/completion -> /v1/response (#2629)

* feat: /v1/chat/completion -> /v1/response

* fix: clean propertyNames for gemini function

* fix: support snake_case fields in GeminiChatGenerationConfig

* chore: update dependencies and lockfile for improved compatibility

- Updated @clerk/clerk-react to version 5.59.3
- Updated @hookform/resolvers to version 5.2.2
- Updated @lobehub/icons to version 2.48.0
- Updated various Radix UI components to their latest versions
- Updated @tanstack/react-query and related packages for better performance
- Updated axios, i18next, and other libraries for security and feature enhancements
- Updated lockfile to include configVersion and ensure consistency across environments

* Merge pull request #2647 from seefs001/feature/status-code-auto-disable

feat: status code auto-disable configuration

* fix: chat2response setting ui (#2643)

* fix: setting ui

* fix: rm global.chat_completions_to_responses_policy

* fix: rm global.chat_completions_to_responses_policy

* Merge pull request #2627 from seefs001/feature/channel-test-param-override

feat: channel testing supports parameter overriding

* chore: update dependencies and lockfile for improved compatibility

- Updated @lobehub/icons to version 4.0.3
- Updated ai to version 6.0.27
- Updated various libraries including axios, react-day-picker, and streamdown for security and feature enhancements
- Updated devDependencies for eslint, prettier, and typescript for better performance and compatibility
- Updated lockfile to ensure consistency across environments

* chore: update lockfile and Vite configuration for improved build process

- Updated lockfile to version 1 for better compatibility and consistency
- Enhanced Vite configuration to support production optimizations, including code minification and chunking for dependencies
- Added environment-specific console and debugger removal for production builds

* chore: migrate from Vite to Rsbuild for build process

- Added Rsbuild configuration for development and production builds
- Updated package.json scripts to use Rsbuild instead of Vite
- Replaced @tailwindcss/vite with @tailwindcss/postcss in dependencies
- Introduced postcss.config.mjs for Tailwind CSS integration
- Updated TypeScript configuration to include Rsbuild config
- Removed Vite configuration file to streamline the build process

* refactor: optimize user data handling and API calls

- Replaced direct API calls to get user data with cached user information from auth-store in ModelsFilter and SummaryCards components.
- Improved session management in RootComponent and Authenticated route to utilize localStorage for user authentication status, reducing unnecessary API requests.
- Added caching for setup status checks to enhance performance during navigation.

* feat: enhance session validation in authenticated route

- Implemented session verification to check user authentication status via API call only once per session.
- Updated beforeLoad logic to redirect users to the login page if session validation fails or if no user information is available in localStorage.
- Improved user data handling by updating the auth store with fresh user information upon successful session verification.

* refactor: improve useMediaQuery hook for better SSR handling

- Enhanced the useMediaQuery hook to check for window availability before accessing matchMedia, preventing errors during server-side rendering.
- Simplified state initialization and change handling by using a dedicated function to determine initial matches.
- Updated event listener management for improved performance and clarity.

* feat(hooks): export useMediaQuery from hooks index

* refactor: update useMediaQuery imports to use unified hooks index

* fix(rsbuild): fix loadEnv API usage and removeConsole type

* feat: customizable automatic retry status codes

* refactor(hooks): use useSyncExternalStore for better SSR handling in useMediaQuery

* refactor: simplify embedded file structure in main.go

- Updated the embedded file directive to include the entire web/dist directory instead of individual assets, streamlining the build process.

* refactor: replace DropdownMenu with Sheet component in ProfileDropdown

- Updated the ProfileDropdown component to use a Sheet for user interactions instead of a DropdownMenu.
- Enhanced user info display with improved layout and styling.
- Added navigation links and sign-out functionality within the Sheet.

* refactor: streamline ProfileDropdown layout and improve user info display

- Removed unused Badge component and secondary text from user display.
- Enhanced styling for user info section and navigation links.
- Updated sign-out functionality to use a button for better accessibility.

* feat: add System Settings link for super admin in ProfileDropdown

- Introduced a new link to System Settings in the ProfileDropdown, visible only to users with the SUPER_ADMIN role.
- Updated imports to include the Settings icon and adjusted the component logic accordingly.
- Removed the Settings entry from the sidebar data to streamline navigation.

* feat: codex channel (#2652)

* feat: codex channel

* feat: codex channel

* feat: codex oauth flow

* feat: codex refresh cred

* feat: codex usage

* fix: codex err message detail

* fix: codex setting ui

* feat: codex refresh cred task

* fix: import err

* fix: codex store must be false

* fix: chat -> responses tool call

* fix: chat -> responses tool call

* feat(i18n): add missing translations

* fix(i18n): restore missing translations for "360" and add "User Menu" in multiple locales

- Reintroduced the translation for "360" in English, French, Japanese, Russian, Vietnamese, and Chinese locales.
- Added the "User Menu" translation in the same languages to enhance user interface consistency.

* fix: openAI function to gemini function field adjusted to whitelist mode

* feat: TLS_INSECURE_SKIP_VERIFY env

* fix: for chat-based calls to the Claude model, tagging is required. Using Claude's rendering logs, the two approaches handle input rendering differently.

* refactor(system-settings): restructure settings sections and navigation

- Replaced SettingsAccordion with a unified SettingsSection component across various settings sections for consistency.
- Introduced a section registry to manage general settings sections dynamically.
- Updated navigation items in the system settings sidebar to utilize the new section registry.
- Enhanced the GeneralSettings component to support section-based content rendering based on user selection.

* fix(system-settings): remove type assertion for quotaDisplayType in GeneralSettings

- Eliminated the type assertion for quotaDisplayType in the GeneralSettings component to improve type inference and maintain cleaner code.

* refactor(system-settings): update zod import syntax in general settings

- Changed the import statement for zod from a default import to a namespace import for better clarity and consistency in the codebase.

* fix: the login method cannot be displayed under the aff link.

* feat(system-settings): implement generic settings page and enhance navigation

- Added a new generic SettingsPage component to handle loading states, data fetching, and section rendering.
- Integrated section registry for general and authentication settings to streamline navigation and content management.
- Updated URL utility functions to improve query parameter handling for active navigation states.
- Enhanced the system settings sidebar to include authentication section items dynamically.

* refactor(system-settings): replace SettingsAccordion with SettingsSection across authentication settings

- Updated BasicAuthSection, BotProtectionSection, OAuthSection, and PasskeySection to use the new SettingsSection component for consistency.
- Introduced a section registry to manage authentication settings dynamically, enhancing navigation and content rendering.

* feat(system-settings): enhance request limits settings with new section and unified component

- Added a new Request Limits section to the system settings sidebar, integrating it with the section registry for improved navigation.
- Replaced SettingsAccordion with SettingsSection in RateLimitSection, SensitiveWordsSection, and SSRFSection for consistency.
- Updated RequestLimitsSettings to utilize the new SettingsPage component for better data handling and rendering.
- Implemented a search schema for request limits to streamline navigation and section management.

* feat(system-settings): integrate content settings sections with unified component and registry

- Added a new Content section to the system settings sidebar, incorporating it into the section registry for improved navigation.
- Replaced SettingsAccordion with SettingsSection in multiple content-related components for consistency.
- Created a section registry to manage content settings dynamically, enhancing the rendering and navigation experience.
- Updated the ContentSettings component to utilize the new section registry and streamline content display.

* feat(system-settings): enhance integrations settings with unified section registry and components

- Introduced a new section registry for integrations settings, consolidating various settings components for better organization and navigation.
- Replaced SettingsAccordion with SettingsSection in multiple integration-related components for consistency.
- Updated IntegrationSettings to utilize the new SettingsPage component, improving data handling and rendering.
- Added a new integrations section to the system settings sidebar, enhancing user experience and accessibility.

* feat(system-settings): unify model settings with new section registry and components

- Introduced a section registry for model settings, consolidating various model-related components for improved organization and navigation.
- Replaced SettingsAccordion with SettingsSection in multiple model settings components for consistency.
- Updated ModelSettings to utilize the new SettingsPage component, enhancing data handling and rendering.
- Added a new Models section to the system settings sidebar, improving user experience and accessibility.

* feat(system-settings): enhance maintenance settings with unified section registry and components

- Introduced a new section registry for maintenance settings, consolidating various maintenance-related components for improved organization and navigation.
- Replaced SettingsAccordion with SettingsSection in multiple maintenance components for consistency.
- Updated MaintenanceSettings to utilize the new section registry, enhancing data handling and rendering.
- Added a new Maintenance section to the system settings sidebar, improving user experience and accessibility.

* feat(system-settings): update section titles for improved clarity and consistency

- Renamed various section titles across content, integrations, maintenance, models, and request limits to enhance clarity and better reflect their functionalities.
- Adjusted titles such as 'Dashboard' to 'Data Dashboard', 'API Info' to 'API Addresses', and 'Update Checker' to 'System maintenance' for improved user understanding.
- Ensured consistency in naming conventions across all settings sections to streamline user experience and navigation.

* feat(nav-group): enhance collapsible menu behavior and URL matching logic

- Added controlled state management for collapsible menu items to automatically expand based on active sub-item paths.
- Updated the URL matching logic in checkIsActive to improve handling of query parameters and ensure accurate navigation state detection.
- Refactored the collapsible component to utilize the new state management, enhancing user experience in the sidebar navigation.

* feat(system-settings): update system settings navigation and redirect logic

- Changed the link in the profile dropdown to point directly to the general section of system settings with a search parameter for section identification.
- Implemented a redirect in the general settings route to ensure users are directed to the default section if no section parameter is provided, enhancing navigation consistency.

* feat(system-settings): unify route configuration for settings sections

- Refactored route configuration for various system settings sections (auth, content, general, integrations, maintenance, models, request limits) to utilize a new `createSettingsRouteConfig` function.
- This change consolidates the repetitive logic of creating search schemas and handling redirects, improving code maintainability and readability.
- Enhanced navigation by ensuring default sections are loaded when no section parameter is provided.

* feat(url-utils): enhance URL handling and matching logic

- Introduced a new utility function `urlToString` to convert various URL formats (string and object) into a standardized string format.
- Updated the `checkIsActive` function to utilize `urlToString`, improving the accuracy of URL matching and handling of query parameters.
- Refactored URL comparison logic to ensure consistent behavior across different URL types, enhancing navigation state detection.

* feat(system-settings): validate DataExportDefaultTime for improved data handling

- Introduced a new function `validateDataExportDefaultTime` to ensure the `DataExportDefaultTime` value is either 'week', 'hour', or 'day', defaulting to 'hour' for unexpected values.
- Updated the `DataExportDefaultTime` assignment in the settings section to utilize this validation function, enhancing data integrity and user experience.

* perf(system-settings): Improve the i18n of system settings content

- Changed button labels in various sections to use consistent capitalization and translation functions, enhancing user experience.
- Updated validation messages in schemas to utilize translation functions for improved internationalization support.
- Ensured all user-facing strings are properly translated, improving accessibility for non-English users.

* fix(system-settings): update ApiInfoFormValues type inference for improved schema validation

- Changed the type inference for ApiInfoFormValues to utilize ReturnType of createApiInfoSchema, ensuring accurate type representation and enhancing type safety in the API info section.

* fix(chat-settings): improve validation logic for chat settings schema

- Updated the validation logic to ensure that null values are correctly handled and that only objects are accepted as valid items in the chat settings schema.
- Simplified error handling by removing the error message from the catch block, providing a consistent user-facing message for invalid JSON strings.

* fix(system-settings): enhance validation error handling in uptime-kuma schema

- Updated the validation logic for category name, URL, and slug fields to use an object format for error messages, improving clarity and consistency in user feedback.
- Ensured that all validation messages are properly structured to enhance internationalization support.

* fix(i18n): add translations for Uptime Kuma group management

- Added English, French, Japanese, Russian, Vietnamese, and Chinese translations for "Add Uptime Kuma Group" and "Edit Uptime Kuma Group" to enhance internationalization support.
- Included validation messages for category name and slug fields across multiple languages to improve user feedback and accessibility.

* fix(system-settings): improve validation error message structure for SystemName

- Updated the validation logic for the SystemName field to use an object format for error messages, enhancing clarity and consistency in user feedback.
- This change aligns with recent improvements in internationalization support across the system settings schemas.

* perf(i18n): add new validation error message translations

- Added translations for the new validation error message "Invalid JSON format or values out of allowed range" in English, French, Japanese, Russian, Vietnamese, and Chinese.
- This update enhances internationalization support by ensuring users receive clear feedback across multiple languages.

* fix(i18n): update Japanese translation for payment method configuration message

- Corrected the Japanese translation for the message regarding payment methods configuration to use the term "メソッド" instead of "方法" for improved accuracy and consistency in user feedback.
- This change enhances the clarity of the message for Japanese-speaking users.

* fix(i18n): remove unnecessary loading messages from French translations

- Removed the French translations for "Loading settings...", "Loading maintenance settings...", and "Loading content settings..." to streamline the localization file.
- This change improves the clarity and relevance of the translations provided to users.

* fix(i18n): add translations for Uptime Kuma group management in multiple languages

- Added French, Japanese, Russian, Vietnamese, and Chinese translations for "Add Uptime Kuma Group" and "Edit Uptime Kuma Group" to enhance internationalization support.
- This update improves user experience by providing clear and consistent messaging across different languages.

* fix(validation): enhance pricing schema error messages and add translations

- Updated the pricing schema to include localized error messages for validation, ensuring users receive clear feedback when input values are invalid.
- Added new translations for "Exchange rate is required" and "Exchange rate must be greater than 0" in English, French, Japanese, and Chinese to improve internationalization support.
- This change enhances user experience by providing accurate and contextually relevant messages across multiple languages.

* fix: codex Unsupported parameter: max_output_tokens

* fix(model-mapping-editor): simplify JSON parsing logic in useEffect

* fix: jimeng i2v support multi image by metadata

* refactor(models): restructure models section handling and improve UI components

- Replaced tab-based navigation with section-based navigation for better clarity and organization.
- Introduced a new section registry to manage model sections, including 'metadata' and 'deployments'.
- Updated the ModelsContent component to reflect the new section structure and added a Create Deployment button.
- Removed the ModelsTabs component as it was no longer needed.
- Enhanced internationalization support by adding new translations for section descriptions and management tasks.
- Adjusted sidebar configuration to accommodate the new section structure.

* fix: update warning threshold label from '5$' to '2$'

* fix: video content api Priority use url field

* fix: update abortWithOpenAiMessage function to use types.ErrorCode

* feat(deployment): introduce CreateDeploymentDrawer component and update dialog references

- Replaced the CreateDeploymentDialog with a new CreateDeploymentDrawer component for improved user experience.
- Added comprehensive form handling for deployment creation, including validation and price estimation features.
- Updated internationalization files to include new translations for UI elements and descriptions related to deployment configuration.
- Enhanced the ModelsContent component to integrate the new drawer for creating deployments.

* perf(i18n): enhance internationalization for models table and columns

- Updated labels and titles in the ModelsTable and useModelsColumns components to utilize translation functions for improved localization.
- Changed static text for vendor and sync status to dynamic translations, enhancing user experience for non-English speakers.
- Updated empty state messages in the ModelsTable to support internationalization, ensuring clarity for all users.

* fix: fix email send

* fix: issue where consecutive calls to multiple tools in gemini all returned an index of 0

* fix: replace Alibaba's Claude-compatible interface with the new interface

* fix: Only models with the "qwen" designation can use the Claude-compatible interface; others require conversion.

* feat: log shows request conversion

* feat: optimized display

* feat: optimized display

* feat: optimized display

* fix: codex rm Temperature

* Revert "fix: video content api Priority use url field"

* feat: requestId time string use UTC

* feat(qwen): support qwen image sync image model config

* feat: sync old ui

* feat: more ui sync

* feat: replace theme

* fix build

* refactor(web): revert theme colors and variables in CSS

Updated color variables for light and dark themes to improve consistency and visual appeal.

* feat(deployment): enhance deployment access guard and model deployment settings

- Introduced loading phase management in the DeploymentAccessGuard component to provide better user feedback during connection checks.
- Updated the ModelsContent component to prefetch the deployments list while checking connection status, improving data readiness.
- Implemented a caching mechanism for connection status in useModelDeploymentSettings to optimize performance and reduce unnecessary API calls.
- Enhanced loading states and error handling for improved user experience during deployment settings retrieval and connection testing.

* feat(i18n): add new translations for connection and loading states across multiple languages

- Introduced translations for "Checking connection" and "Loading configuration" in English, French, Japanese, Russian, Vietnamese, and Chinese.
- This update enhances the internationalization support, providing clearer user feedback during connection checks and loading phases.

* refactor(pagination): adjust layout and styling for pagination component

- Updated the pagination component to improve layout by removing unnecessary width constraints and enhancing responsiveness.
- Increased minimum width for pagination text to ensure better visibility and alignment across different screen sizes.

* feat(i18n): implement translations for various UI elements across multiple components

- Updated several components to utilize the translation function for titles and placeholders, enhancing internationalization support.
- Added new translation entries for "Filter by name or key..." and "Log Type" in English, French, Japanese, Russian, Vietnamese, and Chinese.
- This update improves user experience by providing localized text in the ChannelsTable, SummaryCards, ApiKeysTable, RedemptionsTable, UsageLogsTable, and UsersTable components.

* feat(i18n): integrate translation support in SummaryCards component

- Added the useTranslation hook to the SummaryCards component to enhance internationalization.
- This update allows for localized text rendering, improving user experience for diverse language speakers.

* feat(dashboard): refactor dashboard structure and introduce section-based navigation

- Removed the tab navigation in favor of a section-based approach, enhancing user experience by providing clearer context for the dashboard content.
- Introduced a new section registry to manage dashboard sections, allowing for easier expansion and maintenance.
- Updated sidebar configuration to reflect the new section structure, ensuring proper navigation links are displayed.
- Added translations for new section titles and descriptions to support internationalization.

* feat(i18n): update time range labels and enhance translation support

- Changed time range labels from shorthand (e.g., '1D') to full text (e.g., '1 Day') for better clarity.
- Updated various components to utilize the translation function for time range labels, improving internationalization.
- Added new translation entries for time ranges in English, French, Japanese, Russian, Vietnamese, and Chinese, enhancing user experience across languages.

* feat(dashboard): enhance type safety and improve component structure

- Updated the Dashboard component to use specific types for model data and filters, enhancing type safety.
- Introduced new types for announcements and FAQs, improving clarity and maintainability.
- Refactored LogStatCards and UptimePanel components to utilize AbortController for better data fetching management.
- Optimized the rendering of announcements and FAQs by using unique keys based on item IDs.
- Improved theme management in ModelCharts by caching the ThemeManager import to reduce dynamic imports.

* feat(agents): add comprehensive guidelines for React and Next.js development

- Introduced a new set of best practices and optimization techniques for React and Next.js applications, aimed at enhancing performance and maintainability.
- Included detailed rules covering various aspects such as event handling, API routes, rendering strategies, and state management.
- Added extensive documentation in AGENTS.md and SKILL.md to support developers in adhering to these practices.
- This update serves as a foundational resource for improving code quality and efficiency in React-based projects.

* chore(web): update package.json dependencies

- Removed outdated dependencies including @base-ui/react, @clerk/clerk-react, and others to streamline the project.
- Updated remaining dependencies to their latest versions for improved performance and security.
- This cleanup enhances the overall maintainability of the project.

* feat(usage-logs): implement section-based navigation and enhance log management

- Introduced a section registry for usage logs, allowing for better organization and navigation between different log categories (common, drawing, task).
- Updated the UsageLogsContent component to dynamically render titles and descriptions based on the selected section.
- Refactored UsageLogsTable and UsageLogsPrimaryButtons components to accept the active log category as a prop, improving modularity.
- Enhanced sidebar configuration to support new section navigation, ensuring users can easily access different log types.
- Updated routing to redirect to the default section if none is specified, improving user experience.

* feat(i18n): enhance internationalization across usage logs components

- Integrated the useTranslation hook in various components related to usage logs, including CommonLogsStats, UsageLogsTable, and column helpers.
- Updated labels, titles, and messages to utilize translation functions, improving localization support.
- Added new translation entries for log-related terms in English, French, Japanese, Russian, Vietnamese, and Chinese, enhancing user experience for diverse language speakers.

* feat(datetime-picker): integrate dayjs for date formatting

- Added dayjs as a dependency to the project for improved date handling.
- Updated the DateTimePicker component to use dayjs for formatting dates, enhancing consistency and readability of date displays.

* feat(date-handling): replace date-fns with dayjs for improved date management

- Updated the project to use dayjs instead of date-fns for date formatting and manipulation, enhancing consistency across components.
- Refactored DatePicker, DateTimePicker, and other components to utilize dayjs for date-related functionalities.
- Added a new dayjs configuration file to extend its capabilities with relative time support.
- Updated AGENTS.md to reflect the new technology stack, emphasizing the use of dayjs for date handling.

* refactor(agents): streamline front-end development guidelines and update technology stack

- Revised AGENTS.md to condense front-end development standards and best practices, making it more accessible for developers and AI assistants.
- Updated the technology stack section to reflect current dependencies, emphasizing the use of Bun, React 19, TypeScript, and other key libraries.
- Enhanced the document structure with a new table format for better readability and navigation, including a comprehensive table of contents for quick access to sections.

* feat(i18n): enhance date picker and datetime picker localization support

- Integrated internationalization support in DatePicker and DateTimePicker components by adding locale handling for multiple languages (English, French, Japanese, Russian, Vietnamese, Chinese).
- Updated the calendar component to accept a locale prop, ensuring proper localization of month and weekday labels.
- Improved user experience by allowing date selection to adapt based on the user's language preference.

* feat(layout): add SectionPageLayout component for structured page layouts

- Introduced a new SectionPageLayout component to facilitate structured layouts for pages with sections, enhancing the organization of content.
- Added subcomponents for Title, Description, Actions, and Content to improve clarity and maintainability of page structures.
- Updated AGENTS.md to include guidelines on avoiding unnecessary destructuring of props for better code readability.

* feat(layout): refactor components to use SectionPageLayout for improved structure

- Replaced AppHeader and Main components with SectionPageLayout across multiple features including Channels, Dashboard, ApiKeys, Models, Redemption Codes, Usage Logs, Users, and Wallet.
- Enhanced page organization by utilizing SectionPageLayout's Title, Description, Actions, and Content subcomponents, improving clarity and maintainability.
- This update standardizes the layout structure across the application, facilitating a more cohesive user experience.

* feat(usage-logs): enhance URL state management and redirection logic

- Added useEffect to synchronize column filters with URL search changes, preventing infinite loops caused by inline references.
- Improved redirection logic in usage logs to clear 'type' from the URL when the section is not 'common', enhancing user experience and URL cleanliness.

* fix(usage-logs): disable global filter and update DataTableToolbar props

- Disabled the global filter in the UsageLogsTable component to streamline the user interface.
- Updated the DataTableToolbar component to accept a null customSearch prop, enhancing flexibility in toolbar configuration.

* feat(routes): implement section-based routing for system settings and dashboard

- Introduced section-based routing for system settings and dashboard features, enhancing navigation and organization.
- Updated route definitions to include dynamic sections, allowing for more granular access to settings and dashboard components.
- Refactored existing routes to redirect to default sections when no specific section is provided, improving user experience.
- Added new section routes for models, usage logs, and system settings, ensuring consistency across the application.
- Removed deprecated routes to streamline the routing structure and improve maintainability.

* refactor(usage-logs): update column helper functions to require config parameter

- Modified createFailReasonColumn and createProgressColumn functions to require a config parameter instead of allowing it to be optional.
- Simplified destructuring of config to enhance clarity and ensure necessary properties are always provided, improving code reliability.

* refactor(usage-logs): improve section ID validation and routing logic

- Introduced a type guard function, isUsageLogsSectionId, to validate section IDs, enhancing type safety and reducing the need for casting.
- Updated UsageLogsContent to utilize the new validation function for determining the active category, improving clarity and reliability.
- Refactored routing logic to use isUsageLogsSectionId for section validation, ensuring proper redirection to the default section when necessary.

* refactor(calendar): update locale documentation for i18n support

- Revised the locale prop documentation in the Calendar component to specify the use of react-day-picker for internationalization, clarifying the expected locale setup for users.

* chore(i18n): remove redundant user information description from locale files

- Removed the user information description from English, French, Japanese, Russian, Vietnamese, and Chinese locale files to streamline translations and improve clarity.

* chore(i18n): streamline locale files by removing redundant entries

- Removed unnecessary entries from English, French, Japanese, Russian, Vietnamese, and Chinese locale files to enhance clarity and reduce clutter.
- Adjusted translations for consistency and improved user experience across multiple languages.

* chore(sidebar): remove deprecated usage logs route from sidebar config

- Eliminated the '/usage-logs' entry from the sidebar configuration to streamline navigation and improve clarity in the sidebar structure.

* refactor(redemption-codes): enhance internationalization support and improve UI consistency

- Updated various components to utilize translation functions for user-facing strings, ensuring a consistent experience across different languages.
- Added meta labels for table columns to improve accessibility and clarity.
- Revised confirmation and action texts in dialogs and tooltips to leverage translation, enhancing user experience.
- Updated locale files to include new translations for improved clarity and consistency.

* feat(masked-value-display): add MaskedValueDisplay component for sensitive data handling

- Introduced a new MaskedValueDisplay component to display masked values with a popover for full value visibility and a copy button for easy access.
- Updated api-keys-columns and redemptions-columns to utilize the new component, enhancing code reusability and UI consistency.
- Revised translation keys in locale files to remove colons for improved clarity.

* refactor(url-utils): simplify query parameter matching logic in checkIsActive function

- Updated the checkIsActive function to streamline the logic for matching URLs with and without query parameters.
- Removed unnecessary checks for query parameters when matching base paths, improving clarity and maintainability of the code.

* fix(channels-table): update group filter label to use translation function

- Replaced hardcoded 'All Groups' label with a translation function call to enhance internationalization support in the ChannelsTable component.

* chore(api-keys): remove deprecated API key action messages and related exports

- Deleted the api-key-actions.ts file, which contained action messages for enabling, disabling, and deleting API keys.
- Updated index.ts to remove the export of getApiKeyActionMessage, streamlining the codebase by eliminating unused functionality.

* refactor(i18n): enhance internationalization support across various components

- Updated multiple components to utilize translation functions for user-facing strings, ensuring a consistent experience across different languages.
- Revised constants and labels in the channels and redemption codes features to use i18n keys, improving maintainability and clarity.
- Ensured that success and error messages leverage translation functions, enhancing user experience and accessibility.
- Streamlined the handling of i18n keys in the constants files for better organization and clarity.

* refactor(i18n): enhance translation support across various components

- Updated multiple components to utilize translation functions for user-facing strings, ensuring a consistent experience across different languages.
- Revised pagination and status labels to use i18n keys, improving maintainability and clarity.
- Enhanced response time formatting to support internationalization, allowing for localized display of time values.
- Updated locale files to include new translations for improved clarity and consistency.

* docs(AGENTS): add type checking requirement for TypeScript changes

- Included a new guideline stating that type checks must be executed after modifying TypeScript or TSX code, ensuring no type errors are left unresolved.
- Updated the document to reflect this addition in the relevant section for better clarity on coding standards.

* feat(combobox-input): add ComboboxInput component for enhanced token selection

- Introduced a new ComboboxInput component to facilitate token name selection with search and filtering capabilities.
- Integrated the ComboboxInput into the UsageLogsFilterDialog for improved user experience when filtering by token name.
- Updated locale files to include new translations for user-facing strings related to token filtering.

* feat(combobox): integrate translation support for custom value prompt

- Added translation functionality to the Combobox component, replacing hardcoded text with a translatable string for the custom value prompt.
- Utilized the useTranslation hook from react-i18next to enhance internationalization support, ensuring a consistent user experience across different languages.

* refactor(i18n): improve Chinese translations for consistency and clarity

- Adjusted spacing in various Chinese translations to enhance readability and maintain consistency across the locale file.
- Updated multiple user-facing strings to ensure proper formatting and alignment with localization standards.

* feat(calendar): add CalendarDropdown component for enhanced dropdown functionality

- Introduced a new CalendarDropdown component to improve user interaction with dropdown selections in the calendar.
- Implemented state management for dropdown visibility and selection handling, enhancing the overall user experience.
- Updated styling for dropdown elements to ensure consistency and better alignment with the UI design.

* fix(balance-query-dialog): handle null currentRow and improve usage query logic

- Updated the BalanceQueryDialog component to safely access currentRow properties using optional chaining.
- Added a check to ensure currentRow is not null before proceeding with usage queries, preventing potential runtime errors.
- Refactored the handleQueryCodexUsage function to use a local variable for currentRow, enhancing code clarity.

* feat(i18n): add new translations for batch creation and channel updates

- Added new translation strings for batch creation instructions across multiple languages, enhancing user guidance.
- Included translations for the "Update Channel" prompt to improve clarity in channel configuration settings.
- Ensured consistency in terminology across locale files for better user experience.

* feat(channel-mutate-drawer): improve API key input handling and update translations

- Refactored the API key input logic in the ChannelMutateDrawer component to enhance readability and maintainability.
- Added new placeholder translations for batch creation and existing key prompts in multiple languages, improving user guidance.
- Ensured consistency in translation strings across locale files for better user experience.

* feat(fetch-models-dialog): implement sorting for model categories

- Added a new function to sort model categories alphabetically, placing 'Other' at the end for easier navigation.
- Updated the rendering logic in the FetchModelsDialog component to utilize the new sorting function for both new and existing models, enhancing user experience.

* refactor(wallet-stats-card): standardize props usage and improve layout consistency

Standardizes props usage and improves layout consistency in wallet stats card

Refactors the wallet stats card component to:
- Use props directly instead of destructuring for consistency
- Add min-w-0 to prevent content overflow
- Adjust text sizing with break-all for proper wrapping
- Implement responsive font sizes (3xl on mobile, 4xl on larger screens)
- Improve leading and tracking for better readability

Refactor wallet stats card for consistency and layout

Standardizes props usage and improves layout consistency in wallet stats card

- Uses props directly instead of destructuring for consistency
- Adds min-w-0 to prevent content overflow
- Adjusts text sizing with break-all for proper wrapping
- Implements responsive font sizes (3xl on mobile, 4xl on larger screens)
- Improves leading and tracking for better readability

* feat(web): add subscription management and admin settings UI

* feat(web): add subscription management and admin settings UI

- Add subscription management module (plans, pricing, toggle status, and related dialogs/tables with Stripe/Creem integration notes)
- Add channel affinity (rules and cache stats), Waffo integration, performance, and Grok model sections to system settings, with extended types and section registry
- Add status code mapping validation/risk warnings, upstream update hooks, and utilities for channels; add available models and sidebar module cards to user profile
- Add chat2link route and useMinimumLoadingTime, useTableCompactMode shared hooks

Made-with: Cursor

* fix: remove duplicate GenerateOAuthCode and add missing TaskBulkUpdate

- remove duplicate GenerateOAuthCode from github.go since oauth.go already has the generic version.
- add model.TaskBulkUpdate for bulk update by upstream task_id strings, fixing task_video.go build failure.

* feat(router): add chat2link and subscriptions routes

- register /chat2link page route under authenticated layout.
- register /subscriptions/ page route under authenticated layout.
- update auto-generated routeTree type definitions and route mappings.

* feat(docker): add development environment setup with Docker Compose

- Introduced docker-compose.dev.yml for local development, including services for new-api, Redis, and PostgreSQL.
- Created Dockerfile.dev for backend-only builds, optimizing the development workflow.
- Updated makefile to include new commands for starting backend services and frontend development.

* feat(web): complete i18n coverage for setup wizard and add language switcher

- wrap all hardcoded English strings in setup-wizard, database-step, usage-mode-step, and complete-step with t() calls, covering step titles, descriptions, form validation messages, and fallback strings.
- add LanguageSwitcher component to the top-right corner of the setup page so users can switch language during initial setup.
- register 25 dynamic i18n keys in static-keys.ts and provide full translations for zh/en/ja/fr/ru/vi.

* feat(i18n): internationalize default version text in workspace-switcher

- remove hardcoded 'Unknown version' default, use t('Unknown version') for i18n fallback
- add "Unknown version" translation entries across all 6 locale files (zh/en/fr/ru/ja/vi)

* feat(i18n): add full i18n coverage for channel-affinity settings page

- replace Chinese t() keys with English keys across three channel-affinity components to align with new frontend i18n conventions.
- add 51 translation entries to all 6 locale files (en/zh/ja/fr/ru/vi) covering main page, rule editor, and cache stats dialog.
- register section-registry dynamic keys in static-keys.ts.

* feat(i18n): add full i18n coverage for Waffo payment settings page

- replace Chinese i18n keys with English keys in waffo-settings-section.tsx for consistency.
- wrap previously hardcoded labels (Pay Method Type / Pay Method Name) with t().
- add 26 Waffo-related translation entries across all 6 locale files (en/zh/fr/ru/ja/vi).

* feat(i18n): add missing translations for global model settings page

- add all 6 locale translations for 3 missing t() keys in global-settings-card.
- register dynamically used 'Grok' key in static-keys.ts for i18n scanner coverage.

* feat(i18n): add full i18n coverage for Grok model settings page

- add translations in all 6 locales (en/zh/fr/ja/ru/vi) for grok-settings-card t() calls.
- cover violation fee toggle, amount input, and official docs link labels.
- include section-registry descriptionKey translation entries.

* feat(i18n): add full i18n coverage for performance settings page

- migrate all t() keys from Chinese to English to align with project conventions.
- add translations for all 6 locales (en/zh/ja/fr/ru/vi) covering disk cache,
  system monitoring, log management, and stats dashboard sections.
- remove 71 obsolete Chinese-keyed entries from every locale file.

* fix(i18n): add 116 missing English translation keys across all locales

- scan all t() calls to identify English keys used in code but absent from locale files.
- add translations for zh/en/fr/ja/ru/vi, keeping key sets and sort order consistent.
- covers system-settings, channels, models, auth, wallet and other modules.

* fix(i18n): add missing translations for log cleanup quick-select and confirm dialog

- wrap quick-select button labels (24 hours ago / 7 days ago / 30 days ago) with t().
- replace hardcoded English strings in purge confirm dialog with t() calls and date interpolation.
- add 5 new translation keys across all 6 locale files (zh/en/fr/ja/ru/vi).

* refactor(web): unify all time display with dayjs formatting

- replace all toLocaleString/toLocaleDateString/toLocaleTimeString and manual padStart concatenation with dayjs.format().
- standardize output: datetime as YYYY-MM-DD HH:mm:ss, date as YYYY-MM-DD, time as HH:mm:ss.
- add formatDateTimeStr, formatDateStr, formatTimeStr dayjs-based helpers in lib/format.ts.
- update 12 files across core utils and feature components.

* refactor(web): replace native datetime-local input with DateTimePicker in announcements

- swap browser-native datetime-local for the project's DateTimePicker component to match the UI used in log cleanup and other pages.
- convert between Date objects and ISO strings to bridge the form's string-based schema.

* refactor(web): replace native HTML elements with design system components

- replace ~35 native <button> with <Button> across pricing, profile, channels modules
- replace native <input>/<textarea>/<label> with <Input>/<Textarea>/<Label> for consistent form styling
- replace native <table> with <Table> components, <details>/<summary> with <Collapsible>
- replace decorative <hr> with <Separator> to ensure global UI consistency

* refactor(web): enhance profile components with design system consistency

- update ProfileSecurityCard to use buttons for security actions, improving accessibility and styling.
- modify AccountBindingsTab layout to a grid for better responsiveness and visual alignment.
- refactor NotificationTab to utilize icons for notification methods, enhancing user experience and clarity.

* fix(i18n): complete i18n coverage for profile page components

- wrap passkey card status badges (enabled/disabled, backup state) and last-used text with t()
- fix hardcoded button labels in security dialogs (change password, access token, delete account)
- internationalize all 2FA dialog strings (setup, disable, backup codes)
- fix email bind dialog description and button state text missing i18n
- wrap remaining hardcoded strings in notification tab and checkin calendar
- add all missing translation entries to zh.json and en.json

* fix(i18n): enhance error messages with translations for deployment access and settings

- wrap connection error messages in DeploymentAccessGuard and IoNetDeploymentSettingsSection with t() for internationalization.
- add missing translation key for "io.net model deployment is not enabled or api key missing" in all locale files (en, fr, ja, ru, vi, zh).

* 🧹 chore(web): resolve all ESLint errors and warnings

Align the Vite/React frontend with the current ESLint flat config and
React Compiler–related rules by fixing violations instead of broad
suppression where practical.

- Replace `any` with concrete types (`unknown`, `Record<string, unknown>`,
  domain types) where upstream/API shapes allow
- Fix duplicate imports, unused bindings, `no-console`, and empty blocks
- Address react-hooks issues: reorder declarations, memoize unstable
  callbacks (`useCallback`), extend dependency arrays, and use targeted
  disables only where sync-from-props in `useEffect` is intentional
- Refactor `motion.create` usage in ai-elements shimmer to avoid creating
  components during render (static-components)
- Stabilize TanStack Query/Mutation hook usage (query keys, `mutate` in
  deps) and add narrowly scoped rule disables where the linter conflicts
  with library patterns
- Disable `react-hooks/incompatible-library` in ESLint config for
  TanStack Table / RHF false positives
- Add file-level `react-refresh/only-export-components` disables for
  registry/provider/column modules that intentionally mix exports

`bun lint` completes with 0 errors and 0 warnings.

*  feat(web): add subscription management to sidebar and align drawer with project conventions

- Register "Subscription Management" nav item in the admin sidebar group
  with CreditCard icon pointing to /subscriptions
- Add subscription module to sidebar config defaults and URL mapping so it
  integrates with the admin sidebar modules toggle in system settings
- Add subscription entry to sidebar-modules-section moduleMeta for the
  maintenance settings UI
- Refactor SubscriptionsMutateDrawer to follow the same patterns used by
  users, redemption-codes, and other mutate drawers:
  - Use shadcn Form/FormField/FormItem/FormControl/FormLabel/FormMessage
    instead of raw register() + Label + manual error display
  - Move SheetFooter outside the form with form attribute association
  - Use SheetClose for the cancel button
  - Reset form state on drawer close
  - Align SheetContent width (sm:max-w-[600px]) and spacing conventions

*  feat(web): overhaul UI/UX with Vercel Geist design alignment

Refactor the entire frontend UI/UX to align with Vercel/OpenAI design
principles, covering layout, animations, skeleton loading, and overall
visual polish.

Motion & Page Transitions:
- Add centralized motion system (lib/motion.ts) with Vercel-style
  transition presets, stagger variants for tables, cards, and sidebars
- Implement AnimatedOutlet for route-level page enter animations
  using TanStack Router pathname keying
- Add PageTransition, StaggerContainer, StaggerItem, CardStagger,
  and TableStagger wrapper components for progressive reveal effects

Skeleton Loading — Vercel Geist Style:
- Replace shadcn default `animate-pulse` with Geist-style shimmer
  sweep animation (linear-gradient + background-position keyframes)
- Add `--skeleton-base` / `--skeleton-highlight` CSS variables tuned
  for both light and dark themes with neutral oklch tones
- Override auto-skeleton-react inline styles via CSS to unify all
  skeleton elements under the same shimmer effect
- Update TableSkeleton with varied column widths for a natural feel
- Add ContentSkeleton and QuerySkeleton wrappers for auto-skeleton
  integration with React Query error/loading states
- Respect prefers-reduced-motion: disable shimmer for accessibility

Layout & Sidebar:
- Upgrade sidebar expand/collapse transitions to cubic-bezier easing
- Add hover micro-interactions (background-color, color, transform)
  to sidebar menu buttons with smooth 150ms transitions
- Fix oklch color compatibility in sidebar outline variant
- Integrate AnimatedOutlet into AuthenticatedLayout for unified
  route-level animations

Theme & CSS:
- Streamline theme.css with cleaner oklch color definitions
- Add CSS table row stagger-in animations with nth-child delays
- Fix hover-scrollbar color bug (hsl → color-mix for oklch compat)
- Add content-auto utility for long list rendering optimization

Cleanup:
- Remove deprecated skeleton-wrapper.tsx
- Remove unused imports and dead code across components
- Add empty-state, error-state, and loading-state utility components

* 🐛 fix(docker): track bun.lock to fix Docker build failure

Remove `web/bun.lock` from `.gitignore` so the lock file is committed
to version control. The Dockerfile `COPY web/bun.lock .` instruction
requires this file to be present in the build context, and ignoring it
caused the build to fail with a "not found" error.

* ⬆️ chore(web): upgrade dependencies and fix all type/lint errors

Upgrade all frontend dependencies to latest stable versions:
- lucide-react 0.562 → 1.7 (major: brand icons removed)
- shiki 3.x → 4.x, eslint 9.x → 10.x, knip 5.x → 6.x
- @rsbuild/core 1.3 → 1.7, @types/node 24 → 25
- tailwindcss/postcss 4.1 → 4.2, motion 12.25 → 12.38
- @tanstack/react-query 5.90 → 5.95, zod 4.3.5 → 4.3.6
- react 19.2.3 → 19.2.4, axios 1.13.2 → 1.13.6
- prettier 3.7 → 3.8, typescript-eslint 8.52 → 8.57
- Add missing optional deps: @xyflow/react, embla-carousel-react

Resolve all TypeScript compilation errors introduced by upgrades:
- Replace lucide-react brand icons (Github) with react-icons/si
- Fix react-hook-form Control/Resolver generics for zod v4
- Fix Record<string, unknown> type constraints across API utils
- Fix axios interceptor return types in lib/api.ts
- Add type assertions for useSettings/useStatus hook returns
- Resolve Badge variant, spread type, and route path mismatches

Resolve all ESLint 10 errors:
- preserve-caught-error: attach cause to re-thrown errors
- no-useless-assignment: refactor redundant variable assignments
- prefer-as-const: use `as const` over literal type assertions
- no-unused-vars: prefix type-only schemas with underscore

Update tsconfig lib from ES2020 to ES2022 for Error.cause support.

* 🐛 fix(web): stop pricing model row from centering its content

Wrapping the row in shadcn <Button variant='ghost'> inherits
`justify-center`, and the inner flex container had no width, so
`justify-between` collapsed and the row appeared centered.

* feat: add Waffo payment integration and related UI components

- Introduced Waffo payment method with support for custom icons and settings.
- Updated payment settings section to include Waffo settings.
- Added Waffo payment request handling in the wallet API.
- Enhanced wallet recharge form to support Waffo payment methods.
- Implemented hooks for Waffo payment processing.
- Updated localization files for new Waffo-related strings.
- Added new payment type and icon for Waffo in constants and UI components.
- Refactored topup info handling to include Waffo payment methods and configurations.

* feat(profile): add admin-only upstream model update notification setting

* fix(web): make sidebar module user settings actually take effect

Previously, saving sidebar module preferences in profile had no effect
because the client ignored user-level sidebar_modules entirely. This
fix wires user config into useSidebarConfig so the sidebar updates
immediately without a page refresh.

Changes:
- Add UserPermissions type with sidebar_settings/sidebar_modules fields
- Refactor useSidebarConfig to merge admin × user config with AND logic
- Sync sidebar_modules to auth store on save for immediate UI updates
- Conditionally render SidebarModulesCard based on user permissions
- Treat null/empty user config as "do not narrow" for legacy users

* feat(web): add custom OAuth provider CRUD and login button support

Migrate custom OAuth from v1 to v2:
- Admin CRUD UI with provider table, form dialog, preset templates, and OIDC discovery
- Login page renders dynamic buttons for custom OAuth providers
- Fix account bindings display showing "Not bound" text when already bound

* feat(web): add ServerAddress, SMTPForceAuthLogin, CreateCacheRatio and group special usable settings

Migrate missing v1 system settings to v2:
- ServerAddress input in General > System Information
- SMTPForceAuthLogin toggle in Integrations > Email
- CreateCacheRatio JSON editor in Models > Ratio
- Group special usable group rules editor in Models > Ratio

* feat(web): wire user subscriptions dialog to users table row actions

The UserSubscriptionsDialog component already existed but had no entry point
in the users table dropdown menu. Add "Manage Subscriptions" menu item.

* chore(web): update i18n translations for new settings and custom OAuth

* 💎 refactor(web): redesign pricing page with flat, typography-driven layout

* 🌐 chore(i18n): complete missing translations and normalize project config

- Add 425+ missing translations across fr, ja, ru, zh, vi locales
  for subscription management, sidebar navigation, Grok settings,
  upstream model updates, pricing page, and other UI components
- Add 37 missing i18n keys used in t() calls but absent from locale
  files (pricing filters, display options, audio/cache labels, etc.)
- Fix stale tech stack info in CLAUDE.md, AGENTS.md, and project.mdc:
  React 18 → 19, Vite → Rsbuild, Semi Design → Radix UI + Tailwind
- Fix i18n key format description: "Chinese source strings" → English
- Deduplicate .cursor/rules/project.mdc to avoid triple-loading the
  same rules already present in root CLAUDE.md and AGENTS.md
- Add i18n-translate Cursor skill for repeatable translation workflow

* 🎨 refactor(web): redesign dashboard with flat, typography-driven layout

Replace Card-based dashboard components with a flat, border-driven design
system consistent with the pricing page, following the ui-style.mdc conventions.

Overview section:
- StatCard: replace Card wrapper with flat flex layout, monospace tabular
  values, uppercase tracking-wider labels, layered opacity hierarchy
- PanelWrapper: replace Card/CardHeader/CardContent with rounded-lg border
  container and border-b header
- SummaryCards: merge three stat cards into a single bordered container
  with divide-x separators; decouple border from stagger animation to
  prevent border deformation during entrance transitions
- ApiInfoPanel/Item: full-width list rows with border-b separators,
  monospace route names, layered opacity for URLs and descriptions
- AnnouncementsPanel: native button rows with hover:bg-muted/40, i18n for
  "Click for details" hint
- FAQPanel: lighter border-border/60 accordion dividers, muted answer text
- UptimePanel: uppercase tracking-wider group headers with bg-muted/30
  background, monospace uptime percentages, fine-grained border opacity

Models section:
- LogStatCards: replace Card with rounded-lg border + divide-x grid,
  fix react-hooks/exhaustive-deps by destructuring props before useEffect
- ModelCharts: replace Card+Tabs with bordered container + custom
  segmented control matching ui-style spec
- Suspense fallbacks: match new flat skeleton layout with accurate
  column structure

Animation:
- Wrap models section in FadeIn with staggered delay
- Keep CardStagger for overview panel grid (each panel has own border)

Other:
- Add ui-style.mdc cursor rule documenting the design language
- Disable react-refresh/only-export-components for src/routes/** in
  eslint config (TanStack Router route files always export Route objects)
- Fix zh.json: "Token-based" translation "基于令牌的" → "按量计费"

*  refactor(web): adopt flat dot-and-text design for all status badges

Replace the bordered/colored-background StatusBadge and Badge components
across the entire frontend with a minimal flat design: a small colored
dot followed by colored text, eliminating visual noise from heavy
borders, backgrounds, and rounded pill shapes.

Key changes:

- Redesign StatusBadge to use dot + text instead of bordered pill style,
  removing cva-based background/border variants in favor of exported
  dotColorMap and textColorMap lookup tables
- Add children prop support to StatusBadge for flexible content rendering
  alongside the existing label prop
- Migrate all Badge usages (except pricing page) to StatusBadge with
  appropriate variant mappings (default→info, secondary→neutral,
  outline→neutral, destructive→danger)
- Consolidate adjacent multi-badge groups into single-dot layouts with
  dot separators (·) to reduce visual clutter in:
  - Channel balance columns (used + remaining)
  - Channel type column (type + IO.NET indicator)
  - User invite info column (invited + revenue + inviter)
  - Usage log stats bar (usage + RPM + TPM)
  - Usage log time/FRT column (time + FRT + stream status)
  - Subscription plan counts (active + expired)
  - Channel affinity scope/regex/key-source columns
  - Prefill group card headers (type + ID)
- Export dotColorMap and textColorMap for direct use in custom inline
  layouts that need consistent status colors without the full component

*  refactor(web): redesign public layout and landing page with modern UI

Overhaul the public-facing layout, header, and homepage to deliver a
polished, animation-rich landing experience inspired by contemporary
SaaS design patterns.

Header:
- Replace sticky header with fixed floating navbar that compacts into
  a pill-shaped glass-morphism bar on scroll (backdrop-blur + ring)
- Add smooth 700ms cubic-bezier transitions for scroll-based shrinking
- Build full-screen mobile menu overlay with staggered entry animations
- Remove background color from logo container, show logo image directly

Homepage sections:
- Hero: gradient text title, radial gradient + grid pattern background,
  interactive terminal demo showcasing API request/response
- Terminal demo: auto-cycles through gpt-4o, claude-sonnet-4-20250514,
  gemini-2.5-pro, deepseek-chat with smooth cross-fade transitions,
  clickable model badges, dual theme support (light/dark), fixed height
- Stats: animated counters driven by IntersectionObserver with
  cubic-bezier easing, supports integer and decimal modes
- Features: Bento grid layout with gap-px border technique, each card
  includes contextual visuals (model list, security badge, workflow)
- How It Works: new three-step process section (Configure → Connect →
  Monitor) with connecting gradient line and numbered badges
- CTA: gradient mesh background with scale-in scroll animation
- Footer: streamlined brand column + link columns layout

New components:
- AnimateInView: IntersectionObserver-based scroll animation component
  supporting fade-up, fade-in, scale-in, fade-left, fade-right
- HeroTerminalDemo: themed terminal with model carousel and live
  request/response preview

CSS:
- Add landing page scroll-triggered keyframe animations
- Add terminal demo animations (blink cursor, spinner, pulse indicator)
- Respect prefers-reduced-motion throughout

i18n:
- Add 17 new translation keys across all 6 locales (en/zh/fr/ja/ru/vi)

*  feat(web): align usage logs and channels with legacy UI

Usage logs
- Show Refund (type 6) in detail dialog and hide conversion chain for refunds
- Sync filter dialog state from URL for model, token, group, username, and requestId

Channels
- Support optional stream flag in channel test API, actions, and test dialog
- Show upstream model update badges (+added / -removed) on fetchable channel types
- Add form fields and drawer toggles for upstream model update check and auto-sync
- Persist upstream model update flags in channel settings JSON for fetchable types

i18n
- Add locale strings for upstream model update UI (en, zh, fr, ja, ru, vi)

* 🐛 fix(web): prevent transient vertical scrollbar on tables during animations

Add overflow-y-clip to the shared Table container (data-slot=table-container)
alongside overflow-x-auto. Setting overflow-x to auto implicitly pairs with
overflow-y: auto in browsers, which made the table shell briefly show a
vertical scrollbar during route enter motion (y/blur) and table row stagger.

Remove the redundant descendant selector workaround from the model pricing
GroupPricingSection; behavior is now covered globally by the Table component.

* 🏗️ refactor(web): redesign console layout with fixed header, scrollable content, and pinned footer

Overhaul the authenticated console layout to match the OpenAI dashboard
pattern: header and page title bar stay fixed at the top, only the
content area scrolls, and table pagination is pinned to the bottom.

Layout architecture:
- Lock SidebarInset to full viewport height (h-svh) so all inner
  regions are controlled by flexbox instead of document scroll
- Convert Main from a generic div to a semantic <main> flex container
  with overflow-hidden, removing the legacy `fixed` prop and
  `data-layout` attribute
- Strip scroll-shadow logic and `fixed` prop from Header/AppHeader;
  the header is now naturally fixed as a shrink-0 flex child
- Restructure SectionPageLayout into three flex regions: a shrink-0
  title bar, a flex-1 overflow-auto content area, and a shrink-0
  footer portal target with empty:hidden
- Add min-h-0 to AnimatedOutlet wrappers to prevent flex overflow

Footer portal system:
- Introduce PageFooterProvider / PageFooterPortal (React Context +
  createPortal) so deeply nested table components can render their
  DataTablePagination into the fixed footer without prop drilling
- Migrate all 8 data tables (api-keys, channels, users, models,
  deployments, usage-logs, subscriptions, redemption-codes) to use
  PageFooterPortal for pagination

Page-level fixes:
- Profile: wrap content in a scrollable flex child with proper padding
- SystemSettings: remove overflow-auto from wrapper to avoid nested
  scrollbars (sub-pages manage their own scroll)
- Playground / Error pages: remove obsolete `fixed` props

API keys UX improvement:
- Replace inline key show/hide toggle with a Popover-based reveal,
  removing toggleKeyVisibility and keyVisibility state from the
  provider context

Cleanup:
- Remove dead CSS rule for body:has([data-layout='fixed'])
- Remove unused `fixed` prop from Header, AppHeader, and Main types
- Export PageFooterPortal from layout barrel file

* 💅 refactor(web): polish table UI consistency and add pagination transitions

- Standardize primary action buttons (Create, Add, Search) to size="sm"
  across all pages for visual consistency with channels and models
- Redesign NumericSpinnerInput with minimal inline style: plain text by
  default, hover-revealed +/- buttons, click-to-edit — replacing the
  clunky bordered input with stacked chevron arrows
- Fix vertical scrollbar in channels group column by replacing
  overflow-x-auto with overflow-hidden (redundant with +N collapse)
- Simplify API keys group column: replace colorful StatusBadge pairs
  with clean typography using opacity hierarchy and dot separators
- Move API key copy loading indicator from key text to the copy button
  itself, eliminating layout shift during key resolution
- Reduce page title from text-2xl to text-lg and subtitle to text-sm
  in SectionPageLayout for a more compact header
- Add smooth opacity transition (duration-150) on all 7 server-paginated
  tables during background data fetches (isFetching && !isLoading),
  with pointer-events-none to prevent interaction during loading
- Constrain usage logs Details column width (size: 200, maxSize: 220)

* 🐛 fix(web): restore missing padding on system settings content

The console layout refactor in d2150469 moved padding ownership from
Main onto each route, but SystemSettings was missed — its Outlet
wrapper had no padding, so the content area sat flush against the
sidebar and top nav. Add `px-4 pt-6 pb-4` to match the vertical
rhythm used by SectionPageLayout and the Profile page.

* 📱 refactor(web): standardize mobile responsive layout across all table pages

Unify mobile experience for all data table pages (channels, keys, models,
deployments, usage-logs, users, redemption-codes, subscriptions) with a
consistent layout pattern and cleaner header area.

DataTableToolbar:
- Redesign mobile layout: full-width search input + collapsible filter
  toggle button with active filter count badge
- Filters, additional search, and reset button collapse into an
  expandable section on mobile, keeping the default view compact
- Desktop layout remains unchanged

SectionPageLayout:
- Tighten mobile spacing (padding, gaps) for higher content density
- Scale down title (text-base) and description (text-xs) on mobile
- Shrink action button gaps on small screens

ChannelsPrimaryButtons:
- Move Tag Mode and Sort by ID toggles into the "More" dropdown on
  mobile (via DropdownMenuCheckboxItem), freeing header space
- Desktop toggle switches remain visible outside the dropdown

MobileCardList (shared component):
- Compact list-item layout with title + badge header row and
  side-by-side key fields, replacing individual card components
- Structured (CompactRow) and fallback (FallbackRow) rendering modes
  driven by column meta (mobileTitle, mobileBadge, mobileHidden)

New MobileCardList integration:
- Users table: username as title, status as badge; hide id,
  display_name, invite_info on mobile
- Redemptions table: name as title, status as badge; hide id,
  created_time, expired_time, used_user_id on mobile
- Subscriptions table: plan title as title, enabled as badge; hide id,
  sort_order, reset, payment, total_amount, upgrade_group on mobile

Column meta updates:
- Add mobileTitle/mobileBadge/mobileHidden meta across all 8 table
  column definitions for consistent mobile field prioritization

Minor fixes:
- Hide Subscriptions Stripe/Creem alert on mobile
- Disable card hover animations on mobile via CSS media query

* 🐛 fix(web): sync favicon with custom system logo

Favicon stayed at the hardcoded /logo.png while document.title already
followed system_name, leaving tab icon and site branding out of sync.
Apply the logo as favicon from localStorage cache on startup, refresh
from getStatus(), and re-apply when useSystemConfig finishes preloading.
Extract applyFaviconToDom helper into lib/dom-utils with idempotent guard
to avoid redundant DOM writes.

*  feat(web): add channel affinity rule templates and CreateCacheRatio visual editing

Port missing features from legacy frontend (b8650b9 merge) to the new
React frontend:

- Add Codex CLI and Claude CLI channel affinity rule templates with
  header passthrough presets (pass_headers operations for Originator,
  Session_id, X-Codex-*, X-Stainless-*, Anthropic-*, etc.)
- Introduce "Add Rule" dropdown menu with blank, Codex CLI, and Claude
  CLI template options in the channel affinity settings page
- Add "Fill Templates" button to batch-append both CLI templates with
  duplicate name resolution and confirmation dialog
- Support templateKey prop in RuleEditorDialog to pre-fill form fields
  from selected template, auto-expanding advanced settings when a
  param_override_template is present
- Add CreateCacheRatio support to the model ratio visual editor, edit
  dialog, and form — previously only editable in JSON mode, now fully
  integrated into the visual table column, add/edit dialog fields, and
  save/delete handlers

* 🐛 fix(web): fix content-type detection bugs in About and Home pages

- Fix About page URL detection: replace naive `startsWith('https://')`
  with proper `new URL()` validation to support both http and https, and
  handle untrimmed input that previously caused silent misdetection
- Fix About page HTML detection: remove overly broad `startsWith('<')`
  and `endsWith('>')` checks that could misclassify Markdown or XML
  content; align with LegalDocument's regex-only `isLikelyHtml` approach
- Fix Home page URL detection: same `startsWith('https://')` bug,
  replaced with `new URL()` protocol validation
- Refactor About page to use early-return pattern instead of deeply
  nested ternary expressions for better readability
- Replace About loading spinner with Skeleton placeholder consistent
  with LegalDocument
- Add `prose prose-neutral dark:prose-invert` typography classes to
  About HTML/Markdown rendering for proper dark mode support
- Remove unused `Code` icon import from About page

*  feat(web): port missing features from legacy frontend and complete i18n

Backport and enhance several features from the old frontend (web/old)
that were missing or incomplete in the new React frontend:

- Playground & channel test: parse structured JSON error responses from
  SSE streams and non-streaming API calls, extract error codes, and
  display actionable UI for `model_price_error` (admin settings link)
- User management: replace local quota manipulation with atomic
  server-side quota adjustments (add/subtract/override) via dedicated
  API endpoint, making the quota field read-only in the edit drawer
- Subscriptions: display next quota reset time for active subscriptions
- Dashboard: limit model ranking chart to top 20 models with an "Other"
  bucket, add dimension tooltips with sorted values and totals to model
  call trend and user consumption trend charts
- i18n: add 24 new translation keys across all 6 locales (en, zh, fr,
  ja, ru, vi) for the newly introduced UI elements and messages

* 🎨 feat: add backend-configurable frontend theme switching (default/classic)

Introduce runtime frontend theme switching so administrators can switch
between the new frontend (Radix UI + Tailwind) and the classic frontend
(Semi Design) from the settings page without restarting the server.

Directory restructuring:
- Move new frontend from web/ to web/default/
- Move classic frontend from web/old/ to web/classic/
- One-frontend-per-folder layout for extensibility

Backend (injection pattern):
- Add setting/system_setting/theme.go with GlobalConfig.Register("theme")
  so the DB key "theme.frontend" is handled automatically by
  handleConfigUpdate — no switch-case in updateOptionMap needed
- Use atomic.Value in common.GetTheme()/SetTheme() for lock-free
  concurrent reads on the hot path (static file middleware)
- Add themeAwareFileSystem that delegates to the correct embedded FS
  based on the current theme at request time
- Embed both frontends into the binary via go:embed
- Add controller validation for theme.frontend values
- Expose theme in GET /api/status response

Frontend settings UI:
- New frontend: add "Frontend Theme" select in System Information section
  using Radix UI Select + react-hook-form + Zod validation
- Classic frontend: add "前端主题" select in Personalization section
  using Semi Design Form.Select

Build system:
- Update Dockerfile with multi-stage builds for both frontends
- Update Makefile with separate build targets for each frontend
- Update GitHub Actions release workflow for dual frontend builds

i18n:
- New frontend: add translations for all 6 locales (en/zh/fr/ja/ru/vi)
- Classic frontend: add translations for all 7 locales (en/zh-TW/ja/fr/ru/vi)
- Fix zh "AI Proxy Library" → "AI 代理库"

Documentation:
- Update CLAUDE.md, AGENTS.md, .cursor/rules/project.mdc to reflect
  the new web/default/ and web/classic/ directory structure

*  feat(web): add allow_speed passthrough for Claude channels, fix multi-key index and inference_geo scope

- Add `allow_speed` toggle for Anthropic (type 14) channels to control
  Claude inference speed mode passthrough, with full form schema,
  settings persistence, and UI switch
- Fix `allow_inference_geo` to also apply to Anthropic (type 14) channels,
  not just OpenAI (type 1), matching the backend behavior for Claude data
  residency region control
- Fix multi-key management dialog to display 1-based key indices instead
  of 0-based (#{key.index + 1})
- Fix TypeScript type error in section-registry by adding type assertion
  for theme.frontend enum
- Add i18n translations for all new keys across 6 locales (en, zh, fr,
  ja, ru, vi)

* 🧹 chore: clean up editor configs, consolidate agent skills, and set classic as default theme

- Add .cursor/ to .gitignore and remove tracked editor config files
  (.cursor/rules/, .cursor/skills/) from version control
- Consolidate .agents/skills/vercel-react-best-practices by keeping only
  the compiled AGENTS.md and removing redundant SKILL.md and 57 individual
  rule files under rules/
- Change default frontend theme from "default" to "classic" in both
  common/constants.go init and setting/system_setting/theme.go

* feat: Frontend Tiered Pricing, Waffo Payments, and Rsbuild 2 Upgrade (#24)

* feat(ui): add codex extra limits, key last used, and admin audit surfaces

- codex usage dialog: render `additional_rate_limits` with `RateLimitGroupSection` and typed base/secondary window data.
- api keys table: add "Last Used" column from `accessed_time`.
- usage log details: show top-up audit and manage operator for admins; extend `LogOtherData` audit fields; broaden IP display; warn when legacy records lack audit data.
- billing history: show user id badge for admins; add zh i18n for new strings.

* feat(web): add dynamic pricing breakdown and Waffo Pancake payments

- add billing-expr parsing and DynamicPricingBreakdown; surface tiered_expr in model list/details.
- extend PricingModel with billing_mode, billing_expr, and pricing_version for backend parity.
- add Waffo Pancake integration settings, amount/pay APIs, hook, and recharge flow wiring.
- update payment confirm/recharge UI and Chinese locale strings.

* feat(pricing): add tiered billing editor and tool price settings

- introduce tier-expr and extend billing-expr (time/param conditions, combine/split helpers, editor utilities) for visual tiers and request rules.
- support tiered_expr in model ratio dialog, form, and visual editor with billing_setting fields and default JSON placeholders.
- add TieredPricingEditor and tool price settings UI plus i18n updates.

* chore(web): bump rsbuild to v2 and align build config

- upgrade @rsbuild/core, @rsbuild/plugin-react, and Rspack 2 transitives; bump TanStack Router packages and refresh bun.lock.
- replace deprecated performance.chunkSplit with top-level splitChunks cache groups for react, radix, and tanstack vendors.
- factor dev server proxy into devProxy; set legalComments to none in prod; enable performance.buildCache keyed by VITE_REACT_APP_VERSION.
- TanStack Router plugin: enable autoCodeSplitting only in production for faster dev navigation and HMR.

* fix(i18n): update translations for API keys and Waffo Pancake settings

- Corrected translations for "API Private Key" and "Merchant ID is required" across multiple languages.
- Added new translation for "Configure Waffo Pancake hosted checkout integration for USD-priced top-ups."
- Updated various existing translations to ensure consistency and clarity in user interface text.

* refactor(code-block): simplify code highlighting and improve theme handling

- Updated the highlightCode function to support dual themes in a single call, reducing complexity.
- Removed unnecessary state management for dark theme HTML, streamlining the component.
- Enhanced CSS for Shiki themes to ensure proper token color application in dark mode.

* refactor(wallet): use isWaffoPancakePayment for pancake payment dispatch

- replace the waffo_pancake string literal with the shared helper for consistency with use-payment and PAYMENT_TYPES.
- centralize the value so a constant change does not require hunting for typos in multiple call sites.

* fix(wallet): validate waffo pancake checkout url and safe open

- allow only parseable http/https redirect targets from the backend, rejecting dangerous schemes.
- pass noopener and noreferrer in window.open to reduce reverse tabnabbing.
- show a toast and abort on invalid URLs; add i18n entries across locales.

* fix(wallet): harden payment icon image URLs

- add normalizeHttpIconUrl to allow only http(s) after resolution and reject userinfo in URLs.
- set referrerPolicy, lazy loading, and async decode on the icon <img> to cut referrer leakage.
- fall back to built-in icons on invalid URLs, same as when iconUrl is missing.

* fix(pricing): label param() conditions as body param in dynamic pricing

- non-header request rules map to `param()`, not query strings.
- align with tiered pricing editor by using the existing `Body param` string.

* fix(rsbuild): update legalComments handling in build config

- Rely on Rsbuild's default legalComments setting in all modes to ensure compliance with open-source licensing requirements.
- Clarified comments to explain the implications of omitting legalComments in production.

* fix(i18n): correct billing and codex UI strings in locale files

- restore ~83 en.json values to English (tool pricing, audit text, alipay label, etc.).
- add proper fr/ru/vi/ja strings so those locales no longer copy zh.
- change five locale files only; zh.json unchanged.

* fix(i18n): update locale files for improved translations and sync report

- Added missing translations and corrected existing strings in English, French, Japanese, Russian, Vietnamese, and Chinese locale files.
- Updated the sync report to reflect zero missing translations across multiple locales.
- Enhanced the untranslated count for Japanese locale to ensure completeness.
- Changed the base locale from zh.json to en.json for better alignment.

* chore(agents): add i18n-translate agent skill

- add `.agents/skills/i18n-translate/SKILL.md` documenting locale layout under `web/default` and
  `bun run i18n:sync` usage.
- capture a repeatable maintainer workflow with embedded script examples to find missing keys
  and untranslated values.
- give agents a clear path to complete and verify translations across en, zh, fr, ja, ru, and vi.

* feat(settings): hide frontend theme setting (#25)

* feat(settings): hide frontend theme setting

- add a local hidden feature flag with window.newapiUnlock support.
- hide the frontend theme option by default and reveal it immediately after unlock.

* feat(settings): support click unlock for frontend theme setting

- add a shared hidden click unlock hook for repeated-click gated UI.
- reveal the frontend theme option after triple-clicking the system information title.
- preserve the Doubao API address ten-click unlock behavior and remove global unlock functions.

* feat(sync 59337e9): Sync classic tiered billing, upstream price synchronization, and model management features to web/default (#26)

* feat(skill): add classic-to-default-sync skill for auditing and syncing web/classic changes to web/default

- Introduced a new skill to inspect a given commit's changes in web/classic and synchronize features and fixes to web/default.
- Documented workflow steps for extracting diffs, mapping changes, triaging, implementing, and reporting on the synchronization process.
- Emphasized quality standards and internationalization considerations for new user-visible strings.

* feat(web/default): sync billing and model management features from classic

- add `len` condition variable (total input context length); introduce
  BILLING_PRICING_VARS / BILLING_CONDITION_VARS to separate pricing vars
  from condition-only vars; fix tier condition regex to accept `len`.
- rewrite upstream ratio sync components to support per-model grouped
  rows and new ratio types (create_cache, image, audio, billing_expr).
- add LlmPromptHelper component; update tiered presets to use `len` for
  conditions; add GLM-4.5 Air, Doubao Seed 1.8, Qwen3 Omni Flash, and
  weekend-discount presets.
- add created_at / last_login_at columns to users table; add "Removed
  Models" tab to FetchModelsDialog for mapping source keys not in the
  models list.
- add extractMappingSourceModels helper; update dynamic-pricing-breakdown
  to use system currency settings; add 19 i18n keys across all locales.

*  feat(default): surface tiered billing in usage logs and gate Passkey ops behind 2FA

Continues the classic-to-default sync (commit 1be6cdb) by porting the
remaining audit-log, pricing-hint, and Passkey lifecycle features from
web/classic to web/default using the default frontend's component
patterns (Radix UI, Tailwind, shadcn-style dialogs).

* feat(usage-logs): show tiered_expr breakdown and matched tier in details

  - Extend `LogOtherData` with `billing_mode`, `expr_b64`, and
    `matched_tier` fields populated by the backend for tiered logs.
  - Add `decodeBillingExprB64`, `resolveMatchedTier`, and
    `getTieredBillingSummary` helpers in `usage-logs/lib/format.ts` that
    centralise tiered-billing parsing on top of the canonical
    `parseTiersFromExpr` / `BILLING_PRICING_VARS` from the pricing
    feature, instead of duplicating the classic-frontend renderer.
  - Render `<DynamicPricingBreakdown>` inside the consume-log details
    dialog with the matched tier row highlighted in emerald and tagged
    "Matched"; suppress the legacy claude/audio/image cost rows when a
    tiered expression is in effect.
  - Surface per-tier prices and the matched tier label in log row
    segments and the billing breakdown table.

* feat(pricing): show tier-count, time-based, and request-based hints in model list

  - Add `summarizeTieredExpr` that derives compact dynamic-pricing
    metadata (tier count + presence of time/request conditions) from a
    `tiered_expr` model, computed once per render via `useMemo`, so
    users can tell *what kind* of dynamic pricing applies before
    drilling into the model details.
  - Render the hints alongside the existing "Dynamic Pricing" badge in
    `<ModelRow>`.
  - Extend `<DynamicPricingBreakdown>` with a `matchedTierLabel` prop so
    the same component can be reused from the usage-log details dialog
    to highlight the tier that actually fired.

* feat(profile): require Security Verification for Passkey register/remove

  - Wire `usePasskeyManagement` through `useSecureVerification` and
    `<SecureVerificationDialog>` in `<PasskeyCard>`.
  - Registration prompts for 2FA before issuing the Passkey credential
    (only when 2FA is already enabled — otherwise the browser-level
    Passkey prompt itself acts as proof of presence and we register
    directly).
  - Removal prompts for 2FA or Passkey, whichever the account has
    enabled, with informative toasts when neither method is available
    or the device lacks Passkey support.
  - Scope the dialog method set to the required factor so users cannot
    fall back to a weaker method, and propagate cancellation cleanly.

* refactor: tighten upstream-ratio-sync and fix tier editor narrowing

  - Drop the unused `hasSynced` state and dead `getOrderedRatioTypes` /
    `isSelectableUpstreamValue` imports from `upstream-ratio-sync.tsx`.
  - In the cost estimator, narrow `BILLING_EXTRA_VARS` entries with a
    null-`field` guard to silence the type checker and make the
    "pricing variables only" contract explicit.
  - Apply Prettier-consistent formatting to the upstream-ratio-sync
    table/columns, channel mutate drawer, system info section,
    tier-expr, and wallet helpers (no behaviour change).

* i18n: add 9 keys across en/zh/fr/ja/ru/vi

  - `{{count}} tiers`, `Billing Process`, `Matched`, `Matched Tier`,
    `Request-based`, `Security verification`, `Time-based`, plus the
    two new Passkey verification description strings.

* 🔧 refactor(default): align upstream price sync, tiered billing, and fetch-models with classic 59337e9

Port and optimize the remaining web/classic features from commit 59337e9 to web/default,
covering upstream price synchronization, tiered billing expressions, model fetching, and
channel preset detection. Improve component architecture, memoization, and i18n coverage.

Upstream Price Sync
- Extend sync to all ratio fields: CacheRatio, CreateCacheRatio, ImageRatio,
  AudioRatio, AudioCompletionRatio in addition to ModelRatio / CompletionRatio
  / ModelPrice
- Add tiered billing sync (billing_mode + billing_expr) with auto-pairing so
  selecting one upstream tier value populates the other from the same source
- Bulk select / unselect per upstream column with indeterminate checkbox state
  reflecting partial selection
- Confidence indicators warn when an upstream entry is heuristically derived
- Conflict confirm dialog gains loading state and disables actions during sync
- Default endpoint per channel: /api/pricing for official preset,
  /api/models.dev for the models.dev preset, /api/v1/models for OpenRouter,
  with the rest falling back to the global default
- Rename tab label from "Upstream sync" to "Upstream price sync" for clarity

Tiered Pricing Editor
- Add `len` (full input length, including cache hits) as a tier-condition
  variable to avoid mis-routing when cache hits reduce `p`
- When inserting a new tier, automatically convert the previous catch-all into
  a bounded tier with a `len <= X` upper bound
- Cap each tier at 0~2 conditions and disable the add-condition button at the
  limit, with an Alert explaining the recommended `len` usage
- Extend presets with Multimodal (img / img_o / ai / ao), Request rule
  (header/param matching), and Time-based (hour / weekday) entries
- Embed an LLM prompt helper that copies a model-aware template for designing
  expressions with ChatGPT / Claude

Fetch Models Dialog
- Add a "Removed Models" tab listing models still in the local selection but
  no longer returned by the upstream listing
- Exclude `model_mapping` source keys from the removed view so aliases never
  appear as missing entries
- Force-remount tab content on tab switch via `key` prop to clear stale state
- Switch count placeholders to `{{count}}` interpolation across "Existing
  Models", "New Models", and "Removed Models" labels

Channel Selector & Constants
- Recognize the models.dev preset (id, base_url, name) alongside the existing
  official-channel preset detection
- Add MODELS_DEV_PRESET_* and OPENROUTER_* constants and reorder
  ENDPOINT_OPTIONS so `pricing` is preferred over `ratio_config`
- Expose the new ratio types in RATIO_TYPE_OPTIONS for the sync filter

Types
- Add optional `type` field to UpstreamChannel for endpoint inference
- Extend RatioType union with create_cache_ratio, image_ratio, audio_ratio,
  audio_completion_ratio, billing_mode, and billing_expr

Code Quality & Performance
- Extract upstream-ratio-sync-helpers.ts to host shared types
  (RatioDifferenceEntry, ModelRow, ResolutionsMap), field ordering
  (RATIO_SYNC_FIELDS, SYNC_FIELD_ORDER, NUMERIC_SYNC_FIELDS), and selection
  logic (getPreferredSyncField, isSelectableUpstreamValue, getSyncFieldLabel)
- Memoize the column definitions in useUpstreamRatioSyncColumns and pull the
  per-cell rendering into a renderUpstreamValue helper to remove inline IIFEs
- Wrap handleBulkSelect / handleBulkUnselect in useCallback for stable refs;
  rename the misleading `_upstream` parameter to `upstream`
- Convert parsedRatios from useCallback (returning a function) to useMemo
  (returning the value) and update all call sites to read it as a value
- Memoize the channels list with useMemo so the endpoint-init effect no
  longer fires on every render due to a fresh `?? []` reference

i18n
- Add and translate new keys ("Upstream price sync", "Audio Ratio", "Audio
  Completion Ratio", "Cache Create Ratio", "Image Ratio", "Expression
  Billing", "Fixed Price", "{{n}} model(s) selected", tier guidance, etc.)
  across en, zh, fr, ja, ru, vi
- Fix truncated keys ("Existing Models (", "New Models (", "Removed Models (")
  to proper {{count}} interpolated forms in every locale
- bun run i18n:sync reports 0 missing and 0 extra keys for every locale

Verification
- bun run typecheck: pass
- bun run lint: pass
- bun run i18n:sync: pass (0 missing / 0 extras across all locales)

* 🐛 fix(default): port classic 73e5557 tiered-billing fixes and dedupe Title-Case ratio i18n keys

Sync the web/classic frontend fixes from upstream merge 73e5557 to
web/default, and clean up duplicated Title-Case ratio labels in the
upstream sync UI that were shadowing the canonical sentence-case i18n
keys.

Cache-token filter for tiered model price (port of 9f8a4ec05)
- The matched-tier breakdown shown in the usage-log details dialog
  and in the log table previously listed every cache-related price
  (Cache Read, Cache Write, Cache Write 1h) regardless of whether
  the request actually consumed cache tokens.
- `getTieredBillingSummary` in `usage-logs/lib/format.ts` now skips
  `cache`-group vars when none of `cache_tokens`,
  `cache_creation_tokens`, `cache_creation_tokens_5m`, or
  `cache_creation_tokens_1h` are positive, mirroring the classic
  `renderTieredModelPrice` / `renderTieredModelPriceSimple` logic.
- Extract `hasAnyCacheTokens(other)` as an exported helper so the
  predicate is defined once.
- Add a `hideCacheColumns?: boolean` prop to
  `DynamicPricingBreakdown` and wire it up from the log details
  dialog so the full tier table hides cache columns under the same
  condition. `model-details.tsx` keeps the default (show all
  configured prices), since that view represents the model's
  pricing structure rather than a specific call.

`tiered_expr` ratio/price fallback during sync delays (port of bee339d27)
- When saving a model in tiered-expression mode, the visual editor
  used to delete every ratio/price map entry for the model and only
  write `billing_setting.billing_mode` /
  `billing_setting.billing_expr`. In multi-instance deployments,
  instances that had not yet observed the billing_setting update
  fell back to ratios that no longer existed, breaking pricing.
- `model-ratio-dialog.tsx`: `handleSubmit` always passes every form
  field (`price`, `ratio`, `cacheRatio`, `createCacheRatio`,
  `completionRatio`, `imageRatio`, `audioRatio`,
  `audioCompletionRatio`) into the data object regardless of
  `pricingMode`, so a switch from per-token to tiered_expr no
  longer drops the previously entered ratios.
- `model-ratio-visual-editor.tsx`:
  - The row builder now also surfaces ratio/price values for
    `tiered_expr` rows, so they survive the edit-and-save round
    trip and the next save.
  - `handleSave` factors out a `setIfPresent` helper and persists
    ratio/price entries for `tiered_expr` models alongside
    billing_mode / billing_expr. These act purely as fallback
    because the backend's `ModelPriceHelper` checks `billing_mode`
    first.
  - Cell rendering mutes ratio/price values whenever the row is
    `tiered_expr` (in addition to the existing per-request
    muting), making it visually clear the values are fallback,
    not the active pricing source.

i18n: dedupe Title-Case ratio labels in upstream sync
- `upstream-ratio-sync` `RATIO_TYPE_OPTIONS` previously used
  Title-Case labels (`Model Ratio`, `Cache Ratio`, `Audio
  Completion Ratio`, …) that were rendered through `t()` but never
  existed as canonical keys in the catalogue. The form-field side
  has used sentence-case (`Model ratio`, `Cache ratio`, …) for
  some time, leaving two parallel translation entries per ratio
  type and causing the upstream sync UI to fall back to the
  English source string in zh/ja/ru/fr/vi.
- Rewrite `RATIO_TYPE_OPTIONS` in
  `system-settings/models/constants.ts` and the conflict-detection
  labels in `upstream-ratio-sync.tsx` to reuse the sentence-case
  keys.
- Drop the duplicate Title-Case entries from every locale and
  promote the better translations onto the surviving sentence-case
  keys (e.g. zh `Image ratio` keeps "图片倍率", `Audio completion
  ratio` keeps "音频补全倍率").
- Add a comment to `RATIO_TYPE_OPTIONS` warning future
  contributors not to switch back to Title Case without updating
  the catalogue.

Note on backend fix 4e93148d9
- The backend portion of the merge (allocating a fresh map in
  `updateConfigFromMap` so removed keys are properly cleared) is
  already on HEAD; no additional change is needed.

Verification
- `bun run typecheck`: pass
- `bun run lint`: pass
- `bun run i18n:sync`: 0 missing / 0 extras across
  en / zh / fr / ja / ru / vi

---------

Co-authored-by: Seefs <40468931+seefs001@users.noreply.github.com>
Co-authored-by: Seefs <i@seefs.me>
Co-authored-by: feitianbubu <feitianbubu@qq.com>
Co-authored-by: Calcium-Ion <i@caion.me>
Co-authored-by: Xyfacai <xyfacai@gmail.com>
Co-authored-by: xiangsx <1984871009@qq.com>
Co-authored-by: 郑伯涛 <351175318@qq.com>
Co-authored-by: RedwindA <austinaosid@gmail.com>
Co-authored-by: dean <1006393151@qq.com>
Co-authored-by: QuentinHsu <xuquentinyang@gmail.com>
Co-authored-by: Bliod <bliod@bliod.lan>
Co-authored-by: Apple\Apple <zeraturing@foxmail.com>
This commit is contained in:
同語
2026-04-28 14:19:19 +08:00
committed by GitHub
parent 9f8a4ec050
commit a42b397607
1290 changed files with 158786 additions and 53 deletions
@@ -0,0 +1,329 @@
/*
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 React from 'react';
import {
Button,
Dropdown,
Modal,
Switch,
Typography,
Select,
} from '@douyinfe/semi-ui';
import CompactModeToggle from '../../common/ui/CompactModeToggle';
const ChannelsActions = ({
enableBatchDelete,
batchDeleteChannels,
setShowBatchSetTag,
testAllChannels,
fixChannelsAbilities,
updateAllChannelsBalance,
deleteAllDisabledChannels,
applyAllUpstreamUpdates,
detectAllUpstreamUpdates,
detectAllUpstreamUpdatesLoading,
applyAllUpstreamUpdatesLoading,
compactMode,
setCompactMode,
idSort,
setIdSort,
setEnableBatchDelete,
enableTagMode,
setEnableTagMode,
statusFilter,
setStatusFilter,
getFormValues,
loadChannels,
searchChannels,
activeTypeKey,
activePage,
pageSize,
setActivePage,
t,
}) => {
return (
<div className='flex flex-col gap-2'>
{/* 第一行:批量操作按钮 + 设置开关 */}
<div className='flex flex-col md:flex-row justify-between gap-2'>
{/* 左侧:批量操作按钮 */}
<div className='flex flex-wrap md:flex-nowrap items-center gap-2 w-full md:w-auto order-2 md:order-1'>
<Button
size='small'
disabled={!enableBatchDelete}
type='danger'
className='w-full md:w-auto'
onClick={() => {
Modal.confirm({
title: t('确定是否要删除所选通道?'),
content: t('此修改将不可逆'),
onOk: () => batchDeleteChannels(),
});
}}
>
{t('删除所选通道')}
</Button>
<Button
size='small'
disabled={!enableBatchDelete}
type='tertiary'
onClick={() => setShowBatchSetTag(true)}
className='w-full md:w-auto'
>
{t('批量设置标签')}
</Button>
<Dropdown
size='small'
trigger='click'
render={
<Dropdown.Menu>
<Dropdown.Item>
<Button
size='small'
type='tertiary'
className='w-full'
loading={detectAllUpstreamUpdatesLoading}
disabled={detectAllUpstreamUpdatesLoading}
onClick={() => {
Modal.confirm({
title: t('确定?'),
content: t('确定要测试所有未手动禁用渠道吗?'),
onOk: () => testAllChannels(),
size: 'small',
centered: true,
});
}}
>
{t('测试所有未手动禁用渠道')}
</Button>
</Dropdown.Item>
<Dropdown.Item>
<Button
size='small'
className='w-full'
onClick={() => {
Modal.confirm({
title: t('确定是否要修复数据库一致性?'),
content: t(
'进行该操作时,可能导致渠道访问错误,请仅在数据库出现问题时使用',
),
onOk: () => fixChannelsAbilities(),
size: 'sm',
centered: true,
});
}}
>
{t('修复数据库一致性')}
</Button>
</Dropdown.Item>
<Dropdown.Item>
<Button
size='small'
type='secondary'
className='w-full'
onClick={() => {
Modal.confirm({
title: t('确定?'),
content: t('确定要更新所有已启用通道余额吗?'),
onOk: () => updateAllChannelsBalance(),
size: 'sm',
centered: true,
});
}}
>
{t('更新所有已启用通道余额')}
</Button>
</Dropdown.Item>
<Dropdown.Item>
<Button
size='small'
type='tertiary'
className='w-full'
onClick={() => {
Modal.confirm({
title: t('确定?'),
content: t(
'确定要仅检测全部渠道上游模型更新吗?(不执行新增/删除)',
),
onOk: () => detectAllUpstreamUpdates(),
size: 'sm',
centered: true,
});
}}
>
{t('检测全部渠道上游更新')}
</Button>
</Dropdown.Item>
<Dropdown.Item>
<Button
size='small'
type='primary'
className='w-full'
loading={applyAllUpstreamUpdatesLoading}
disabled={applyAllUpstreamUpdatesLoading}
onClick={() => {
Modal.confirm({
title: t('确定?'),
content: t('确定要对全部渠道执行上游模型更新吗?'),
onOk: () => applyAllUpstreamUpdates(),
size: 'sm',
centered: true,
});
}}
>
{t('处理全部渠道上游更新')}
</Button>
</Dropdown.Item>
<Dropdown.Item>
<Button
size='small'
type='danger'
className='w-full'
onClick={() => {
Modal.confirm({
title: t('确定是否要删除禁用通道?'),
content: t('此修改将不可逆'),
onOk: () => deleteAllDisabledChannels(),
size: 'sm',
centered: true,
});
}}
>
{t('删除禁用通道')}
</Button>
</Dropdown.Item>
</Dropdown.Menu>
}
>
<Button
size='small'
theme='light'
type='tertiary'
className='w-full md:w-auto'
>
{t('批量操作')}
</Button>
</Dropdown>
<CompactModeToggle
compactMode={compactMode}
setCompactMode={setCompactMode}
t={t}
/>
</div>
{/* 右侧:设置开关区域 */}
<div className='flex flex-col md:flex-row items-start md:items-center gap-2 w-full md:w-auto order-1 md:order-2'>
<div className='flex items-center justify-between w-full md:w-auto'>
<Typography.Text strong className='mr-2'>
{t('使用ID排序')}
</Typography.Text>
<Switch
size='small'
checked={idSort}
onChange={(v) => {
localStorage.setItem('id-sort', v + '');
setIdSort(v);
const { searchKeyword, searchGroup, searchModel } =
getFormValues();
if (
searchKeyword === '' &&
searchGroup === '' &&
searchModel === ''
) {
loadChannels(activePage, pageSize, v, enableTagMode);
} else {
searchChannels(
enableTagMode,
activeTypeKey,
statusFilter,
activePage,
pageSize,
v,
);
}
}}
/>
</div>
<div className='flex items-center justify-between w-full md:w-auto'>
<Typography.Text strong className='mr-2'>
{t('开启批量操作')}
</Typography.Text>
<Switch
size='small'
checked={enableBatchDelete}
onChange={(v) => {
localStorage.setItem('enable-batch-delete', v + '');
setEnableBatchDelete(v);
}}
/>
</div>
<div className='flex items-center justify-between w-full md:w-auto'>
<Typography.Text strong className='mr-2'>
{t('标签聚合模式')}
</Typography.Text>
<Switch
size='small'
checked={enableTagMode}
onChange={(v) => {
localStorage.setItem('enable-tag-mode', v + '');
setEnableTagMode(v);
setActivePage(1);
loadChannels(1, pageSize, idSort, v);
}}
/>
</div>
<div className='flex items-center justify-between w-full md:w-auto'>
<Typography.Text strong className='mr-2'>
{t('状态筛选')}
</Typography.Text>
<Select
size='small'
value={statusFilter}
onChange={(v) => {
localStorage.setItem('channel-status-filter', v);
setStatusFilter(v);
setActivePage(1);
loadChannels(
1,
pageSize,
idSort,
enableTagMode,
activeTypeKey,
v,
);
}}
>
<Select.Option value='all'>{t('全部')}</Select.Option>
<Select.Option value='enabled'>{t('已启用')}</Select.Option>
<Select.Option value='disabled'>{t('已禁用')}</Select.Option>
</Select>
</div>
</div>
</div>
</div>
);
};
export default ChannelsActions;
@@ -0,0 +1,906 @@
/*
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 React from 'react';
import {
Button,
Dropdown,
InputNumber,
Modal,
Space,
SplitButtonGroup,
Tag,
Tooltip,
Typography,
} from '@douyinfe/semi-ui';
import {
timestamp2string,
renderGroup,
renderQuota,
getChannelIcon,
renderQuotaWithAmount,
showSuccess,
showError,
showInfo,
} from '../../../helpers';
import {
CHANNEL_OPTIONS,
MODEL_FETCHABLE_CHANNEL_TYPES,
} from '../../../constants';
import { parseUpstreamUpdateMeta } from '../../../hooks/channels/upstreamUpdateUtils';
import {
IconTreeTriangleDown,
IconMore,
IconAlertTriangle,
} from '@douyinfe/semi-icons';
import { FaRandom } from 'react-icons/fa';
// Render functions
const renderType = (type, record = {}, t) => {
const channelInfo = record?.channel_info;
let type2label = new Map();
for (let i = 0; i < CHANNEL_OPTIONS.length; i++) {
type2label[CHANNEL_OPTIONS[i].value] = CHANNEL_OPTIONS[i];
}
type2label[0] = { value: 0, label: t('未知类型'), color: 'grey' };
let icon = getChannelIcon(type);
if (channelInfo?.is_multi_key) {
icon =
channelInfo?.multi_key_mode === 'random' ? (
<div className='flex items-center gap-1'>
<FaRandom className='text-blue-500' />
{icon}
</div>
) : (
<div className='flex items-center gap-1'>
<IconTreeTriangleDown className='text-blue-500' />
{icon}
</div>
);
}
const typeTag = (
<Tag color={type2label[type]?.color} shape='circle' prefixIcon={icon}>
{type2label[type]?.label}
</Tag>
);
let ionetMeta = null;
if (record?.other_info) {
try {
const parsed = JSON.parse(record.other_info);
if (parsed && typeof parsed === 'object' && parsed.source === 'ionet') {
ionetMeta = parsed;
}
} catch (error) {
// ignore invalid metadata
}
}
if (!ionetMeta) {
return typeTag;
}
const handleNavigate = (event) => {
event?.stopPropagation?.();
if (!ionetMeta?.deployment_id) {
return;
}
const targetUrl = `/console/deployment?deployment_id=${ionetMeta.deployment_id}`;
window.open(targetUrl, '_blank', 'noopener');
};
return (
<Space spacing={6}>
{typeTag}
<Tooltip
content={
<div className='max-w-xs'>
<div className='text-xs text-gray-600'>
{t('来源于 IO.NET 部署')}
</div>
{ionetMeta?.deployment_id && (
<div className='text-xs text-gray-500 mt-1'>
{t('部署 ID')}: {ionetMeta.deployment_id}
</div>
)}
</div>
}
>
<span>
<Tag
color='purple'
type='light'
className='cursor-pointer'
onClick={handleNavigate}
>
IO.NET
</Tag>
</span>
</Tooltip>
</Space>
);
};
const renderTagType = (t) => {
return (
<Tag color='light-blue' shape='circle' type='light'>
{t('标签聚合')}
</Tag>
);
};
const renderStatus = (status, channelInfo = undefined, t) => {
if (channelInfo) {
if (channelInfo.is_multi_key) {
let keySize = channelInfo.multi_key_size;
let enabledKeySize = keySize;
if (channelInfo.multi_key_status_list) {
enabledKeySize =
keySize - Object.keys(channelInfo.multi_key_status_list).length;
}
return renderMultiKeyStatus(status, keySize, enabledKeySize, t);
}
}
switch (status) {
case 1:
return (
<Tag color='green' shape='circle'>
{t('已启用')}
</Tag>
);
case 2:
return (
<Tag color='red' shape='circle'>
{t('已禁用')}
</Tag>
);
case 3:
return (
<Tag color='yellow' shape='circle'>
{t('自动禁用')}
</Tag>
);
default:
return (
<Tag color='grey' shape='circle'>
{t('未知状态')}
</Tag>
);
}
};
const renderMultiKeyStatus = (status, keySize, enabledKeySize, t) => {
switch (status) {
case 1:
return (
<Tag color='green' shape='circle'>
{t('已启用')} {enabledKeySize}/{keySize}
</Tag>
);
case 2:
return (
<Tag color='red' shape='circle'>
{t('已禁用')} {enabledKeySize}/{keySize}
</Tag>
);
case 3:
return (
<Tag color='yellow' shape='circle'>
{t('自动禁用')} {enabledKeySize}/{keySize}
</Tag>
);
default:
return (
<Tag color='grey' shape='circle'>
{t('未知状态')} {enabledKeySize}/{keySize}
</Tag>
);
}
};
const renderResponseTime = (responseTime, t) => {
let time = responseTime / 1000;
time = time.toFixed(2) + t(' 秒');
if (responseTime === 0) {
return (
<Tag color='grey' shape='circle'>
{t('未测试')}
</Tag>
);
} else if (responseTime <= 1000) {
return (
<Tag color='green' shape='circle'>
{time}
</Tag>
);
} else if (responseTime <= 3000) {
return (
<Tag color='lime' shape='circle'>
{time}
</Tag>
);
} else if (responseTime <= 5000) {
return (
<Tag color='yellow' shape='circle'>
{time}
</Tag>
);
} else {
return (
<Tag color='red' shape='circle'>
{time}
</Tag>
);
}
};
const isRequestPassThroughEnabled = (record) => {
if (!record || record.children !== undefined) {
return false;
}
const settingValue = record.setting;
if (!settingValue) {
return false;
}
if (typeof settingValue === 'object') {
return settingValue.pass_through_body_enabled === true;
}
if (typeof settingValue !== 'string') {
return false;
}
try {
const parsed = JSON.parse(settingValue);
return parsed?.pass_through_body_enabled === true;
} catch (error) {
return false;
}
};
const getUpstreamUpdateMeta = (record) => {
const supported =
!!record &&
record.children === undefined &&
MODEL_FETCHABLE_CHANNEL_TYPES.has(record.type);
if (!record || record.children !== undefined) {
return {
supported: false,
enabled: false,
pendingAddModels: [],
pendingRemoveModels: [],
};
}
const parsed =
record?.upstreamUpdateMeta && typeof record.upstreamUpdateMeta === 'object'
? record.upstreamUpdateMeta
: parseUpstreamUpdateMeta(record?.settings);
return {
supported,
enabled: parsed?.enabled === true,
pendingAddModels: Array.isArray(parsed?.pendingAddModels)
? parsed.pendingAddModels
: [],
pendingRemoveModels: Array.isArray(parsed?.pendingRemoveModels)
? parsed.pendingRemoveModels
: [],
};
};
export const getChannelsColumns = ({
t,
COLUMN_KEYS,
updateChannelBalance,
manageChannel,
manageTag,
submitTagEdit,
testChannel,
setCurrentTestChannel,
setShowModelTestModal,
setEditingChannel,
setShowEdit,
setShowEditTag,
setEditingTag,
copySelectedChannel,
refresh,
activePage,
channels,
checkOllamaVersion,
setShowMultiKeyManageModal,
setCurrentMultiKeyChannel,
openUpstreamUpdateModal,
detectChannelUpstreamUpdates,
}) => {
return [
{
key: COLUMN_KEYS.ID,
title: t('ID'),
dataIndex: 'id',
},
{
key: COLUMN_KEYS.NAME,
title: t('名称'),
dataIndex: 'name',
render: (text, record, index) => {
const passThroughEnabled = isRequestPassThroughEnabled(record);
const upstreamUpdateMeta = getUpstreamUpdateMeta(record);
const pendingAddCount = upstreamUpdateMeta.pendingAddModels.length;
const pendingRemoveCount =
upstreamUpdateMeta.pendingRemoveModels.length;
const showUpstreamUpdateTag =
upstreamUpdateMeta.supported &&
upstreamUpdateMeta.enabled &&
(pendingAddCount > 0 || pendingRemoveCount > 0);
const nameNode =
record.remark && record.remark.trim() !== '' ? (
<Tooltip
content={
<div className='flex flex-col gap-2 max-w-xs'>
<div className='text-sm'>{record.remark}</div>
<Button
size='small'
type='primary'
theme='outline'
onClick={(e) => {
e.stopPropagation();
navigator.clipboard
.writeText(record.remark)
.then(() => {
showSuccess(t('复制成功'));
})
.catch(() => {
showError(t('复制失败'));
});
}}
>
{t('复制')}
</Button>
</div>
}
trigger='hover'
position='topLeft'
>
<span>{text}</span>
</Tooltip>
) : (
<span>{text}</span>
);
if (!passThroughEnabled && !showUpstreamUpdateTag) {
return nameNode;
}
return (
<Space spacing={6} align='center'>
{nameNode}
{passThroughEnabled && (
<Tooltip
content={t(
'该渠道已开启请求透传:参数覆写、模型重定向、渠道适配等 NewAPI 内置功能将失效,非最佳实践;如因此产生问题,请勿提交 issue 反馈。',
)}
trigger='hover'
position='topLeft'
>
<span className='inline-flex items-center'>
<IconAlertTriangle
style={{ color: 'var(--semi-color-warning)' }}
/>
</span>
</Tooltip>
)}
{showUpstreamUpdateTag && (
<Space spacing={4} align='center'>
{pendingAddCount > 0 ? (
<Tooltip content={t('点击处理新增模型')} position='top'>
<Tag
color='green'
type='light'
size='small'
shape='circle'
className='cursor-pointer transition-all duration-150 hover:opacity-85 hover:-translate-y-px active:scale-95'
onClick={(e) => {
e.stopPropagation();
openUpstreamUpdateModal(
record,
upstreamUpdateMeta.pendingAddModels,
upstreamUpdateMeta.pendingRemoveModels,
'add',
);
}}
>
+{pendingAddCount}
</Tag>
</Tooltip>
) : null}
{pendingRemoveCount > 0 ? (
<Tooltip content={t('点击处理删除模型')} position='top'>
<Tag
color='red'
type='light'
size='small'
shape='circle'
className='cursor-pointer transition-all duration-150 hover:opacity-85 hover:-translate-y-px active:scale-95'
onClick={(e) => {
e.stopPropagation();
openUpstreamUpdateModal(
record,
upstreamUpdateMeta.pendingAddModels,
upstreamUpdateMeta.pendingRemoveModels,
'remove',
);
}}
>
-{pendingRemoveCount}
</Tag>
</Tooltip>
) : null}
</Space>
)}
</Space>
);
},
},
{
key: COLUMN_KEYS.GROUP,
title: t('分组'),
dataIndex: 'group',
render: (text, record, index) => (
<div>
<Space spacing={2}>
{text
?.split(',')
.sort((a, b) => {
if (a === 'default') return -1;
if (b === 'default') return 1;
return a.localeCompare(b);
})
.map((item, index) => renderGroup(item))}
</Space>
</div>
),
},
{
key: COLUMN_KEYS.TYPE,
title: t('类型'),
dataIndex: 'type',
render: (text, record, index) => {
if (record.children === undefined) {
return <>{renderType(text, record, t)}</>;
} else {
return <>{renderTagType(t)}</>;
}
},
},
{
key: COLUMN_KEYS.STATUS,
title: t('状态'),
dataIndex: 'status',
render: (text, record, index) => {
if (text === 3) {
if (record.other_info === '') {
record.other_info = '{}';
}
let otherInfo = JSON.parse(record.other_info);
let reason = otherInfo['status_reason'];
let time = otherInfo['status_time'];
return (
<div>
<Tooltip
content={
t('原因:') + reason + t(',时间:') + timestamp2string(time)
}
>
{renderStatus(text, record.channel_info, t)}
</Tooltip>
</div>
);
} else {
return renderStatus(text, record.channel_info, t);
}
},
},
{
key: COLUMN_KEYS.RESPONSE_TIME,
title: t('响应时间'),
dataIndex: 'response_time',
render: (text, record, index) => <div>{renderResponseTime(text, t)}</div>,
},
{
key: COLUMN_KEYS.BALANCE,
title: t('已用/剩余'),
dataIndex: 'expired_time',
render: (text, record, index) => {
if (record.children === undefined) {
return (
<div>
<Space spacing={1}>
<Tooltip content={t('已用额度')}>
<Tag color='white' type='ghost' shape='circle'>
{renderQuota(record.used_quota)}
</Tag>
</Tooltip>
<Tooltip
content={
record.type === 57
? t('查看 Codex 帐号信息与用量')
: t('剩余额度') +
': ' +
renderQuotaWithAmount(record.balance) +
t(',点击更新')
}
>
<Tag
color={record.type === 57 ? 'light-blue' : 'white'}
type={record.type === 57 ? 'light' : 'ghost'}
shape='circle'
className={record.type === 57 ? 'cursor-pointer' : ''}
onClick={() => updateChannelBalance(record)}
>
{record.type === 57
? t('帐号信息')
: renderQuotaWithAmount(record.balance)}
</Tag>
</Tooltip>
</Space>
</div>
);
} else {
return (
<Tooltip content={t('已用额度')}>
<Tag color='white' type='ghost' shape='circle'>
{renderQuota(record.used_quota)}
</Tag>
</Tooltip>
);
}
},
},
{
key: COLUMN_KEYS.PRIORITY,
title: t('优先级'),
dataIndex: 'priority',
render: (text, record, index) => {
if (record.children === undefined) {
return (
<div>
<InputNumber
style={{ width: 70 }}
name='priority'
onBlur={(e) => {
manageChannel(record.id, 'priority', record, e.target.value);
}}
keepFocus={true}
innerButtons
defaultValue={record.priority}
min={-999}
size='small'
/>
</div>
);
} else {
return (
<InputNumber
style={{ width: 70 }}
name='priority'
keepFocus={true}
onBlur={(e) => {
Modal.warning({
title: t('修改子渠道优先级'),
content:
t('确定要修改所有子渠道优先级为 ') +
e.target.value +
t(' 吗?'),
onOk: () => {
if (e.target.value === '') {
return;
}
submitTagEdit('priority', {
tag: record.key,
priority: e.target.value,
});
},
});
}}
innerButtons
defaultValue={record.priority}
min={-999}
size='small'
/>
);
}
},
},
{
key: COLUMN_KEYS.WEIGHT,
title: t('权重'),
dataIndex: 'weight',
render: (text, record, index) => {
if (record.children === undefined) {
return (
<div>
<InputNumber
style={{ width: 70 }}
name='weight'
onBlur={(e) => {
manageChannel(record.id, 'weight', record, e.target.value);
}}
keepFocus={true}
innerButtons
defaultValue={record.weight}
min={0}
size='small'
/>
</div>
);
} else {
return (
<InputNumber
style={{ width: 70 }}
name='weight'
keepFocus={true}
onBlur={(e) => {
Modal.warning({
title: t('修改子渠道权重'),
content:
t('确定要修改所有子渠道权重为 ') +
e.target.value +
t(' 吗?'),
onOk: () => {
if (e.target.value === '') {
return;
}
submitTagEdit('weight', {
tag: record.key,
weight: e.target.value,
});
},
});
}}
innerButtons
defaultValue={record.weight}
min={-999}
size='small'
/>
);
}
},
},
{
key: COLUMN_KEYS.OPERATE,
title: '',
dataIndex: 'operate',
fixed: 'right',
render: (text, record, index) => {
if (record.children === undefined) {
const upstreamUpdateMeta = getUpstreamUpdateMeta(record);
const moreMenuItems = [
{
node: 'item',
name: t('删除'),
type: 'danger',
onClick: () => {
Modal.confirm({
title: t('确定是否要删除此渠道?'),
content: t('此修改将不可逆'),
onOk: () => {
(async () => {
await manageChannel(record.id, 'delete', record);
await refresh();
setTimeout(() => {
if (channels.length === 0 && activePage > 1) {
refresh(activePage - 1);
}
}, 100);
})();
},
});
},
},
{
node: 'item',
name: t('复制'),
type: 'tertiary',
onClick: () => {
Modal.confirm({
title: t('确定是否要复制此渠道?'),
content: t('复制渠道的所有信息'),
onOk: () => copySelectedChannel(record),
});
},
},
];
if (upstreamUpdateMeta.supported) {
moreMenuItems.push({
node: 'item',
name: t('仅检测上游模型更新'),
type: 'tertiary',
onClick: () => {
detectChannelUpstreamUpdates(record);
},
});
moreMenuItems.push({
node: 'item',
name: t('处理上游模型更新'),
type: 'tertiary',
onClick: () => {
if (!upstreamUpdateMeta.enabled) {
showInfo(t('该渠道未开启上游模型更新检测'));
return;
}
if (
upstreamUpdateMeta.pendingAddModels.length === 0 &&
upstreamUpdateMeta.pendingRemoveModels.length === 0
) {
showInfo(t('该渠道暂无可处理的上游模型更新'));
return;
}
openUpstreamUpdateModal(
record,
upstreamUpdateMeta.pendingAddModels,
upstreamUpdateMeta.pendingRemoveModels,
upstreamUpdateMeta.pendingAddModels.length > 0
? 'add'
: 'remove',
);
},
});
}
if (record.type === 4) {
moreMenuItems.unshift({
node: 'item',
name: t('测活'),
type: 'tertiary',
onClick: () => checkOllamaVersion(record),
});
}
return (
<Space wrap>
<SplitButtonGroup
className='overflow-hidden'
aria-label={t('测试单个渠道操作项目组')}
>
<Button
size='small'
type='tertiary'
onClick={() => testChannel(record, '')}
>
{t('测试')}
</Button>
<Button
size='small'
type='tertiary'
icon={<IconTreeTriangleDown />}
onClick={() => {
setCurrentTestChannel(record);
setShowModelTestModal(true);
}}
/>
</SplitButtonGroup>
{record.status === 1 ? (
<Button
type='danger'
size='small'
onClick={() => manageChannel(record.id, 'disable', record)}
>
{t('禁用')}
</Button>
) : (
<Button
size='small'
onClick={() => manageChannel(record.id, 'enable', record)}
>
{t('启用')}
</Button>
)}
{record.channel_info?.is_multi_key ? (
<SplitButtonGroup aria-label={t('多密钥渠道操作项目组')}>
<Button
type='tertiary'
size='small'
onClick={() => {
setEditingChannel(record);
setShowEdit(true);
}}
>
{t('编辑')}
</Button>
<Dropdown
trigger='click'
position='bottomRight'
menu={[
{
node: 'item',
name: t('多密钥管理'),
onClick: () => {
setCurrentMultiKeyChannel(record);
setShowMultiKeyManageModal(true);
},
},
]}
>
<Button
type='tertiary'
size='small'
icon={<IconTreeTriangleDown />}
/>
</Dropdown>
</SplitButtonGroup>
) : (
<Button
type='tertiary'
size='small'
onClick={() => {
setEditingChannel(record);
setShowEdit(true);
}}
>
{t('编辑')}
</Button>
)}
<Dropdown
trigger='click'
position='bottomRight'
menu={moreMenuItems}
>
<Button icon={<IconMore />} type='tertiary' size='small' />
</Dropdown>
</Space>
);
} else {
// 标签操作按钮
return (
<Space wrap>
<Button
type='tertiary'
size='small'
onClick={() => manageTag(record.key, 'enable')}
>
{t('启用全部')}
</Button>
<Button
type='tertiary'
size='small'
onClick={() => manageTag(record.key, 'disable')}
>
{t('禁用全部')}
</Button>
<Button
type='tertiary'
size='small'
onClick={() => {
setShowEditTag(true);
setEditingTag(record.key);
}}
>
{t('编辑')}
</Button>
</Space>
);
}
},
},
];
};
@@ -0,0 +1,159 @@
/*
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 React from 'react';
import { Button, Form } from '@douyinfe/semi-ui';
import { IconSearch } from '@douyinfe/semi-icons';
const ChannelsFilters = ({
setEditingChannel,
setShowEdit,
refresh,
setShowColumnSelector,
formInitValues,
setFormApi,
searchChannels,
enableTagMode,
formApi,
groupOptions,
loading,
searching,
t,
}) => {
return (
<div className='flex flex-col md:flex-row justify-between items-center gap-2 w-full'>
<div className='flex gap-2 w-full md:w-auto order-2 md:order-1'>
<Button
size='small'
theme='light'
type='primary'
className='w-full md:w-auto'
onClick={() => {
setEditingChannel({
id: undefined,
});
setShowEdit(true);
}}
>
{t('添加渠道')}
</Button>
<Button
size='small'
type='tertiary'
className='w-full md:w-auto'
onClick={refresh}
>
{t('刷新')}
</Button>
<Button
size='small'
type='tertiary'
onClick={() => setShowColumnSelector(true)}
className='w-full md:w-auto'
>
{t('列设置')}
</Button>
</div>
<div className='flex flex-col md:flex-row items-center gap-2 w-full md:w-auto order-1 md:order-2'>
<Form
initValues={formInitValues}
getFormApi={(api) => setFormApi(api)}
onSubmit={() => searchChannels(enableTagMode)}
allowEmpty={true}
autoComplete='off'
layout='horizontal'
trigger='change'
stopValidateWithError={false}
className='flex flex-col md:flex-row items-center gap-2 w-full'
>
<div className='relative w-full md:w-64'>
<Form.Input
size='small'
field='searchKeyword'
prefix={<IconSearch />}
placeholder={t('渠道ID,名称,密钥,API地址')}
showClear
pure
/>
</div>
<div className='w-full md:w-48'>
<Form.Input
size='small'
field='searchModel'
prefix={<IconSearch />}
placeholder={t('模型关键字')}
showClear
pure
/>
</div>
<div className='w-full md:w-32'>
<Form.Select
size='small'
field='searchGroup'
placeholder={t('选择分组')}
optionList={[
{ label: t('选择分组'), value: null },
...groupOptions,
]}
className='w-full'
showClear
pure
onChange={() => {
// 延迟执行搜索,让表单值先更新
setTimeout(() => {
searchChannels(enableTagMode);
}, 0);
}}
/>
</div>
<Button
size='small'
type='tertiary'
htmlType='submit'
loading={loading || searching}
className='w-full md:w-auto'
>
{t('查询')}
</Button>
<Button
size='small'
type='tertiary'
onClick={() => {
if (formApi) {
formApi.reset();
// 重置后立即查询,使用setTimeout确保表单重置完成
setTimeout(() => {
refresh();
}, 100);
}
}}
className='w-full md:w-auto'
>
{t('重置')}
</Button>
</Form>
</div>
</div>
);
};
export default ChannelsFilters;
@@ -0,0 +1,177 @@
/*
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 React, { useMemo } from 'react';
import { Empty } from '@douyinfe/semi-ui';
import CardTable from '../../common/ui/CardTable';
import {
IllustrationNoResult,
IllustrationNoResultDark,
} from '@douyinfe/semi-illustrations';
import { getChannelsColumns } from './ChannelsColumnDefs';
const ChannelsTable = (channelsData) => {
const {
channels,
loading,
searching,
activePage,
pageSize,
channelCount,
enableBatchDelete,
compactMode,
visibleColumns,
setSelectedChannels,
handlePageChange,
handlePageSizeChange,
handleRow,
t,
COLUMN_KEYS,
// Column functions and data
updateChannelBalance,
manageChannel,
manageTag,
submitTagEdit,
testChannel,
setCurrentTestChannel,
setShowModelTestModal,
setEditingChannel,
setShowEdit,
setShowEditTag,
setEditingTag,
copySelectedChannel,
refresh,
checkOllamaVersion,
// Multi-key management
setShowMultiKeyManageModal,
setCurrentMultiKeyChannel,
openUpstreamUpdateModal,
detectChannelUpstreamUpdates,
} = channelsData;
// Get all columns
const allColumns = useMemo(() => {
return getChannelsColumns({
t,
COLUMN_KEYS,
updateChannelBalance,
manageChannel,
manageTag,
submitTagEdit,
testChannel,
setCurrentTestChannel,
setShowModelTestModal,
setEditingChannel,
setShowEdit,
setShowEditTag,
setEditingTag,
copySelectedChannel,
refresh,
activePage,
channels,
checkOllamaVersion,
setShowMultiKeyManageModal,
setCurrentMultiKeyChannel,
openUpstreamUpdateModal,
detectChannelUpstreamUpdates,
});
}, [
t,
COLUMN_KEYS,
updateChannelBalance,
manageChannel,
manageTag,
submitTagEdit,
testChannel,
setCurrentTestChannel,
setShowModelTestModal,
setEditingChannel,
setShowEdit,
setShowEditTag,
setEditingTag,
copySelectedChannel,
refresh,
activePage,
channels,
checkOllamaVersion,
setShowMultiKeyManageModal,
setCurrentMultiKeyChannel,
openUpstreamUpdateModal,
detectChannelUpstreamUpdates,
]);
// Filter columns based on visibility settings
const getVisibleColumns = () => {
return allColumns.filter((column) => visibleColumns[column.key]);
};
const visibleColumnsList = useMemo(() => {
return getVisibleColumns();
}, [visibleColumns, allColumns]);
const tableColumns = useMemo(() => {
return compactMode
? visibleColumnsList.map(({ fixed, ...rest }) => rest)
: visibleColumnsList;
}, [compactMode, visibleColumnsList]);
return (
<CardTable
columns={tableColumns}
dataSource={channels}
scroll={compactMode ? undefined : { x: 'max-content' }}
pagination={{
currentPage: activePage,
pageSize: pageSize,
total: channelCount,
pageSizeOpts: [10, 20, 50, 100],
showSizeChanger: true,
onPageSizeChange: handlePageSizeChange,
onPageChange: handlePageChange,
}}
hidePagination={true}
expandAllRows={false}
onRow={handleRow}
rowSelection={
enableBatchDelete
? {
onChange: (selectedRowKeys, selectedRows) => {
setSelectedChannels(selectedRows);
},
}
: null
}
empty={
<Empty
image={<IllustrationNoResult style={{ width: 150, height: 150 }} />}
darkModeImage={
<IllustrationNoResultDark style={{ width: 150, height: 150 }} />
}
description={t('搜索无结果')}
style={{ padding: 30 }}
/>
}
className='rounded-xl overflow-hidden'
size='middle'
loading={loading || searching}
/>
);
};
export default ChannelsTable;
@@ -0,0 +1,97 @@
/*
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 React from 'react';
import { Tabs, TabPane, Tag } from '@douyinfe/semi-ui';
import { CHANNEL_OPTIONS } from '../../../constants';
import { getChannelIcon } from '../../../helpers';
const ChannelsTabs = ({
enableTagMode,
activeTypeKey,
setActiveTypeKey,
channelTypeCounts,
availableTypeKeys,
loadChannels,
activePage,
pageSize,
idSort,
setActivePage,
t,
}) => {
if (enableTagMode) return null;
const handleTabChange = (key) => {
setActiveTypeKey(key);
setActivePage(1);
loadChannels(1, pageSize, idSort, enableTagMode, key);
};
return (
<Tabs
activeKey={activeTypeKey}
type='card'
collapsible
onChange={handleTabChange}
className='mb-2'
>
<TabPane
itemKey='all'
tab={
<span className='flex items-center gap-2'>
{t('全部')}
<Tag
color={activeTypeKey === 'all' ? 'red' : 'grey'}
shape='circle'
>
{channelTypeCounts['all'] || 0}
</Tag>
</span>
}
/>
{CHANNEL_OPTIONS.filter((opt) =>
availableTypeKeys.includes(String(opt.value)),
).map((option) => {
const key = String(option.value);
const count = channelTypeCounts[option.value] || 0;
return (
<TabPane
key={key}
itemKey={key}
tab={
<span className='flex items-center gap-2'>
{getChannelIcon(option.value)}
{option.label}
<Tag
color={activeTypeKey === key ? 'red' : 'grey'}
shape='circle'
>
{count}
</Tag>
</span>
}
/>
);
})}
</Tabs>
);
};
export default ChannelsTabs;
+116
View File
@@ -0,0 +1,116 @@
/*
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 React from 'react';
import { Banner } from '@douyinfe/semi-ui';
import { IconAlertTriangle } from '@douyinfe/semi-icons';
import CardPro from '../../common/ui/CardPro';
import ChannelsTable from './ChannelsTable';
import ChannelsActions from './ChannelsActions';
import ChannelsFilters from './ChannelsFilters';
import ChannelsTabs from './ChannelsTabs';
import { useChannelsData } from '../../../hooks/channels/useChannelsData';
import { useIsMobile } from '../../../hooks/common/useIsMobile';
import BatchTagModal from './modals/BatchTagModal';
import ModelTestModal from './modals/ModelTestModal';
import ColumnSelectorModal from './modals/ColumnSelectorModal';
import EditChannelModal from './modals/EditChannelModal';
import EditTagModal from './modals/EditTagModal';
import MultiKeyManageModal from './modals/MultiKeyManageModal';
import ChannelUpstreamUpdateModal from './modals/ChannelUpstreamUpdateModal';
import { createCardProPagination } from '../../../helpers/utils';
const ChannelsPage = () => {
const channelsData = useChannelsData();
const isMobile = useIsMobile();
return (
<>
{/* Modals */}
<ColumnSelectorModal {...channelsData} />
<EditTagModal
visible={channelsData.showEditTag}
tag={channelsData.editingTag}
handleClose={() => channelsData.setShowEditTag(false)}
refresh={channelsData.refresh}
/>
<EditChannelModal
refresh={channelsData.refresh}
visible={channelsData.showEdit}
handleClose={channelsData.closeEdit}
editingChannel={channelsData.editingChannel}
/>
<BatchTagModal {...channelsData} />
<ModelTestModal {...channelsData} />
<MultiKeyManageModal
visible={channelsData.showMultiKeyManageModal}
onCancel={() => channelsData.setShowMultiKeyManageModal(false)}
channel={channelsData.currentMultiKeyChannel}
onRefresh={channelsData.refresh}
/>
<ChannelUpstreamUpdateModal
visible={channelsData.showUpstreamUpdateModal}
addModels={channelsData.upstreamUpdateAddModels}
removeModels={channelsData.upstreamUpdateRemoveModels}
preferredTab={channelsData.upstreamUpdatePreferredTab}
confirmLoading={channelsData.upstreamApplyLoading}
onConfirm={channelsData.applyUpstreamUpdates}
onCancel={channelsData.closeUpstreamUpdateModal}
/>
{/* Main Content */}
{channelsData.globalPassThroughEnabled ? (
<Banner
type='warning'
closeIcon={null}
icon={
<IconAlertTriangle
size='large'
style={{ color: 'var(--semi-color-warning)' }}
/>
}
description={channelsData.t(
'已开启全局请求透传:参数覆写、模型重定向、渠道适配等 NewAPI 内置功能将失效,非最佳实践;如因此产生问题,请勿提交 issue 反馈。',
)}
style={{ marginBottom: 12 }}
/>
) : null}
<CardPro
type='type3'
tabsArea={<ChannelsTabs {...channelsData} />}
actionsArea={<ChannelsActions {...channelsData} />}
searchArea={<ChannelsFilters {...channelsData} />}
paginationArea={createCardProPagination({
currentPage: channelsData.activePage,
pageSize: channelsData.pageSize,
total: channelsData.channelCount,
onPageChange: channelsData.handlePageChange,
onPageSizeChange: channelsData.handlePageSizeChange,
isMobile: isMobile,
t: channelsData.t,
})}
t={channelsData.t}
>
<ChannelsTable {...channelsData} />
</CardPro>
</>
);
};
export default ChannelsPage;
@@ -0,0 +1,63 @@
/*
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 React from 'react';
import { Modal, Input, Typography } from '@douyinfe/semi-ui';
const BatchTagModal = ({
showBatchSetTag,
setShowBatchSetTag,
batchSetChannelTag,
batchSetTagValue,
setBatchSetTagValue,
selectedChannels,
t,
}) => {
return (
<Modal
title={t('批量设置标签')}
visible={showBatchSetTag}
onOk={batchSetChannelTag}
onCancel={() => setShowBatchSetTag(false)}
maskClosable={false}
centered={true}
size='small'
className='!rounded-lg'
>
<div className='mb-5'>
<Typography.Text>{t('请输入要设置的标签名称')}</Typography.Text>
</div>
<Input
placeholder={t('请输入标签名称')}
value={batchSetTagValue}
onChange={(v) => setBatchSetTagValue(v)}
/>
<div className='mt-4'>
<Typography.Text type='secondary'>
{t('已选择 ${count} 个渠道').replace(
'${count}',
selectedChannels.length,
)}
</Typography.Text>
</div>
</Modal>
);
};
export default BatchTagModal;
@@ -0,0 +1,313 @@
/*
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 React, { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Modal,
Checkbox,
Empty,
Input,
Tabs,
Typography,
} from '@douyinfe/semi-ui';
import {
IllustrationNoResult,
IllustrationNoResultDark,
} from '@douyinfe/semi-illustrations';
import { IconSearch } from '@douyinfe/semi-icons';
import { useIsMobile } from '../../../../hooks/common/useIsMobile';
const normalizeModels = (models = []) =>
Array.from(
new Set(
(models || []).map((model) => String(model || '').trim()).filter(Boolean),
),
);
const filterByKeyword = (models = [], keyword = '') => {
const normalizedKeyword = String(keyword || '')
.trim()
.toLowerCase();
if (!normalizedKeyword) {
return models;
}
return models.filter((model) =>
String(model).toLowerCase().includes(normalizedKeyword),
);
};
const ChannelUpstreamUpdateModal = ({
visible,
addModels = [],
removeModels = [],
preferredTab = 'add',
confirmLoading = false,
onConfirm,
onCancel,
}) => {
const { t } = useTranslation();
const isMobile = useIsMobile();
const normalizedAddModels = useMemo(
() => normalizeModels(addModels),
[addModels],
);
const normalizedRemoveModels = useMemo(
() => normalizeModels(removeModels),
[removeModels],
);
const [selectedAddModels, setSelectedAddModels] = useState([]);
const [selectedRemoveModels, setSelectedRemoveModels] = useState([]);
const [keyword, setKeyword] = useState('');
const [activeTab, setActiveTab] = useState('add');
const [partialSubmitConfirmed, setPartialSubmitConfirmed] = useState(false);
const addTabEnabled = normalizedAddModels.length > 0;
const removeTabEnabled = normalizedRemoveModels.length > 0;
const filteredAddModels = useMemo(
() => filterByKeyword(normalizedAddModels, keyword),
[normalizedAddModels, keyword],
);
const filteredRemoveModels = useMemo(
() => filterByKeyword(normalizedRemoveModels, keyword),
[normalizedRemoveModels, keyword],
);
useEffect(() => {
if (!visible) {
return;
}
setSelectedAddModels([]);
setSelectedRemoveModels([]);
setKeyword('');
setPartialSubmitConfirmed(false);
const normalizedPreferredTab = preferredTab === 'remove' ? 'remove' : 'add';
if (normalizedPreferredTab === 'remove' && removeTabEnabled) {
setActiveTab('remove');
return;
}
if (normalizedPreferredTab === 'add' && addTabEnabled) {
setActiveTab('add');
return;
}
setActiveTab(addTabEnabled ? 'add' : 'remove');
}, [visible, addTabEnabled, removeTabEnabled, preferredTab]);
const currentModels =
activeTab === 'add' ? filteredAddModels : filteredRemoveModels;
const currentSelectedModels =
activeTab === 'add' ? selectedAddModels : selectedRemoveModels;
const currentSetSelectedModels =
activeTab === 'add' ? setSelectedAddModels : setSelectedRemoveModels;
const selectedAddCount = selectedAddModels.length;
const selectedRemoveCount = selectedRemoveModels.length;
const checkedCount = currentModels.filter((model) =>
currentSelectedModels.includes(model),
).length;
const isAllChecked =
currentModels.length > 0 && checkedCount === currentModels.length;
const isIndeterminate =
checkedCount > 0 && checkedCount < currentModels.length;
const handleToggleAllCurrent = (checked) => {
if (checked) {
const merged = normalizeModels([
...currentSelectedModels,
...currentModels,
]);
currentSetSelectedModels(merged);
return;
}
const currentSet = new Set(currentModels);
currentSetSelectedModels(
currentSelectedModels.filter((model) => !currentSet.has(model)),
);
};
const tabList = [
{
itemKey: 'add',
tab: `${t('新增模型')} (${selectedAddCount}/${normalizedAddModels.length})`,
disabled: !addTabEnabled,
},
{
itemKey: 'remove',
tab: `${t('删除模型')} (${selectedRemoveCount}/${normalizedRemoveModels.length})`,
disabled: !removeTabEnabled,
},
];
const submitSelectedChanges = () => {
onConfirm?.({
addModels: selectedAddModels,
removeModels: selectedRemoveModels,
});
};
const handleSubmit = () => {
const hasAnySelected = selectedAddCount > 0 || selectedRemoveCount > 0;
if (!hasAnySelected) {
submitSelectedChanges();
return;
}
const hasBothPending = addTabEnabled && removeTabEnabled;
const hasUnselectedAdd = addTabEnabled && selectedAddCount === 0;
const hasUnselectedRemove = removeTabEnabled && selectedRemoveCount === 0;
if (hasBothPending && (hasUnselectedAdd || hasUnselectedRemove)) {
if (partialSubmitConfirmed) {
submitSelectedChanges();
return;
}
const missingTab = hasUnselectedAdd ? 'add' : 'remove';
const missingType = hasUnselectedAdd ? t('新增') : t('删除');
const missingCount = hasUnselectedAdd
? normalizedAddModels.length
: normalizedRemoveModels.length;
setActiveTab(missingTab);
Modal.confirm({
title: t('仍有未处理项'),
content: t(
'你还没有处理{{type}}模型({{count}}个)。是否仅提交当前已勾选内容?',
{
type: missingType,
count: missingCount,
},
),
okText: t('仅提交已勾选'),
cancelText: t('去处理{{type}}', { type: missingType }),
centered: true,
onOk: () => {
setPartialSubmitConfirmed(true);
submitSelectedChanges();
},
});
return;
}
submitSelectedChanges();
};
return (
<Modal
visible={visible}
title={t('处理上游模型更新')}
okText={t('确定')}
cancelText={t('取消')}
size={isMobile ? 'full-width' : 'medium'}
centered
closeOnEsc
maskClosable
confirmLoading={confirmLoading}
onCancel={onCancel}
onOk={handleSubmit}
>
<div className='flex flex-col gap-3'>
<Typography.Text type='secondary' size='small'>
{t(
'可勾选需要执行的变更:新增会加入渠道模型列表,删除会从渠道模型列表移除。',
)}
</Typography.Text>
<Tabs
type='slash'
size='small'
tabList={tabList}
activeKey={activeTab}
onChange={(key) => setActiveTab(key)}
/>
<div className='flex items-center gap-3 text-xs text-gray-500'>
<span>
{t('新增已选 {{selected}} / {{total}}', {
selected: selectedAddCount,
total: normalizedAddModels.length,
})}
</span>
<span>
{t('删除已选 {{selected}} / {{total}}', {
selected: selectedRemoveCount,
total: normalizedRemoveModels.length,
})}
</span>
</div>
<Input
prefix={<IconSearch size={14} />}
placeholder={t('搜索模型')}
value={keyword}
onChange={(value) => setKeyword(value)}
showClear
/>
<div style={{ maxHeight: 320, overflowY: 'auto', paddingRight: 8 }}>
{currentModels.length === 0 ? (
<Empty
image={
<IllustrationNoResult style={{ width: 150, height: 150 }} />
}
darkModeImage={
<IllustrationNoResultDark style={{ width: 150, height: 150 }} />
}
description={t('暂无匹配模型')}
style={{ padding: 24 }}
/>
) : (
<Checkbox.Group
value={currentSelectedModels}
onChange={(values) =>
currentSetSelectedModels(normalizeModels(values))
}
>
<div className='grid grid-cols-1 md:grid-cols-2 gap-x-4'>
{currentModels.map((model) => (
<Checkbox
key={`${activeTab}:${model}`}
value={model}
className='my-1'
>
{model}
</Checkbox>
))}
</div>
</Checkbox.Group>
)}
</div>
<div className='flex items-center justify-end gap-2'>
<Typography.Text type='secondary' size='small'>
{t('已选择 {{selected}} / {{total}}', {
selected: checkedCount,
total: currentModels.length,
})}
</Typography.Text>
<Checkbox
checked={isAllChecked}
indeterminate={isIndeterminate}
aria-label={t('全选当前列表模型')}
onChange={(e) => handleToggleAllCurrent(e.target.checked)}
/>
</div>
</div>
</Modal>
);
};
export default ChannelUpstreamUpdateModal;
@@ -0,0 +1,172 @@
/*
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 React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Modal,
Button,
Space,
Typography,
Input,
Banner,
} from '@douyinfe/semi-ui';
import { API, copy, showError, showSuccess } from '../../../../helpers';
const { Text } = Typography;
const CodexOAuthModal = ({ visible, onCancel, onSuccess }) => {
const { t } = useTranslation();
const [loading, setLoading] = useState(false);
const [authorizeUrl, setAuthorizeUrl] = useState('');
const [input, setInput] = useState('');
const startOAuth = async () => {
setLoading(true);
try {
const res = await API.post(
'/api/channel/codex/oauth/start',
{},
{ skipErrorHandler: true },
);
if (!res?.data?.success) {
console.error('Codex OAuth start failed:', res?.data?.message);
throw new Error(t('启动授权失败'));
}
const url = res?.data?.data?.authorize_url || '';
if (!url) {
console.error(
'Codex OAuth start response missing authorize_url:',
res?.data,
);
throw new Error(t('响应缺少授权链接'));
}
setAuthorizeUrl(url);
window.open(url, '_blank', 'noopener,noreferrer');
showSuccess(t('已打开授权页面'));
} catch (error) {
showError(error?.message || t('启动授权失败'));
} finally {
setLoading(false);
}
};
const completeOAuth = async () => {
if (!input || !input.trim()) {
showError(t('请先粘贴回调 URL'));
return;
}
setLoading(true);
try {
const res = await API.post(
'/api/channel/codex/oauth/complete',
{ input },
{ skipErrorHandler: true },
);
if (!res?.data?.success) {
console.error('Codex OAuth complete failed:', res?.data?.message);
throw new Error(t('授权失败'));
}
const key = res?.data?.data?.key || '';
if (!key) {
console.error('Codex OAuth complete response missing key:', res?.data);
throw new Error(t('响应缺少凭据'));
}
onSuccess && onSuccess(key);
showSuccess(t('已生成授权凭据'));
onCancel && onCancel();
} catch (error) {
showError(error?.message || t('授权失败'));
} finally {
setLoading(false);
}
};
useEffect(() => {
if (!visible) return;
setAuthorizeUrl('');
setInput('');
}, [visible]);
return (
<Modal
title={t('Codex 授权')}
visible={visible}
onCancel={onCancel}
maskClosable={false}
closeOnEsc
width={720}
footer={
<Space>
<Button theme='borderless' onClick={onCancel} disabled={loading}>
{t('取消')}
</Button>
<Button
theme='solid'
type='primary'
onClick={completeOAuth}
loading={loading}
>
{t('生成并填入')}
</Button>
</Space>
}
>
<Space vertical spacing='tight' style={{ width: '100%' }}>
<Banner
type='info'
description={t(
'1) 点击「打开授权页面」完成登录;2) 浏览器会跳转到 localhost(页面打不开也没关系);3) 复制地址栏完整 URL 粘贴到下方;4) 点击「生成并填入」。',
)}
/>
<Space wrap>
<Button type='primary' onClick={startOAuth} loading={loading}>
{t('打开授权页面')}
</Button>
<Button
theme='outline'
disabled={!authorizeUrl || loading}
onClick={() => copy(authorizeUrl)}
>
{t('复制授权链接')}
</Button>
</Space>
<Input
value={input}
onChange={(value) => setInput(value)}
placeholder={t('请粘贴完整回调 URL(包含 code 与 state')}
showClear
/>
<Text type='tertiary' size='small'>
{t(
'说明:生成结果是可直接粘贴到渠道密钥里的 JSON(包含 access_token / refresh_token / account_id)。',
)}
</Text>
</Space>
</Modal>
);
};
export default CodexOAuthModal;
@@ -0,0 +1,673 @@
/*
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 React, { useCallback, useEffect, useRef, useState } from 'react';
import {
Modal,
Button,
Progress,
Typography,
Spin,
Tag,
Descriptions,
Collapse,
} from '@douyinfe/semi-ui';
import { API, showError } from '../../../../helpers';
import { MOBILE_BREAKPOINT } from '../../../../hooks/common/useIsMobile';
const { Text } = Typography;
const clampPercent = (value) => {
const v = Number(value);
if (!Number.isFinite(v)) return 0;
return Math.max(0, Math.min(100, v));
};
const pickStrokeColor = (percent) => {
const p = clampPercent(percent);
if (p >= 95) return '#ef4444';
if (p >= 80) return '#f59e0b';
return '#3b82f6';
};
const normalizePlanType = (value) => {
if (value == null) return '';
return String(value).trim().toLowerCase();
};
const getWindowDurationSeconds = (windowData) => {
const value = Number(windowData?.limit_window_seconds);
if (!Number.isFinite(value) || value <= 0) return null;
return value;
};
const classifyWindowByDuration = (windowData) => {
const seconds = getWindowDurationSeconds(windowData);
if (seconds == null) return null;
return seconds >= 24 * 60 * 60 ? 'weekly' : 'fiveHour';
};
const resolveRateLimitWindows = (data) => {
const rateLimit = data?.rate_limit ?? {};
const primary = rateLimit?.primary_window ?? null;
const secondary = rateLimit?.secondary_window ?? null;
const windows = [primary, secondary].filter(Boolean);
const planType = normalizePlanType(data?.plan_type ?? rateLimit?.plan_type);
let fiveHourWindow = null;
let weeklyWindow = null;
for (const windowData of windows) {
const bucket = classifyWindowByDuration(windowData);
if (bucket === 'fiveHour' && !fiveHourWindow) {
fiveHourWindow = windowData;
continue;
}
if (bucket === 'weekly' && !weeklyWindow) {
weeklyWindow = windowData;
}
}
if (planType === 'free') {
if (!weeklyWindow) {
weeklyWindow = primary ?? secondary ?? null;
}
return { fiveHourWindow: null, weeklyWindow };
}
if (!fiveHourWindow && !weeklyWindow) {
return {
fiveHourWindow: primary ?? null,
weeklyWindow: secondary ?? null,
};
}
if (!fiveHourWindow) {
fiveHourWindow =
windows.find((windowData) => windowData !== weeklyWindow) ?? null;
}
if (!weeklyWindow) {
weeklyWindow =
windows.find((windowData) => windowData !== fiveHourWindow) ?? null;
}
return { fiveHourWindow, weeklyWindow };
};
const formatDurationSeconds = (seconds, t) => {
const tt = typeof t === 'function' ? t : (v) => v;
const s = Number(seconds);
if (!Number.isFinite(s) || s <= 0) return '-';
const total = Math.floor(s);
const hours = Math.floor(total / 3600);
const minutes = Math.floor((total % 3600) / 60);
const secs = total % 60;
if (hours > 0) return `${hours}${tt('小时')} ${minutes}${tt('分钟')}`;
if (minutes > 0) return `${minutes}${tt('分钟')} ${secs}${tt('秒')}`;
return `${secs}${tt('秒')}`;
};
const formatUnixSeconds = (unixSeconds) => {
const v = Number(unixSeconds);
if (!Number.isFinite(v) || v <= 0) return '-';
try {
return new Date(v * 1000).toLocaleString();
} catch (error) {
return String(unixSeconds);
}
};
const getDisplayText = (value) => {
if (value == null) return '';
return String(value).trim();
};
const isMobileViewport = () =>
typeof window !== 'undefined' && window.innerWidth < MOBILE_BREAKPOINT;
const getCodexUsageModalLayout = () => {
if (isMobileViewport()) {
return {
width: 'calc(100vw - 16px)',
style: {
top: 8,
maxWidth: 'calc(100vw - 16px)',
margin: '0 auto',
},
bodyStyle: {
maxHeight: 'calc(100vh - 148px)',
overflowY: 'auto',
padding: '16px 16px 12px',
},
};
}
return {
width: 900,
style: {
top: 24,
maxWidth: 'min(900px, 92vw)',
},
bodyStyle: {
maxHeight: 'calc(100vh - 172px)',
overflowY: 'auto',
padding: '20px 24px 16px',
},
};
};
const formatAccountTypeLabel = (value, t) => {
const tt = typeof t === 'function' ? t : (v) => v;
const normalized = normalizePlanType(value);
switch (normalized) {
case 'free':
return 'Free';
case 'plus':
return 'Plus';
case 'pro':
return 'Pro';
case 'team':
return 'Team';
case 'enterprise':
return 'Enterprise';
default:
return getDisplayText(value) || tt('未识别');
}
};
const getAccountTypeTagColor = (value) => {
const normalized = normalizePlanType(value);
switch (normalized) {
case 'enterprise':
return 'green';
case 'team':
return 'cyan';
case 'pro':
return 'blue';
case 'plus':
return 'violet';
case 'free':
return 'amber';
default:
return 'grey';
}
};
const resolveUsageStatusTag = (t, rateLimit) => {
const tt = typeof t === 'function' ? t : (v) => v;
if (!rateLimit || Object.keys(rateLimit).length === 0) {
return <Tag color='grey'>{tt('待确认')}</Tag>;
}
if (rateLimit?.allowed && !rateLimit?.limit_reached) {
return <Tag color='green'>{tt('可用')}</Tag>;
}
return <Tag color='red'>{tt('受限')}</Tag>;
};
const AccountInfoValue = ({ t, value, onCopy, monospace = false }) => {
const tt = typeof t === 'function' ? t : (v) => v;
const text = getDisplayText(value);
const hasValue = text !== '';
return (
<div className='flex min-w-0 items-start justify-between gap-2'>
<div
className={`min-w-0 flex-1 break-all text-xs leading-5 text-semi-color-text-1 ${
monospace ? 'font-mono' : ''
}`}
>
{hasValue ? text : '-'}
</div>
<Button
size='small'
type='tertiary'
theme='borderless'
className='shrink-0 px-1 text-xs'
disabled={!hasValue}
onClick={() => onCopy?.(text)}
>
{tt('复制')}
</Button>
</div>
);
};
const RateLimitWindowCard = ({ t, title, windowData }) => {
const tt = typeof t === 'function' ? t : (v) => v;
const hasWindowData =
!!windowData &&
typeof windowData === 'object' &&
Object.keys(windowData).length > 0;
const percent = clampPercent(windowData?.used_percent ?? 0);
const resetAt = windowData?.reset_at;
const resetAfterSeconds = windowData?.reset_after_seconds;
const limitWindowSeconds = windowData?.limit_window_seconds;
return (
<div className='rounded-lg border border-semi-color-border bg-semi-color-bg-0 p-3'>
<div className='flex flex-wrap items-start justify-between gap-x-3 gap-y-1'>
<div className='font-medium'>{title}</div>
<Text type='tertiary' size='small'>
{tt('重置时间:')}
{formatUnixSeconds(resetAt)}
</Text>
</div>
{hasWindowData ? (
<div className='mt-2'>
<Progress
percent={percent}
stroke={pickStrokeColor(percent)}
showInfo={true}
/>
</div>
) : (
<div className='mt-3 text-sm text-semi-color-text-2'>-</div>
)}
<div className='mt-1 flex flex-wrap items-center gap-2 text-xs text-semi-color-text-2'>
<div>
{tt('已使用:')}
{hasWindowData ? `${percent}%` : '-'}
</div>
<div>
{tt('距离重置:')}
{hasWindowData ? formatDurationSeconds(resetAfterSeconds, tt) : '-'}
</div>
<div>
{tt('窗口:')}
{hasWindowData ? formatDurationSeconds(limitWindowSeconds, tt) : '-'}
</div>
</div>
</div>
);
};
const RateLimitWindowGrid = ({ t, fiveHourWindow, weeklyWindow }) => {
const tt = typeof t === 'function' ? t : (v) => v;
return (
<div className='grid grid-cols-1 gap-3 md:grid-cols-2'>
<RateLimitWindowCard
t={tt}
title={tt('5小时窗口')}
windowData={fiveHourWindow}
/>
<RateLimitWindowCard
t={tt}
title={tt('每周窗口')}
windowData={weeklyWindow}
/>
</div>
);
};
const RateLimitGroupSection = ({
t,
title,
description,
rateLimitSource,
statusTag,
meteredFeature,
}) => {
const tt = typeof t === 'function' ? t : (v) => v;
const { fiveHourWindow, weeklyWindow } =
resolveRateLimitWindows(rateLimitSource);
const featureText = getDisplayText(meteredFeature);
return (
<section className='space-y-3'>
<div className='flex flex-wrap items-start justify-between gap-3'>
<div className='min-w-0 space-y-2'>
<div className='flex flex-wrap items-center gap-2'>
<div className='text-sm font-semibold text-semi-color-text-0'>
{title}
</div>
{statusTag}
</div>
{(description || featureText) && (
<div className='flex flex-wrap items-center gap-2 text-xs text-semi-color-text-2'>
{description ? <span>{description}</span> : null}
{featureText ? (
<div className='inline-flex max-w-full items-center gap-2 rounded-full bg-semi-color-fill-0 px-2 py-1'>
<span className='text-[11px] text-semi-color-text-2'>
metered_feature
</span>
<span className='min-w-0 break-all font-mono text-xs text-semi-color-text-0'>
{featureText}
</span>
</div>
) : null}
</div>
)}
</div>
</div>
<RateLimitWindowGrid
t={tt}
fiveHourWindow={fiveHourWindow}
weeklyWindow={weeklyWindow}
/>
</section>
);
};
const CodexUsageView = ({ t, record, payload, onCopy, onRefresh }) => {
const tt = typeof t === 'function' ? t : (v) => v;
const [showRawJson, setShowRawJson] = useState(false);
const data = payload?.data ?? null;
const rateLimit = data?.rate_limit ?? {};
const additionalRateLimits = Array.isArray(data?.additional_rate_limits)
? data.additional_rate_limits.filter(
(item) =>
item && typeof item === 'object' && Object.keys(item).length > 0,
)
: [];
const upstreamStatus = payload?.upstream_status;
const accountType = data?.plan_type ?? rateLimit?.plan_type;
const accountTypeLabel = formatAccountTypeLabel(accountType, tt);
const accountTypeTagColor = getAccountTypeTagColor(accountType);
const statusTag = resolveUsageStatusTag(tt, rateLimit);
const userId = data?.user_id;
const email = data?.email;
const accountId = data?.account_id;
const errorMessage =
payload?.success === false
? getDisplayText(payload?.message) || tt('获取用量失败')
: '';
const rawText =
typeof data === 'string' ? data : JSON.stringify(data ?? payload, null, 2);
return (
<div className='flex flex-col gap-4'>
{errorMessage && (
<div className='rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700'>
{errorMessage}
</div>
)}
<div className='rounded-xl border border-semi-color-border bg-semi-color-bg-0 p-3'>
<div className='flex flex-wrap items-start justify-between gap-2'>
<div className='min-w-0'>
<div className='text-xs font-medium text-semi-color-text-2'>
{tt('Codex 帐号')}
</div>
<div className='mt-2 flex flex-wrap items-center gap-2'>
<Tag
color={accountTypeTagColor}
type='light'
shape='circle'
size='large'
className='font-semibold'
>
{accountTypeLabel}
</Tag>
{statusTag}
<Tag color='grey' type='light' shape='circle'>
{tt('上游状态码:')}
{upstreamStatus ?? '-'}
</Tag>
</div>
</div>
<Button
size='small'
type='tertiary'
theme='outline'
onClick={onRefresh}
>
{tt('刷新')}
</Button>
</div>
<div className='mt-2 rounded-lg bg-semi-color-fill-0 px-3 py-2'>
<Descriptions>
<Descriptions.Item itemKey='User ID'>
<AccountInfoValue
t={tt}
value={userId}
onCopy={onCopy}
monospace={true}
/>
</Descriptions.Item>
<Descriptions.Item itemKey={tt('邮箱')}>
<AccountInfoValue t={tt} value={email} onCopy={onCopy} />
</Descriptions.Item>
<Descriptions.Item itemKey='Account ID'>
<AccountInfoValue
t={tt}
value={accountId}
onCopy={onCopy}
monospace={true}
/>
</Descriptions.Item>
</Descriptions>
</div>
<div className='mt-2 text-xs text-semi-color-text-2'>
{tt('渠道:')}
{record?.name || '-'} ({tt('编号:')}
{record?.id || '-'})
</div>
</div>
<div>
<div className='mb-2'>
<div className='text-sm font-semibold text-semi-color-text-0'>
{tt('额度窗口')}
</div>
<Text type='tertiary' size='small'>
{tt(
'用于观察当前帐号在 Codex 上游的基础限额与附加计费能力使用情况',
)}
</Text>
</div>
</div>
<div className='space-y-5'>
<RateLimitGroupSection
t={tt}
title={tt('基础额度')}
description={tt('当前帐号的基础额度窗口')}
rateLimitSource={data}
statusTag={statusTag}
/>
{additionalRateLimits.length > 0 ? (
<div className='space-y-4 border-t border-semi-color-border pt-4'>
<div>
<div className='text-sm font-semibold text-semi-color-text-0'>
{tt('附加额度')}
</div>
<Text type='tertiary' size='small'>
{tt('按模型或能力拆分的附加计费能力窗口')}
</Text>
</div>
<div className='space-y-4'>
{additionalRateLimits.map((item, index) => {
const limitName =
getDisplayText(item?.limit_name) ||
getDisplayText(item?.metered_feature) ||
`${tt('附加额度')} ${index + 1}`;
return (
<div
key={`${limitName}-${getDisplayText(item?.metered_feature)}-${index}`}
className={
index > 0 ? 'border-t border-semi-color-border pt-4' : ''
}
>
<RateLimitGroupSection
t={tt}
title={limitName}
description={tt('附加计费能力')}
rateLimitSource={item}
statusTag={resolveUsageStatusTag(tt, item?.rate_limit)}
meteredFeature={item?.metered_feature}
/>
</div>
);
})}
</div>
</div>
) : null}
</div>
<Collapse
activeKey={showRawJson ? ['raw-json'] : []}
onChange={(activeKey) => {
const keys = Array.isArray(activeKey) ? activeKey : [activeKey];
setShowRawJson(keys.includes('raw-json'));
}}
>
<Collapse.Panel header={tt('原始 JSON')} itemKey='raw-json'>
<div className='mb-2 flex justify-end'>
<Button
size='small'
type='primary'
theme='outline'
onClick={() => onCopy?.(rawText)}
disabled={!rawText}
>
{tt('复制')}
</Button>
</div>
<pre className='max-h-[50vh] overflow-y-auto rounded-lg bg-semi-color-fill-0 p-3 text-xs text-semi-color-text-0'>
{rawText}
</pre>
</Collapse.Panel>
</Collapse>
</div>
);
};
const CodexUsageLoader = ({ t, record, initialPayload, onCopy }) => {
const tt = typeof t === 'function' ? t : (v) => v;
const [loading, setLoading] = useState(!initialPayload);
const [payload, setPayload] = useState(initialPayload ?? null);
const hasShownErrorRef = useRef(false);
const mountedRef = useRef(true);
const recordId = record?.id;
const fetchUsage = useCallback(async () => {
if (!recordId) {
if (mountedRef.current) setPayload(null);
return;
}
if (mountedRef.current) setLoading(true);
try {
const res = await API.get(`/api/channel/${recordId}/codex/usage`, {
skipErrorHandler: true,
});
if (!mountedRef.current) return;
setPayload(res?.data ?? null);
if (!res?.data?.success && !hasShownErrorRef.current) {
hasShownErrorRef.current = true;
showError(tt('获取用量失败'));
}
} catch (error) {
if (!mountedRef.current) return;
if (!hasShownErrorRef.current) {
hasShownErrorRef.current = true;
showError(tt('获取用量失败'));
}
setPayload({ success: false, message: String(error) });
} finally {
if (mountedRef.current) setLoading(false);
}
}, [recordId, tt]);
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
};
}, []);
useEffect(() => {
if (initialPayload) return;
fetchUsage().catch(() => {});
}, [fetchUsage, initialPayload]);
if (loading) {
return (
<div className='flex items-center justify-center py-10'>
<Spin spinning={true} size='large' tip={tt('加载中...')} />
</div>
);
}
if (!payload) {
return (
<div className='flex flex-col gap-3'>
<Text type='danger'>{tt('获取用量失败')}</Text>
<div className='flex justify-end'>
<Button
size='small'
type='primary'
theme='outline'
onClick={fetchUsage}
>
{tt('刷新')}
</Button>
</div>
</div>
);
}
return (
<CodexUsageView
t={tt}
record={record}
payload={payload}
onCopy={onCopy}
onRefresh={fetchUsage}
/>
);
};
export const openCodexUsageModal = ({ t, record, payload, onCopy }) => {
const tt = typeof t === 'function' ? t : (v) => v;
const layout = getCodexUsageModalLayout();
Modal.info({
title: tt('Codex 帐号与用量'),
centered: false,
width: layout.width,
style: layout.style,
bodyStyle: layout.bodyStyle,
content: (
<CodexUsageLoader
t={tt}
record={record}
initialPayload={payload}
onCopy={onCopy}
/>
),
footer: (
<div className='flex justify-end gap-2'>
<Button type='primary' theme='solid' onClick={() => Modal.destroyAll()}>
{tt('关闭')}
</Button>
</div>
),
});
};
@@ -0,0 +1,128 @@
/*
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 React from 'react';
import { Modal, Button, Checkbox } from '@douyinfe/semi-ui';
import { getChannelsColumns } from '../ChannelsColumnDefs';
const ColumnSelectorModal = ({
showColumnSelector,
setShowColumnSelector,
visibleColumns,
handleColumnVisibilityChange,
handleSelectAll,
initDefaultColumns,
COLUMN_KEYS,
t,
// Props needed for getChannelsColumns
updateChannelBalance,
manageChannel,
manageTag,
submitTagEdit,
testChannel,
setCurrentTestChannel,
setShowModelTestModal,
setEditingChannel,
setShowEdit,
setShowEditTag,
setEditingTag,
copySelectedChannel,
refresh,
activePage,
channels,
}) => {
// Get all columns for display in selector
const allColumns = getChannelsColumns({
t,
COLUMN_KEYS,
updateChannelBalance,
manageChannel,
manageTag,
submitTagEdit,
testChannel,
setCurrentTestChannel,
setShowModelTestModal,
setEditingChannel,
setShowEdit,
setShowEditTag,
setEditingTag,
copySelectedChannel,
refresh,
activePage,
channels,
});
return (
<Modal
title={t('列设置')}
visible={showColumnSelector}
onCancel={() => setShowColumnSelector(false)}
footer={
<div className='flex justify-end'>
<Button onClick={() => initDefaultColumns()}>{t('重置')}</Button>
<Button onClick={() => setShowColumnSelector(false)}>
{t('取消')}
</Button>
<Button onClick={() => setShowColumnSelector(false)}>
{t('确定')}
</Button>
</div>
}
>
<div style={{ marginBottom: 20 }}>
<Checkbox
checked={Object.values(visibleColumns).every((v) => v === true)}
indeterminate={
Object.values(visibleColumns).some((v) => v === true) &&
!Object.values(visibleColumns).every((v) => v === true)
}
onChange={(e) => handleSelectAll(e.target.checked)}
>
{t('全选')}
</Checkbox>
</div>
<div
className='flex flex-wrap max-h-96 overflow-y-auto rounded-lg p-4'
style={{ border: '1px solid var(--semi-color-border)' }}
>
{allColumns.map((column) => {
// Skip columns without title
if (!column.title) {
return null;
}
return (
<div key={column.key} className='w-1/2 mb-4 pr-2'>
<Checkbox
checked={!!visibleColumns[column.key]}
onChange={(e) =>
handleColumnVisibilityChange(column.key, e.target.checked)
}
>
{column.title}
</Checkbox>
</div>
);
})}
</div>
</Modal>
);
};
export default ColumnSelectorModal;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,754 @@
/*
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 React, { useState, useEffect, useRef, useMemo } from 'react';
import {
API,
showError,
showInfo,
showSuccess,
showWarning,
verifyJSON,
selectFilter,
} from '../../../../helpers';
import {
SideSheet,
Space,
Button,
Typography,
Spin,
Banner,
Card,
Tag,
Avatar,
Form,
} from '@douyinfe/semi-ui';
import {
IconSave,
IconClose,
IconBookmark,
IconUser,
IconCode,
IconSetting,
} from '@douyinfe/semi-icons';
import { getChannelModels } from '../../../../helpers';
import { useTranslation } from 'react-i18next';
const { Text, Title } = Typography;
const MODEL_MAPPING_EXAMPLE = {
'gpt-3.5-turbo': 'gpt-3.5-turbo-0125',
};
const EditTagModal = (props) => {
const { t } = useTranslation();
const { visible, tag, handleClose, refresh } = props;
const [loading, setLoading] = useState(false);
const [originModelOptions, setOriginModelOptions] = useState([]);
const [modelOptions, setModelOptions] = useState([]);
const [groupOptions, setGroupOptions] = useState([]);
const [customModel, setCustomModel] = useState('');
const [modelSearchValue, setModelSearchValue] = useState('');
const originInputs = {
tag: '',
new_tag: null,
model_mapping: null,
groups: [],
models: [],
param_override: null,
header_override: null,
};
const [inputs, setInputs] = useState(originInputs);
const modelSearchMatchedCount = useMemo(() => {
const keyword = modelSearchValue.trim();
if (!keyword) {
return modelOptions.length;
}
return modelOptions.reduce(
(count, option) => count + (selectFilter(keyword, option) ? 1 : 0),
0,
);
}, [modelOptions, modelSearchValue]);
const modelSearchHintText = useMemo(() => {
const keyword = modelSearchValue.trim();
if (!keyword || modelSearchMatchedCount !== 0) {
return '';
}
return t('未匹配到模型,按回车键可将「{{name}}」作为自定义模型名添加', {
name: keyword,
});
}, [modelSearchMatchedCount, modelSearchValue, t]);
const formApiRef = useRef(null);
const getInitValues = () => ({ ...originInputs });
const handleInputChange = (name, value) => {
setInputs((inputs) => ({ ...inputs, [name]: value }));
if (formApiRef.current) {
formApiRef.current.setValue(name, value);
}
if (name === 'type') {
let localModels = [];
switch (value) {
case 2:
localModels = [
'mj_imagine',
'mj_variation',
'mj_reroll',
'mj_blend',
'mj_upscale',
'mj_describe',
'mj_uploads',
];
break;
case 5:
localModels = [
'swap_face',
'mj_imagine',
'mj_video',
'mj_edits',
'mj_variation',
'mj_reroll',
'mj_blend',
'mj_upscale',
'mj_describe',
'mj_zoom',
'mj_shorten',
'mj_modal',
'mj_inpaint',
'mj_custom_zoom',
'mj_high_variation',
'mj_low_variation',
'mj_pan',
'mj_uploads',
];
break;
case 36:
localModels = ['suno_music', 'suno_lyrics'];
break;
case 53:
localModels = [
'NousResearch/Hermes-4-405B-FP8',
'Qwen/Qwen3-235B-A22B-Thinking-2507',
'Qwen/Qwen3-Coder-480B-A35B-Instruct-FP8',
'Qwen/Qwen3-235B-A22B-Instruct-2507',
'zai-org/GLM-4.5-FP8',
'openai/gpt-oss-120b',
'deepseek-ai/DeepSeek-R1-0528',
'deepseek-ai/DeepSeek-R1',
'deepseek-ai/DeepSeek-V3-0324',
'deepseek-ai/DeepSeek-V3.1',
];
break;
default:
localModels = getChannelModels(value);
break;
}
if (inputs.models.length === 0) {
setInputs((inputs) => ({ ...inputs, models: localModels }));
}
}
};
const fetchModels = async () => {
try {
let res = await API.get(`/api/channel/models`);
let localModelOptions = res.data.data.map((model) => ({
label: model.id,
value: model.id,
}));
setOriginModelOptions(localModelOptions);
} catch (error) {
showError(error.message);
}
};
const fetchGroups = async () => {
try {
let res = await API.get(`/api/group/`);
if (res === undefined) {
return;
}
setGroupOptions(
res.data.data.map((group) => ({
label: group,
value: group,
})),
);
} catch (error) {
showError(error.message);
}
};
const handleSave = async (values) => {
setLoading(true);
const formVals = values || formApiRef.current?.getValues() || {};
let data = { tag };
if (formVals.model_mapping) {
if (!verifyJSON(formVals.model_mapping)) {
showInfo('模型映射必须是合法的 JSON 格式!');
setLoading(false);
return;
}
data.model_mapping = formVals.model_mapping;
}
if (formVals.groups && formVals.groups.length > 0) {
data.groups = formVals.groups.join(',');
}
if (formVals.models && formVals.models.length > 0) {
data.models = formVals.models.join(',');
}
if (
formVals.param_override !== undefined &&
formVals.param_override !== null
) {
if (typeof formVals.param_override !== 'string') {
showInfo('参数覆盖必须是合法的 JSON 格式!');
setLoading(false);
return;
}
const trimmedParamOverride = formVals.param_override.trim();
if (trimmedParamOverride !== '' && !verifyJSON(trimmedParamOverride)) {
showInfo('参数覆盖必须是合法的 JSON 格式!');
setLoading(false);
return;
}
data.param_override = trimmedParamOverride;
}
if (
formVals.header_override !== undefined &&
formVals.header_override !== null
) {
if (typeof formVals.header_override !== 'string') {
showInfo('请求头覆盖必须是合法的 JSON 格式!');
setLoading(false);
return;
}
const trimmedHeaderOverride = formVals.header_override.trim();
if (trimmedHeaderOverride !== '' && !verifyJSON(trimmedHeaderOverride)) {
showInfo('请求头覆盖必须是合法的 JSON 格式!');
setLoading(false);
return;
}
data.header_override = trimmedHeaderOverride;
}
data.new_tag = formVals.new_tag;
if (
data.model_mapping === undefined &&
data.groups === undefined &&
data.models === undefined &&
data.new_tag === undefined &&
data.param_override === undefined &&
data.header_override === undefined
) {
showWarning('没有任何修改!');
setLoading(false);
return;
}
await submit(data);
setLoading(false);
};
const submit = async (data) => {
try {
const res = await API.put('/api/channel/tag', data);
if (res?.data?.success) {
showSuccess('标签更新成功!');
refresh();
handleClose();
}
} catch (error) {
showError(error);
}
};
useEffect(() => {
let localModelOptions = [...originModelOptions];
inputs.models.forEach((model) => {
if (!localModelOptions.find((option) => option.label === model)) {
localModelOptions.push({
label: model,
value: model,
});
}
});
setModelOptions(localModelOptions);
}, [originModelOptions, inputs.models]);
useEffect(() => {
const fetchTagModels = async () => {
if (!tag) return;
setLoading(true);
try {
const res = await API.get(`/api/channel/tag/models?tag=${tag}`);
if (res?.data?.success) {
const models = res.data.data ? res.data.data.split(',') : [];
handleInputChange('models', models);
} else {
showError(res.data.message);
}
} catch (error) {
showError(error.message);
} finally {
setLoading(false);
}
};
fetchModels().then();
fetchGroups().then();
fetchTagModels().then();
setModelSearchValue('');
if (formApiRef.current) {
formApiRef.current.setValues({
...getInitValues(),
tag: tag,
new_tag: tag,
});
}
setInputs({
...originInputs,
tag: tag,
new_tag: tag,
});
}, [visible, tag]);
useEffect(() => {
if (formApiRef.current) {
formApiRef.current.setValues(inputs);
}
}, [inputs]);
const addCustomModels = () => {
if (customModel.trim() === '') return;
const modelArray = customModel.split(',').map((model) => model.trim());
let localModels = [...inputs.models];
let localModelOptions = [...modelOptions];
const addedModels = [];
modelArray.forEach((model) => {
if (model && !localModels.includes(model)) {
localModels.push(model);
localModelOptions.push({
key: model,
text: model,
value: model,
});
addedModels.push(model);
}
});
setModelOptions(localModelOptions);
setCustomModel('');
handleInputChange('models', localModels);
if (addedModels.length > 0) {
showSuccess(
t('已新增 {{count}} 个模型:{{list}}', {
count: addedModels.length,
list: addedModels.join(', '),
}),
);
} else {
showInfo(t('未发现新增模型'));
}
};
return (
<SideSheet
placement='right'
title={
<Space>
<Tag color='blue' shape='circle'>
{t('编辑')}
</Tag>
<Title heading={4} className='m-0'>
{t('编辑标签')}
</Title>
</Space>
}
bodyStyle={{ padding: '0' }}
visible={visible}
width={600}
onCancel={handleClose}
footer={
<div className='flex justify-end bg-white'>
<Space>
<Button
theme='solid'
onClick={() => formApiRef.current?.submitForm()}
loading={loading}
icon={<IconSave />}
>
{t('保存')}
</Button>
<Button
theme='light'
type='primary'
onClick={handleClose}
icon={<IconClose />}
>
{t('取消')}
</Button>
</Space>
</div>
}
closeIcon={null}
>
<Form
key={tag || 'edit'}
initValues={getInitValues()}
getFormApi={(api) => (formApiRef.current = api)}
onSubmit={handleSave}
>
{() => (
<Spin spinning={loading}>
<div className='p-2'>
<Card className='!rounded-2xl shadow-sm border-0 mb-6'>
{/* Header: Tag Info */}
<div className='flex items-center mb-2'>
<Avatar size='small' color='blue' className='mr-2 shadow-md'>
<IconBookmark size={16} />
</Avatar>
<div>
<Text className='text-lg font-medium'>{t('标签信息')}</Text>
<div className='text-xs text-gray-600'>
{t('标签的基本配置')}
</div>
</div>
</div>
<Banner
type='warning'
description={t('所有编辑均为覆盖操作,留空则不更改')}
className='!rounded-lg mb-4'
/>
<div className='space-y-4'>
<Form.Input
field='new_tag'
label={t('标签名称')}
placeholder={t('请输入新标签,留空则解散标签')}
onChange={(value) => handleInputChange('new_tag', value)}
/>
</div>
</Card>
<Card className='!rounded-2xl shadow-sm border-0 mb-6'>
{/* Header: Model Config */}
<div className='flex items-center mb-2'>
<Avatar
size='small'
color='purple'
className='mr-2 shadow-md'
>
<IconCode size={16} />
</Avatar>
<div>
<Text className='text-lg font-medium'>{t('模型配置')}</Text>
<div className='text-xs text-gray-600'>
{t('模型选择和映射设置')}
</div>
</div>
</div>
<div className='space-y-4'>
<Banner
type='info'
description={t(
'当前模型列表为该标签下所有渠道模型列表最长的一个,并非所有渠道的并集,请注意可能导致某些渠道模型丢失。',
)}
className='!rounded-lg mb-4'
/>
<Form.Select
field='models'
label={t('模型')}
placeholder={t('请选择该渠道所支持的模型,留空则不更改')}
multiple
filter={selectFilter}
allowCreate
autoClearSearchValue={false}
searchPosition='dropdown'
optionList={modelOptions}
onSearch={(value) => setModelSearchValue(value)}
innerBottomSlot={
modelSearchHintText ? (
<Text className='px-3 py-2 block text-xs !text-semi-color-text-2'>
{modelSearchHintText}
</Text>
) : null
}
style={{ width: '100%' }}
onChange={(value) => handleInputChange('models', value)}
/>
<Form.Input
field='custom_model'
label={t('自定义模型名称')}
placeholder={t('输入自定义模型名称')}
onChange={(value) => setCustomModel(value.trim())}
suffix={
<Button
size='small'
type='primary'
onClick={addCustomModels}
>
{t('填入')}
</Button>
}
/>
<Form.TextArea
field='model_mapping'
label={t('模型重定向')}
placeholder={t(
'此项可选,用于修改请求体中的模型名称,为一个 JSON 字符串,键为请求中模型名称,值为要替换的模型名称,留空则不更改',
)}
autosize
onChange={(value) =>
handleInputChange('model_mapping', value)
}
extraText={
<Space>
<Text
className='!text-semi-color-primary cursor-pointer'
onClick={() =>
handleInputChange(
'model_mapping',
JSON.stringify(MODEL_MAPPING_EXAMPLE, null, 2),
)
}
>
{t('填入模板')}
</Text>
<Text
className='!text-semi-color-primary cursor-pointer'
onClick={() =>
handleInputChange(
'model_mapping',
JSON.stringify({}, null, 2),
)
}
>
{t('清空重定向')}
</Text>
<Text
className='!text-semi-color-primary cursor-pointer'
onClick={() => handleInputChange('model_mapping', '')}
>
{t('不更改')}
</Text>
</Space>
}
/>
</div>
</Card>
<Card className='!rounded-2xl shadow-sm border-0 mb-6'>
{/* Header: Advanced Settings */}
<div className='flex items-center mb-2'>
<Avatar
size='small'
color='orange'
className='mr-2 shadow-md'
>
<IconSetting size={16} />
</Avatar>
<div>
<Text className='text-lg font-medium'>{t('高级设置')}</Text>
<div className='text-xs text-gray-600'>
{t('渠道的高级配置选项')}
</div>
</div>
</div>
<div className='space-y-4'>
<Form.TextArea
field='param_override'
label={t('参数覆盖')}
placeholder={
t('此项可选,用于覆盖请求参数。不支持覆盖 stream 参数') +
'\n' +
t('旧格式(直接覆盖):') +
'\n{\n "temperature": 0,\n "max_tokens": 1000\n}' +
'\n\n' +
t('新格式(支持条件判断与json自定义):') +
'\n{\n "operations": [\n {\n "path": "temperature",\n "mode": "set",\n "value": 0.7,\n "conditions": [\n {\n "path": "model",\n "mode": "prefix",\n "value": "gpt"\n }\n ]\n }\n ]\n}'
}
autosize
showClear
onChange={(value) =>
handleInputChange('param_override', value)
}
extraText={
<div className='flex gap-2 flex-wrap'>
<Text
className='!text-semi-color-primary cursor-pointer'
onClick={() =>
handleInputChange(
'param_override',
JSON.stringify({ temperature: 0 }, null, 2),
)
}
>
{t('旧格式模板')}
</Text>
<Text
className='!text-semi-color-primary cursor-pointer'
onClick={() =>
handleInputChange(
'param_override',
JSON.stringify(
{
operations: [
{
path: 'temperature',
mode: 'set',
value: 0.7,
conditions: [
{
path: 'model',
mode: 'prefix',
value: 'gpt',
},
],
logic: 'AND',
},
],
},
null,
2,
),
)
}
>
{t('新格式模板')}
</Text>
<Text
className='!text-semi-color-primary cursor-pointer'
onClick={() =>
handleInputChange('param_override', null)
}
>
{t('不更改')}
</Text>
</div>
}
/>
<Form.TextArea
field='header_override'
label={t('请求头覆盖')}
placeholder={
t('此项可选,用于覆盖请求头参数') +
'\n' +
t('格式示例:') +
'\n{\n "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36 Edg/139.0.0.0",\n "Authorization": "Bearer {api_key}"\n}'
}
autosize
showClear
onChange={(value) =>
handleInputChange('header_override', value)
}
extraText={
<div className='flex flex-col gap-1'>
<div className='flex gap-2 flex-wrap items-center'>
<Text
className='!text-semi-color-primary cursor-pointer'
onClick={() =>
handleInputChange(
'header_override',
JSON.stringify(
{
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36 Edg/139.0.0.0',
Authorization: 'Bearer {api_key}',
},
null,
2,
),
)
}
>
{t('填入模板')}
</Text>
<Text
className='!text-semi-color-primary cursor-pointer'
onClick={() =>
handleInputChange('header_override', null)
}
>
{t('不更改')}
</Text>
</div>
<div>
<Text type='tertiary' size='small'>
{t('支持变量:')}
</Text>
<div className='text-xs text-tertiary ml-2'>
<div>
{t('渠道密钥')}: {'{api_key}'}
</div>
</div>
</div>
</div>
}
/>
</div>
</Card>
<Card className='!rounded-2xl shadow-sm border-0'>
{/* Header: Group Settings */}
<div className='flex items-center mb-2'>
<Avatar size='small' color='green' className='mr-2 shadow-md'>
<IconUser size={16} />
</Avatar>
<div>
<Text className='text-lg font-medium'>{t('分组设置')}</Text>
<div className='text-xs text-gray-600'>
{t('用户分组配置')}
</div>
</div>
</div>
<div className='space-y-4'>
<Form.Select
field='groups'
label={t('分组')}
placeholder={t('请选择可以使用该渠道的分组,留空则不更改')}
multiple
allowAdditions
additionLabel={t(
'请在系统设置页面编辑分组倍率以添加新的分组:',
)}
optionList={groupOptions}
style={{ width: '100%' }}
onChange={(value) => handleInputChange('groups', value)}
/>
</div>
</Card>
</div>
</Spin>
)}
</Form>
</SideSheet>
);
};
export default EditTagModal;
@@ -0,0 +1,468 @@
/*
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 React, { useState, useEffect, useMemo } from 'react';
import { useIsMobile } from '../../../../hooks/common/useIsMobile';
import {
Modal,
Checkbox,
Spin,
Input,
Typography,
Empty,
Tabs,
Collapse,
Tooltip,
} from '@douyinfe/semi-ui';
import {
IllustrationNoResult,
IllustrationNoResultDark,
} from '@douyinfe/semi-illustrations';
import { IconSearch, IconInfoCircle } from '@douyinfe/semi-icons';
import { useTranslation } from 'react-i18next';
import { getModelCategories } from '../../../../helpers/render';
const ModelSelectModal = ({
visible,
models = [],
selected = [],
redirectModels = [],
redirectSourceModels = [],
onConfirm,
onCancel,
}) => {
const { t } = useTranslation();
const getModelName = (model) => {
if (!model) return '';
if (typeof model === 'string') return model;
if (typeof model === 'object' && model.model_name) return model.model_name;
return String(model ?? '');
};
const normalizeModelList = (modelList = []) =>
Array.from(
new Set(
(modelList || [])
.map((model) => getModelName(model).trim())
.filter(Boolean),
),
);
const normalizedSelected = useMemo(
() => (selected || []).map(getModelName),
[selected],
);
const [checkedList, setCheckedList] = useState(normalizedSelected);
const [keyword, setKeyword] = useState('');
const [activeTab, setActiveTab] = useState('new');
const isMobile = useIsMobile();
const normalizeModelName = (model) =>
typeof model === 'string' ? model.trim() : '';
const normalizedRedirectModels = useMemo(
() =>
Array.from(
new Set(
(redirectModels || [])
.map((model) => normalizeModelName(model))
.filter(Boolean),
),
),
[redirectModels],
);
const normalizedRedirectSourceSet = useMemo(
() => new Set(normalizeModelList(redirectSourceModels)),
[redirectSourceModels],
);
const normalizedSelectedSet = useMemo(() => {
const set = new Set();
(selected || []).forEach((model) => {
const normalized = normalizeModelName(model);
if (normalized) {
set.add(normalized);
}
});
return set;
}, [selected]);
const classificationSet = useMemo(() => {
const set = new Set(normalizedSelectedSet);
normalizedRedirectModels.forEach((model) => set.add(model));
return set;
}, [normalizedSelectedSet, normalizedRedirectModels]);
const redirectOnlySet = useMemo(() => {
const set = new Set();
normalizedRedirectModels.forEach((model) => {
if (!normalizedSelectedSet.has(model)) {
set.add(model);
}
});
return set;
}, [normalizedRedirectModels, normalizedSelectedSet]);
const filteredModels = models.filter((m) =>
String(m || '')
.toLowerCase()
.includes(keyword.toLowerCase()),
);
// 分类模型:新获取的模型和已有模型
const isExistingModel = (model) =>
classificationSet.has(normalizeModelName(model));
const newModels = filteredModels.filter((model) => !isExistingModel(model));
const existingModels = filteredModels.filter((model) =>
isExistingModel(model),
);
const fetchedModelSet = useMemo(
() => new Set(normalizeModelList(models)),
[models],
);
const removedModels = normalizeModelList(selected).filter(
(model) =>
!fetchedModelSet.has(model) &&
!normalizedRedirectSourceSet.has(model) &&
model.toLowerCase().includes(keyword.toLowerCase()),
);
// 同步外部选中值
useEffect(() => {
if (visible) {
setCheckedList(normalizedSelected);
}
}, [visible, normalizedSelected]);
// 当模型列表变化时,设置默认tab
useEffect(() => {
if (visible) {
if (newModels.length > 0) {
setActiveTab('new');
} else if (removedModels.length > 0) {
setActiveTab('removed');
} else {
setActiveTab('existing');
}
}
}, [visible, newModels.length, removedModels.length, selected]);
const handleOk = () => {
onConfirm && onConfirm(checkedList);
};
// 按厂商分类模型
const categorizeModels = (models) => {
const categories = getModelCategories(t);
const categorizedModels = {};
const uncategorizedModels = [];
models.forEach((model) => {
let foundCategory = false;
for (const [key, category] of Object.entries(categories)) {
if (key !== 'all' && category.filter({ model_name: model })) {
if (!categorizedModels[key]) {
categorizedModels[key] = {
label: category.label,
icon: category.icon,
models: [],
};
}
categorizedModels[key].models.push(model);
foundCategory = true;
break;
}
}
if (!foundCategory) {
uncategorizedModels.push(model);
}
});
// 如果有未分类模型,添加到"其他"分类
if (uncategorizedModels.length > 0) {
categorizedModels['other'] = {
label: t('其他'),
icon: null,
models: uncategorizedModels,
};
}
return categorizedModels;
};
const newModelsByCategory = categorizeModels(newModels);
const existingModelsByCategory = categorizeModels(existingModels);
// Tab列表配置
const tabList = [
...(newModels.length > 0
? [
{
tab: `${t('新获取的模型')} (${newModels.length})`,
itemKey: 'new',
},
]
: []),
...(existingModels.length > 0
? [
{
tab: `${t('已有的模型')} (${existingModels.length})`,
itemKey: 'existing',
},
]
: []),
...(removedModels.length > 0
? [
{
tab: `${t('上游已删除的模型')} (${removedModels.length})`,
itemKey: 'removed',
},
]
: []),
];
// 处理分类全选/取消全选
const handleCategorySelectAll = (categoryModels, isChecked) => {
let newCheckedList = [...checkedList];
if (isChecked) {
// 全选:添加该分类下所有未选中的模型
categoryModels.forEach((model) => {
if (!newCheckedList.includes(model)) {
newCheckedList.push(model);
}
});
} else {
// 取消全选:移除该分类下所有已选中的模型
newCheckedList = newCheckedList.filter(
(model) => !categoryModels.includes(model),
);
}
setCheckedList(newCheckedList);
};
// 检查分类是否全选
const isCategoryAllSelected = (categoryModels) => {
return (
categoryModels.length > 0 &&
categoryModels.every((model) => checkedList.includes(model))
);
};
// 检查分类是否部分选中
const isCategoryIndeterminate = (categoryModels) => {
const selectedCount = categoryModels.filter((model) =>
checkedList.includes(model),
).length;
return selectedCount > 0 && selectedCount < categoryModels.length;
};
const renderModelsByCategory = (modelsByCategory, categoryKeyPrefix) => {
const categoryEntries = Object.entries(modelsByCategory);
if (categoryEntries.length === 0) return null;
// 生成所有面板的key,确保都展开
const allActiveKeys = categoryEntries.map(
(_, index) => `${categoryKeyPrefix}_${index}`,
);
return (
<Collapse
key={`${categoryKeyPrefix}_${categoryEntries.length}`}
defaultActiveKey={[]}
>
{categoryEntries.map(([key, categoryData], index) => (
<Collapse.Panel
key={`${categoryKeyPrefix}_${index}`}
itemKey={`${categoryKeyPrefix}_${index}`}
header={`${categoryData.label} (${categoryData.models.length})`}
extra={
<Checkbox
checked={isCategoryAllSelected(categoryData.models)}
indeterminate={isCategoryIndeterminate(categoryData.models)}
onChange={(e) => {
e.stopPropagation(); // 防止触发面板折叠
handleCategorySelectAll(
categoryData.models,
e.target.checked,
);
}}
onClick={(e) => e.stopPropagation()} // 防止点击checkbox时折叠面板
/>
}
>
<div className='flex items-center gap-2 mb-3'>
{categoryData.icon}
<Typography.Text type='secondary' size='small'>
{t('已选择 {{selected}} / {{total}}', {
selected: categoryData.models.filter((model) =>
checkedList.includes(model),
).length,
total: categoryData.models.length,
})}
</Typography.Text>
</div>
<div className='grid grid-cols-2 gap-x-4'>
{categoryData.models.map((model) => (
<Checkbox key={model} value={model} className='my-1'>
<span className='flex items-center gap-2'>
<span>{model}</span>
{redirectOnlySet.has(normalizeModelName(model)) && (
<Tooltip
position='top'
content={t('来自模型重定向,尚未加入模型列表')}
>
<IconInfoCircle
size='small'
className='text-amber-500 cursor-help'
/>
</Tooltip>
)}
</span>
</Checkbox>
))}
</div>
</Collapse.Panel>
))}
</Collapse>
);
};
return (
<Modal
header={
<div className='flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 sm:gap-4 py-4'>
<Typography.Title heading={5} className='m-0'>
{t('选择模型')}
</Typography.Title>
<div className='flex-shrink-0'>
<Tabs
type='slash'
size='small'
tabList={tabList}
activeKey={activeTab}
onChange={(key) => setActiveTab(key)}
/>
</div>
</div>
}
visible={visible}
onOk={handleOk}
onCancel={onCancel}
okText={t('确定')}
cancelText={t('取消')}
size={isMobile ? 'full-width' : 'large'}
closeOnEsc
maskClosable
centered
>
<Input
prefix={<IconSearch size={14} />}
placeholder={t('搜索模型')}
value={keyword}
onChange={(v) => setKeyword(v)}
showClear
/>
<Spin
spinning={!models || (models.length === 0 && removedModels.length === 0)}
>
<div style={{ maxHeight: 400, overflowY: 'auto', paddingRight: 8 }}>
{filteredModels.length === 0 && removedModels.length === 0 ? (
<Empty
image={
<IllustrationNoResult style={{ width: 150, height: 150 }} />
}
darkModeImage={
<IllustrationNoResultDark style={{ width: 150, height: 150 }} />
}
description={t('暂无匹配模型')}
style={{ padding: 30 }}
/>
) : (
<Checkbox.Group
value={checkedList}
onChange={(vals) => setCheckedList(vals)}
>
{activeTab === 'new' && newModels.length > 0 && (
<div>{renderModelsByCategory(newModelsByCategory, 'new')}</div>
)}
{activeTab === 'existing' && existingModels.length > 0 && (
<div>
{renderModelsByCategory(existingModelsByCategory, 'existing')}
</div>
)}
{activeTab === 'removed' && removedModels.length > 0 && (
<div>
{renderModelsByCategory(
categorizeModels(removedModels),
'removed',
)}
</div>
)}
</Checkbox.Group>
)}
</div>
</Spin>
<Typography.Text
type='secondary'
size='small'
className='block text-right mt-4'
>
<div className='flex items-center justify-end gap-2'>
{(() => {
const currentModels =
activeTab === 'new'
? newModels
: activeTab === 'removed'
? removedModels
: existingModels;
const currentSelected = currentModels.filter((model) =>
checkedList.includes(model),
).length;
const isAllSelected =
currentModels.length > 0 &&
currentSelected === currentModels.length;
const isIndeterminate =
currentSelected > 0 && currentSelected < currentModels.length;
return (
<>
<span>
{t('已选择 {{selected}} / {{total}}', {
selected: currentSelected,
total: currentModels.length,
})}
</span>
<Checkbox
checked={isAllSelected}
indeterminate={isIndeterminate}
onChange={(e) => {
handleCategorySelectAll(currentModels, e.target.checked);
}}
/>
</>
);
})()}
</div>
</Typography.Text>
</Modal>
);
};
export default ModelSelectModal;
@@ -0,0 +1,396 @@
/*
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 React from 'react';
import {
Modal,
Button,
Input,
Table,
Tag,
Typography,
Select,
Switch,
Banner,
} from '@douyinfe/semi-ui';
import { IconSearch, IconInfoCircle } from '@douyinfe/semi-icons';
import { Settings } from 'lucide-react';
import { copy, showError, showInfo, showSuccess } from '../../../../helpers';
import { MODEL_TABLE_PAGE_SIZE } from '../../../../constants';
const ModelTestModal = ({
showModelTestModal,
currentTestChannel,
handleCloseModal,
isBatchTesting,
batchTestModels,
modelSearchKeyword,
setModelSearchKeyword,
selectedModelKeys,
setSelectedModelKeys,
modelTestResults,
testingModels,
testChannel,
modelTablePage,
setModelTablePage,
selectedEndpointType,
setSelectedEndpointType,
isStreamTest,
setIsStreamTest,
allSelectingRef,
isMobile,
t,
}) => {
const hasChannel = Boolean(currentTestChannel);
const streamToggleDisabled = [
'embeddings',
'image-generation',
'jina-rerank',
'openai-response-compact',
].includes(selectedEndpointType);
React.useEffect(() => {
if (streamToggleDisabled && isStreamTest) {
setIsStreamTest(false);
}
}, [streamToggleDisabled, isStreamTest, setIsStreamTest]);
const filteredModels = hasChannel
? currentTestChannel.models
.split(',')
.filter((model) =>
model.toLowerCase().includes(modelSearchKeyword.toLowerCase()),
)
: [];
const endpointTypeOptions = [
{ value: '', label: t('自动检测') },
{ value: 'openai', label: 'OpenAI (/v1/chat/completions)' },
{ value: 'openai-response', label: 'OpenAI Response (/v1/responses)' },
{
value: 'openai-response-compact',
label: 'OpenAI Response Compaction (/v1/responses/compact)',
},
{ value: 'anthropic', label: 'Anthropic (/v1/messages)' },
{
value: 'gemini',
label: 'Gemini (/v1beta/models/{model}:generateContent)',
},
{ value: 'jina-rerank', label: 'Jina Rerank (/v1/rerank)' },
{
value: 'image-generation',
label: t('图像生成') + ' (/v1/images/generations)',
},
{ value: 'embeddings', label: 'Embeddings (/v1/embeddings)' },
];
const handleCopySelected = () => {
if (selectedModelKeys.length === 0) {
showError(t('请先选择模型!'));
return;
}
copy(selectedModelKeys.join(',')).then((ok) => {
if (ok) {
showSuccess(
t('已复制 ${count} 个模型').replace(
'${count}',
selectedModelKeys.length,
),
);
} else {
showError(t('复制失败,请手动复制'));
}
});
};
const handleSelectSuccess = () => {
if (!currentTestChannel) return;
const successKeys = currentTestChannel.models
.split(',')
.filter((m) => m.toLowerCase().includes(modelSearchKeyword.toLowerCase()))
.filter((m) => {
const result = modelTestResults[`${currentTestChannel.id}-${m}`];
return result && result.success;
});
if (successKeys.length === 0) {
showInfo(t('暂无成功模型'));
}
setSelectedModelKeys(successKeys);
};
const columns = [
{
title: t('模型名称'),
dataIndex: 'model',
render: (text) => (
<div className='flex items-center'>
<Typography.Text strong>{text}</Typography.Text>
</div>
),
},
{
title: t('状态'),
dataIndex: 'status',
render: (text, record) => {
const testResult =
modelTestResults[`${currentTestChannel.id}-${record.model}`];
const isTesting = testingModels.has(record.model);
if (isTesting) {
return (
<Tag color='blue' shape='circle'>
{t('测试中')}
</Tag>
);
}
if (!testResult) {
return (
<Tag color='grey' shape='circle'>
{t('未开始')}
</Tag>
);
}
return (
<div className='flex flex-col gap-1'>
<div className='flex items-center gap-2'>
<Tag color={testResult.success ? 'green' : 'red'} shape='circle'>
{testResult.success ? t('成功') : t('失败')}
</Tag>
{testResult.success && (
<Typography.Text type='tertiary'>
{t('请求时长: ${time}s').replace(
'${time}',
testResult.time.toFixed(2),
)}
</Typography.Text>
)}
</div>
{!testResult.success && testResult.message && (
<div className='flex flex-col gap-1'>
<Typography.Text
type='danger'
size='small'
className='break-all'
style={{ maxWidth: '400px', fontSize: '12px' }}
>
{testResult.message}
</Typography.Text>
{testResult.errorCode === 'model_price_error' && (
<Button
size='small'
theme='light'
type='warning'
icon={<Settings size={12} />}
onClick={() => window.open('/console/setting?tab=ratio', '_blank')}
style={{ width: 'fit-content' }}
>
{t('前往设置')}
</Button>
)}
</div>
)}
</div>
);
},
},
{
title: '',
dataIndex: 'operate',
render: (text, record) => {
const isTesting = testingModels.has(record.model);
return (
<Button
type='tertiary'
onClick={() =>
testChannel(
currentTestChannel,
record.model,
selectedEndpointType,
isStreamTest,
)
}
loading={isTesting}
size='small'
>
{t('测试')}
</Button>
);
},
},
];
const dataSource = (() => {
if (!hasChannel) return [];
const start = (modelTablePage - 1) * MODEL_TABLE_PAGE_SIZE;
const end = start + MODEL_TABLE_PAGE_SIZE;
return filteredModels.slice(start, end).map((model) => ({
model,
key: model,
}));
})();
return (
<Modal
title={
hasChannel ? (
<div className='flex flex-col gap-2 w-full'>
<div className='flex items-center gap-2'>
<Typography.Text
strong
className='!text-[var(--semi-color-text-0)] !text-base'
>
{currentTestChannel.name} {t('渠道的模型测试')}
</Typography.Text>
<Typography.Text type='tertiary' size='small'>
{t('共')} {currentTestChannel.models.split(',').length}{' '}
{t('个模型')}
</Typography.Text>
</div>
</div>
) : null
}
visible={showModelTestModal}
onCancel={handleCloseModal}
footer={
hasChannel ? (
<div className='flex justify-end'>
{isBatchTesting ? (
<Button type='danger' onClick={handleCloseModal}>
{t('停止测试')}
</Button>
) : (
<Button type='tertiary' onClick={handleCloseModal}>
{t('取消')}
</Button>
)}
<Button
onClick={batchTestModels}
loading={isBatchTesting}
disabled={isBatchTesting}
>
{isBatchTesting
? t('测试中...')
: t('批量测试${count}个模型').replace(
'${count}',
filteredModels.length,
)}
</Button>
</div>
) : null
}
maskClosable={!isBatchTesting}
className='!rounded-lg'
size={isMobile ? 'full-width' : 'large'}
>
{hasChannel && (
<div className='model-test-scroll'>
{/* Endpoint toolbar */}
<div className='flex flex-col sm:flex-row sm:items-center gap-2 w-full mb-2'>
<div className='flex items-center gap-2 flex-1 min-w-0'>
<Typography.Text strong className='shrink-0'>
{t('端点类型')}:
</Typography.Text>
<Select
value={selectedEndpointType}
onChange={setSelectedEndpointType}
optionList={endpointTypeOptions}
className='!w-full min-w-0'
placeholder={t('选择端点类型')}
/>
</div>
<div className='flex items-center justify-between sm:justify-end gap-2 shrink-0'>
<Typography.Text strong className='shrink-0'>
{t('流式')}:
</Typography.Text>
<Switch
checked={isStreamTest}
onChange={setIsStreamTest}
size='small'
disabled={streamToggleDisabled}
aria-label={t('流式')}
/>
</div>
</div>
<Banner
type='info'
closeIcon={null}
icon={<IconInfoCircle />}
className='!rounded-lg mb-2'
description={t(
'说明:本页测试为非流式请求;若渠道仅支持流式返回,可能出现测试失败,请以实际使用为准。',
)}
/>
{/* 搜索与操作按钮 */}
<div className='flex flex-col sm:flex-row sm:items-center gap-2 w-full mb-2'>
<Input
placeholder={t('搜索模型...')}
value={modelSearchKeyword}
onChange={(v) => {
setModelSearchKeyword(v);
setModelTablePage(1);
}}
className='!w-full sm:!flex-1'
prefix={<IconSearch />}
showClear
/>
<div className='flex items-center justify-end gap-2'>
<Button onClick={handleCopySelected}>{t('复制已选')}</Button>
<Button type='tertiary' onClick={handleSelectSuccess}>
{t('选择成功')}
</Button>
</div>
</div>
<Table
columns={columns}
dataSource={dataSource}
rowSelection={{
selectedRowKeys: selectedModelKeys,
onChange: (keys) => {
if (allSelectingRef.current) {
allSelectingRef.current = false;
return;
}
setSelectedModelKeys(keys);
},
onSelectAll: (checked) => {
allSelectingRef.current = true;
setSelectedModelKeys(checked ? filteredModels : []);
},
}}
pagination={{
currentPage: modelTablePage,
pageSize: MODEL_TABLE_PAGE_SIZE,
total: filteredModels.length,
showSizeChanger: false,
onPageChange: (page) => setModelTablePage(page),
}}
/>
</div>
)}
</Modal>
);
};
export default ModelTestModal;
@@ -0,0 +1,742 @@
/*
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 React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import {
Modal,
Button,
Table,
Tag,
Typography,
Space,
Tooltip,
Popconfirm,
Empty,
Spin,
Select,
Row,
Col,
Badge,
Progress,
Card,
} from '@douyinfe/semi-ui';
import {
IllustrationNoResult,
IllustrationNoResultDark,
} from '@douyinfe/semi-illustrations';
import {
API,
showError,
showSuccess,
timestamp2string,
} from '../../../../helpers';
const { Text } = Typography;
const MultiKeyManageModal = ({ visible, onCancel, channel, onRefresh }) => {
const { t } = useTranslation();
const [loading, setLoading] = useState(false);
const [keyStatusList, setKeyStatusList] = useState([]);
const [operationLoading, setOperationLoading] = useState({});
// Pagination states
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [total, setTotal] = useState(0);
const [totalPages, setTotalPages] = useState(0);
// Statistics states
const [enabledCount, setEnabledCount] = useState(0);
const [manualDisabledCount, setManualDisabledCount] = useState(0);
const [autoDisabledCount, setAutoDisabledCount] = useState(0);
// Filter states
const [statusFilter, setStatusFilter] = useState(null); // null=all, 1=enabled, 2=manual_disabled, 3=auto_disabled
// Load key status data
const loadKeyStatus = async (
page = currentPage,
size = pageSize,
status = statusFilter,
) => {
if (!channel?.id) return;
setLoading(true);
try {
const requestData = {
channel_id: channel.id,
action: 'get_key_status',
page: page,
page_size: size,
};
// Add status filter if specified
if (status !== null) {
requestData.status = status;
}
const res = await API.post('/api/channel/multi_key/manage', requestData);
if (res.data.success) {
const data = res.data.data;
setKeyStatusList(data.keys || []);
setTotal(data.total || 0);
setCurrentPage(data.page || 1);
setPageSize(data.page_size || 10);
setTotalPages(data.total_pages || 0);
// Update statistics (these are always the overall statistics)
setEnabledCount(data.enabled_count || 0);
setManualDisabledCount(data.manual_disabled_count || 0);
setAutoDisabledCount(data.auto_disabled_count || 0);
} else {
showError(res.data.message);
}
} catch (error) {
console.error(error);
showError(t('获取密钥状态失败'));
} finally {
setLoading(false);
}
};
// Disable a specific key
const handleDisableKey = async (keyIndex) => {
const operationId = `disable_${keyIndex}`;
setOperationLoading((prev) => ({ ...prev, [operationId]: true }));
try {
const res = await API.post('/api/channel/multi_key/manage', {
channel_id: channel.id,
action: 'disable_key',
key_index: keyIndex,
});
if (res.data.success) {
showSuccess(t('密钥已禁用'));
await loadKeyStatus(currentPage, pageSize); // Reload current page
onRefresh && onRefresh(); // Refresh parent component
} else {
showError(res.data.message);
}
} catch (error) {
showError(t('禁用密钥失败'));
} finally {
setOperationLoading((prev) => ({ ...prev, [operationId]: false }));
}
};
// Enable a specific key
const handleEnableKey = async (keyIndex) => {
const operationId = `enable_${keyIndex}`;
setOperationLoading((prev) => ({ ...prev, [operationId]: true }));
try {
const res = await API.post('/api/channel/multi_key/manage', {
channel_id: channel.id,
action: 'enable_key',
key_index: keyIndex,
});
if (res.data.success) {
showSuccess(t('密钥已启用'));
await loadKeyStatus(currentPage, pageSize); // Reload current page
onRefresh && onRefresh(); // Refresh parent component
} else {
showError(res.data.message);
}
} catch (error) {
showError(t('启用密钥失败'));
} finally {
setOperationLoading((prev) => ({ ...prev, [operationId]: false }));
}
};
// Enable all disabled keys
const handleEnableAll = async () => {
setOperationLoading((prev) => ({ ...prev, enable_all: true }));
try {
const res = await API.post('/api/channel/multi_key/manage', {
channel_id: channel.id,
action: 'enable_all_keys',
});
if (res.data.success) {
showSuccess(res.data.message || t('已启用所有密钥'));
// Reset to first page after bulk operation
setCurrentPage(1);
await loadKeyStatus(1, pageSize);
onRefresh && onRefresh(); // Refresh parent component
} else {
showError(res.data.message);
}
} catch (error) {
showError(t('启用所有密钥失败'));
} finally {
setOperationLoading((prev) => ({ ...prev, enable_all: false }));
}
};
// Disable all enabled keys
const handleDisableAll = async () => {
setOperationLoading((prev) => ({ ...prev, disable_all: true }));
try {
const res = await API.post('/api/channel/multi_key/manage', {
channel_id: channel.id,
action: 'disable_all_keys',
});
if (res.data.success) {
showSuccess(res.data.message || t('已禁用所有密钥'));
// Reset to first page after bulk operation
setCurrentPage(1);
await loadKeyStatus(1, pageSize);
onRefresh && onRefresh(); // Refresh parent component
} else {
showError(res.data.message);
}
} catch (error) {
showError(t('禁用所有密钥失败'));
} finally {
setOperationLoading((prev) => ({ ...prev, disable_all: false }));
}
};
// Delete all disabled keys
const handleDeleteDisabledKeys = async () => {
setOperationLoading((prev) => ({ ...prev, delete_disabled: true }));
try {
const res = await API.post('/api/channel/multi_key/manage', {
channel_id: channel.id,
action: 'delete_disabled_keys',
});
if (res.data.success) {
showSuccess(res.data.message);
// Reset to first page after deletion as data structure might change
setCurrentPage(1);
await loadKeyStatus(1, pageSize);
onRefresh && onRefresh(); // Refresh parent component
} else {
showError(res.data.message);
}
} catch (error) {
showError(t('删除禁用密钥失败'));
} finally {
setOperationLoading((prev) => ({ ...prev, delete_disabled: false }));
}
};
// Delete a specific key
const handleDeleteKey = async (keyIndex) => {
const operationId = `delete_${keyIndex}`;
setOperationLoading((prev) => ({ ...prev, [operationId]: true }));
try {
const res = await API.post('/api/channel/multi_key/manage', {
channel_id: channel.id,
action: 'delete_key',
key_index: keyIndex,
});
if (res.data.success) {
showSuccess(t('密钥已删除'));
await loadKeyStatus(currentPage, pageSize); // Reload current page
onRefresh && onRefresh(); // Refresh parent component
} else {
showError(res.data.message);
}
} catch (error) {
showError(t('删除密钥失败'));
} finally {
setOperationLoading((prev) => ({ ...prev, [operationId]: false }));
}
};
// Handle page change
const handlePageChange = (page) => {
setCurrentPage(page);
loadKeyStatus(page, pageSize);
};
// Handle page size change
const handlePageSizeChange = (size) => {
setPageSize(size);
setCurrentPage(1); // Reset to first page
loadKeyStatus(1, size);
};
// Handle status filter change
const handleStatusFilterChange = (status) => {
setStatusFilter(status);
setCurrentPage(1); // Reset to first page when filter changes
loadKeyStatus(1, pageSize, status);
};
// Effect to load data when modal opens
useEffect(() => {
if (visible && channel?.id) {
setCurrentPage(1); // Reset to first page when opening
loadKeyStatus(1, pageSize);
}
}, [visible, channel?.id]);
// Reset pagination when modal closes
useEffect(() => {
if (!visible) {
setCurrentPage(1);
setKeyStatusList([]);
setTotal(0);
setTotalPages(0);
setEnabledCount(0);
setManualDisabledCount(0);
setAutoDisabledCount(0);
setStatusFilter(null); // Reset filter
}
}, [visible]);
// Percentages for progress display
const enabledPercent =
total > 0 ? Math.round((enabledCount / total) * 100) : 0;
const manualDisabledPercent =
total > 0 ? Math.round((manualDisabledCount / total) * 100) : 0;
const autoDisabledPercent =
total > 0 ? Math.round((autoDisabledCount / total) * 100) : 0;
// 取消饼图:不再需要图表数据与配置
// Get status tag component
const renderStatusTag = (status) => {
switch (status) {
case 1:
return (
<Tag color='green' shape='circle' size='small'>
{t('已启用')}
</Tag>
);
case 2:
return (
<Tag color='red' shape='circle' size='small'>
{t('已禁用')}
</Tag>
);
case 3:
return (
<Tag color='orange' shape='circle' size='small'>
{t('自动禁用')}
</Tag>
);
default:
return (
<Tag color='grey' shape='circle' size='small'>
{t('未知状态')}
</Tag>
);
}
};
// Table columns definition
const columns = [
{
title: t('索引'),
dataIndex: 'index',
render: (text) => `#${Number(text) + 1}`,
},
// {
// title: t('密钥预览'),
// dataIndex: 'key_preview',
// render: (text) => (
// <Text code style={{ fontSize: '12px' }}>
// {text}
// </Text>
// ),
// },
{
title: t('状态'),
dataIndex: 'status',
render: (status) => renderStatusTag(status),
},
{
title: t('禁用原因'),
dataIndex: 'reason',
render: (reason, record) => {
if (record.status === 1 || !reason) {
return <Text type='quaternary'>-</Text>;
}
return (
<Tooltip content={reason}>
<Text style={{ maxWidth: '200px', display: 'block' }} ellipsis>
{reason}
</Text>
</Tooltip>
);
},
},
{
title: t('禁用时间'),
dataIndex: 'disabled_time',
render: (time, record) => {
if (record.status === 1 || !time) {
return <Text type='quaternary'>-</Text>;
}
return (
<Tooltip content={timestamp2string(time)}>
<Text style={{ fontSize: '12px' }}>{timestamp2string(time)}</Text>
</Tooltip>
);
},
},
{
title: t('操作'),
key: 'action',
fixed: 'right',
width: 150,
render: (_, record) => (
<Space>
{record.status === 1 ? (
<Button
type='danger'
size='small'
loading={operationLoading[`disable_${record.index}`]}
onClick={() => handleDisableKey(record.index)}
>
{t('禁用')}
</Button>
) : (
<Button
type='primary'
size='small'
loading={operationLoading[`enable_${record.index}`]}
onClick={() => handleEnableKey(record.index)}
>
{t('启用')}
</Button>
)}
<Popconfirm
title={t('确定要删除此密钥吗?')}
content={t('此操作不可撤销,将永久删除该密钥')}
onConfirm={() => handleDeleteKey(record.index)}
okType={'danger'}
position={'topRight'}
>
<Button
type='danger'
size='small'
loading={operationLoading[`delete_${record.index}`]}
>
{t('删除')}
</Button>
</Popconfirm>
</Space>
),
},
];
return (
<Modal
title={
<Space>
<Text>{t('多密钥管理')}</Text>
{channel?.name && (
<Tag size='small' shape='circle' color='white'>
{channel.name}
</Tag>
)}
<Tag size='small' shape='circle' color='white'>
{t('总密钥数')}: {total}
</Tag>
{channel?.channel_info?.multi_key_mode && (
<Tag size='small' shape='circle' color='white'>
{channel.channel_info.multi_key_mode === 'random'
? t('随机模式')
: t('轮询模式')}
</Tag>
)}
</Space>
}
visible={visible}
onCancel={onCancel}
width={900}
footer={null}
>
<div className='flex flex-col mb-5'>
{/* Stats & Mode */}
<div
className='rounded-xl p-4 mb-3'
style={{
background: 'var(--semi-color-bg-1)',
border: '1px solid var(--semi-color-border)',
}}
>
<Row gutter={16} align='middle'>
<Col span={8}>
<div
style={{
background: 'var(--semi-color-bg-0)',
border: '1px solid var(--semi-color-border)',
borderRadius: 12,
padding: 12,
}}
>
<div className='flex items-center gap-2 mb-2'>
<Badge dot type='success' />
<Text type='tertiary'>{t('已启用')}</Text>
</div>
<div className='flex items-end gap-2 mb-2'>
<Text
style={{ fontSize: 18, fontWeight: 700, color: '#22c55e' }}
>
{enabledCount}
</Text>
<Text
style={{ fontSize: 18, color: 'var(--semi-color-text-2)' }}
>
/ {total}
</Text>
</div>
<Progress
percent={enabledPercent}
showInfo={false}
size='small'
stroke='#22c55e'
style={{ height: 6, borderRadius: 999 }}
/>
</div>
</Col>
<Col span={8}>
<div
style={{
background: 'var(--semi-color-bg-0)',
border: '1px solid var(--semi-color-border)',
borderRadius: 12,
padding: 12,
}}
>
<div className='flex items-center gap-2 mb-2'>
<Badge dot type='danger' />
<Text type='tertiary'>{t('手动禁用')}</Text>
</div>
<div className='flex items-end gap-2 mb-2'>
<Text
style={{ fontSize: 18, fontWeight: 700, color: '#ef4444' }}
>
{manualDisabledCount}
</Text>
<Text
style={{ fontSize: 18, color: 'var(--semi-color-text-2)' }}
>
/ {total}
</Text>
</div>
<Progress
percent={manualDisabledPercent}
showInfo={false}
size='small'
stroke='#ef4444'
style={{ height: 6, borderRadius: 999 }}
/>
</div>
</Col>
<Col span={8}>
<div
style={{
background: 'var(--semi-color-bg-0)',
border: '1px solid var(--semi-color-border)',
borderRadius: 12,
padding: 12,
}}
>
<div className='flex items-center gap-2 mb-2'>
<Badge dot type='warning' />
<Text type='tertiary'>{t('自动禁用')}</Text>
</div>
<div className='flex items-end gap-2 mb-2'>
<Text
style={{ fontSize: 18, fontWeight: 700, color: '#f59e0b' }}
>
{autoDisabledCount}
</Text>
<Text
style={{ fontSize: 18, color: 'var(--semi-color-text-2)' }}
>
/ {total}
</Text>
</div>
<Progress
percent={autoDisabledPercent}
showInfo={false}
size='small'
stroke='#f59e0b'
style={{ height: 6, borderRadius: 999 }}
/>
</div>
</Col>
</Row>
</div>
{/* Table */}
<div className='flex-1 flex flex-col min-h-0'>
<Spin spinning={loading}>
<Card className='!rounded-xl'>
<Table
title={() => (
<Row gutter={12} style={{ width: '100%' }}>
<Col span={14}>
<Row gutter={12} style={{ alignItems: 'center' }}>
<Col>
<Select
value={statusFilter}
onChange={handleStatusFilterChange}
size='small'
placeholder={t('全部状态')}
>
<Select.Option value={null}>
{t('全部状态')}
</Select.Option>
<Select.Option value={1}>
{t('已启用')}
</Select.Option>
<Select.Option value={2}>
{t('手动禁用')}
</Select.Option>
<Select.Option value={3}>
{t('自动禁用')}
</Select.Option>
</Select>
</Col>
</Row>
</Col>
<Col
span={10}
style={{ display: 'flex', justifyContent: 'flex-end' }}
>
<Space>
<Button
size='small'
type='tertiary'
onClick={() => loadKeyStatus(currentPage, pageSize)}
loading={loading}
>
{t('刷新')}
</Button>
{manualDisabledCount + autoDisabledCount > 0 && (
<Popconfirm
title={t('确定要启用所有密钥吗?')}
onConfirm={handleEnableAll}
position={'topRight'}
>
<Button
size='small'
type='primary'
loading={operationLoading.enable_all}
>
{t('启用全部')}
</Button>
</Popconfirm>
)}
{enabledCount > 0 && (
<Popconfirm
title={t('确定要禁用所有的密钥吗?')}
onConfirm={handleDisableAll}
okType={'danger'}
position={'topRight'}
>
<Button
size='small'
type='danger'
loading={operationLoading.disable_all}
>
{t('禁用全部')}
</Button>
</Popconfirm>
)}
<Popconfirm
title={t('确定要删除所有已自动禁用的密钥吗?')}
content={t(
'此操作不可撤销,将永久删除已自动禁用的密钥',
)}
onConfirm={handleDeleteDisabledKeys}
okType={'danger'}
position={'topRight'}
>
<Button
size='small'
type='warning'
loading={operationLoading.delete_disabled}
>
{t('删除自动禁用密钥')}
</Button>
</Popconfirm>
</Space>
</Col>
</Row>
)}
columns={columns}
dataSource={keyStatusList}
pagination={{
currentPage: currentPage,
pageSize: pageSize,
total: total,
showSizeChanger: true,
showQuickJumper: true,
pageSizeOpts: [10, 20, 50, 100],
onChange: (page, size) => {
setCurrentPage(page);
loadKeyStatus(page, size);
},
onShowSizeChange: (current, size) => {
setCurrentPage(1);
handlePageSizeChange(size);
},
}}
size='small'
bordered={false}
rowKey='index'
scroll={{ x: 'max-content' }}
empty={
<Empty
image={
<IllustrationNoResult
style={{ width: 140, height: 140 }}
/>
}
darkModeImage={
<IllustrationNoResultDark
style={{ width: 140, height: 140 }}
/>
}
title={t('暂无密钥数据')}
description={t('请检查渠道配置或刷新重试')}
style={{ padding: 30 }}
/>
}
/>
</Card>
</Spin>
</div>
</div>
</Modal>
);
};
export default MultiKeyManageModal;
@@ -0,0 +1,778 @@
/*
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 React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import {
Modal,
Button,
Typography,
Card,
List,
Space,
Input,
Spin,
Popconfirm,
Tag,
Empty,
Row,
Col,
Progress,
Checkbox,
} from '@douyinfe/semi-ui';
import {
IconDownload,
IconDelete,
IconRefresh,
IconSearch,
IconPlus,
} from '@douyinfe/semi-icons';
import {
API,
authHeader,
getUserIdFromLocalStorage,
showError,
showSuccess,
} from '../../../../helpers';
const { Text, Title } = Typography;
const CHANNEL_TYPE_OLLAMA = 4;
const parseMaybeJSON = (value) => {
if (!value) return null;
if (typeof value === 'object') return value;
if (typeof value === 'string') {
try {
return JSON.parse(value);
} catch (error) {
return null;
}
}
return null;
};
const resolveOllamaBaseUrl = (info) => {
if (!info) {
return '';
}
const direct = typeof info.base_url === 'string' ? info.base_url.trim() : '';
if (direct) {
return direct;
}
const alt =
typeof info.ollama_base_url === 'string' ? info.ollama_base_url.trim() : '';
if (alt) {
return alt;
}
const parsed = parseMaybeJSON(info.other_info);
if (parsed && typeof parsed === 'object') {
const candidate =
(typeof parsed.base_url === 'string' && parsed.base_url.trim()) ||
(typeof parsed.public_url === 'string' && parsed.public_url.trim()) ||
(typeof parsed.api_url === 'string' && parsed.api_url.trim());
if (candidate) {
return candidate;
}
}
return '';
};
const normalizeModels = (items) => {
if (!Array.isArray(items)) {
return [];
}
return items
.map((item) => {
if (!item) {
return null;
}
if (typeof item === 'string') {
return {
id: item,
owned_by: 'ollama',
};
}
if (typeof item === 'object') {
const candidateId =
item.id || item.ID || item.name || item.model || item.Model;
if (!candidateId) {
return null;
}
const metadata = item.metadata || item.Metadata;
const normalized = {
...item,
id: candidateId,
owned_by: item.owned_by || item.ownedBy || 'ollama',
};
if (typeof item.size === 'number' && !normalized.size) {
normalized.size = item.size;
}
if (metadata && typeof metadata === 'object') {
if (typeof metadata.size === 'number' && !normalized.size) {
normalized.size = metadata.size;
}
if (!normalized.digest && typeof metadata.digest === 'string') {
normalized.digest = metadata.digest;
}
if (
!normalized.modified_at &&
typeof metadata.modified_at === 'string'
) {
normalized.modified_at = metadata.modified_at;
}
if (metadata.details && !normalized.details) {
normalized.details = metadata.details;
}
}
return normalized;
}
return null;
})
.filter(Boolean);
};
const OllamaModelModal = ({
visible,
onCancel,
channelId,
channelInfo,
onModelsUpdate,
onApplyModels,
}) => {
const { t } = useTranslation();
const [loading, setLoading] = useState(false);
const [models, setModels] = useState([]);
const [filteredModels, setFilteredModels] = useState([]);
const [searchValue, setSearchValue] = useState('');
const [pullModelName, setPullModelName] = useState('');
const [pullLoading, setPullLoading] = useState(false);
const [pullProgress, setPullProgress] = useState(null);
const [eventSource, setEventSource] = useState(null);
const [selectedModelIds, setSelectedModelIds] = useState([]);
const handleApplyAllModels = () => {
if (!onApplyModels || selectedModelIds.length === 0) {
return;
}
onApplyModels({ mode: 'append', modelIds: selectedModelIds });
};
const handleToggleModel = (modelId, checked) => {
if (!modelId) {
return;
}
setSelectedModelIds((prev) => {
if (checked) {
if (prev.includes(modelId)) {
return prev;
}
return [...prev, modelId];
}
return prev.filter((id) => id !== modelId);
});
};
const handleSelectAll = () => {
setSelectedModelIds(models.map((item) => item?.id).filter(Boolean));
};
const handleClearSelection = () => {
setSelectedModelIds([]);
};
// 获取模型列表
const fetchModels = async () => {
const channelType = Number(channelInfo?.type ?? CHANNEL_TYPE_OLLAMA);
const shouldTryLiveFetch = channelType === CHANNEL_TYPE_OLLAMA;
const resolvedBaseUrl = resolveOllamaBaseUrl(channelInfo);
setLoading(true);
let liveFetchSucceeded = false;
let fallbackSucceeded = false;
let lastError = '';
let nextModels = [];
try {
if (shouldTryLiveFetch && resolvedBaseUrl) {
try {
const payload = {
base_url: resolvedBaseUrl,
type: CHANNEL_TYPE_OLLAMA,
key: channelInfo?.key || '',
};
const res = await API.post('/api/channel/fetch_models', payload, {
skipErrorHandler: true,
});
if (res?.data?.success) {
nextModels = normalizeModels(res.data.data);
liveFetchSucceeded = true;
} else if (res?.data?.message) {
lastError = res.data.message;
}
} catch (error) {
const message = error?.response?.data?.message || error.message;
if (message) {
lastError = message;
}
}
} else if (shouldTryLiveFetch && !resolvedBaseUrl && !channelId) {
lastError = t('请先填写 Ollama API 地址');
}
if ((!liveFetchSucceeded || nextModels.length === 0) && channelId) {
try {
const res = await API.get(`/api/channel/fetch_models/${channelId}`, {
skipErrorHandler: true,
});
if (res?.data?.success) {
nextModels = normalizeModels(res.data.data);
fallbackSucceeded = true;
lastError = '';
} else if (res?.data?.message) {
lastError = res.data.message;
}
} catch (error) {
const message = error?.response?.data?.message || error.message;
if (message) {
lastError = message;
}
}
}
if (!liveFetchSucceeded && !fallbackSucceeded && lastError) {
showError(`${t('获取模型列表失败')}: ${lastError}`);
}
const normalized = nextModels;
setModels(normalized);
setFilteredModels(normalized);
setSelectedModelIds((prev) => {
if (!normalized || normalized.length === 0) {
return [];
}
if (!prev || prev.length === 0) {
return normalized.map((item) => item.id).filter(Boolean);
}
const available = prev.filter((id) =>
normalized.some((item) => item.id === id),
);
return available.length > 0
? available
: normalized.map((item) => item.id).filter(Boolean);
});
} finally {
setLoading(false);
}
};
// 拉取模型 (流式,支持进度)
const pullModel = async () => {
if (!pullModelName.trim()) {
showError(t('请输入模型名称'));
return;
}
setPullLoading(true);
setPullProgress({ status: 'starting', completed: 0, total: 0 });
let hasRefreshed = false;
const refreshModels = async () => {
if (hasRefreshed) return;
hasRefreshed = true;
await fetchModels();
if (onModelsUpdate) {
onModelsUpdate({ silent: true });
}
};
try {
// 关闭之前的连接
if (eventSource) {
eventSource.close();
setEventSource(null);
}
const controller = new AbortController();
const closable = {
close: () => controller.abort(),
};
setEventSource(closable);
// 使用 fetch 请求 SSE 流
const authHeaders = authHeader();
const userId = getUserIdFromLocalStorage();
const fetchHeaders = {
'Content-Type': 'application/json',
Accept: 'text/event-stream',
'New-API-User': String(userId),
...authHeaders,
};
const response = await fetch('/api/channel/ollama/pull/stream', {
method: 'POST',
headers: fetchHeaders,
body: JSON.stringify({
channel_id: channelId,
model_name: pullModelName.trim(),
}),
signal: controller.signal,
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
// 读取 SSE 流
const processStream = async () => {
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (!line.startsWith('data: ')) {
continue;
}
try {
const eventData = line.substring(6);
if (eventData === '[DONE]') {
setPullLoading(false);
setPullProgress(null);
setEventSource(null);
return;
}
const data = JSON.parse(eventData);
if (data.status) {
// 处理进度数据
setPullProgress(data);
} else if (data.error) {
// 处理错误
showError(data.error);
setPullProgress(null);
setPullLoading(false);
setEventSource(null);
return;
} else if (data.message) {
// 处理成功消息
showSuccess(data.message);
setPullModelName('');
setPullProgress(null);
setPullLoading(false);
setEventSource(null);
await fetchModels();
if (onModelsUpdate) {
onModelsUpdate({ silent: true });
}
await refreshModels();
return;
}
} catch (e) {
console.error('Failed to parse SSE data:', e);
}
}
}
// 正常结束流
setPullLoading(false);
setPullProgress(null);
setEventSource(null);
await refreshModels();
} catch (error) {
if (error?.name === 'AbortError') {
setPullProgress(null);
setPullLoading(false);
setEventSource(null);
return;
}
console.error('Stream processing error:', error);
showError(t('数据传输中断'));
setPullProgress(null);
setPullLoading(false);
setEventSource(null);
await refreshModels();
}
};
await processStream();
} catch (error) {
if (error?.name !== 'AbortError') {
showError(t('模型拉取失败: {{error}}', { error: error.message }));
}
setPullLoading(false);
setPullProgress(null);
setEventSource(null);
await refreshModels();
}
};
// 删除模型
const deleteModel = async (modelName) => {
try {
const res = await API.delete('/api/channel/ollama/delete', {
data: {
channel_id: channelId,
model_name: modelName,
},
});
if (res.data.success) {
showSuccess(t('模型删除成功'));
await fetchModels(); // 重新获取模型列表
if (onModelsUpdate) {
onModelsUpdate({ silent: true }); // 通知父组件更新
}
} else {
showError(res.data.message || t('模型删除失败'));
}
} catch (error) {
showError(t('模型删除失败: {{error}}', { error: error.message }));
}
};
// 搜索过滤
useEffect(() => {
if (!searchValue) {
setFilteredModels(models);
} else {
const filtered = models.filter((model) =>
model.id.toLowerCase().includes(searchValue.toLowerCase()),
);
setFilteredModels(filtered);
}
}, [models, searchValue]);
useEffect(() => {
if (!visible) {
setSelectedModelIds([]);
setPullModelName('');
setPullProgress(null);
setPullLoading(false);
}
}, [visible]);
// 组件加载时获取模型列表
useEffect(() => {
if (!visible) {
return;
}
if (channelId || Number(channelInfo?.type) === CHANNEL_TYPE_OLLAMA) {
fetchModels();
}
}, [
visible,
channelId,
channelInfo?.type,
channelInfo?.base_url,
channelInfo?.other_info,
channelInfo?.ollama_base_url,
]);
// 组件卸载时清理 EventSource
useEffect(() => {
return () => {
if (eventSource) {
eventSource.close();
}
};
}, [eventSource]);
const formatModelSize = (size) => {
if (!size) return '-';
const gb = size / (1024 * 1024 * 1024);
return gb >= 1
? `${gb.toFixed(1)} GB`
: `${(size / (1024 * 1024)).toFixed(0)} MB`;
};
return (
<Modal
title={t('Ollama 模型管理')}
visible={visible}
onCancel={onCancel}
width={720}
style={{ maxWidth: '95vw' }}
footer={
<Button theme='solid' type='primary' onClick={onCancel}>
{t('关闭')}
</Button>
}
>
<Space vertical spacing='medium' style={{ width: '100%' }}>
<div>
<Text type='tertiary' size='small'>
{channelInfo?.name ? `${channelInfo.name} - ` : ''}
{t('管理 Ollama 模型的拉取和删除')}
</Text>
</div>
{/* 拉取新模型 */}
<Card>
<Title heading={6} className='m-0 mb-3'>
{t('拉取新模型')}
</Title>
<Row gutter={12} align='middle'>
<Col span={16}>
<Input
placeholder={t('请输入模型名称,例如: llama3.2, qwen2.5:7b')}
value={pullModelName}
onChange={(value) => setPullModelName(value)}
onEnterPress={pullModel}
disabled={pullLoading}
showClear
/>
</Col>
<Col span={8}>
<Button
theme='solid'
type='primary'
onClick={pullModel}
loading={pullLoading}
disabled={!pullModelName.trim()}
icon={<IconDownload />}
block
>
{pullLoading ? t('拉取中...') : t('拉取模型')}
</Button>
</Col>
</Row>
{/* 进度条显示 */}
{pullProgress &&
(() => {
const completedBytes = Number(pullProgress.completed) || 0;
const totalBytes = Number(pullProgress.total) || 0;
const hasTotal = Number.isFinite(totalBytes) && totalBytes > 0;
const safePercent = hasTotal
? Math.min(
100,
Math.max(
0,
Math.round((completedBytes / totalBytes) * 100),
),
)
: null;
const percentText =
hasTotal && safePercent !== null
? `${safePercent.toFixed(0)}%`
: pullProgress.status || t('处理中');
return (
<div style={{ marginTop: 12 }}>
<div className='flex items-center justify-between mb-2'>
<Text strong>{t('拉取进度')}</Text>
<Text type='tertiary' size='small'>
{percentText}
</Text>
</div>
{hasTotal && safePercent !== null ? (
<div>
<Progress
percent={safePercent}
showInfo={false}
stroke='#1890ff'
size='small'
/>
<div className='flex justify-between mt-1'>
<Text type='tertiary' size='small'>
{(completedBytes / (1024 * 1024 * 1024)).toFixed(2)}{' '}
GB
</Text>
<Text type='tertiary' size='small'>
{(totalBytes / (1024 * 1024 * 1024)).toFixed(2)} GB
</Text>
</div>
</div>
) : (
<div className='flex items-center gap-2 text-xs text-[var(--semi-color-text-2)]'>
<Spin size='small' />
<span>{t('准备中...')}</span>
</div>
)}
</div>
);
})()}
<Text type='tertiary' size='small' className='mt-2 block'>
{t(
'支持拉取 Ollama 官方模型库中的所有模型,拉取过程可能需要几分钟时间',
)}
</Text>
</Card>
{/* 已有模型列表 */}
<Card>
<div className='flex items-center justify-between mb-3'>
<div className='flex items-center gap-2'>
<Title heading={6} className='m-0'>
{t('已有模型')}
</Title>
{models.length > 0 ? (
<Tag color='blue'>{models.length}</Tag>
) : null}
</div>
<Space wrap>
<Input
prefix={<IconSearch />}
placeholder={t('搜索模型...')}
value={searchValue}
onChange={(value) => setSearchValue(value)}
style={{ width: 200 }}
showClear
/>
<Button
size='small'
theme='light'
onClick={handleSelectAll}
disabled={models.length === 0}
>
{t('全选')}
</Button>
<Button
size='small'
theme='light'
onClick={handleClearSelection}
disabled={selectedModelIds.length === 0}
>
{t('清空')}
</Button>
<Button
theme='solid'
type='primary'
icon={<IconPlus />}
onClick={handleApplyAllModels}
disabled={selectedModelIds.length === 0}
size='small'
>
{t('加入渠道')}
</Button>
<Button
theme='light'
type='primary'
onClick={fetchModels}
loading={loading}
icon={<IconRefresh />}
size='small'
>
{t('刷新')}
</Button>
</Space>
</div>
<Spin spinning={loading}>
{filteredModels.length === 0 ? (
<Empty
title={searchValue ? t('未找到匹配的模型') : t('暂无模型')}
description={
searchValue
? t('请尝试其他搜索关键词')
: t('您可以在上方拉取需要的模型')
}
style={{ padding: '40px 0' }}
/>
) : (
<List
dataSource={filteredModels}
split
renderItem={(model) => (
<List.Item key={model.id}>
<div className='flex items-center justify-between w-full'>
<div className='flex items-center flex-1 min-w-0 gap-3'>
<Checkbox
checked={selectedModelIds.includes(model.id)}
onChange={(checked) =>
handleToggleModel(model.id, checked)
}
/>
<div className='flex-1 min-w-0'>
<Text strong className='block truncate'>
{model.id}
</Text>
<div className='flex items-center space-x-2 mt-1'>
<Tag color='cyan' size='small'>
{model.owned_by || 'ollama'}
</Tag>
{model.size && (
<Text type='tertiary' size='small'>
{formatModelSize(model.size)}
</Text>
)}
</div>
</div>
</div>
<div className='flex items-center space-x-2 ml-4'>
<Popconfirm
title={t('确认删除模型')}
content={t(
'删除后无法恢复,确定要删除模型 "{{name}}" 吗?',
{ name: model.id },
)}
onConfirm={() => deleteModel(model.id)}
okText={t('确认')}
cancelText={t('取消')}
>
<Button
theme='borderless'
type='danger'
size='small'
icon={<IconDelete />}
/>
</Popconfirm>
</div>
</div>
</List.Item>
)}
/>
)}
</Spin>
</Card>
</Space>
</Modal>
);
};
export default OllamaModelModal;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,195 @@
/*
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 React, { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useIsMobile } from '../../../../hooks/common/useIsMobile';
import {
Collapse,
Empty,
Input,
Modal,
Radio,
Typography,
} from '@douyinfe/semi-ui';
import {
IllustrationNoResult,
IllustrationNoResultDark,
} from '@douyinfe/semi-illustrations';
import { IconSearch } from '@douyinfe/semi-icons';
import { getModelCategories } from '../../../../helpers/render';
const SingleModelSelectModal = ({
visible,
models = [],
selected = '',
onConfirm,
onCancel,
}) => {
const { t } = useTranslation();
const isMobile = useIsMobile();
const normalizeModelName = (model) => String(model ?? '').trim();
const normalizedModels = useMemo(() => {
const list = Array.isArray(models) ? models : [];
return Array.from(new Set(list.map(normalizeModelName).filter(Boolean)));
}, [models]);
const [keyword, setKeyword] = useState('');
const [selectedModel, setSelectedModel] = useState('');
useEffect(() => {
if (visible) {
setKeyword('');
setSelectedModel(normalizeModelName(selected));
}
}, [visible, selected]);
const filteredModels = useMemo(() => {
const lower = keyword.trim().toLowerCase();
if (!lower) return normalizedModels;
return normalizedModels.filter((m) => m.toLowerCase().includes(lower));
}, [normalizedModels, keyword]);
const modelsByCategory = useMemo(() => {
const categories = getModelCategories(t);
const categorized = {};
const uncategorized = [];
filteredModels.forEach((model) => {
let foundCategory = false;
for (const [key, category] of Object.entries(categories)) {
if (key !== 'all' && category.filter({ model_name: model })) {
if (!categorized[key]) {
categorized[key] = {
label: category.label,
icon: category.icon,
models: [],
};
}
categorized[key].models.push(model);
foundCategory = true;
break;
}
}
if (!foundCategory) {
uncategorized.push(model);
}
});
if (uncategorized.length > 0) {
categorized.other = {
label: t('其他'),
icon: null,
models: uncategorized,
};
}
return categorized;
}, [filteredModels, t]);
const categoryEntries = useMemo(
() => Object.entries(modelsByCategory),
[modelsByCategory],
);
return (
<Modal
header={
<div className='flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 sm:gap-4 py-4'>
<Typography.Title heading={5} className='m-0'>
{t('选择模型')}
</Typography.Title>
</div>
}
visible={visible}
onOk={() => onConfirm?.(selectedModel)}
onCancel={onCancel}
okText={t('确定')}
cancelText={t('取消')}
okButtonProps={{ disabled: !selectedModel }}
size={isMobile ? 'full-width' : 'large'}
closeOnEsc
maskClosable
centered
>
<Input
prefix={<IconSearch size={14} />}
placeholder={t('搜索模型')}
value={keyword}
onChange={(v) => setKeyword(v)}
showClear
/>
<div style={{ maxHeight: 400, overflowY: 'auto', paddingRight: 8 }}>
{filteredModels.length === 0 ? (
<Empty
image={<IllustrationNoResult style={{ width: 150, height: 150 }} />}
darkModeImage={
<IllustrationNoResultDark style={{ width: 150, height: 150 }} />
}
description={t('暂无匹配模型')}
style={{ padding: 30 }}
/>
) : (
<Radio.Group
className='w-full'
style={{ width: '100%' }}
value={selectedModel}
onChange={(val) => {
const next = val && val.target ? val.target.value : val;
setSelectedModel(next);
}}
>
<Collapse
className='w-full'
style={{ width: '100%' }}
defaultActiveKey={[]}
>
{categoryEntries.map(([key, categoryData], index) => (
<Collapse.Panel
key={`${key}_${index}`}
itemKey={`${key}_${index}`}
header={
<span className='flex items-center gap-2'>
{categoryData.icon}
<span>
{categoryData.label} ({categoryData.models.length})
</span>
</span>
}
>
<div className='grid grid-cols-2 gap-x-4'>
{categoryData.models.map((model) => (
<Radio key={model} value={model} className='my-1'>
{model}
</Radio>
))}
</div>
</Collapse.Panel>
))}
</Collapse>
</Radio.Group>
)}
</div>
</Modal>
);
};
export default SingleModelSelectModal;
@@ -0,0 +1,41 @@
import React, { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import RiskAcknowledgementModal from '../../../common/modals/RiskAcknowledgementModal';
import {
STATUS_CODE_RISK_I18N_KEYS,
STATUS_CODE_RISK_CHECKLIST_KEYS,
} from './statusCodeRiskGuard';
const StatusCodeRiskGuardModal = React.memo(function StatusCodeRiskGuardModal({
visible,
detailItems,
onCancel,
onConfirm,
}) {
const { t, i18n } = useTranslation();
const checklist = useMemo(
() => STATUS_CODE_RISK_CHECKLIST_KEYS.map((item) => t(item)),
[t, i18n.language],
);
return (
<RiskAcknowledgementModal
visible={visible}
title={t(STATUS_CODE_RISK_I18N_KEYS.title)}
markdownContent={t(STATUS_CODE_RISK_I18N_KEYS.markdown)}
detailTitle={t(STATUS_CODE_RISK_I18N_KEYS.detailTitle)}
detailItems={detailItems}
checklist={checklist}
inputPrompt={t(STATUS_CODE_RISK_I18N_KEYS.inputPrompt)}
requiredText={t(STATUS_CODE_RISK_I18N_KEYS.confirmText)}
inputPlaceholder={t(STATUS_CODE_RISK_I18N_KEYS.inputPlaceholder)}
mismatchText={t(STATUS_CODE_RISK_I18N_KEYS.mismatchText)}
cancelText={t('取消')}
confirmText={t(STATUS_CODE_RISK_I18N_KEYS.confirmButton)}
onCancel={onCancel}
onConfirm={onConfirm}
/>
);
});
export default StatusCodeRiskGuardModal;
@@ -0,0 +1,132 @@
const NON_REDIRECTABLE_STATUS_CODES = new Set([504, 524]);
export const STATUS_CODE_RISK_I18N_KEYS = {
title: '高危操作确认',
detailTitle: '检测到以下高危状态码重定向规则',
inputPrompt: '操作确认',
confirmButton: '我确认开启高危重试',
markdown: '高危状态码重试风险告知与免责声明Markdown',
confirmText: '高危状态码重试风险确认输入文本',
inputPlaceholder: '高危状态码重试风险输入框占位文案',
mismatchText: '高危状态码重试风险输入不匹配提示',
};
export const STATUS_CODE_RISK_CHECKLIST_KEYS = [
'高危状态码重试风险确认项1',
'高危状态码重试风险确认项2',
'高危状态码重试风险确认项3',
'高危状态码重试风险确认项4',
];
function parseStatusCodeKey(rawKey) {
if (typeof rawKey !== 'string') {
return null;
}
const normalized = rawKey.trim();
if (!/^[1-5]\d{2}$/.test(normalized)) {
return null;
}
return Number.parseInt(normalized, 10);
}
function parseStatusCodeMappingTarget(rawValue) {
if (typeof rawValue === 'number' && Number.isInteger(rawValue)) {
return rawValue >= 100 && rawValue <= 599 ? rawValue : null;
}
if (typeof rawValue === 'string') {
const normalized = rawValue.trim();
if (!/^[1-5]\d{2}$/.test(normalized)) {
return null;
}
const code = Number.parseInt(normalized, 10);
return code >= 100 && code <= 599 ? code : null;
}
return null;
}
export function collectInvalidStatusCodeEntries(statusCodeMappingStr) {
if (
typeof statusCodeMappingStr !== 'string' ||
statusCodeMappingStr.trim() === ''
) {
return [];
}
let parsed;
try {
parsed = JSON.parse(statusCodeMappingStr);
} catch {
return [];
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
return [];
}
const invalid = [];
for (const [rawKey, rawValue] of Object.entries(parsed)) {
const fromCode = parseStatusCodeKey(rawKey);
const toCode = parseStatusCodeMappingTarget(rawValue);
if (fromCode === null || toCode === null) {
invalid.push(`${rawKey}${rawValue}`);
}
}
return invalid;
}
export function collectDisallowedStatusCodeRedirects(statusCodeMappingStr) {
if (
typeof statusCodeMappingStr !== 'string' ||
statusCodeMappingStr.trim() === ''
) {
return [];
}
let parsed;
try {
parsed = JSON.parse(statusCodeMappingStr);
} catch (error) {
return [];
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
return [];
}
const riskyMappings = [];
Object.entries(parsed).forEach(([rawFrom, rawTo]) => {
const fromCode = parseStatusCodeKey(rawFrom);
const toCode = parseStatusCodeMappingTarget(rawTo);
if (fromCode === null || toCode === null) {
return;
}
if (!NON_REDIRECTABLE_STATUS_CODES.has(fromCode)) {
return;
}
if (fromCode === toCode) {
return;
}
riskyMappings.push(`${fromCode} -> ${toCode}`);
});
return Array.from(new Set(riskyMappings)).sort();
}
export function collectNewDisallowedStatusCodeRedirects(
originalStatusCodeMappingStr,
currentStatusCodeMappingStr,
) {
const currentRisky = collectDisallowedStatusCodeRedirects(
currentStatusCodeMappingStr,
);
if (currentRisky.length === 0) {
return [];
}
const originalRiskySet = new Set(
collectDisallowedStatusCodeRedirects(originalStatusCodeMappingStr),
);
return currentRisky.filter((mapping) => !originalRiskySet.has(mapping));
}
@@ -0,0 +1,69 @@
/*
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 React from 'react';
import { Skeleton, Typography } from '@douyinfe/semi-ui';
import { useMinimumLoadingTime } from '../../../hooks/common/useMinimumLoadingTime';
import { IconEyeOpened } from '@douyinfe/semi-icons';
import CompactModeToggle from '../../common/ui/CompactModeToggle';
const { Text } = Typography;
const MjLogsActions = ({
loading,
showBanner,
isAdminUser,
compactMode,
setCompactMode,
t,
}) => {
const showSkeleton = useMinimumLoadingTime(loading);
const placeholder = (
<div className='flex items-center mb-2 md:mb-0'>
<IconEyeOpened className='mr-2' />
<Skeleton.Title style={{ width: 300, height: 21, borderRadius: 6 }} />
</div>
);
return (
<div className='flex flex-col md:flex-row justify-between items-start md:items-center gap-2 w-full'>
<Skeleton loading={showSkeleton} active placeholder={placeholder}>
<div className='flex items-center mb-2 md:mb-0'>
<IconEyeOpened className='mr-2' />
<Text>
{isAdminUser && showBanner
? t(
'当前未开启Midjourney回调,部分项目可能无法获得绘图结果,可在运营设置中开启。',
)
: t('Midjourney 任务记录')}
</Text>
</div>
</Skeleton>
<CompactModeToggle
compactMode={compactMode}
setCompactMode={setCompactMode}
t={t}
/>
</div>
);
};
export default MjLogsActions;
@@ -0,0 +1,511 @@
/*
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 React from 'react';
import { Button, Progress, Tag, Typography } from '@douyinfe/semi-ui';
import {
Palette,
ZoomIn,
Shuffle,
Move,
FileText,
Blend,
Upload,
Minimize2,
RotateCcw,
PaintBucket,
Focus,
Move3D,
Monitor,
UserCheck,
HelpCircle,
CheckCircle,
Clock,
Copy,
FileX,
Pause,
XCircle,
Loader,
AlertCircle,
Hash,
Video,
} from 'lucide-react';
const colors = [
'amber',
'blue',
'cyan',
'green',
'grey',
'indigo',
'light-blue',
'lime',
'orange',
'pink',
'purple',
'red',
'teal',
'violet',
'yellow',
];
// Render functions
function renderType(type, t) {
switch (type) {
case 'IMAGINE':
return (
<Tag color='blue' shape='circle' prefixIcon={<Palette size={14} />}>
{t('绘图')}
</Tag>
);
case 'UPSCALE':
return (
<Tag color='orange' shape='circle' prefixIcon={<ZoomIn size={14} />}>
{t('放大')}
</Tag>
);
case 'VIDEO':
return (
<Tag color='orange' shape='circle' prefixIcon={<Video size={14} />}>
{t('视频')}
</Tag>
);
case 'EDITS':
return (
<Tag color='orange' shape='circle' prefixIcon={<Video size={14} />}>
{t('编辑')}
</Tag>
);
case 'VARIATION':
return (
<Tag color='purple' shape='circle' prefixIcon={<Shuffle size={14} />}>
{t('变换')}
</Tag>
);
case 'HIGH_VARIATION':
return (
<Tag color='purple' shape='circle' prefixIcon={<Shuffle size={14} />}>
{t('强变换')}
</Tag>
);
case 'LOW_VARIATION':
return (
<Tag color='purple' shape='circle' prefixIcon={<Shuffle size={14} />}>
{t('弱变换')}
</Tag>
);
case 'PAN':
return (
<Tag color='cyan' shape='circle' prefixIcon={<Move size={14} />}>
{t('平移')}
</Tag>
);
case 'DESCRIBE':
return (
<Tag color='yellow' shape='circle' prefixIcon={<FileText size={14} />}>
{t('图生文')}
</Tag>
);
case 'BLEND':
return (
<Tag color='lime' shape='circle' prefixIcon={<Blend size={14} />}>
{t('图混合')}
</Tag>
);
case 'UPLOAD':
return (
<Tag color='blue' shape='circle' prefixIcon={<Upload size={14} />}>
上传文件
</Tag>
);
case 'SHORTEN':
return (
<Tag color='pink' shape='circle' prefixIcon={<Minimize2 size={14} />}>
{t('缩词')}
</Tag>
);
case 'REROLL':
return (
<Tag color='indigo' shape='circle' prefixIcon={<RotateCcw size={14} />}>
{t('重绘')}
</Tag>
);
case 'INPAINT':
return (
<Tag
color='violet'
shape='circle'
prefixIcon={<PaintBucket size={14} />}
>
{t('局部重绘-提交')}
</Tag>
);
case 'ZOOM':
return (
<Tag color='teal' shape='circle' prefixIcon={<Focus size={14} />}>
{t('变焦')}
</Tag>
);
case 'CUSTOM_ZOOM':
return (
<Tag color='teal' shape='circle' prefixIcon={<Move3D size={14} />}>
{t('自定义变焦-提交')}
</Tag>
);
case 'MODAL':
return (
<Tag color='green' shape='circle' prefixIcon={<Monitor size={14} />}>
{t('窗口处理')}
</Tag>
);
case 'SWAP_FACE':
return (
<Tag
color='light-green'
shape='circle'
prefixIcon={<UserCheck size={14} />}
>
{t('换脸')}
</Tag>
);
default:
return (
<Tag color='white' shape='circle' prefixIcon={<HelpCircle size={14} />}>
{t('未知')}
</Tag>
);
}
}
function renderCode(code, t) {
switch (code) {
case 1:
return (
<Tag
color='green'
shape='circle'
prefixIcon={<CheckCircle size={14} />}
>
{t('已提交')}
</Tag>
);
case 21:
return (
<Tag color='lime' shape='circle' prefixIcon={<Clock size={14} />}>
{t('等待中')}
</Tag>
);
case 22:
return (
<Tag color='orange' shape='circle' prefixIcon={<Copy size={14} />}>
{t('重复提交')}
</Tag>
);
case 0:
return (
<Tag color='yellow' shape='circle' prefixIcon={<FileX size={14} />}>
{t('未提交')}
</Tag>
);
default:
return (
<Tag color='white' shape='circle' prefixIcon={<HelpCircle size={14} />}>
{t('未知')}
</Tag>
);
}
}
function renderStatus(type, t) {
switch (type) {
case 'SUCCESS':
return (
<Tag
color='green'
shape='circle'
prefixIcon={<CheckCircle size={14} />}
>
{t('成功')}
</Tag>
);
case 'NOT_START':
return (
<Tag color='grey' shape='circle' prefixIcon={<Pause size={14} />}>
{t('未启动')}
</Tag>
);
case 'SUBMITTED':
return (
<Tag color='yellow' shape='circle' prefixIcon={<Clock size={14} />}>
{t('队列中')}
</Tag>
);
case 'IN_PROGRESS':
return (
<Tag color='blue' shape='circle' prefixIcon={<Loader size={14} />}>
{t('执行中')}
</Tag>
);
case 'FAILURE':
return (
<Tag color='red' shape='circle' prefixIcon={<XCircle size={14} />}>
{t('失败')}
</Tag>
);
case 'MODAL':
return (
<Tag
color='yellow'
shape='circle'
prefixIcon={<AlertCircle size={14} />}
>
{t('窗口等待')}
</Tag>
);
default:
return (
<Tag color='white' shape='circle' prefixIcon={<HelpCircle size={14} />}>
{t('未知')}
</Tag>
);
}
}
const renderTimestamp = (timestampInSeconds) => {
const date = new Date(timestampInSeconds * 1000);
const year = date.getFullYear();
const month = ('0' + (date.getMonth() + 1)).slice(-2);
const day = ('0' + date.getDate()).slice(-2);
const hours = ('0' + date.getHours()).slice(-2);
const minutes = ('0' + date.getMinutes()).slice(-2);
const seconds = ('0' + date.getSeconds()).slice(-2);
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
};
function renderDuration(submit_time, finishTime, t) {
if (!submit_time || !finishTime) return 'N/A';
const start = new Date(submit_time);
const finish = new Date(finishTime);
const durationMs = finish - start;
const durationSec = (durationMs / 1000).toFixed(1);
const color = durationSec > 60 ? 'red' : 'green';
return (
<Tag color={color} shape='circle' prefixIcon={<Clock size={14} />}>
{durationSec} {t('秒')}
</Tag>
);
}
export const getMjLogsColumns = ({
t,
COLUMN_KEYS,
copyText,
openContentModal,
openImageModal,
isAdminUser,
}) => {
return [
{
key: COLUMN_KEYS.SUBMIT_TIME,
title: t('提交时间'),
dataIndex: 'submit_time',
render: (text, record, index) => {
return <div>{renderTimestamp(text / 1000)}</div>;
},
},
{
key: COLUMN_KEYS.DURATION,
title: t('花费时间'),
dataIndex: 'finish_time',
render: (finish, record) => {
return renderDuration(record.submit_time, finish, t);
},
},
{
key: COLUMN_KEYS.CHANNEL,
title: t('渠道'),
dataIndex: 'channel_id',
render: (text, record, index) => {
return isAdminUser ? (
<div>
<Tag
color={colors[parseInt(text) % colors.length]}
shape='circle'
prefixIcon={<Hash size={14} />}
onClick={() => {
copyText(text);
}}
>
{' '}
{text}{' '}
</Tag>
</div>
) : (
<></>
);
},
},
{
key: COLUMN_KEYS.TYPE,
title: t('类型'),
dataIndex: 'action',
render: (text, record, index) => {
return <div>{renderType(text, t)}</div>;
},
},
{
key: COLUMN_KEYS.TASK_ID,
title: t('任务ID'),
dataIndex: 'mj_id',
render: (text, record, index) => {
return <div>{text}</div>;
},
},
{
key: COLUMN_KEYS.SUBMIT_RESULT,
title: t('提交结果'),
dataIndex: 'code',
render: (text, record, index) => {
return isAdminUser ? <div>{renderCode(text, t)}</div> : <></>;
},
},
{
key: COLUMN_KEYS.TASK_STATUS,
title: t('任务状态'),
dataIndex: 'status',
render: (text, record, index) => {
return <div>{renderStatus(text, t)}</div>;
},
},
{
key: COLUMN_KEYS.PROGRESS,
title: t('进度'),
dataIndex: 'progress',
render: (text, record, index) => {
return (
<div>
{
<Progress
stroke={
record.status === 'FAILURE'
? 'var(--semi-color-warning)'
: null
}
percent={text ? parseInt(text.replace('%', '')) : 0}
showInfo={true}
aria-label='drawing progress'
style={{ minWidth: '160px' }}
/>
}
</div>
);
},
},
{
key: COLUMN_KEYS.IMAGE,
title: t('结果图片'),
dataIndex: 'image_url',
render: (text, record, index) => {
if (!text) {
return t('无');
}
return (
<Button
size='small'
onClick={() => {
openImageModal(text);
}}
>
{t('查看图片')}
</Button>
);
},
},
{
key: COLUMN_KEYS.PROMPT,
title: 'Prompt',
dataIndex: 'prompt',
render: (text, record, index) => {
if (!text) {
return t('无');
}
return (
<Typography.Text
ellipsis={{ showTooltip: true }}
style={{ width: 100 }}
onClick={() => {
openContentModal(text);
}}
>
{text}
</Typography.Text>
);
},
},
{
key: COLUMN_KEYS.PROMPT_EN,
title: 'PromptEn',
dataIndex: 'prompt_en',
render: (text, record, index) => {
if (!text) {
return t('无');
}
return (
<Typography.Text
ellipsis={{ showTooltip: true }}
style={{ width: 100 }}
onClick={() => {
openContentModal(text);
}}
>
{text}
</Typography.Text>
);
},
},
{
key: COLUMN_KEYS.FAIL_REASON,
title: t('失败原因'),
dataIndex: 'fail_reason',
fixed: 'right',
render: (text, record, index) => {
if (!text) {
return t('无');
}
return (
<Typography.Text
ellipsis={{ showTooltip: true }}
style={{ width: 100 }}
onClick={() => {
openContentModal(text);
}}
>
{text}
</Typography.Text>
);
},
},
];
};
@@ -0,0 +1,130 @@
/*
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 React from 'react';
import { Button, Form } from '@douyinfe/semi-ui';
import { IconSearch } from '@douyinfe/semi-icons';
import { DATE_RANGE_PRESETS } from '../../../constants/console.constants';
const MjLogsFilters = ({
formInitValues,
setFormApi,
refresh,
setShowColumnSelector,
formApi,
loading,
isAdminUser,
t,
}) => {
return (
<Form
initValues={formInitValues}
getFormApi={(api) => setFormApi(api)}
onSubmit={refresh}
allowEmpty={true}
autoComplete='off'
layout='vertical'
trigger='change'
stopValidateWithError={false}
>
<div className='flex flex-col gap-2'>
<div className='grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-2'>
{/* 时间选择器 */}
<div className='col-span-1 lg:col-span-2'>
<Form.DatePicker
field='dateRange'
className='w-full'
type='dateTimeRange'
placeholder={[t('开始时间'), t('结束时间')]}
showClear
pure
size='small'
presets={DATE_RANGE_PRESETS.map((preset) => ({
text: t(preset.text),
start: preset.start(),
end: preset.end(),
}))}
/>
</div>
{/* 任务 ID */}
<Form.Input
field='mj_id'
prefix={<IconSearch />}
placeholder={t('任务 ID')}
showClear
pure
size='small'
/>
{/* 渠道 ID - 仅管理员可见 */}
{isAdminUser && (
<Form.Input
field='channel_id'
prefix={<IconSearch />}
placeholder={t('渠道 ID')}
showClear
pure
size='small'
/>
)}
</div>
{/* 操作按钮区域 */}
<div className='flex justify-between items-center'>
<div></div>
<div className='flex gap-2'>
<Button
type='tertiary'
htmlType='submit'
loading={loading}
size='small'
>
{t('查询')}
</Button>
<Button
type='tertiary'
onClick={() => {
if (formApi) {
formApi.reset();
setTimeout(() => {
refresh();
}, 100);
}
}}
size='small'
>
{t('重置')}
</Button>
<Button
type='tertiary'
onClick={() => setShowColumnSelector(true)}
size='small'
>
{t('列设置')}
</Button>
</div>
</div>
</div>
</Form>
);
};
export default MjLogsFilters;
+108
View File
@@ -0,0 +1,108 @@
/*
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 React, { useMemo } from 'react';
import { Empty } from '@douyinfe/semi-ui';
import CardTable from '../../common/ui/CardTable';
import {
IllustrationNoResult,
IllustrationNoResultDark,
} from '@douyinfe/semi-illustrations';
import { getMjLogsColumns } from './MjLogsColumnDefs';
const MjLogsTable = (mjLogsData) => {
const {
logs,
loading,
activePage,
pageSize,
logCount,
compactMode,
visibleColumns,
handlePageChange,
handlePageSizeChange,
copyText,
openContentModal,
openImageModal,
isAdminUser,
t,
COLUMN_KEYS,
} = mjLogsData;
// Get all columns
const allColumns = useMemo(() => {
return getMjLogsColumns({
t,
COLUMN_KEYS,
copyText,
openContentModal,
openImageModal,
isAdminUser,
});
}, [t, COLUMN_KEYS, copyText, openContentModal, openImageModal, isAdminUser]);
// Filter columns based on visibility settings
const getVisibleColumns = () => {
return allColumns.filter((column) => visibleColumns[column.key]);
};
const visibleColumnsList = useMemo(() => {
return getVisibleColumns();
}, [visibleColumns, allColumns]);
const tableColumns = useMemo(() => {
return compactMode
? visibleColumnsList.map(({ fixed, ...rest }) => rest)
: visibleColumnsList;
}, [compactMode, visibleColumnsList]);
return (
<CardTable
columns={tableColumns}
dataSource={logs}
rowKey='key'
loading={loading}
scroll={compactMode ? undefined : { x: 'max-content' }}
className='rounded-xl overflow-hidden'
size='middle'
empty={
<Empty
image={<IllustrationNoResult style={{ width: 150, height: 150 }} />}
darkModeImage={
<IllustrationNoResultDark style={{ width: 150, height: 150 }} />
}
description={t('搜索无结果')}
style={{ padding: 30 }}
/>
}
pagination={{
currentPage: activePage,
pageSize: pageSize,
total: logCount,
pageSizeOptions: [10, 20, 50, 100],
showSizeChanger: true,
onPageSizeChange: handlePageSizeChange,
onPageChange: handlePageChange,
}}
hidePagination={true}
/>
);
};
export default MjLogsTable;
+65
View File
@@ -0,0 +1,65 @@
/*
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 React from 'react';
import { Layout } from '@douyinfe/semi-ui';
import CardPro from '../../common/ui/CardPro';
import MjLogsTable from './MjLogsTable';
import MjLogsActions from './MjLogsActions';
import MjLogsFilters from './MjLogsFilters';
import ColumnSelectorModal from './modals/ColumnSelectorModal';
import ContentModal from './modals/ContentModal';
import { useMjLogsData } from '../../../hooks/mj-logs/useMjLogsData';
import { useIsMobile } from '../../../hooks/common/useIsMobile';
import { createCardProPagination } from '../../../helpers/utils';
const MjLogsPage = () => {
const mjLogsData = useMjLogsData();
const isMobile = useIsMobile();
return (
<>
{/* Modals */}
<ColumnSelectorModal {...mjLogsData} />
<ContentModal {...mjLogsData} />
<Layout>
<CardPro
type='type2'
statsArea={<MjLogsActions {...mjLogsData} />}
searchArea={<MjLogsFilters {...mjLogsData} />}
paginationArea={createCardProPagination({
currentPage: mjLogsData.activePage,
pageSize: mjLogsData.pageSize,
total: mjLogsData.logCount,
onPageChange: mjLogsData.handlePageChange,
onPageSizeChange: mjLogsData.handlePageSizeChange,
isMobile: isMobile,
t: mjLogsData.t,
})}
t={mjLogsData.t}
>
<MjLogsTable {...mjLogsData} />
</CardPro>
</Layout>
</>
);
};
export default MjLogsPage;
@@ -0,0 +1,109 @@
/*
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 React from 'react';
import { Modal, Button, Checkbox } from '@douyinfe/semi-ui';
import { getMjLogsColumns } from '../MjLogsColumnDefs';
const ColumnSelectorModal = ({
showColumnSelector,
setShowColumnSelector,
visibleColumns,
handleColumnVisibilityChange,
handleSelectAll,
initDefaultColumns,
COLUMN_KEYS,
isAdminUser,
copyText,
openContentModal,
openImageModal,
t,
}) => {
// Get all columns for display in selector
const allColumns = getMjLogsColumns({
t,
COLUMN_KEYS,
copyText,
openContentModal,
openImageModal,
isAdminUser,
});
return (
<Modal
title={t('列设置')}
visible={showColumnSelector}
onCancel={() => setShowColumnSelector(false)}
footer={
<div className='flex justify-end'>
<Button onClick={() => initDefaultColumns()}>{t('重置')}</Button>
<Button onClick={() => setShowColumnSelector(false)}>
{t('取消')}
</Button>
<Button onClick={() => setShowColumnSelector(false)}>
{t('确定')}
</Button>
</div>
}
>
<div style={{ marginBottom: 20 }}>
<Checkbox
checked={Object.values(visibleColumns).every((v) => v === true)}
indeterminate={
Object.values(visibleColumns).some((v) => v === true) &&
!Object.values(visibleColumns).every((v) => v === true)
}
onChange={(e) => handleSelectAll(e.target.checked)}
>
{t('全选')}
</Checkbox>
</div>
<div
className='flex flex-wrap max-h-96 overflow-y-auto rounded-lg p-4'
style={{ border: '1px solid var(--semi-color-border)' }}
>
{allColumns.map((column) => {
// Skip admin-only columns for non-admin users
if (
!isAdminUser &&
(column.key === COLUMN_KEYS.CHANNEL ||
column.key === COLUMN_KEYS.SUBMIT_RESULT)
) {
return null;
}
return (
<div key={column.key} className='w-1/2 mb-4 pr-2'>
<Checkbox
checked={!!visibleColumns[column.key]}
onChange={(e) =>
handleColumnVisibilityChange(column.key, e.target.checked)
}
>
{column.title}
</Checkbox>
</div>
);
})}
</div>
</Modal>
);
};
export default ColumnSelectorModal;
@@ -0,0 +1,55 @@
/*
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 React from 'react';
import { Modal, ImagePreview } from '@douyinfe/semi-ui';
const ContentModal = ({
isModalOpen,
setIsModalOpen,
modalContent,
isModalOpenurl,
setIsModalOpenurl,
modalImageUrl,
}) => {
return (
<>
{/* Text Content Modal */}
<Modal
visible={isModalOpen}
onOk={() => setIsModalOpen(false)}
onCancel={() => setIsModalOpen(false)}
closable={null}
bodyStyle={{ height: '400px', overflow: 'auto' }}
width={800}
>
<p style={{ whiteSpace: 'pre-line' }}>{modalContent}</p>
</Modal>
{/* Image Preview Modal */}
<ImagePreview
src={modalImageUrl}
visible={isModalOpenurl}
onVisibleChange={(visible) => setIsModalOpenurl(visible)}
/>
</>
);
};
export default ContentModal;
@@ -0,0 +1,109 @@
/*
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 React from 'react';
import { Button, Popconfirm } from '@douyinfe/semi-ui';
import CompactModeToggle from '../../common/ui/CompactModeToggle';
const DeploymentsActions = ({
selectedKeys,
setSelectedKeys,
setEditingDeployment,
setShowEdit,
batchDeleteDeployments,
batchOperationsEnabled = true,
compactMode,
setCompactMode,
showCreateModal,
setShowCreateModal,
t,
}) => {
const hasSelected = batchOperationsEnabled && selectedKeys.length > 0;
const handleAddDeployment = () => {
if (setShowCreateModal) {
setShowCreateModal(true);
} else {
// Fallback to old behavior if setShowCreateModal is not provided
setEditingDeployment({ id: undefined });
setShowEdit(true);
}
};
const handleBatchDelete = () => {
batchDeleteDeployments();
};
const handleDeselectAll = () => {
setSelectedKeys([]);
};
return (
<div className='flex flex-wrap gap-2 w-full md:w-auto order-2 md:order-1'>
<Button
type='primary'
className='flex-1 md:flex-initial'
onClick={handleAddDeployment}
size='small'
>
{t('新建容器')}
</Button>
{hasSelected && (
<>
<Popconfirm
title={t('确认删除')}
content={`${t('确定要删除选中的')} ${selectedKeys.length} ${t('个部署吗?此操作不可逆。')}`}
okText={t('删除')}
cancelText={t('取消')}
okType='danger'
onConfirm={handleBatchDelete}
>
<Button
type='danger'
className='flex-1 md:flex-initial'
disabled={selectedKeys.length === 0}
size='small'
>
{t('批量删除')} ({selectedKeys.length})
</Button>
</Popconfirm>
<Button
type='tertiary'
className='flex-1 md:flex-initial'
onClick={handleDeselectAll}
size='small'
>
{t('取消选择')}
</Button>
</>
)}
{/* Compact Mode */}
<CompactModeToggle
compactMode={compactMode}
setCompactMode={setCompactMode}
t={t}
/>
</div>
);
};
export default DeploymentsActions;
@@ -0,0 +1,702 @@
/*
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 React from 'react';
import { Button, Dropdown, Tag, Typography } from '@douyinfe/semi-ui';
import { timestamp2string, showSuccess, showError } from '../../../helpers';
import { IconMore } from '@douyinfe/semi-icons';
import {
FaPlay,
FaTrash,
FaServer,
FaMemory,
FaMicrochip,
FaCheckCircle,
FaSpinner,
FaClock,
FaExclamationCircle,
FaBan,
FaTerminal,
FaPlus,
FaCog,
FaInfoCircle,
FaLink,
FaStop,
FaHourglassHalf,
FaGlobe,
} from 'react-icons/fa';
const normalizeStatus = (status) =>
typeof status === 'string' ? status.trim().toLowerCase() : '';
const STATUS_TAG_CONFIG = {
running: {
color: 'green',
labelKey: '运行中',
icon: <FaPlay size={12} className='text-green-600' />,
},
deploying: {
color: 'blue',
labelKey: '部署中',
icon: <FaSpinner size={12} className='text-blue-600' />,
},
pending: {
color: 'orange',
labelKey: '待部署',
icon: <FaClock size={12} className='text-orange-600' />,
},
stopped: {
color: 'grey',
labelKey: '已停止',
icon: <FaStop size={12} className='text-gray-500' />,
},
error: {
color: 'red',
labelKey: '错误',
icon: <FaExclamationCircle size={12} className='text-red-500' />,
},
failed: {
color: 'red',
labelKey: '失败',
icon: <FaExclamationCircle size={12} className='text-red-500' />,
},
destroyed: {
color: 'red',
labelKey: '已销毁',
icon: <FaBan size={12} className='text-red-500' />,
},
completed: {
color: 'green',
labelKey: '已完成',
icon: <FaCheckCircle size={12} className='text-green-600' />,
},
'deployment requested': {
color: 'blue',
labelKey: '部署请求中',
icon: <FaSpinner size={12} className='text-blue-600' />,
},
'termination requested': {
color: 'orange',
labelKey: '终止请求中',
icon: <FaClock size={12} className='text-orange-600' />,
},
};
const DEFAULT_STATUS_CONFIG = {
color: 'grey',
labelKey: null,
icon: <FaInfoCircle size={12} className='text-gray-500' />,
};
const parsePercentValue = (value) => {
if (value === null || value === undefined) return null;
if (typeof value === 'string') {
const parsed = parseFloat(value.replace(/[^0-9.+-]/g, ''));
return Number.isFinite(parsed) ? parsed : null;
}
if (typeof value === 'number') {
return Number.isFinite(value) ? value : null;
}
return null;
};
const clampPercent = (value) => {
if (value === null || value === undefined) return null;
return Math.min(100, Math.max(0, Math.round(value)));
};
const formatRemainingMinutes = (minutes, t) => {
if (minutes === null || minutes === undefined) return null;
const numeric = Number(minutes);
if (!Number.isFinite(numeric)) return null;
const totalMinutes = Math.max(0, Math.round(numeric));
const days = Math.floor(totalMinutes / 1440);
const hours = Math.floor((totalMinutes % 1440) / 60);
const mins = totalMinutes % 60;
const parts = [];
if (days > 0) {
parts.push(`${days}${t('天')}`);
}
if (hours > 0) {
parts.push(`${hours}${t('小时')}`);
}
if (parts.length === 0 || mins > 0) {
parts.push(`${mins}${t('分钟')}`);
}
return parts.join(' ');
};
const getRemainingTheme = (percentRemaining) => {
if (percentRemaining === null) {
return {
iconColor: 'var(--semi-color-primary)',
tagColor: 'blue',
textColor: 'var(--semi-color-text-2)',
};
}
if (percentRemaining <= 10) {
return {
iconColor: '#ff5a5f',
tagColor: 'red',
textColor: '#ff5a5f',
};
}
if (percentRemaining <= 30) {
return {
iconColor: '#ffb400',
tagColor: 'orange',
textColor: '#ffb400',
};
}
return {
iconColor: '#2ecc71',
tagColor: 'green',
textColor: '#2ecc71',
};
};
const renderStatus = (status, t) => {
const normalizedStatus = normalizeStatus(status);
const config = STATUS_TAG_CONFIG[normalizedStatus] || DEFAULT_STATUS_CONFIG;
const statusText = typeof status === 'string' ? status : '';
const labelText = config.labelKey
? t(config.labelKey)
: statusText || t('未知状态');
return (
<Tag
color={config.color}
shape='circle'
size='small'
prefixIcon={config.icon}
>
{labelText}
</Tag>
);
};
// Container Name Cell Component - to properly handle React hooks
const ContainerNameCell = ({ text, record, t }) => {
const handleCopyId = async () => {
try {
await navigator.clipboard.writeText(record.id);
showSuccess(t('已复制 ID 到剪贴板'));
} catch (err) {
showError(t('复制失败'));
}
};
return (
<div className='flex flex-col gap-1'>
<Typography.Text strong className='text-base'>
{text}
</Typography.Text>
<Typography.Text
type='secondary'
size='small'
className='text-xs cursor-pointer hover:text-blue-600 transition-colors select-all'
onClick={handleCopyId}
title={t('点击复制ID')}
>
ID: {record.id}
</Typography.Text>
</div>
);
};
// Render resource configuration
const renderResourceConfig = (resource, t) => {
if (!resource) return '-';
const { cpu, memory, gpu } = resource;
return (
<div className='flex flex-col gap-1'>
{cpu && (
<div className='flex items-center gap-1 text-xs'>
<FaMicrochip className='text-blue-500' />
<span>CPU: {cpu}</span>
</div>
)}
{memory && (
<div className='flex items-center gap-1 text-xs'>
<FaMemory className='text-green-500' />
<span>内存: {memory}</span>
</div>
)}
{gpu && (
<div className='flex items-center gap-1 text-xs'>
<FaServer className='text-purple-500' />
<span>GPU: {gpu}</span>
</div>
)}
</div>
);
};
// Render instance count with status indicator
const renderInstanceCount = (count, record, t) => {
const normalizedStatus = normalizeStatus(record?.status);
const statusConfig = STATUS_TAG_CONFIG[normalizedStatus];
const countColor = statusConfig?.color ?? 'grey';
return (
<Tag color={countColor} size='small' shape='circle'>
{count || 0} {t('个实例')}
</Tag>
);
};
// Main function to get all deployment columns
export const getDeploymentsColumns = ({
t,
COLUMN_KEYS,
startDeployment,
restartDeployment,
deleteDeployment,
setEditingDeployment,
setShowEdit,
refresh,
activePage,
deployments,
// New handlers for enhanced operations
onViewLogs,
onExtendDuration,
onViewDetails,
onUpdateConfig,
onSyncToChannel,
}) => {
const columns = [
{
title: t('容器名称'),
dataIndex: 'container_name',
key: COLUMN_KEYS.container_name,
width: 300,
ellipsis: true,
render: (text, record) => (
<ContainerNameCell text={text} record={record} t={t} />
),
},
{
title: t('状态'),
dataIndex: 'status',
key: COLUMN_KEYS.status,
width: 140,
render: (status) => (
<div className='flex items-center gap-2'>{renderStatus(status, t)}</div>
),
},
{
title: t('服务商'),
dataIndex: 'provider',
key: COLUMN_KEYS.provider,
width: 140,
render: (provider) =>
provider ? (
<div
className='flex items-center gap-1.5 rounded-full border px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide'
style={{
borderColor: 'rgba(59, 130, 246, 0.4)',
backgroundColor: 'rgba(59, 130, 246, 0.08)',
color: '#2563eb',
}}
>
<FaGlobe className='text-[11px]' />
<span>{provider}</span>
</div>
) : (
<Typography.Text
type='tertiary'
size='small'
className='text-xs text-gray-500'
>
{t('暂无')}
</Typography.Text>
),
},
{
title: t('剩余时间'),
dataIndex: 'time_remaining',
key: COLUMN_KEYS.time_remaining,
width: 200,
render: (text, record) => {
const normalizedStatus = normalizeStatus(record?.status);
const percentUsedRaw = parsePercentValue(record?.completed_percent);
const percentUsed = clampPercent(percentUsedRaw);
const percentRemaining =
percentUsed === null ? null : clampPercent(100 - percentUsed);
const theme = getRemainingTheme(percentRemaining);
const statusDisplayMap = {
completed: t('已完成'),
destroyed: t('已销毁'),
failed: t('失败'),
error: t('失败'),
stopped: t('已停止'),
pending: t('待部署'),
deploying: t('部署中'),
'deployment requested': t('部署请求中'),
'termination requested': t('终止中'),
};
const statusOverride = statusDisplayMap[normalizedStatus];
const baseTimeDisplay =
text && String(text).trim() !== '' ? text : t('计算中');
const timeDisplay = baseTimeDisplay;
const humanReadable = formatRemainingMinutes(
record.compute_minutes_remaining,
t,
);
const showProgress = !statusOverride && normalizedStatus === 'running';
const showExtraInfo = Boolean(humanReadable || percentUsed !== null);
const showRemainingMeta =
record.compute_minutes_remaining !== undefined &&
record.compute_minutes_remaining !== null &&
percentRemaining !== null;
return (
<div className='flex flex-col gap-1 leading-tight text-xs'>
<div className='flex items-center gap-1.5'>
<FaHourglassHalf
className='text-sm'
style={{ color: theme.iconColor }}
/>
<Typography.Text className='text-sm font-medium text-[var(--semi-color-text-0)]'>
{timeDisplay}
</Typography.Text>
{showProgress && percentRemaining !== null ? (
<Tag size='small' color={theme.tagColor}>
{percentRemaining}%
</Tag>
) : statusOverride ? (
<Tag size='small' color='grey'>
{statusOverride}
</Tag>
) : null}
</div>
{showExtraInfo && (
<div className='flex items-center gap-3 text-[var(--semi-color-text-2)]'>
{humanReadable && (
<span className='flex items-center gap-1'>
<FaClock className='text-[11px]' />
{t('约')} {humanReadable}
</span>
)}
{percentUsed !== null && (
<span className='flex items-center gap-1'>
<FaCheckCircle className='text-[11px]' />
{t('已用')} {percentUsed}%
</span>
)}
</div>
)}
{showProgress && showRemainingMeta && (
<div className='text-[10px]' style={{ color: theme.textColor }}>
{t('剩余')} {record.compute_minutes_remaining} {t('分钟')}
</div>
)}
</div>
);
},
},
{
title: t('硬件配置'),
dataIndex: 'hardware_info',
key: COLUMN_KEYS.hardware_info,
width: 220,
ellipsis: true,
render: (text, record) => (
<div className='flex items-center gap-2'>
<div className='flex items-center gap-1 px-2 py-1 bg-green-50 border border-green-200 rounded-md'>
<FaServer className='text-green-600 text-xs' />
<span className='text-xs font-medium text-green-700'>
{record.hardware_name}
</span>
</div>
<span className='text-xs text-gray-500 font-medium'>
x{record.hardware_quantity}
</span>
</div>
),
},
{
title: t('创建时间'),
dataIndex: 'created_at',
key: COLUMN_KEYS.created_at,
width: 150,
render: (text) => (
<span className='text-sm text-gray-600'>{timestamp2string(text)}</span>
),
},
{
title: t('操作'),
key: COLUMN_KEYS.actions,
fixed: 'right',
width: 120,
render: (_, record) => {
const { status, id } = record;
const normalizedStatus = normalizeStatus(status);
const isEnded =
normalizedStatus === 'completed' || normalizedStatus === 'destroyed';
const handleDelete = () => {
// Use enhanced confirmation dialog
onUpdateConfig?.(record, 'delete');
};
// Get primary action based on status
const getPrimaryAction = () => {
switch (normalizedStatus) {
case 'running':
return {
icon: <FaInfoCircle className='text-xs' />,
text: t('查看详情'),
onClick: () => onViewDetails?.(record),
type: 'secondary',
theme: 'borderless',
};
case 'failed':
case 'error':
return {
icon: <FaPlay className='text-xs' />,
text: t('重试'),
onClick: () => startDeployment(id),
type: 'primary',
theme: 'solid',
};
case 'stopped':
return {
icon: <FaPlay className='text-xs' />,
text: t('启动'),
onClick: () => startDeployment(id),
type: 'primary',
theme: 'solid',
};
case 'deployment requested':
case 'deploying':
return {
icon: <FaClock className='text-xs' />,
text: t('部署中'),
onClick: () => {},
type: 'secondary',
theme: 'light',
disabled: true,
};
case 'pending':
return {
icon: <FaClock className='text-xs' />,
text: t('待部署'),
onClick: () => {},
type: 'secondary',
theme: 'light',
disabled: true,
};
case 'termination requested':
return {
icon: <FaClock className='text-xs' />,
text: t('终止中'),
onClick: () => {},
type: 'secondary',
theme: 'light',
disabled: true,
};
case 'completed':
case 'destroyed':
default:
return {
icon: <FaInfoCircle className='text-xs' />,
text: t('已结束'),
onClick: () => {},
type: 'tertiary',
theme: 'borderless',
disabled: true,
};
}
};
const primaryAction = getPrimaryAction();
const primaryTheme = primaryAction.theme || 'solid';
const primaryType = primaryAction.type || 'primary';
if (isEnded) {
return (
<div className='flex w-full items-center justify-start gap-1 pr-2'>
<Button
size='small'
type='tertiary'
theme='borderless'
onClick={() => onViewDetails?.(record)}
icon={<FaInfoCircle className='text-xs' />}
>
{t('查看详情')}
</Button>
</div>
);
}
// All actions dropdown with enhanced operations
const dropdownItems = [
<Dropdown.Item
key='details'
onClick={() => onViewDetails?.(record)}
icon={<FaInfoCircle />}
>
{t('查看详情')}
</Dropdown.Item>,
];
if (!isEnded) {
dropdownItems.push(
<Dropdown.Item
key='logs'
onClick={() => onViewLogs?.(record)}
icon={<FaTerminal />}
>
{t('查看日志')}
</Dropdown.Item>,
);
}
const managementItems = [];
if (normalizedStatus === 'running') {
if (onSyncToChannel) {
managementItems.push(
<Dropdown.Item
key='sync-channel'
onClick={() => onSyncToChannel(record)}
icon={<FaLink />}
>
{t('同步到渠道')}
</Dropdown.Item>,
);
}
}
if (normalizedStatus === 'failed' || normalizedStatus === 'error') {
managementItems.push(
<Dropdown.Item
key='retry'
onClick={() => startDeployment(id)}
icon={<FaPlay />}
>
{t('重试')}
</Dropdown.Item>,
);
}
if (normalizedStatus === 'stopped') {
managementItems.push(
<Dropdown.Item
key='start'
onClick={() => startDeployment(id)}
icon={<FaPlay />}
>
{t('启动')}
</Dropdown.Item>,
);
}
if (managementItems.length > 0) {
dropdownItems.push(<Dropdown.Divider key='management-divider' />);
dropdownItems.push(...managementItems);
}
const configItems = [];
if (
!isEnded &&
(normalizedStatus === 'running' ||
normalizedStatus === 'deployment requested')
) {
configItems.push(
<Dropdown.Item
key='extend'
onClick={() => onExtendDuration?.(record)}
icon={<FaPlus />}
>
{t('延长时长')}
</Dropdown.Item>,
);
}
// if (!isEnded && normalizedStatus === 'running') {
// configItems.push(
// <Dropdown.Item key="update-config" onClick={() => onUpdateConfig?.(record)} icon={<FaCog />}>
// {t('更新配置')}
// </Dropdown.Item>,
// );
// }
if (configItems.length > 0) {
dropdownItems.push(<Dropdown.Divider key='config-divider' />);
dropdownItems.push(...configItems);
}
if (!isEnded) {
dropdownItems.push(<Dropdown.Divider key='danger-divider' />);
dropdownItems.push(
<Dropdown.Item
key='delete'
type='danger'
onClick={handleDelete}
icon={<FaTrash />}
>
{t('销毁容器')}
</Dropdown.Item>,
);
}
const allActions = <Dropdown.Menu>{dropdownItems}</Dropdown.Menu>;
const hasDropdown = dropdownItems.length > 0;
return (
<div className='flex w-full items-center justify-start gap-1 pr-2'>
<Button
size='small'
theme={primaryTheme}
type={primaryType}
icon={primaryAction.icon}
onClick={primaryAction.onClick}
className='px-2 text-xs'
disabled={primaryAction.disabled}
>
{primaryAction.text}
</Button>
{hasDropdown && (
<Dropdown
trigger='click'
position='bottomRight'
render={allActions}
>
<Button
size='small'
theme='light'
type='tertiary'
icon={<IconMore />}
className='px-1'
/>
</Dropdown>
)}
</div>
);
},
},
];
return columns;
};
@@ -0,0 +1,130 @@
/*
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 React, { useRef } from 'react';
import { Form, Button } from '@douyinfe/semi-ui';
import { IconSearch, IconRefresh } from '@douyinfe/semi-icons';
const DeploymentsFilters = ({
formInitValues,
setFormApi,
searchDeployments,
loading,
searching,
setShowColumnSelector,
t,
}) => {
const formApiRef = useRef(null);
const handleSubmit = (values) => {
searchDeployments(values);
};
const handleReset = () => {
if (!formApiRef.current) return;
formApiRef.current.reset();
setTimeout(() => {
formApiRef.current.submitForm();
}, 0);
};
const statusOptions = [
{ label: t('全部状态'), value: '' },
{ label: t('运行中'), value: 'running' },
{ label: t('已完成'), value: 'completed' },
{ label: t('失败'), value: 'failed' },
{ label: t('部署请求中'), value: 'deployment requested' },
{ label: t('终止请求中'), value: 'termination requested' },
{ label: t('已销毁'), value: 'destroyed' },
];
return (
<Form
layout='horizontal'
onSubmit={handleSubmit}
initValues={formInitValues}
getFormApi={(formApi) => {
setFormApi(formApi);
formApiRef.current = formApi;
}}
className='w-full md:w-auto order-1 md:order-2'
>
<div className='flex flex-col md:flex-row items-center gap-2 w-full md:w-auto'>
<div className='w-full md:w-64'>
<Form.Input
field='searchKeyword'
placeholder={t('搜索部署名称')}
prefix={<IconSearch />}
showClear
size='small'
pure
/>
</div>
<div className='w-full md:w-48'>
<Form.Select
field='searchStatus'
placeholder={t('选择状态')}
optionList={statusOptions}
className='w-full'
showClear
size='small'
pure
/>
</div>
<div className='flex gap-2 w-full md:w-auto'>
<Button
htmlType='submit'
type='tertiary'
icon={<IconSearch />}
loading={searching}
disabled={loading}
size='small'
className='flex-1 md:flex-initial md:w-auto'
>
{t('查询')}
</Button>
<Button
type='tertiary'
icon={<IconRefresh />}
onClick={handleReset}
disabled={loading || searching}
size='small'
className='flex-1 md:flex-initial md:w-auto'
>
{t('重置')}
</Button>
<Button
type='tertiary'
onClick={() => setShowColumnSelector(true)}
size='small'
className='flex-1 md:flex-initial md:w-auto'
>
{t('列设置')}
</Button>
</div>
</div>
</Form>
);
};
export default DeploymentsFilters;
@@ -0,0 +1,247 @@
/*
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 React, { useMemo, useState } from 'react';
import { Empty } from '@douyinfe/semi-ui';
import CardTable from '../../common/ui/CardTable';
import {
IllustrationNoResult,
IllustrationNoResultDark,
} from '@douyinfe/semi-illustrations';
import { getDeploymentsColumns } from './DeploymentsColumnDefs';
// Import all the new modals
import ViewLogsModal from './modals/ViewLogsModal';
import ExtendDurationModal from './modals/ExtendDurationModal';
import ViewDetailsModal from './modals/ViewDetailsModal';
import UpdateConfigModal from './modals/UpdateConfigModal';
import ConfirmationDialog from './modals/ConfirmationDialog';
const DeploymentsTable = (deploymentsData) => {
const {
deployments,
loading,
searching,
activePage,
pageSize,
deploymentCount,
compactMode,
visibleColumns,
rowSelection,
batchOperationsEnabled = true,
handlePageChange,
handlePageSizeChange,
handleRow,
t,
COLUMN_KEYS,
// Column functions and data
startDeployment,
restartDeployment,
deleteDeployment,
syncDeploymentToChannel,
setEditingDeployment,
setShowEdit,
refresh,
} = deploymentsData;
// Modal states
const [selectedDeployment, setSelectedDeployment] = useState(null);
const [showLogsModal, setShowLogsModal] = useState(false);
const [showExtendModal, setShowExtendModal] = useState(false);
const [showDetailsModal, setShowDetailsModal] = useState(false);
const [showConfigModal, setShowConfigModal] = useState(false);
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
const [confirmOperation, setConfirmOperation] = useState('delete');
// Enhanced modal handlers
const handleViewLogs = (deployment) => {
setSelectedDeployment(deployment);
setShowLogsModal(true);
};
const handleExtendDuration = (deployment) => {
setSelectedDeployment(deployment);
setShowExtendModal(true);
};
const handleViewDetails = (deployment) => {
setSelectedDeployment(deployment);
setShowDetailsModal(true);
};
const handleUpdateConfig = (deployment, operation = 'update') => {
setSelectedDeployment(deployment);
if (operation === 'delete' || operation === 'destroy') {
setConfirmOperation(operation);
setShowConfirmDialog(true);
} else {
setShowConfigModal(true);
}
};
const handleConfirmAction = () => {
if (
selectedDeployment &&
(confirmOperation === 'delete' || confirmOperation === 'destroy')
) {
deleteDeployment(selectedDeployment.id);
}
setShowConfirmDialog(false);
setSelectedDeployment(null);
};
const handleModalSuccess = (updatedDeployment) => {
// Refresh the deployments list
refresh?.();
};
// Get all columns
const allColumns = useMemo(() => {
return getDeploymentsColumns({
t,
COLUMN_KEYS,
startDeployment,
restartDeployment,
deleteDeployment,
setEditingDeployment,
setShowEdit,
refresh,
activePage,
deployments,
// Enhanced handlers
onViewLogs: handleViewLogs,
onExtendDuration: handleExtendDuration,
onViewDetails: handleViewDetails,
onUpdateConfig: handleUpdateConfig,
onSyncToChannel: syncDeploymentToChannel,
});
}, [
t,
COLUMN_KEYS,
startDeployment,
restartDeployment,
deleteDeployment,
syncDeploymentToChannel,
setEditingDeployment,
setShowEdit,
refresh,
activePage,
deployments,
]);
// Filter columns based on visibility settings
const getVisibleColumns = () => {
return allColumns.filter((column) => visibleColumns[column.key]);
};
const visibleColumnsList = useMemo(() => {
return getVisibleColumns();
}, [visibleColumns, allColumns]);
const tableColumns = useMemo(() => {
if (compactMode) {
// In compact mode, remove fixed columns and adjust widths
return visibleColumnsList.map(({ fixed, width, ...rest }) => ({
...rest,
width: width ? Math.max(width * 0.8, 80) : undefined, // Reduce width by 20% but keep minimum
}));
}
return visibleColumnsList;
}, [compactMode, visibleColumnsList]);
return (
<>
<CardTable
columns={tableColumns}
dataSource={deployments}
scroll={compactMode ? { x: 800 } : { x: 1200 }}
pagination={{
currentPage: activePage,
pageSize: pageSize,
total: deploymentCount,
pageSizeOpts: [10, 20, 50, 100],
showSizeChanger: true,
onPageSizeChange: handlePageSizeChange,
onPageChange: handlePageChange,
}}
hidePagination={true}
expandAllRows={false}
onRow={handleRow}
rowSelection={batchOperationsEnabled ? rowSelection : undefined}
empty={
<Empty
image={<IllustrationNoResult style={{ width: 150, height: 150 }} />}
darkModeImage={
<IllustrationNoResultDark style={{ width: 150, height: 150 }} />
}
description={t('搜索无结果')}
style={{ padding: 30 }}
/>
}
className='rounded-xl overflow-hidden'
size='middle'
loading={loading || searching}
/>
{/* Enhanced Modals */}
<ViewLogsModal
visible={showLogsModal}
onCancel={() => setShowLogsModal(false)}
deployment={selectedDeployment}
t={t}
/>
<ExtendDurationModal
visible={showExtendModal}
onCancel={() => setShowExtendModal(false)}
deployment={selectedDeployment}
onSuccess={handleModalSuccess}
t={t}
/>
<ViewDetailsModal
visible={showDetailsModal}
onCancel={() => setShowDetailsModal(false)}
deployment={selectedDeployment}
t={t}
/>
<UpdateConfigModal
visible={showConfigModal}
onCancel={() => setShowConfigModal(false)}
deployment={selectedDeployment}
onSuccess={handleModalSuccess}
t={t}
/>
<ConfirmationDialog
visible={showConfirmDialog}
onCancel={() => setShowConfirmDialog(false)}
onConfirm={handleConfirmAction}
title={t('确认操作')}
type='danger'
deployment={selectedDeployment}
operation={confirmOperation}
t={t}
/>
</>
);
};
export default DeploymentsTable;
@@ -0,0 +1,152 @@
/*
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 React, { useState } from 'react';
import CardPro from '../../common/ui/CardPro';
import DeploymentsTable from './DeploymentsTable';
import DeploymentsActions from './DeploymentsActions';
import DeploymentsFilters from './DeploymentsFilters';
import EditDeploymentModal from './modals/EditDeploymentModal';
import CreateDeploymentModal from './modals/CreateDeploymentModal';
import ColumnSelectorModal from './modals/ColumnSelectorModal';
import { useDeploymentsData } from '../../../hooks/model-deployments/useDeploymentsData';
import { useIsMobile } from '../../../hooks/common/useIsMobile';
import { createCardProPagination } from '../../../helpers/utils';
const DeploymentsPage = () => {
const deploymentsData = useDeploymentsData();
const isMobile = useIsMobile();
// Create deployment modal state
const [showCreateModal, setShowCreateModal] = useState(false);
const batchOperationsEnabled = false;
const {
// Edit state
showEdit,
editingDeployment,
closeEdit,
refresh,
// Actions state
selectedKeys,
setSelectedKeys,
setEditingDeployment,
setShowEdit,
batchDeleteDeployments,
// Filters state
formInitValues,
setFormApi,
searchDeployments,
loading,
searching,
// Column visibility
showColumnSelector,
setShowColumnSelector,
visibleColumns,
setVisibleColumns,
COLUMN_KEYS,
// Description state
compactMode,
setCompactMode,
// Translation
t,
} = deploymentsData;
return (
<>
{/* Modals */}
<EditDeploymentModal
refresh={refresh}
editingDeployment={editingDeployment}
visible={showEdit}
handleClose={closeEdit}
/>
<CreateDeploymentModal
visible={showCreateModal}
onCancel={() => setShowCreateModal(false)}
onSuccess={refresh}
t={t}
/>
<ColumnSelectorModal
visible={showColumnSelector}
onCancel={() => setShowColumnSelector(false)}
visibleColumns={visibleColumns}
onVisibleColumnsChange={setVisibleColumns}
columnKeys={COLUMN_KEYS}
t={t}
/>
{/* Main Content */}
<CardPro
type='type3'
actionsArea={
<div className='flex flex-col md:flex-row justify-between items-center gap-2 w-full'>
<DeploymentsActions
selectedKeys={selectedKeys}
setSelectedKeys={setSelectedKeys}
setEditingDeployment={setEditingDeployment}
setShowEdit={setShowEdit}
batchDeleteDeployments={batchDeleteDeployments}
batchOperationsEnabled={batchOperationsEnabled}
compactMode={compactMode}
setCompactMode={setCompactMode}
showCreateModal={showCreateModal}
setShowCreateModal={setShowCreateModal}
setShowColumnSelector={setShowColumnSelector}
t={t}
/>
<DeploymentsFilters
formInitValues={formInitValues}
setFormApi={setFormApi}
searchDeployments={searchDeployments}
loading={loading}
searching={searching}
setShowColumnSelector={setShowColumnSelector}
t={t}
/>
</div>
}
paginationArea={createCardProPagination({
currentPage: deploymentsData.activePage,
pageSize: deploymentsData.pageSize,
total: deploymentsData.deploymentCount,
onPageChange: deploymentsData.handlePageChange,
onPageSizeChange: deploymentsData.handlePageSizeChange,
isMobile: isMobile,
t: deploymentsData.t,
})}
t={deploymentsData.t}
>
<DeploymentsTable
{...deploymentsData}
batchOperationsEnabled={batchOperationsEnabled}
/>
</CardPro>
</>
);
};
export default DeploymentsPage;
@@ -0,0 +1,127 @@
/*
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 React, { useMemo } from 'react';
import { Modal, Button, Checkbox } from '@douyinfe/semi-ui';
const ColumnSelectorModal = ({
visible,
onCancel,
visibleColumns,
onVisibleColumnsChange,
columnKeys,
t,
}) => {
const columnOptions = useMemo(
() => [
{ key: columnKeys.container_name, label: t('容器名称'), required: true },
{ key: columnKeys.status, label: t('状态') },
{ key: columnKeys.time_remaining, label: t('剩余时间') },
{ key: columnKeys.hardware_info, label: t('硬件配置') },
{ key: columnKeys.created_at, label: t('创建时间') },
{ key: columnKeys.actions, label: t('操作'), required: true },
],
[columnKeys, t],
);
const handleColumnVisibilityChange = (key, checked) => {
const column = columnOptions.find((option) => option.key === key);
if (column?.required) return;
onVisibleColumnsChange({
...visibleColumns,
[key]: checked,
});
};
const handleSelectAll = (checked) => {
const updated = { ...visibleColumns };
columnOptions.forEach(({ key, required }) => {
updated[key] = required ? true : checked;
});
onVisibleColumnsChange(updated);
};
const handleReset = () => {
const defaults = columnOptions.reduce((acc, { key }) => {
acc[key] = true;
return acc;
}, {});
onVisibleColumnsChange({
...visibleColumns,
...defaults,
});
};
const allSelected = columnOptions.every(
({ key, required }) => required || visibleColumns[key],
);
const indeterminate =
columnOptions.some(
({ key, required }) => !required && visibleColumns[key],
) && !allSelected;
const handleConfirm = () => onCancel();
return (
<Modal
title={t('列设置')}
visible={visible}
onCancel={onCancel}
footer={
<div className='flex justify-end gap-2'>
<Button onClick={handleReset}>{t('重置')}</Button>
<Button onClick={onCancel}>{t('取消')}</Button>
<Button type='primary' onClick={handleConfirm}>
{t('确定')}
</Button>
</div>
}
>
<div style={{ marginBottom: 20 }}>
<Checkbox
checked={allSelected}
indeterminate={indeterminate}
onChange={(e) => handleSelectAll(e.target.checked)}
>
{t('全选')}
</Checkbox>
</div>
<div
className='flex flex-wrap max-h-96 overflow-y-auto rounded-lg p-4'
style={{ border: '1px solid var(--semi-color-border)' }}
>
{columnOptions.map(({ key, label, required }) => (
<div key={key} className='w-1/2 mb-4 pr-2'>
<Checkbox
checked={!!visibleColumns[key]}
disabled={required}
onChange={(e) =>
handleColumnVisibilityChange(key, e.target.checked)
}
>
{label}
</Checkbox>
</div>
))}
</div>
</Modal>
);
};
export default ColumnSelectorModal;
@@ -0,0 +1,99 @@
/*
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 React, { useState, useEffect } from 'react';
import { Modal, Typography, Input } from '@douyinfe/semi-ui';
const { Text } = Typography;
const ConfirmationDialog = ({
visible,
onCancel,
onConfirm,
title,
type = 'danger',
deployment,
t,
loading = false,
}) => {
const [confirmText, setConfirmText] = useState('');
useEffect(() => {
if (!visible) {
setConfirmText('');
}
}, [visible]);
const requiredText = deployment?.container_name || deployment?.id || '';
const isConfirmed = Boolean(requiredText) && confirmText === requiredText;
const handleCancel = () => {
setConfirmText('');
onCancel();
};
const handleConfirm = () => {
if (isConfirmed) {
onConfirm();
handleCancel();
}
};
return (
<Modal
title={title}
visible={visible}
onCancel={handleCancel}
onOk={handleConfirm}
okText={t('确认')}
cancelText={t('取消')}
okButtonProps={{
disabled: !isConfirmed,
type: type === 'danger' ? 'danger' : 'primary',
loading,
}}
width={480}
>
<div className='space-y-4'>
<Text type='danger' strong>
{t('此操作具有风险,请确认要继续执行')}
</Text>
<Text>
{t('请输入部署名称以完成二次确认')}
<Text code className='ml-1'>
{requiredText || t('未知部署')}
</Text>
</Text>
<Input
value={confirmText}
onChange={setConfirmText}
placeholder={t('再次输入部署名称')}
autoFocus
/>
{!isConfirmed && confirmText && (
<Text type='danger' size='small'>
{t('部署名称不匹配,请检查后重新输入')}
</Text>
)}
</div>
</Modal>
);
};
export default ConfirmationDialog;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,241 @@
/*
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 React, { useState, useEffect, useRef } from 'react';
import {
SideSheet,
Form,
Button,
Space,
Spin,
Typography,
Card,
InputNumber,
Select,
Input,
Row,
Col,
Divider,
Tag,
} from '@douyinfe/semi-ui';
import { Save, X, Server } from 'lucide-react';
import { API, showError, showSuccess } from '../../../../helpers';
import { useTranslation } from 'react-i18next';
import { useIsMobile } from '../../../../hooks/common/useIsMobile';
const { Text, Title } = Typography;
const EditDeploymentModal = ({
refresh,
editingDeployment,
visible,
handleClose,
}) => {
const { t } = useTranslation();
const isMobile = useIsMobile();
const [loading, setLoading] = useState(false);
const [models, setModels] = useState([]);
const [loadingModels, setLoadingModels] = useState(false);
const formRef = useRef();
const isEdit = Boolean(editingDeployment?.id);
const title = t('重命名部署');
// Resource configuration options
const cpuOptions = [
{ label: '0.5 Core', value: '0.5' },
{ label: '1 Core', value: '1' },
{ label: '2 Cores', value: '2' },
{ label: '4 Cores', value: '4' },
{ label: '8 Cores', value: '8' },
];
const memoryOptions = [
{ label: '1GB', value: '1Gi' },
{ label: '2GB', value: '2Gi' },
{ label: '4GB', value: '4Gi' },
{ label: '8GB', value: '8Gi' },
{ label: '16GB', value: '16Gi' },
{ label: '32GB', value: '32Gi' },
];
const gpuOptions = [
{ label: t('无GPU'), value: '' },
{ label: '1 GPU', value: '1' },
{ label: '2 GPUs', value: '2' },
{ label: '4 GPUs', value: '4' },
];
// Load available models
const loadModels = async () => {
setLoadingModels(true);
try {
const res = await API.get('/api/models/?page_size=1000');
if (res.data.success) {
const items = res.data.data.items || res.data.data || [];
const modelOptions = items.map((model) => ({
label: `${model.model_name} (${model.vendor?.name || 'Unknown'})`,
value: model.model_name,
model_id: model.id,
}));
setModels(modelOptions);
}
} catch (error) {
console.error('Failed to load models:', error);
showError(t('加载模型列表失败'));
}
setLoadingModels(false);
};
// Form submission
const handleSubmit = async (values) => {
if (!isEdit || !editingDeployment?.id) {
showError(t('无效的部署信息'));
return;
}
setLoading(true);
try {
// Only handle name update for now
const res = await API.put(
`/api/deployments/${editingDeployment.id}/name`,
{
name: values.deployment_name,
},
);
if (res.data.success) {
showSuccess(t('部署名称更新成功'));
handleClose();
refresh();
} else {
showError(res.data.message || t('更新失败'));
}
} catch (error) {
console.error('Submit error:', error);
showError(t('更新失败,请检查输入信息'));
}
setLoading(false);
};
// Load models when modal opens
useEffect(() => {
if (visible) {
loadModels();
}
}, [visible]);
// Set form values when editing
useEffect(() => {
if (formRef.current && editingDeployment && visible && isEdit) {
formRef.current.setValues({
deployment_name: editingDeployment.deployment_name || '',
});
}
}, [editingDeployment, visible, isEdit]);
return (
<SideSheet
title={
<div className='flex items-center gap-2'>
<Server size={20} />
<span>{title}</span>
</div>
}
visible={visible}
onCancel={handleClose}
width={isMobile ? '100%' : 600}
bodyStyle={{ padding: 0 }}
maskClosable={false}
closeOnEsc={true}
>
<div className='p-6 h-full overflow-auto'>
<Spin spinning={loading} style={{ width: '100%' }}>
<Form
ref={formRef}
onSubmit={handleSubmit}
labelPosition='top'
style={{ width: '100%' }}
>
<Card>
<Title heading={5} style={{ marginBottom: 16 }}>
{t('修改部署名称')}
</Title>
<Row gutter={16}>
<Col span={24}>
<Form.Input
field='deployment_name'
label={t('部署名称')}
placeholder={t('请输入新的部署名称')}
rules={[
{ required: true, message: t('请输入部署名称') },
{
pattern: /^[a-zA-Z0-9-_\u4e00-\u9fa5]+$/,
message: t(
'部署名称只能包含字母、数字、横线、下划线和中文',
),
},
]}
/>
</Col>
</Row>
{isEdit && (
<div className='mt-4 p-3 bg-gray-50 rounded'>
<Text type='secondary'>{t('部署ID')}: </Text>
<Text code>{editingDeployment.id}</Text>
<br />
<Text type='secondary'>{t('当前状态')}: </Text>
<Tag
color={
editingDeployment.status === 'running' ? 'green' : 'grey'
}
>
{editingDeployment.status}
</Tag>
</div>
)}
</Card>
</Form>
</Spin>
</div>
<div className='p-4 border-t border-gray-200 bg-gray-50 flex justify-end'>
<Space>
<Button theme='outline' onClick={handleClose} disabled={loading}>
<X size={16} className='mr-1' />
{t('取消')}
</Button>
<Button
theme='solid'
type='primary'
loading={loading}
onClick={() => formRef.current?.submitForm()}
>
<Save size={16} className='mr-1' />
{isEdit ? t('更新') : t('创建')}
</Button>
</Space>
</div>
</SideSheet>
);
};
export default EditDeploymentModal;
@@ -0,0 +1,542 @@
/*
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 React, { useEffect, useRef, useState } from 'react';
import {
Modal,
Form,
InputNumber,
Typography,
Card,
Space,
Divider,
Button,
Tag,
Banner,
Spin,
} from '@douyinfe/semi-ui';
import {
FaClock,
FaCalculator,
FaInfoCircle,
FaExclamationTriangle,
} from 'react-icons/fa';
import { API, showError, showSuccess } from '../../../../helpers';
const { Text } = Typography;
const ExtendDurationModal = ({
visible,
onCancel,
deployment,
onSuccess,
t,
}) => {
const formRef = useRef(null);
const [loading, setLoading] = useState(false);
const [durationHours, setDurationHours] = useState(1);
const [costLoading, setCostLoading] = useState(false);
const [priceEstimation, setPriceEstimation] = useState(null);
const [priceError, setPriceError] = useState(null);
const [detailsLoading, setDetailsLoading] = useState(false);
const [deploymentDetails, setDeploymentDetails] = useState(null);
const costRequestIdRef = useRef(0);
const resetState = () => {
costRequestIdRef.current += 1;
setDurationHours(1);
setPriceEstimation(null);
setPriceError(null);
setDeploymentDetails(null);
setCostLoading(false);
};
const fetchDeploymentDetails = async (deploymentId) => {
setDetailsLoading(true);
try {
const response = await API.get(`/api/deployments/${deploymentId}`);
if (response.data.success) {
const details = response.data.data;
setDeploymentDetails(details);
setPriceError(null);
return details;
}
const message = response.data.message || '';
const errorMessage = t('获取详情失败') + (message ? `: ${message}` : '');
showError(errorMessage);
setDeploymentDetails(null);
setPriceEstimation(null);
setPriceError(errorMessage);
return null;
} catch (error) {
const message = error?.response?.data?.message || error.message || '';
const errorMessage = t('获取详情失败') + (message ? `: ${message}` : '');
showError(errorMessage);
setDeploymentDetails(null);
setPriceEstimation(null);
setPriceError(errorMessage);
return null;
} finally {
setDetailsLoading(false);
}
};
const calculatePrice = async (hours, details) => {
if (!visible || !details) {
return;
}
const sanitizedHours = Number.isFinite(hours) ? Math.round(hours) : 0;
if (sanitizedHours <= 0) {
setPriceEstimation(null);
setPriceError(null);
return;
}
const hardwareId = Number(details?.hardware_id) || 0;
const totalGPUs = Number(details?.total_gpus) || 0;
const totalContainers = Number(details?.total_containers) || 0;
const baseGpusPerContainer = Number(details?.gpus_per_container) || 0;
const resolvedGpusPerContainer =
baseGpusPerContainer > 0
? baseGpusPerContainer
: totalContainers > 0 && totalGPUs > 0
? Math.max(1, Math.round(totalGPUs / totalContainers))
: 0;
const resolvedReplicaCount =
totalContainers > 0
? totalContainers
: resolvedGpusPerContainer > 0 && totalGPUs > 0
? Math.max(1, Math.round(totalGPUs / resolvedGpusPerContainer))
: 0;
const locationIds = Array.isArray(details?.locations)
? details.locations
.map((location) =>
Number(
location?.id ?? location?.location_id ?? location?.locationId,
),
)
.filter((id) => Number.isInteger(id) && id > 0)
: [];
if (
hardwareId <= 0 ||
resolvedGpusPerContainer <= 0 ||
resolvedReplicaCount <= 0 ||
locationIds.length === 0
) {
setPriceEstimation(null);
setPriceError(t('价格计算失败'));
return;
}
const requestId = Date.now();
costRequestIdRef.current = requestId;
setCostLoading(true);
setPriceError(null);
const payload = {
location_ids: locationIds,
hardware_id: hardwareId,
gpus_per_container: resolvedGpusPerContainer,
duration_hours: sanitizedHours,
replica_count: resolvedReplicaCount,
currency: 'usdc',
duration_type: 'hour',
duration_qty: sanitizedHours,
hardware_qty: resolvedGpusPerContainer,
};
try {
const response = await API.post(
'/api/deployments/price-estimation',
payload,
);
if (costRequestIdRef.current !== requestId) {
return;
}
if (response.data.success) {
setPriceEstimation(response.data.data);
} else {
const message = response.data.message || '';
setPriceEstimation(null);
setPriceError(t('价格计算失败') + (message ? `: ${message}` : ''));
}
} catch (error) {
if (costRequestIdRef.current !== requestId) {
return;
}
const message = error?.response?.data?.message || error.message || '';
setPriceEstimation(null);
setPriceError(t('价格计算失败') + (message ? `: ${message}` : ''));
} finally {
if (costRequestIdRef.current === requestId) {
setCostLoading(false);
}
}
};
useEffect(() => {
if (visible && deployment?.id) {
resetState();
if (formRef.current) {
formRef.current.setValue('duration_hours', 1);
}
fetchDeploymentDetails(deployment.id);
}
if (!visible) {
resetState();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [visible, deployment?.id]);
useEffect(() => {
if (!visible) {
return;
}
if (!deploymentDetails) {
return;
}
calculatePrice(durationHours, deploymentDetails);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [durationHours, deploymentDetails, visible]);
const handleExtend = async () => {
try {
if (formRef.current) {
await formRef.current.validate();
}
setLoading(true);
const response = await API.post(
`/api/deployments/${deployment.id}/extend`,
{
duration_hours: Math.round(durationHours),
},
);
if (response.data.success) {
showSuccess(t('容器时长延长成功'));
onSuccess?.(response.data.data);
handleCancel();
}
} catch (error) {
showError(
t('延长时长失败') +
': ' +
(error?.response?.data?.message || error.message),
);
} finally {
setLoading(false);
}
};
const handleCancel = () => {
if (formRef.current) {
formRef.current.reset();
}
resetState();
onCancel();
};
const currentRemainingTime = deployment?.time_remaining || '0分钟';
const newTotalTime = `${currentRemainingTime} + ${durationHours}${t('小时')}`;
const priceData = priceEstimation || {};
const breakdown = priceData.price_breakdown || priceData.PriceBreakdown || {};
const currencyLabel = (priceData.currency || priceData.Currency || 'USDC')
.toString()
.toUpperCase();
const estimatedTotalCost =
typeof priceData.estimated_cost === 'number'
? priceData.estimated_cost
: typeof priceData.EstimatedCost === 'number'
? priceData.EstimatedCost
: typeof breakdown.total_cost === 'number'
? breakdown.total_cost
: breakdown.TotalCost;
const hourlyRate =
typeof breakdown.hourly_rate === 'number'
? breakdown.hourly_rate
: breakdown.HourlyRate;
const computeCost =
typeof breakdown.compute_cost === 'number'
? breakdown.compute_cost
: breakdown.ComputeCost;
const resolvedHardwareName =
deploymentDetails?.hardware_name || deployment?.hardware_name || '--';
const gpuCount =
deploymentDetails?.total_gpus || deployment?.hardware_quantity || 0;
const containers = deploymentDetails?.total_containers || 0;
return (
<Modal
title={
<div className='flex items-center gap-2'>
<FaClock className='text-blue-500' />
<span>{t('延长容器时长')}</span>
</div>
}
visible={visible}
onCancel={handleCancel}
onOk={handleExtend}
okText={t('确认延长')}
cancelText={t('取消')}
confirmLoading={loading}
okButtonProps={{
disabled:
!deployment?.id ||
detailsLoading ||
!durationHours ||
durationHours < 1,
}}
width={600}
className='extend-duration-modal'
>
<div className='space-y-4'>
<Card className='border-0 bg-gray-50'>
<div className='flex items-center justify-between'>
<div>
<Text strong className='text-base'>
{deployment?.container_name || deployment?.deployment_name}
</Text>
<div className='mt-1'>
<Text type='secondary' size='small'>
ID: {deployment?.id}
</Text>
</div>
</div>
<div className='text-right'>
<div className='flex items-center gap-2 mb-1'>
<Tag color='blue' size='small'>
{resolvedHardwareName}
{gpuCount ? ` x${gpuCount}` : ''}
</Tag>
</div>
<Text size='small' type='secondary'>
{t('当前剩余')}: <Text strong>{currentRemainingTime}</Text>
</Text>
</div>
</div>
</Card>
<Banner
type='warning'
icon={<FaExclamationTriangle />}
title={t('重要提醒')}
description={
<div className='space-y-2'>
<p>
{t('延长容器时长将会产生额外费用,请确认您有足够的账户余额。')}
</p>
<p>{t('延长操作一旦确认无法撤销,费用将立即扣除。')}</p>
</div>
}
/>
<Form
getFormApi={(api) => (formRef.current = api)}
layout='vertical'
onValueChange={(values) => {
if (values.duration_hours !== undefined) {
const numericValue = Number(values.duration_hours);
setDurationHours(
Number.isFinite(numericValue) ? numericValue : 0,
);
}
}}
>
<Form.InputNumber
field='duration_hours'
label={t('延长时长(小时)')}
placeholder={t('请输入要延长的小时数')}
min={1}
max={720}
step={1}
initValue={1}
style={{ width: '100%' }}
suffix={t('小时')}
rules={[
{ required: true, message: t('请输入延长时长') },
{
type: 'number',
min: 1,
message: t('延长时长至少为1小时'),
},
{
type: 'number',
max: 720,
message: t('延长时长不能超过720小时(30天)'),
},
]}
/>
</Form>
<div className='space-y-2'>
<Text size='small' type='secondary'>
{t('快速选择')}:
</Text>
<Space wrap>
{[1, 2, 6, 12, 24, 48, 72, 168].map((hours) => (
<Button
key={hours}
size='small'
theme={durationHours === hours ? 'solid' : 'borderless'}
type={durationHours === hours ? 'primary' : 'secondary'}
onClick={() => {
setDurationHours(hours);
if (formRef.current) {
formRef.current.setValue('duration_hours', hours);
}
}}
>
{hours < 24
? `${hours}${t('小时')}`
: `${hours / 24}${t('天')}`}
</Button>
))}
</Space>
</div>
<Divider />
<Card
title={
<div className='flex items-center gap-2'>
<FaCalculator className='text-green-500' />
<span>{t('费用预估')}</span>
</div>
}
className='border border-green-200'
>
{priceEstimation ? (
<div className='space-y-3'>
<div className='flex items-center justify-between'>
<Text>{t('延长时长')}:</Text>
<Text strong>
{Math.round(durationHours)} {t('小时')}
</Text>
</div>
<div className='flex items-center justify-between'>
<Text>{t('硬件配置')}:</Text>
<Text strong>
{resolvedHardwareName}
{gpuCount ? ` x${gpuCount}` : ''}
</Text>
</div>
{containers ? (
<div className='flex items-center justify-between'>
<Text>{t('容器数量')}:</Text>
<Text strong>{containers}</Text>
</div>
) : null}
<div className='flex items-center justify-between'>
<Text>{t('单GPU小时费率')}:</Text>
<Text strong>
{typeof hourlyRate === 'number'
? `${hourlyRate.toFixed(4)} ${currencyLabel}`
: '--'}
</Text>
</div>
{typeof computeCost === 'number' && (
<div className='flex items-center justify-between'>
<Text>{t('计算成本')}:</Text>
<Text strong>
{computeCost.toFixed(4)} {currencyLabel}
</Text>
</div>
)}
<Divider margin='12px' />
<div className='flex items-center justify-between'>
<Text strong className='text-lg'>
{t('预估总费用')}:
</Text>
<Text strong className='text-lg text-green-600'>
{typeof estimatedTotalCost === 'number'
? `${estimatedTotalCost.toFixed(4)} ${currencyLabel}`
: '--'}
</Text>
</div>
<div className='bg-blue-50 p-3 rounded-lg'>
<div className='flex items-start gap-2'>
<FaInfoCircle className='text-blue-500 mt-0.5' />
<div>
<Text size='small' type='secondary'>
{t('延长后总时长')}: <Text strong>{newTotalTime}</Text>
</Text>
<br />
<Text size='small' type='secondary'>
{t('预估费用仅供参考,实际费用可能略有差异')}
</Text>
</div>
</div>
</div>
</div>
) : (
<div className='text-center text-gray-500 py-4'>
{costLoading ? (
<Space align='center' className='justify-center'>
<Spin size='small' />
<Text type='secondary'>{t('计算费用中...')}</Text>
</Space>
) : priceError ? (
<Text type='danger'>{priceError}</Text>
) : deploymentDetails ? (
<Text type='secondary'>{t('请输入延长时长')}</Text>
) : (
<Text type='secondary'>{t('加载详情中...')}</Text>
)}
</div>
)}
</Card>
<div className='bg-red-50 border border-red-200 rounded-lg p-3'>
<div className='flex items-start gap-2'>
<FaExclamationTriangle className='text-red-500 mt-0.5' />
<div>
<Text strong className='text-red-700'>
{t('确认延长容器时长')}
</Text>
<div className='mt-1'>
<Text size='small' className='text-red-600'>
{t('点击"确认延长"后将立即扣除费用并延长容器运行时间')}
</Text>
</div>
</div>
</div>
</div>
</div>
</Modal>
);
};
export default ExtendDurationModal;
@@ -0,0 +1,497 @@
/*
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 React, { useState, useEffect, useRef } from 'react';
import {
Modal,
Form,
Input,
InputNumber,
Typography,
Card,
Space,
Divider,
Button,
Banner,
Tag,
Collapse,
TextArea,
Switch,
} from '@douyinfe/semi-ui';
import {
FaCog,
FaDocker,
FaKey,
FaTerminal,
FaNetworkWired,
FaExclamationTriangle,
FaPlus,
FaMinus,
} from 'react-icons/fa';
import { API, showError, showSuccess } from '../../../../helpers';
const { Text, Title } = Typography;
const UpdateConfigModal = ({ visible, onCancel, deployment, onSuccess, t }) => {
const formRef = useRef(null);
const [loading, setLoading] = useState(false);
const [envVars, setEnvVars] = useState([]);
const [secretEnvVars, setSecretEnvVars] = useState([]);
// Initialize form data when modal opens
useEffect(() => {
if (visible && deployment) {
// Set initial form values based on deployment data
const initialValues = {
image_url: deployment.container_config?.image_url || '',
traffic_port: deployment.container_config?.traffic_port || null,
entrypoint: deployment.container_config?.entrypoint?.join(' ') || '',
registry_username: '',
registry_secret: '',
command: '',
};
if (formRef.current) {
formRef.current.setValues(initialValues);
}
// Initialize environment variables
const envVarsList = deployment.container_config?.env_variables
? Object.entries(deployment.container_config.env_variables).map(
([key, value]) => ({
key,
value: String(value),
}),
)
: [];
setEnvVars(envVarsList);
setSecretEnvVars([]);
}
}, [visible, deployment]);
const handleUpdate = async () => {
try {
const formValues = formRef.current
? await formRef.current.validate()
: {};
setLoading(true);
// Prepare the update payload
const payload = {};
if (formValues.image_url) payload.image_url = formValues.image_url;
if (formValues.traffic_port)
payload.traffic_port = formValues.traffic_port;
if (formValues.registry_username)
payload.registry_username = formValues.registry_username;
if (formValues.registry_secret)
payload.registry_secret = formValues.registry_secret;
if (formValues.command) payload.command = formValues.command;
// Process entrypoint
if (formValues.entrypoint) {
payload.entrypoint = formValues.entrypoint
.split(' ')
.filter((cmd) => cmd.trim());
}
// Process environment variables
if (envVars.length > 0) {
payload.env_variables = envVars.reduce((acc, env) => {
if (env.key && env.value !== undefined) {
acc[env.key] = env.value;
}
return acc;
}, {});
}
// Process secret environment variables
if (secretEnvVars.length > 0) {
payload.secret_env_variables = secretEnvVars.reduce((acc, env) => {
if (env.key && env.value !== undefined) {
acc[env.key] = env.value;
}
return acc;
}, {});
}
const response = await API.put(
`/api/deployments/${deployment.id}`,
payload,
);
if (response.data.success) {
showSuccess(t('容器配置更新成功'));
onSuccess?.(response.data.data);
handleCancel();
}
} catch (error) {
showError(
t('更新配置失败') +
': ' +
(error.response?.data?.message || error.message),
);
} finally {
setLoading(false);
}
};
const handleCancel = () => {
if (formRef.current) {
formRef.current.reset();
}
setEnvVars([]);
setSecretEnvVars([]);
onCancel();
};
const addEnvVar = () => {
setEnvVars([...envVars, { key: '', value: '' }]);
};
const removeEnvVar = (index) => {
const newEnvVars = envVars.filter((_, i) => i !== index);
setEnvVars(newEnvVars);
};
const updateEnvVar = (index, field, value) => {
const newEnvVars = [...envVars];
newEnvVars[index][field] = value;
setEnvVars(newEnvVars);
};
const addSecretEnvVar = () => {
setSecretEnvVars([...secretEnvVars, { key: '', value: '' }]);
};
const removeSecretEnvVar = (index) => {
const newSecretEnvVars = secretEnvVars.filter((_, i) => i !== index);
setSecretEnvVars(newSecretEnvVars);
};
const updateSecretEnvVar = (index, field, value) => {
const newSecretEnvVars = [...secretEnvVars];
newSecretEnvVars[index][field] = value;
setSecretEnvVars(newSecretEnvVars);
};
return (
<Modal
title={
<div className='flex items-center gap-2'>
<FaCog className='text-blue-500' />
<span>{t('更新容器配置')}</span>
</div>
}
visible={visible}
onCancel={handleCancel}
onOk={handleUpdate}
okText={t('更新配置')}
cancelText={t('取消')}
confirmLoading={loading}
width={700}
className='update-config-modal'
>
<div className='space-y-4 max-h-[600px] overflow-y-auto'>
{/* Container Info */}
<Card className='border-0 bg-gray-50'>
<div className='flex items-center justify-between'>
<div>
<Text strong className='text-base'>
{deployment?.container_name}
</Text>
<div className='mt-1'>
<Text type='secondary' size='small'>
ID: {deployment?.id}
</Text>
</div>
</div>
<Tag color='blue'>{deployment?.status}</Tag>
</div>
</Card>
{/* Warning Banner */}
<Banner
type='warning'
icon={<FaExclamationTriangle />}
title={t('重要提醒')}
description={
<div className='space-y-2'>
<p>
{t(
'更新容器配置可能会导致容器重启,请确保在合适的时间进行此操作。',
)}
</p>
<p>{t('某些配置更改可能需要几分钟才能生效。')}</p>
</div>
}
/>
<Form getFormApi={(api) => (formRef.current = api)} layout='vertical'>
<Collapse defaultActiveKey={['docker']}>
{/* Docker Configuration */}
<Collapse.Panel
header={
<div className='flex items-center gap-2'>
<FaDocker className='text-blue-600' />
<span>{t('镜像配置')}</span>
</div>
}
itemKey='docker'
>
<div className='space-y-4'>
<Form.Input
field='image_url'
label={t('镜像地址')}
placeholder={t('例如: nginx:latest')}
rules={[
{
type: 'string',
message: t('请输入有效的镜像地址'),
},
]}
/>
<Form.Input
field='registry_username'
label={t('镜像仓库用户名')}
placeholder={t('如果镜像为私有,请填写用户名')}
/>
<Form.Input
field='registry_secret'
label={t('镜像仓库密码')}
mode='password'
placeholder={t('如果镜像为私有,请填写密码或Token')}
/>
</div>
</Collapse.Panel>
{/* Network Configuration */}
<Collapse.Panel
header={
<div className='flex items-center gap-2'>
<FaNetworkWired className='text-green-600' />
<span>{t('网络配置')}</span>
</div>
}
itemKey='network'
>
<Form.InputNumber
field='traffic_port'
label={t('流量端口')}
placeholder={t('容器对外暴露的端口')}
min={1}
max={65535}
style={{ width: '100%' }}
rules={[
{
type: 'number',
min: 1,
max: 65535,
message: t('端口号必须在1-65535之间'),
},
]}
/>
</Collapse.Panel>
{/* Startup Configuration */}
<Collapse.Panel
header={
<div className='flex items-center gap-2'>
<FaTerminal className='text-purple-600' />
<span>{t('启动配置')}</span>
</div>
}
itemKey='startup'
>
<div className='space-y-4'>
<Form.Input
field='entrypoint'
label={t('启动命令 (Entrypoint)')}
placeholder={t('例如: /bin/bash -c "python app.py"')}
helpText={t('多个命令用空格分隔')}
/>
<Form.Input
field='command'
label={t('运行命令 (Command)')}
placeholder={t('容器启动后执行的命令')}
/>
</div>
</Collapse.Panel>
{/* Environment Variables */}
<Collapse.Panel
header={
<div className='flex items-center gap-2'>
<FaKey className='text-orange-600' />
<span>{t('环境变量')}</span>
<Tag size='small'>{envVars.length}</Tag>
</div>
}
itemKey='env'
>
<div className='space-y-4'>
{/* Regular Environment Variables */}
<div>
<div className='flex items-center justify-between mb-3'>
<Text strong>{t('普通环境变量')}</Text>
<Button
size='small'
icon={<FaPlus />}
onClick={addEnvVar}
theme='borderless'
type='primary'
>
{t('添加')}
</Button>
</div>
{envVars.map((envVar, index) => (
<div key={index} className='flex items-end gap-2 mb-2'>
<Input
placeholder={t('变量名')}
value={envVar.key}
onChange={(value) => updateEnvVar(index, 'key', value)}
style={{ flex: 1 }}
/>
<Text>=</Text>
<Input
placeholder={t('变量值')}
value={envVar.value}
onChange={(value) =>
updateEnvVar(index, 'value', value)
}
style={{ flex: 2 }}
/>
<Button
size='small'
icon={<FaMinus />}
onClick={() => removeEnvVar(index)}
theme='borderless'
type='danger'
/>
</div>
))}
{envVars.length === 0 && (
<div className='text-center text-gray-500 py-4 border-2 border-dashed border-gray-300 rounded-lg'>
<Text type='secondary'>{t('暂无环境变量')}</Text>
</div>
)}
</div>
<Divider />
{/* Secret Environment Variables */}
<div>
<div className='flex items-center justify-between mb-3'>
<div className='flex items-center gap-2'>
<Text strong>{t('机密环境变量')}</Text>
<Tag size='small' type='danger'>
{t('加密存储')}
</Tag>
</div>
<Button
size='small'
icon={<FaPlus />}
onClick={addSecretEnvVar}
theme='borderless'
type='danger'
>
{t('添加')}
</Button>
</div>
{secretEnvVars.map((envVar, index) => (
<div key={index} className='flex items-end gap-2 mb-2'>
<Input
placeholder={t('变量名')}
value={envVar.key}
onChange={(value) =>
updateSecretEnvVar(index, 'key', value)
}
style={{ flex: 1 }}
/>
<Text>=</Text>
<Input
mode='password'
placeholder={t('变量值')}
value={envVar.value}
onChange={(value) =>
updateSecretEnvVar(index, 'value', value)
}
style={{ flex: 2 }}
/>
<Button
size='small'
icon={<FaMinus />}
onClick={() => removeSecretEnvVar(index)}
theme='borderless'
type='danger'
/>
</div>
))}
{secretEnvVars.length === 0 && (
<div className='text-center text-gray-500 py-4 border-2 border-dashed border-red-200 rounded-lg bg-red-50'>
<Text type='secondary'>{t('暂无机密环境变量')}</Text>
</div>
)}
<Banner
type='info'
title={t('机密环境变量说明')}
description={t(
'机密环境变量将被加密存储,适用于存储密码、API密钥等敏感信息。',
)}
size='small'
/>
</div>
</div>
</Collapse.Panel>
</Collapse>
</Form>
{/* Final Warning */}
<div className='bg-yellow-50 border border-yellow-200 rounded-lg p-3'>
<div className='flex items-start gap-2'>
<FaExclamationTriangle className='text-yellow-600 mt-0.5' />
<div>
<Text strong className='text-yellow-800'>
{t('配置更新确认')}
</Text>
<div className='mt-1'>
<Text size='small' className='text-yellow-700'>
{t(
'更新配置后,容器可能需要重启以应用新的设置。请确保您了解这些更改的影响。',
)}
</Text>
</div>
</div>
</div>
</div>
</div>
</Modal>
);
};
export default UpdateConfigModal;
@@ -0,0 +1,601 @@
/*
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 React, { useState, useEffect } from 'react';
import {
Modal,
Typography,
Card,
Tag,
Progress,
Descriptions,
Spin,
Empty,
Button,
Badge,
Tooltip,
} from '@douyinfe/semi-ui';
import {
FaInfoCircle,
FaServer,
FaClock,
FaMapMarkerAlt,
FaDocker,
FaMoneyBillWave,
FaChartLine,
FaCopy,
FaLink,
} from 'react-icons/fa';
import { IconRefresh } from '@douyinfe/semi-icons';
import {
API,
showError,
showSuccess,
timestamp2string,
} from '../../../../helpers';
const { Text, Title } = Typography;
const ViewDetailsModal = ({ visible, onCancel, deployment, t }) => {
const [details, setDetails] = useState(null);
const [loading, setLoading] = useState(false);
const [containers, setContainers] = useState([]);
const [containersLoading, setContainersLoading] = useState(false);
const fetchDetails = async () => {
if (!deployment?.id) return;
setLoading(true);
try {
const response = await API.get(`/api/deployments/${deployment.id}`);
if (response.data.success) {
setDetails(response.data.data);
}
} catch (error) {
showError(
t('获取详情失败') +
': ' +
(error.response?.data?.message || error.message),
);
} finally {
setLoading(false);
}
};
const fetchContainers = async () => {
if (!deployment?.id) return;
setContainersLoading(true);
try {
const response = await API.get(
`/api/deployments/${deployment.id}/containers`,
);
if (response.data.success) {
setContainers(response.data.data?.containers || []);
}
} catch (error) {
showError(
t('获取容器信息失败') +
': ' +
(error.response?.data?.message || error.message),
);
} finally {
setContainersLoading(false);
}
};
useEffect(() => {
if (visible && deployment?.id) {
fetchDetails();
fetchContainers();
} else if (!visible) {
setDetails(null);
setContainers([]);
}
}, [visible, deployment?.id]);
const handleCopyId = () => {
navigator.clipboard.writeText(deployment?.id);
showSuccess(t('已复制 ID 到剪贴板'));
};
const handleRefresh = () => {
fetchDetails();
fetchContainers();
};
const getStatusConfig = (status) => {
const statusConfig = {
running: { color: 'green', text: '运行中', icon: '🟢' },
completed: { color: 'green', text: '已完成', icon: '✅' },
'deployment requested': { color: 'blue', text: '部署请求中', icon: '🔄' },
'termination requested': {
color: 'orange',
text: '终止请求中',
icon: '⏸️',
},
destroyed: { color: 'red', text: '已销毁', icon: '🔴' },
failed: { color: 'red', text: '失败', icon: '❌' },
};
return statusConfig[status] || { color: 'grey', text: status, icon: '❓' };
};
const statusConfig = getStatusConfig(deployment?.status);
return (
<Modal
title={
<div className='flex items-center gap-2'>
<FaInfoCircle className='text-blue-500' />
<span>{t('容器详情')}</span>
</div>
}
visible={visible}
onCancel={onCancel}
footer={
<div className='flex justify-between'>
<Button
icon={<IconRefresh />}
onClick={handleRefresh}
loading={loading || containersLoading}
theme='borderless'
>
{t('刷新')}
</Button>
<Button onClick={onCancel}>{t('关闭')}</Button>
</div>
}
width={800}
className='deployment-details-modal'
>
{loading && !details ? (
<div className='flex items-center justify-center py-12'>
<Spin size='large' tip={t('加载详情中...')} />
</div>
) : details ? (
<div className='space-y-4 max-h-[600px] overflow-y-auto'>
{/* Basic Info */}
<Card
title={
<div className='flex items-center gap-2'>
<FaServer className='text-blue-500' />
<span>{t('基本信息')}</span>
</div>
}
className='border-0 shadow-sm'
>
<Descriptions
data={[
{
key: t('容器名称'),
value: (
<div className='flex items-center gap-2'>
<Text strong className='text-base'>
{details.deployment_name || details.id}
</Text>
<Button
size='small'
theme='borderless'
icon={<FaCopy />}
onClick={handleCopyId}
className='opacity-70 hover:opacity-100'
/>
</div>
),
},
{
key: t('容器ID'),
value: (
<Text type='secondary' className='font-mono text-sm'>
{details.id}
</Text>
),
},
{
key: t('状态'),
value: (
<div className='flex items-center gap-2'>
<span>{statusConfig.icon}</span>
<Tag color={statusConfig.color}>
{t(statusConfig.text)}
</Tag>
</div>
),
},
{
key: t('创建时间'),
value: timestamp2string(details.created_at),
},
]}
/>
</Card>
{/* Hardware & Performance */}
<Card
title={
<div className='flex items-center gap-2'>
<FaChartLine className='text-green-500' />
<span>{t('硬件与性能')}</span>
</div>
}
className='border-0 shadow-sm'
>
<div className='space-y-4'>
<Descriptions
data={[
{
key: t('硬件类型'),
value: (
<div className='flex items-center gap-2'>
<Tag color='blue'>{details.brand_name}</Tag>
<Text strong>{details.hardware_name}</Text>
</div>
),
},
{
key: t('GPU数量'),
value: (
<div className='flex items-center gap-2'>
<Badge
count={details.total_gpus}
theme='solid'
type='primary'
>
<FaServer className='text-purple-500' />
</Badge>
<Text>
{t('总计')} {details.total_gpus} {t('个GPU')}
</Text>
</div>
),
},
{
key: t('容器配置'),
value: (
<div className='space-y-1'>
<div>
{t('每容器GPU数')}: {details.gpus_per_container}
</div>
<div>
{t('容器总数')}: {details.total_containers}
</div>
</div>
),
},
]}
/>
{/* Progress Bar */}
<div className='space-y-2'>
<div className='flex items-center justify-between'>
<Text strong>{t('完成进度')}</Text>
<Text>{details.completed_percent}%</Text>
</div>
<Progress
percent={details.completed_percent}
status={
details.completed_percent === 100 ? 'success' : 'normal'
}
strokeWidth={8}
showInfo={false}
/>
<div className='flex justify-between text-xs text-gray-500'>
<span>
{t('已服务')}: {details.compute_minutes_served} {t('分钟')}
</span>
<span>
{t('剩余')}: {details.compute_minutes_remaining} {t('分钟')}
</span>
</div>
</div>
</div>
</Card>
{/* Container Configuration */}
{details.container_config && (
<Card
title={
<div className='flex items-center gap-2'>
<FaDocker className='text-blue-600' />
<span>{t('容器配置')}</span>
</div>
}
className='border-0 shadow-sm'
>
<div className='space-y-3'>
<Descriptions
data={[
{
key: t('镜像地址'),
value: (
<Text className='font-mono text-sm break-all'>
{details.container_config.image_url || 'N/A'}
</Text>
),
},
{
key: t('流量端口'),
value: details.container_config.traffic_port || 'N/A',
},
{
key: t('启动命令'),
value: (
<Text className='font-mono text-sm'>
{details.container_config.entrypoint
? details.container_config.entrypoint.join(' ')
: 'N/A'}
</Text>
),
},
]}
/>
{/* Environment Variables */}
{details.container_config.env_variables &&
Object.keys(details.container_config.env_variables).length >
0 && (
<div className='mt-4'>
<Text strong className='block mb-2'>
{t('环境变量')}:
</Text>
<div className='bg-gray-50 p-3 rounded-lg max-h-32 overflow-y-auto'>
{Object.entries(
details.container_config.env_variables,
).map(([key, value]) => (
<div
key={key}
className='flex gap-2 text-sm font-mono mb-1'
>
<span className='text-blue-600 font-medium'>
{key}=
</span>
<span className='text-gray-700 break-all'>
{String(value)}
</span>
</div>
))}
</div>
</div>
)}
</div>
</Card>
)}
{/* Containers List */}
<Card
title={
<div className='flex items-center gap-2'>
<FaServer className='text-indigo-500' />
<span>{t('容器实例')}</span>
</div>
}
className='border-0 shadow-sm'
>
{containersLoading ? (
<div className='flex items-center justify-center py-6'>
<Spin tip={t('加载容器信息中...')} />
</div>
) : containers.length === 0 ? (
<Empty
description={t('暂无容器信息')}
image={Empty.PRESENTED_IMAGE_SIMPLE}
/>
) : (
<div className='space-y-3'>
{containers.map((ctr) => (
<Card
key={ctr.container_id}
className='bg-gray-50 border border-gray-100'
bodyStyle={{ padding: '12px 16px' }}
>
<div className='flex flex-wrap items-center justify-between gap-3'>
<div className='flex flex-col gap-1'>
<Text strong className='font-mono text-sm'>
{ctr.container_id}
</Text>
<Text size='small' type='secondary'>
{t('设备')} {ctr.device_id || '--'} · {t('状态')}{' '}
{ctr.status || '--'}
</Text>
<Text size='small' type='secondary'>
{t('创建时间')}:{' '}
{ctr.created_at
? timestamp2string(ctr.created_at)
: '--'}
</Text>
</div>
<div className='flex flex-col items-end gap-2'>
<Tag color='blue' size='small'>
{t('GPU/容器')}: {ctr.gpus_per_container ?? '--'}
</Tag>
{ctr.public_url && (
<Tooltip content={ctr.public_url}>
<Button
icon={<FaLink />}
size='small'
theme='light'
onClick={() =>
window.open(
ctr.public_url,
'_blank',
'noopener,noreferrer',
)
}
>
{t('访问容器')}
</Button>
</Tooltip>
)}
</div>
</div>
{ctr.events && ctr.events.length > 0 && (
<div className='mt-3 bg-white rounded-md border border-gray-100 p-3'>
<Text
size='small'
type='secondary'
className='block mb-2'
>
{t('最近事件')}
</Text>
<div className='space-y-2 max-h-32 overflow-y-auto'>
{ctr.events.map((event, index) => (
<div
key={`${ctr.container_id}-${event.time}-${index}`}
className='flex gap-3 text-xs font-mono'
>
<span className='text-gray-500 min-w-[140px]'>
{event.time
? timestamp2string(event.time)
: '--'}
</span>
<span className='text-gray-700 break-all flex-1'>
{event.message || '--'}
</span>
</div>
))}
</div>
</div>
)}
</Card>
))}
</div>
)}
</Card>
{/* Location Information */}
{details.locations && details.locations.length > 0 && (
<Card
title={
<div className='flex items-center gap-2'>
<FaMapMarkerAlt className='text-orange-500' />
<span>{t('部署位置')}</span>
</div>
}
className='border-0 shadow-sm'
>
<div className='flex flex-wrap gap-2'>
{details.locations.map((location) => (
<Tag key={location.id} color='orange' size='large'>
<div className='flex items-center gap-1'>
<span>🌍</span>
<span>
{location.name} ({location.iso2})
</span>
</div>
</Tag>
))}
</div>
</Card>
)}
{/* Cost Information */}
<Card
title={
<div className='flex items-center gap-2'>
<FaMoneyBillWave className='text-green-500' />
<span>{t('费用信息')}</span>
</div>
}
className='border-0 shadow-sm'
>
<div className='space-y-3'>
<div className='flex items-center justify-between p-3 bg-green-50 rounded-lg'>
<Text>{t('已支付金额')}</Text>
<Text strong className='text-lg text-green-600'>
$
{details.amount_paid
? details.amount_paid.toFixed(2)
: '0.00'}{' '}
USDC
</Text>
</div>
<div className='grid grid-cols-2 gap-4 text-sm'>
<div className='flex justify-between'>
<Text type='secondary'>{t('计费开始')}:</Text>
<Text>
{details.started_at
? timestamp2string(details.started_at)
: 'N/A'}
</Text>
</div>
<div className='flex justify-between'>
<Text type='secondary'>{t('预计结束')}:</Text>
<Text>
{details.finished_at
? timestamp2string(details.finished_at)
: 'N/A'}
</Text>
</div>
</div>
</div>
</Card>
{/* Time Information */}
<Card
title={
<div className='flex items-center gap-2'>
<FaClock className='text-purple-500' />
<span>{t('时间信息')}</span>
</div>
}
className='border-0 shadow-sm'
>
<div className='grid grid-cols-1 md:grid-cols-2 gap-4'>
<div className='space-y-2'>
<div className='flex items-center justify-between'>
<Text type='secondary'>{t('已运行时间')}:</Text>
<Text strong>
{Math.floor(details.compute_minutes_served / 60)}h{' '}
{details.compute_minutes_served % 60}m
</Text>
</div>
<div className='flex items-center justify-between'>
<Text type='secondary'>{t('剩余时间')}:</Text>
<Text strong className='text-orange-600'>
{Math.floor(details.compute_minutes_remaining / 60)}h{' '}
{details.compute_minutes_remaining % 60}m
</Text>
</div>
</div>
<div className='space-y-2'>
<div className='flex items-center justify-between'>
<Text type='secondary'>{t('创建时间')}:</Text>
<Text>{timestamp2string(details.created_at)}</Text>
</div>
<div className='flex items-center justify-between'>
<Text type='secondary'>{t('最后更新')}:</Text>
<Text>{timestamp2string(details.updated_at)}</Text>
</div>
</div>
</div>
</Card>
</div>
) : (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={t('无法获取容器详情')}
/>
)}
</Modal>
);
};
export default ViewDetailsModal;
@@ -0,0 +1,723 @@
/*
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 React, { useState, useEffect, useRef } from 'react';
import {
Modal,
Button,
Typography,
Select,
Input,
Space,
Spin,
Card,
Tag,
Empty,
Switch,
Divider,
Tooltip,
Radio,
} from '@douyinfe/semi-ui';
import {
FaCopy,
FaSearch,
FaClock,
FaTerminal,
FaServer,
FaInfoCircle,
FaLink,
} from 'react-icons/fa';
import { IconRefresh, IconDownload } from '@douyinfe/semi-icons';
import {
API,
showError,
showSuccess,
copy,
timestamp2string,
} from '../../../../helpers';
const { Text } = Typography;
const ALL_CONTAINERS = '__all__';
const ViewLogsModal = ({ visible, onCancel, deployment, t }) => {
const [logLines, setLogLines] = useState([]);
const [loading, setLoading] = useState(false);
const [autoRefresh, setAutoRefresh] = useState(false);
const [searchTerm, setSearchTerm] = useState('');
const [following, setFollowing] = useState(false);
const [containers, setContainers] = useState([]);
const [containersLoading, setContainersLoading] = useState(false);
const [selectedContainerId, setSelectedContainerId] =
useState(ALL_CONTAINERS);
const [containerDetails, setContainerDetails] = useState(null);
const [containerDetailsLoading, setContainerDetailsLoading] = useState(false);
const [streamFilter, setStreamFilter] = useState('stdout');
const [lastUpdatedAt, setLastUpdatedAt] = useState(null);
const logContainerRef = useRef(null);
const autoRefreshRef = useRef(null);
// Auto scroll to bottom when new logs arrive
const scrollToBottom = () => {
if (logContainerRef.current) {
logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight;
}
};
const resolveStreamValue = (value) => {
if (typeof value === 'string') {
return value;
}
if (value && typeof value.value === 'string') {
return value.value;
}
if (value && value.target && typeof value.target.value === 'string') {
return value.target.value;
}
return '';
};
const handleStreamChange = (value) => {
const next = resolveStreamValue(value) || 'stdout';
setStreamFilter(next);
};
const fetchLogs = async (containerIdOverride = undefined) => {
if (!deployment?.id) return;
const containerId =
typeof containerIdOverride === 'string'
? containerIdOverride
: selectedContainerId;
if (!containerId || containerId === ALL_CONTAINERS) {
setLogLines([]);
setLastUpdatedAt(null);
setLoading(false);
return;
}
setLoading(true);
try {
const params = new URLSearchParams();
params.append('container_id', containerId);
const streamValue = resolveStreamValue(streamFilter) || 'stdout';
if (streamValue && streamValue !== 'all') {
params.append('stream', streamValue);
}
if (following) params.append('follow', 'true');
const response = await API.get(
`/api/deployments/${deployment.id}/logs?${params}`,
);
if (response.data.success) {
const rawContent =
typeof response.data.data === 'string' ? response.data.data : '';
const normalized = rawContent.replace(/\r\n?/g, '\n');
const lines = normalized ? normalized.split('\n') : [];
setLogLines(lines);
setLastUpdatedAt(new Date());
setTimeout(scrollToBottom, 100);
}
} catch (error) {
showError(
t('获取日志失败') +
': ' +
(error.response?.data?.message || error.message),
);
} finally {
setLoading(false);
}
};
const fetchContainers = async () => {
if (!deployment?.id) return;
setContainersLoading(true);
try {
const response = await API.get(
`/api/deployments/${deployment.id}/containers`,
);
if (response.data.success) {
const list = response.data.data?.containers || [];
setContainers(list);
setSelectedContainerId((current) => {
if (
current !== ALL_CONTAINERS &&
list.some((item) => item.container_id === current)
) {
return current;
}
return list.length > 0 ? list[0].container_id : ALL_CONTAINERS;
});
if (list.length === 0) {
setContainerDetails(null);
}
}
} catch (error) {
showError(
t('获取容器列表失败') +
': ' +
(error.response?.data?.message || error.message),
);
} finally {
setContainersLoading(false);
}
};
const fetchContainerDetails = async (containerId) => {
if (!deployment?.id || !containerId || containerId === ALL_CONTAINERS) {
setContainerDetails(null);
return;
}
setContainerDetailsLoading(true);
try {
const response = await API.get(
`/api/deployments/${deployment.id}/containers/${containerId}`,
);
if (response.data.success) {
setContainerDetails(response.data.data || null);
}
} catch (error) {
showError(
t('获取容器详情失败') +
': ' +
(error.response?.data?.message || error.message),
);
} finally {
setContainerDetailsLoading(false);
}
};
const handleContainerChange = (value) => {
const newValue = value || ALL_CONTAINERS;
setSelectedContainerId(newValue);
setLogLines([]);
setLastUpdatedAt(null);
};
const refreshContainerDetails = () => {
if (selectedContainerId && selectedContainerId !== ALL_CONTAINERS) {
fetchContainerDetails(selectedContainerId);
}
};
const renderContainerStatusTag = (status) => {
if (!status) {
return (
<Tag color='grey' size='small'>
{t('未知状态')}
</Tag>
);
}
const normalized =
typeof status === 'string' ? status.trim().toLowerCase() : '';
const statusMap = {
running: { color: 'green', label: '运行中' },
pending: { color: 'orange', label: '准备中' },
deployed: { color: 'blue', label: '已部署' },
failed: { color: 'red', label: '失败' },
destroyed: { color: 'red', label: '已销毁' },
stopping: { color: 'orange', label: '停止中' },
terminated: { color: 'grey', label: '已终止' },
};
const config = statusMap[normalized] || { color: 'grey', label: status };
return (
<Tag color={config.color} size='small'>
{t(config.label)}
</Tag>
);
};
const currentContainer =
selectedContainerId !== ALL_CONTAINERS
? containers.find((ctr) => ctr.container_id === selectedContainerId)
: null;
const refreshLogs = () => {
if (selectedContainerId && selectedContainerId !== ALL_CONTAINERS) {
fetchContainerDetails(selectedContainerId);
}
fetchLogs();
};
const downloadLogs = () => {
const sourceLogs = filteredLogs.length > 0 ? filteredLogs : logLines;
if (sourceLogs.length === 0) {
showError(t('暂无日志可下载'));
return;
}
const logText = sourceLogs.join('\n');
const blob = new Blob([logText], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
const safeContainerId =
selectedContainerId && selectedContainerId !== ALL_CONTAINERS
? selectedContainerId.replace(/[^a-zA-Z0-9_-]/g, '-')
: '';
const fileName = safeContainerId
? `deployment-${deployment.id}-container-${safeContainerId}-logs.txt`
: `deployment-${deployment.id}-logs.txt`;
a.download = fileName;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
showSuccess(t('日志已下载'));
};
const copyAllLogs = async () => {
const sourceLogs = filteredLogs.length > 0 ? filteredLogs : logLines;
if (sourceLogs.length === 0) {
showError(t('暂无日志可复制'));
return;
}
const logText = sourceLogs.join('\n');
const copied = await copy(logText);
if (copied) {
showSuccess(t('日志已复制到剪贴板'));
} else {
showError(t('复制失败,请手动选择文本复制'));
}
};
// Auto refresh functionality
useEffect(() => {
if (autoRefresh && visible) {
autoRefreshRef.current = setInterval(() => {
fetchLogs();
}, 5000);
} else {
if (autoRefreshRef.current) {
clearInterval(autoRefreshRef.current);
autoRefreshRef.current = null;
}
}
return () => {
if (autoRefreshRef.current) {
clearInterval(autoRefreshRef.current);
}
};
}, [autoRefresh, visible, selectedContainerId, streamFilter, following]);
useEffect(() => {
if (visible && deployment?.id) {
fetchContainers();
} else if (!visible) {
setContainers([]);
setSelectedContainerId(ALL_CONTAINERS);
setContainerDetails(null);
setStreamFilter('stdout');
setLogLines([]);
setLastUpdatedAt(null);
}
}, [visible, deployment?.id]);
useEffect(() => {
if (visible) {
setStreamFilter('stdout');
}
}, [selectedContainerId, visible]);
useEffect(() => {
if (visible && deployment?.id) {
fetchContainerDetails(selectedContainerId);
}
}, [visible, deployment?.id, selectedContainerId]);
// Initial load and cleanup
useEffect(() => {
if (visible && deployment?.id) {
fetchLogs();
}
return () => {
if (autoRefreshRef.current) {
clearInterval(autoRefreshRef.current);
}
};
}, [visible, deployment?.id, streamFilter, selectedContainerId, following]);
// Filter logs based on search term
const filteredLogs = logLines
.map((line) => line ?? '')
.filter(
(line) =>
!searchTerm || line.toLowerCase().includes(searchTerm.toLowerCase()),
);
const renderLogEntry = (line, index) => (
<div
key={`${index}-${line.slice(0, 20)}`}
className='py-1 px-3 hover:bg-gray-50 font-mono text-sm border-b border-gray-100 whitespace-pre-wrap break-words'
>
{line}
</div>
);
return (
<Modal
title={
<div className='flex items-center gap-2'>
<FaTerminal className='text-blue-500' />
<span>{t('容器日志')}</span>
<Text type='secondary' size='small'>
- {deployment?.container_name || deployment?.id}
</Text>
</div>
}
visible={visible}
onCancel={onCancel}
footer={null}
width={1000}
height={700}
className='logs-modal'
style={{ top: 20 }}
>
<div className='flex flex-col h-full max-h-[600px]'>
{/* Controls */}
<Card className='mb-4 border-0 shadow-sm'>
<div className='flex items-center justify-between flex-wrap gap-3'>
<Space wrap>
<Select
prefix={<FaServer />}
placeholder={t('选择容器')}
value={selectedContainerId}
onChange={handleContainerChange}
style={{ width: 240 }}
size='small'
loading={containersLoading}
dropdownStyle={{ maxHeight: 320, overflowY: 'auto' }}
>
<Select.Option value={ALL_CONTAINERS}>
{t('全部容器')}
</Select.Option>
{containers.map((ctr) => (
<Select.Option
key={ctr.container_id}
value={ctr.container_id}
>
<div className='flex flex-col'>
<span className='font-mono text-xs'>
{ctr.container_id}
</span>
<span className='text-xs text-gray-500'>
{ctr.brand_name || 'IO.NET'}
{ctr.hardware ? ` · ${ctr.hardware}` : ''}
</span>
</div>
</Select.Option>
))}
</Select>
<Input
prefix={<FaSearch />}
placeholder={t('搜索日志内容')}
value={searchTerm}
onChange={setSearchTerm}
style={{ width: 200 }}
size='small'
/>
<Space align='center' className='ml-2'>
<Text size='small' type='secondary'>
{t('日志流')}
</Text>
<Radio.Group
type='button'
size='small'
value={streamFilter}
onChange={handleStreamChange}
>
<Radio value='stdout'>STDOUT</Radio>
<Radio value='stderr'>STDERR</Radio>
</Radio.Group>
</Space>
<div className='flex items-center gap-2'>
<Switch
checked={autoRefresh}
onChange={setAutoRefresh}
size='small'
/>
<Text size='small'>{t('自动刷新')}</Text>
</div>
<div className='flex items-center gap-2'>
<Switch
checked={following}
onChange={setFollowing}
size='small'
/>
<Text size='small'>{t('跟随日志')}</Text>
</div>
</Space>
<Space>
<Tooltip content={t('刷新日志')}>
<Button
icon={<IconRefresh />}
onClick={refreshLogs}
loading={loading}
size='small'
theme='borderless'
/>
</Tooltip>
<Tooltip content={t('复制日志')}>
<Button
icon={<FaCopy />}
onClick={copyAllLogs}
size='small'
theme='borderless'
disabled={logLines.length === 0}
/>
</Tooltip>
<Tooltip content={t('下载日志')}>
<Button
icon={<IconDownload />}
onClick={downloadLogs}
size='small'
theme='borderless'
disabled={logLines.length === 0}
/>
</Tooltip>
</Space>
</div>
{/* Status Info */}
<Divider margin='12px' />
<div className='flex items-center justify-between'>
<Space size='large'>
<Text size='small' type='secondary'>
{t('共 {{count}} 条日志', { count: logLines.length })}
</Text>
{searchTerm && (
<Text size='small' type='secondary'>
{t('(筛选后显示 {{count}} 条)', {
count: filteredLogs.length,
})}
</Text>
)}
{autoRefresh && (
<Tag color='green' size='small'>
<FaClock className='mr-1' />
{t('自动刷新中')}
</Tag>
)}
</Space>
<Text size='small' type='secondary'>
{t('状态')}: {deployment?.status || 'unknown'}
</Text>
</div>
{selectedContainerId !== ALL_CONTAINERS && (
<>
<Divider margin='12px' />
<div className='flex flex-col gap-3'>
<div className='flex items-center justify-between flex-wrap gap-2'>
<Space>
<Tag color='blue' size='small'>
{t('容器')}
</Tag>
<Text className='font-mono text-xs'>
{selectedContainerId}
</Text>
{renderContainerStatusTag(
containerDetails?.status || currentContainer?.status,
)}
</Space>
<Space>
{containerDetails?.public_url && (
<Tooltip content={containerDetails.public_url}>
<Button
icon={<FaLink />}
size='small'
theme='borderless'
onClick={() =>
window.open(containerDetails.public_url, '_blank')
}
/>
</Tooltip>
)}
<Tooltip content={t('刷新容器信息')}>
<Button
icon={<IconRefresh />}
onClick={refreshContainerDetails}
size='small'
theme='borderless'
loading={containerDetailsLoading}
/>
</Tooltip>
</Space>
</div>
{containerDetailsLoading ? (
<div className='flex items-center justify-center py-6'>
<Spin tip={t('加载容器详情中...')} />
</div>
) : containerDetails ? (
<div className='grid gap-4 md:grid-cols-2 text-sm'>
<div className='flex items-center gap-2'>
<FaInfoCircle className='text-blue-500' />
<Text type='secondary'>{t('硬件')}</Text>
<Text>
{containerDetails?.brand_name ||
currentContainer?.brand_name ||
t('未知品牌')}
{containerDetails?.hardware ||
currentContainer?.hardware
? ` · ${containerDetails?.hardware || currentContainer?.hardware}`
: ''}
</Text>
</div>
<div className='flex items-center gap-2'>
<FaServer className='text-purple-500' />
<Text type='secondary'>{t('GPU/容器')}</Text>
<Text>
{containerDetails?.gpus_per_container ??
currentContainer?.gpus_per_container ??
0}
</Text>
</div>
<div className='flex items-center gap-2'>
<FaClock className='text-orange-500' />
<Text type='secondary'>{t('创建时间')}</Text>
<Text>
{containerDetails?.created_at
? timestamp2string(containerDetails.created_at)
: currentContainer?.created_at
? timestamp2string(currentContainer.created_at)
: t('未知')}
</Text>
</div>
<div className='flex items-center gap-2'>
<FaInfoCircle className='text-green-500' />
<Text type='secondary'>{t('运行时长')}</Text>
<Text>
{containerDetails?.uptime_percent ??
currentContainer?.uptime_percent ??
0}
%
</Text>
</div>
</div>
) : (
<Text size='small' type='secondary'>
{t('暂无容器详情')}
</Text>
)}
{containerDetails?.events &&
containerDetails.events.length > 0 && (
<div className='bg-gray-50 rounded-lg p-3'>
<Text size='small' type='secondary'>
{t('最近事件')}
</Text>
<div className='mt-2 space-y-2 max-h-32 overflow-y-auto'>
{containerDetails.events
.slice(0, 5)
.map((event, index) => (
<div
key={`${event.time}-${index}`}
className='flex gap-3 text-xs font-mono'
>
<span className='text-gray-500'>
{event.time
? timestamp2string(event.time)
: '--'}
</span>
<span className='text-gray-700 break-all flex-1'>
{event.message}
</span>
</div>
))}
</div>
</div>
)}
</div>
</>
)}
</Card>
{/* Log Content */}
<div className='flex-1 flex flex-col border rounded-lg bg-gray-50 overflow-hidden'>
<div
ref={logContainerRef}
className='flex-1 overflow-y-auto bg-white'
style={{ maxHeight: '400px' }}
>
{loading && logLines.length === 0 ? (
<div className='flex items-center justify-center p-8'>
<Spin tip={t('加载日志中...')} />
</div>
) : filteredLogs.length === 0 ? (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={
searchTerm ? t('没有匹配的日志条目') : t('暂无日志')
}
style={{ padding: '60px 20px' }}
/>
) : (
<div>
{filteredLogs.map((log, index) => renderLogEntry(log, index))}
</div>
)}
</div>
{/* Footer status */}
{logLines.length > 0 && (
<div className='flex items-center justify-between px-3 py-2 bg-gray-50 border-t text-xs text-gray-500'>
<span>{following ? t('正在跟随最新日志') : t('日志已加载')}</span>
<span>
{t('最后更新')}:{' '}
{lastUpdatedAt ? lastUpdatedAt.toLocaleTimeString() : '--'}
</span>
</div>
)}
</div>
</div>
</Modal>
);
};
export default ViewLogsModal;
@@ -0,0 +1,123 @@
/*
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 React from 'react';
import SelectableButtonGroup from '../../../common/ui/SelectableButtonGroup';
const PricingDisplaySettings = ({
showWithRecharge,
setShowWithRecharge,
currency,
setCurrency,
siteDisplayType,
showRatio,
setShowRatio,
viewMode,
setViewMode,
tokenUnit,
setTokenUnit,
loading = false,
t,
}) => {
const supportsCurrencyDisplay = siteDisplayType !== 'TOKENS';
const items = [
...(supportsCurrencyDisplay
? [
{
value: 'recharge',
label: t('充值价格显示'),
},
]
: []),
{
value: 'ratio',
label: t('显示倍率'),
},
{
value: 'tableView',
label: t('表格视图'),
},
{
value: 'tokenUnit',
label: t('按K显示单位'),
},
];
const currencyItems = [
{ value: 'USD', label: 'USD ($)' },
{ value: 'CNY', label: 'CNY (¥)' },
{ value: 'CUSTOM', label: t('自定义货币') },
];
const handleChange = (value) => {
switch (value) {
case 'recharge':
setShowWithRecharge(!showWithRecharge);
break;
case 'ratio':
setShowRatio(!showRatio);
break;
case 'tableView':
setViewMode(viewMode === 'table' ? 'card' : 'table');
break;
case 'tokenUnit':
setTokenUnit(tokenUnit === 'K' ? 'M' : 'K');
break;
}
};
const getActiveValues = () => {
const activeValues = [];
if (supportsCurrencyDisplay && showWithRecharge) activeValues.push('recharge');
if (showRatio) activeValues.push('ratio');
if (viewMode === 'table') activeValues.push('tableView');
if (tokenUnit === 'K') activeValues.push('tokenUnit');
return activeValues;
};
return (
<div>
<SelectableButtonGroup
title={t('显示设置')}
items={items}
activeValue={getActiveValues()}
onChange={handleChange}
withCheckbox
collapsible={false}
loading={loading}
t={t}
/>
{supportsCurrencyDisplay && showWithRecharge && (
<SelectableButtonGroup
title={t('货币单位')}
items={currencyItems}
activeValue={currency}
onChange={setCurrency}
collapsible={false}
loading={loading}
t={t}
/>
)}
</div>
);
};
export default PricingDisplaySettings;
@@ -0,0 +1,103 @@
/*
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 React from 'react';
import SelectableButtonGroup from '../../../common/ui/SelectableButtonGroup';
/**
* 端点类型筛选组件
* @param {string|'all'} filterEndpointType 当前值
* @param {Function} setFilterEndpointType setter
* @param {Array} models 模型列表
* @param {boolean} loading 是否加载中
* @param {Function} t i18n
*/
const PricingEndpointTypes = ({
filterEndpointType,
setFilterEndpointType,
models = [],
allModels = [],
loading = false,
t,
}) => {
// 获取系统中所有端点类型(基于 allModels,如果未提供则退化为 models)
const getAllEndpointTypes = () => {
const endpointTypes = new Set();
(allModels.length > 0 ? allModels : models).forEach((model) => {
if (
model.supported_endpoint_types &&
Array.isArray(model.supported_endpoint_types)
) {
model.supported_endpoint_types.forEach((endpoint) => {
endpointTypes.add(endpoint);
});
}
});
return Array.from(endpointTypes).sort();
};
// 计算每个端点类型的模型数量
const getEndpointTypeCount = (endpointType) => {
if (endpointType === 'all') {
return models.length;
}
return models.filter(
(model) =>
model.supported_endpoint_types &&
model.supported_endpoint_types.includes(endpointType),
).length;
};
// 端点类型显示名称映射
const getEndpointTypeLabel = (endpointType) => {
return endpointType;
};
const availableEndpointTypes = getAllEndpointTypes();
const items = [
{
value: 'all',
label: t('全部端点'),
tagCount: getEndpointTypeCount('all'),
},
...availableEndpointTypes.map((endpointType) => {
const count = getEndpointTypeCount(endpointType);
return {
value: endpointType,
label: getEndpointTypeLabel(endpointType),
tagCount: count,
};
}),
];
return (
<SelectableButtonGroup
title={t('端点类型')}
items={items}
activeValue={filterEndpointType}
onChange={setFilterEndpointType}
loading={loading}
variant='green'
t={t}
/>
);
};
export default PricingEndpointTypes;
@@ -0,0 +1,84 @@
/*
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 React from 'react';
import SelectableButtonGroup from '../../../common/ui/SelectableButtonGroup';
/**
* 分组筛选组件
* @param {string} filterGroup 当前选中的分组,'all' 表示不过滤
* @param {Function} setFilterGroup 设置选中分组
* @param {Record<string, any>} usableGroup 后端返回的可用分组对象
* @param {Record<string, number>} groupRatio 分组倍率对象
* @param {Array} models 模型列表
* @param {boolean} loading 是否加载中
* @param {Function} t i18n
*/
const PricingGroups = ({
filterGroup,
setFilterGroup,
usableGroup = {},
groupRatio = {},
models = [],
loading = false,
t,
}) => {
const groups = [
'all',
...Object.keys(usableGroup).filter((key) => key !== ''),
];
const items = groups.map((g) => {
const modelCount =
g === 'all'
? models.length
: models.filter((m) => m.enable_groups && m.enable_groups.includes(g))
.length;
let ratioDisplay = '';
if (g === 'all') {
// ratioDisplay = t('全部');
} else {
const ratio = groupRatio[g];
if (ratio !== undefined && ratio !== null) {
ratioDisplay = `${ratio}x`;
} else {
ratioDisplay = '1x';
}
}
return {
value: g,
label: g === 'all' ? t('全部分组') : g,
tagCount: ratioDisplay,
};
});
return (
<SelectableButtonGroup
title={t('可用令牌分组')}
items={items}
activeValue={filterGroup}
onChange={setFilterGroup}
loading={loading}
variant='teal'
t={t}
/>
);
};
export default PricingGroups;
@@ -0,0 +1,61 @@
/*
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 React from 'react';
import SelectableButtonGroup from '../../../common/ui/SelectableButtonGroup';
/**
* 计费类型筛选组件
* @param {string|'all'|0|1} filterQuotaType 当前值
* @param {Function} setFilterQuotaType setter
* @param {Array} models 模型列表
* @param {boolean} loading 是否加载中
* @param {Function} t i18n
*/
const PricingQuotaTypes = ({
filterQuotaType,
setFilterQuotaType,
models = [],
loading = false,
t,
}) => {
const qtyCount = (type) =>
models.filter((m) => (type === 'all' ? true : m.quota_type === type))
.length;
const items = [
{ value: 'all', label: t('全部类型'), tagCount: qtyCount('all') },
{ value: 0, label: t('按量计费'), tagCount: qtyCount(0) },
{ value: 1, label: t('按次计费'), tagCount: qtyCount(1) },
];
return (
<SelectableButtonGroup
title={t('计费类型')}
items={items}
activeValue={filterQuotaType}
onChange={setFilterQuotaType}
loading={loading}
variant='amber'
t={t}
/>
);
};
export default PricingQuotaTypes;
@@ -0,0 +1,109 @@
/*
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 React from 'react';
import SelectableButtonGroup from '../../../common/ui/SelectableButtonGroup';
/**
* 模型标签筛选组件
* @param {string|'all'} filterTag 当前选中的标签
* @param {Function} setFilterTag setter
* @param {Array} models 当前过滤后模型列表(用于计数)
* @param {Array} allModels 所有模型列表(用于获取所有标签)
* @param {boolean} loading 是否加载中
* @param {Function} t i18n
*/
const PricingTags = ({
filterTag,
setFilterTag,
models = [],
allModels = [],
loading = false,
t,
}) => {
// 提取系统所有标签
const getAllTags = React.useMemo(() => {
const tagSet = new Set();
(allModels.length > 0 ? allModels : models).forEach((model) => {
if (model.tags) {
model.tags
.split(/[,;|]+/) // 逗号、分号或竖线(保留空格,允许多词标签如 "open weights"
.map((tag) => tag.trim())
.filter(Boolean)
.forEach((tag) => tagSet.add(tag.toLowerCase()));
}
});
return Array.from(tagSet).sort((a, b) => a.localeCompare(b));
}, [allModels, models]);
// 计算标签对应的模型数量
const getTagCount = React.useCallback(
(tag) => {
if (tag === 'all') return models.length;
const tagLower = tag.toLowerCase();
return models.filter((model) => {
if (!model.tags) return false;
return model.tags
.toLowerCase()
.split(/[,;|]+/)
.map((tg) => tg.trim())
.includes(tagLower);
}).length;
},
[models],
);
const items = React.useMemo(() => {
const result = [
{
value: 'all',
label: t('全部标签'),
tagCount: getTagCount('all'),
},
];
getAllTags.forEach((tag) => {
const count = getTagCount(tag);
result.push({
value: tag,
label: tag,
tagCount: count,
});
});
return result;
}, [getAllTags, getTagCount, t, models.length]);
return (
<SelectableButtonGroup
title={t('标签')}
items={items}
activeValue={filterTag}
onChange={setFilterTag}
loading={loading}
variant='rose'
t={t}
/>
);
};
export default PricingTags;
@@ -0,0 +1,127 @@
/*
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 React from 'react';
import SelectableButtonGroup from '../../../common/ui/SelectableButtonGroup';
import { getLobeHubIcon } from '../../../../helpers';
/**
* 供应商筛选组件
* @param {string|'all'} filterVendor 当前值
* @param {Function} setFilterVendor setter
* @param {Array} models 模型列表
* @param {Array} allModels 所有模型列表(用于获取全部供应商)
* @param {boolean} loading 是否加载中
* @param {Function} t i18n
*/
const PricingVendors = ({
filterVendor,
setFilterVendor,
models = [],
allModels = [],
loading = false,
t,
}) => {
// 获取系统中所有供应商(基于 allModels,如果未提供则退化为 models)
const getAllVendors = React.useMemo(() => {
const vendors = new Set();
const vendorIcons = new Map();
let hasUnknownVendor = false;
(allModels.length > 0 ? allModels : models).forEach((model) => {
if (model.vendor_name) {
vendors.add(model.vendor_name);
if (model.vendor_icon && !vendorIcons.has(model.vendor_name)) {
vendorIcons.set(model.vendor_name, model.vendor_icon);
}
} else {
hasUnknownVendor = true;
}
});
return {
vendors: Array.from(vendors).sort(),
vendorIcons,
hasUnknownVendor,
};
}, [allModels, models]);
// 计算每个供应商的模型数量(基于当前过滤后的 models)
const getVendorCount = React.useCallback(
(vendor) => {
if (vendor === 'all') {
return models.length;
}
if (vendor === 'unknown') {
return models.filter((model) => !model.vendor_name).length;
}
return models.filter((model) => model.vendor_name === vendor).length;
},
[models],
);
// 生成供应商选项
const items = React.useMemo(() => {
const result = [
{
value: 'all',
label: t('全部供应商'),
tagCount: getVendorCount('all'),
},
];
// 添加所有已知供应商
getAllVendors.vendors.forEach((vendor) => {
const count = getVendorCount(vendor);
const icon = getAllVendors.vendorIcons.get(vendor);
result.push({
value: vendor,
label: vendor,
icon: icon ? getLobeHubIcon(icon, 16) : null,
tagCount: count,
});
});
// 如果系统中存在未知供应商,添加"未知供应商"选项
if (getAllVendors.hasUnknownVendor) {
const count = getVendorCount('unknown');
result.push({
value: 'unknown',
label: t('未知供应商'),
tagCount: count,
});
}
return result;
}, [getAllVendors, getVendorCount, t]);
return (
<SelectableButtonGroup
title={t('供应商')}
items={items}
activeValue={filterVendor}
onChange={setFilterVendor}
loading={loading}
variant='violet'
t={t}
/>
);
};
export default PricingVendors;
@@ -0,0 +1,86 @@
/*
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 React from 'react';
import { Layout, ImagePreview } from '@douyinfe/semi-ui';
import PricingSidebar from './PricingSidebar';
import PricingContent from './content/PricingContent';
import ModelDetailSideSheet from '../modal/ModelDetailSideSheet';
import { useModelPricingData } from '../../../../hooks/model-pricing/useModelPricingData';
import { useIsMobile } from '../../../../hooks/common/useIsMobile';
const PricingPage = () => {
const pricingData = useModelPricingData();
const { Sider, Content } = Layout;
const isMobile = useIsMobile();
const [showRatio, setShowRatio] = React.useState(false);
const [viewMode, setViewMode] = React.useState('card');
const allProps = {
...pricingData,
showRatio,
setShowRatio,
viewMode,
setViewMode,
};
return (
<div className='bg-white'>
<Layout className='pricing-layout'>
{!isMobile && (
<Sider className='pricing-scroll-hide pricing-sidebar'>
<PricingSidebar {...allProps} />
</Sider>
)}
<Content className='pricing-scroll-hide pricing-content'>
<PricingContent
{...allProps}
isMobile={isMobile}
sidebarProps={allProps}
/>
</Content>
</Layout>
<ImagePreview
src={pricingData.modalImageUrl}
visible={pricingData.isModalOpenurl}
onVisibleChange={(visible) => pricingData.setIsModalOpenurl(visible)}
/>
<ModelDetailSideSheet
visible={pricingData.showModelDetail}
onClose={pricingData.closeModelDetail}
modelData={pricingData.selectedModel}
groupRatio={pricingData.groupRatio}
usableGroup={pricingData.usableGroup}
currency={pricingData.currency}
siteDisplayType={pricingData.siteDisplayType}
tokenUnit={pricingData.tokenUnit}
displayPrice={pricingData.displayPrice}
showRatio={allProps.showRatio}
vendorsMap={pricingData.vendorsMap}
endpointMap={pricingData.endpointMap}
autoGroups={pricingData.autoGroups}
t={pricingData.t}
/>
</div>
);
};
export default PricingPage;
@@ -0,0 +1,155 @@
/*
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 React from 'react';
import { Button } from '@douyinfe/semi-ui';
import PricingGroups from '../filter/PricingGroups';
import PricingQuotaTypes from '../filter/PricingQuotaTypes';
import PricingEndpointTypes from '../filter/PricingEndpointTypes';
import PricingVendors from '../filter/PricingVendors';
import PricingTags from '../filter/PricingTags';
import { resetPricingFilters } from '../../../../helpers/utils';
import { usePricingFilterCounts } from '../../../../hooks/model-pricing/usePricingFilterCounts';
const PricingSidebar = ({
showWithRecharge,
setShowWithRecharge,
currency,
setCurrency,
handleChange,
setActiveKey,
showRatio,
setShowRatio,
viewMode,
setViewMode,
filterGroup,
setFilterGroup,
handleGroupClick,
filterQuotaType,
setFilterQuotaType,
filterEndpointType,
setFilterEndpointType,
filterVendor,
setFilterVendor,
filterTag,
setFilterTag,
currentPage,
setCurrentPage,
tokenUnit,
setTokenUnit,
loading,
t,
...categoryProps
}) => {
const {
quotaTypeModels,
endpointTypeModels,
vendorModels,
tagModels,
groupCountModels,
} = usePricingFilterCounts({
models: categoryProps.models,
filterGroup,
filterQuotaType,
filterEndpointType,
filterVendor,
filterTag,
searchValue: categoryProps.searchValue,
});
const handleResetFilters = () =>
resetPricingFilters({
handleChange,
setShowWithRecharge,
setCurrency,
setShowRatio,
setViewMode,
setFilterGroup,
setFilterQuotaType,
setFilterEndpointType,
setFilterVendor,
setFilterTag,
setCurrentPage,
setTokenUnit,
});
return (
<div className='p-2'>
<div className='flex items-center justify-between mb-6'>
<div className='text-lg font-semibold text-gray-800'>{t('筛选')}</div>
<Button
theme='outline'
type='tertiary'
onClick={handleResetFilters}
className='text-gray-500 hover:text-gray-700'
>
{t('重置')}
</Button>
</div>
<PricingVendors
filterVendor={filterVendor}
setFilterVendor={setFilterVendor}
models={vendorModels}
allModels={categoryProps.models}
loading={loading}
t={t}
/>
<PricingGroups
filterGroup={filterGroup}
setFilterGroup={handleGroupClick}
usableGroup={categoryProps.usableGroup}
groupRatio={categoryProps.groupRatio}
models={groupCountModels}
loading={loading}
t={t}
/>
<PricingQuotaTypes
filterQuotaType={filterQuotaType}
setFilterQuotaType={setFilterQuotaType}
models={quotaTypeModels}
loading={loading}
t={t}
/>
<PricingTags
filterTag={filterTag}
setFilterTag={setFilterTag}
models={tagModels}
allModels={categoryProps.models}
loading={loading}
t={t}
/>
<PricingEndpointTypes
filterEndpointType={filterEndpointType}
setFilterEndpointType={setFilterEndpointType}
models={endpointTypeModels}
allModels={categoryProps.models}
loading={loading}
t={t}
/>
</div>
);
};
export default PricingSidebar;
@@ -0,0 +1,60 @@
/*
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 React from 'react';
import PricingTopSection from '../header/PricingTopSection';
import PricingView from './PricingView';
const PricingContent = ({ isMobile, sidebarProps, ...props }) => {
return (
<div
className={isMobile ? 'pricing-content-mobile' : 'pricing-scroll-hide'}
>
{/* 固定的顶部区域(分类介绍 + 搜索和操作) */}
<div className='pricing-search-header'>
<PricingTopSection
{...props}
isMobile={isMobile}
sidebarProps={sidebarProps}
showWithRecharge={sidebarProps.showWithRecharge}
setShowWithRecharge={sidebarProps.setShowWithRecharge}
currency={sidebarProps.currency}
setCurrency={sidebarProps.setCurrency}
showRatio={sidebarProps.showRatio}
setShowRatio={sidebarProps.setShowRatio}
viewMode={sidebarProps.viewMode}
setViewMode={sidebarProps.setViewMode}
tokenUnit={sidebarProps.tokenUnit}
setTokenUnit={sidebarProps.setTokenUnit}
/>
</div>
{/* 可滚动的内容区域 */}
<div
className={
isMobile ? 'pricing-view-container-mobile' : 'pricing-view-container'
}
>
<PricingView {...props} viewMode={sidebarProps.viewMode} />
</div>
</div>
);
};
export default PricingContent;
@@ -0,0 +1,32 @@
/*
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 React from 'react';
import PricingTable from '../../view/table/PricingTable';
import PricingCardView from '../../view/card/PricingCardView';
const PricingView = ({ viewMode = 'table', ...props }) => {
return viewMode === 'card' ? (
<PricingCardView {...props} />
) : (
<PricingTable {...props} />
);
};
export default PricingView;
@@ -0,0 +1,124 @@
/*
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 React, { useState, memo } from 'react';
import PricingFilterModal from '../../modal/PricingFilterModal';
import PricingVendorIntroWithSkeleton from './PricingVendorIntroWithSkeleton';
import SearchActions from './SearchActions';
const PricingTopSection = memo(
({
selectedRowKeys,
copyText,
handleChange,
handleCompositionStart,
handleCompositionEnd,
isMobile,
sidebarProps,
filterVendor,
models,
filteredModels,
loading,
searchValue,
showWithRecharge,
setShowWithRecharge,
currency,
setCurrency,
siteDisplayType,
showRatio,
setShowRatio,
viewMode,
setViewMode,
tokenUnit,
setTokenUnit,
t,
}) => {
const [showFilterModal, setShowFilterModal] = useState(false);
return (
<>
{isMobile ? (
<>
<div className='w-full'>
<SearchActions
selectedRowKeys={selectedRowKeys}
copyText={copyText}
handleChange={handleChange}
handleCompositionStart={handleCompositionStart}
handleCompositionEnd={handleCompositionEnd}
isMobile={isMobile}
searchValue={searchValue}
setShowFilterModal={setShowFilterModal}
showWithRecharge={showWithRecharge}
setShowWithRecharge={setShowWithRecharge}
currency={currency}
setCurrency={setCurrency}
siteDisplayType={siteDisplayType}
showRatio={showRatio}
setShowRatio={setShowRatio}
viewMode={viewMode}
setViewMode={setViewMode}
tokenUnit={tokenUnit}
setTokenUnit={setTokenUnit}
t={t}
/>
</div>
<PricingFilterModal
visible={showFilterModal}
onClose={() => setShowFilterModal(false)}
sidebarProps={sidebarProps}
t={t}
/>
</>
) : (
<PricingVendorIntroWithSkeleton
loading={loading}
filterVendor={filterVendor}
models={filteredModels}
allModels={models}
t={t}
selectedRowKeys={selectedRowKeys}
copyText={copyText}
handleChange={handleChange}
handleCompositionStart={handleCompositionStart}
handleCompositionEnd={handleCompositionEnd}
isMobile={isMobile}
searchValue={searchValue}
setShowFilterModal={setShowFilterModal}
showWithRecharge={showWithRecharge}
setShowWithRecharge={setShowWithRecharge}
currency={currency}
setCurrency={setCurrency}
siteDisplayType={siteDisplayType}
showRatio={showRatio}
setShowRatio={setShowRatio}
viewMode={viewMode}
setViewMode={setViewMode}
tokenUnit={tokenUnit}
setTokenUnit={setTokenUnit}
/>
)}
</>
);
},
);
PricingTopSection.displayName = 'PricingTopSection';
export default PricingTopSection;
@@ -0,0 +1,419 @@
/*
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 React, { useState, useEffect, useMemo, useCallback, memo } from 'react';
import {
Card,
Tag,
Avatar,
Typography,
Tooltip,
Modal,
} from '@douyinfe/semi-ui';
import { getLobeHubIcon } from '../../../../../helpers';
import SearchActions from './SearchActions';
const { Paragraph } = Typography;
const CONFIG = {
CAROUSEL_INTERVAL: 2000,
ICON_SIZE: 40,
UNKNOWN_VENDOR: 'unknown',
};
const THEME_COLORS = {
allVendors: {
primary: '37 99 235',
background: 'rgba(59, 130, 246, 0.08)',
},
specific: {
primary: '16 185 129',
background: 'rgba(16, 185, 129, 0.1)',
},
};
const COMPONENT_STYLES = {
tag: {
backgroundColor: 'rgba(255,255,255,0.95)',
color: '#1f2937',
border: '1px solid rgba(255,255,255,0.8)',
fontWeight: '500',
},
avatarContainer:
'w-16 h-16 rounded-2xl bg-white/90 shadow-md backdrop-blur-sm flex items-center justify-center',
titleText: { color: 'white' },
descriptionText: { color: 'rgba(255,255,255,0.9)' },
};
const CONTENT_TEXTS = {
unknown: {
displayName: (t) => t('未知供应商'),
description: (t) =>
t(
'包含来自未知或未标明供应商的AI模型,这些模型可能来自小型供应商或开源项目。',
),
},
all: {
description: (t) =>
t('查看所有可用的AI模型供应商,包括众多知名供应商的模型。'),
},
fallback: {
description: (t) => t('该供应商提供多种AI模型,适用于不同的应用场景。'),
},
};
const getVendorDisplayName = (vendorName, t) => {
return vendorName === CONFIG.UNKNOWN_VENDOR
? CONTENT_TEXTS.unknown.displayName(t)
: vendorName;
};
const createDefaultAvatar = () => (
<div className={COMPONENT_STYLES.avatarContainer}>
<Avatar size='large' color='transparent'>
AI
</Avatar>
</div>
);
const getAvatarBackgroundColor = (isAllVendors) =>
isAllVendors
? THEME_COLORS.allVendors.background
: THEME_COLORS.specific.background;
const getAvatarText = (vendorName) =>
vendorName === CONFIG.UNKNOWN_VENDOR
? '?'
: vendorName.charAt(0).toUpperCase();
const createAvatarContent = (vendor, isAllVendors) => {
if (vendor.icon) {
return getLobeHubIcon(vendor.icon, CONFIG.ICON_SIZE);
}
return (
<Avatar
size='large'
style={{ backgroundColor: getAvatarBackgroundColor(isAllVendors) }}
>
{getAvatarText(vendor.name)}
</Avatar>
);
};
const renderVendorAvatar = (vendor, t, isAllVendors = false) => {
if (!vendor) {
return createDefaultAvatar();
}
const displayName = getVendorDisplayName(vendor.name, t);
const avatarContent = createAvatarContent(vendor, isAllVendors);
return (
<Tooltip content={displayName} position='top'>
<div className={COMPONENT_STYLES.avatarContainer}>{avatarContent}</div>
</Tooltip>
);
};
const PricingVendorIntro = memo(
({
filterVendor,
models = [],
allModels = [],
t,
selectedRowKeys = [],
copyText,
handleChange,
handleCompositionStart,
handleCompositionEnd,
isMobile = false,
searchValue = '',
setShowFilterModal,
showWithRecharge,
setShowWithRecharge,
currency,
setCurrency,
showRatio,
setShowRatio,
viewMode,
setViewMode,
tokenUnit,
setTokenUnit,
}) => {
const [currentOffset, setCurrentOffset] = useState(0);
const [descModalVisible, setDescModalVisible] = useState(false);
const [descModalContent, setDescModalContent] = useState('');
const handleOpenDescModal = useCallback((content) => {
setDescModalContent(content || '');
setDescModalVisible(true);
}, []);
const handleCloseDescModal = useCallback(() => {
setDescModalVisible(false);
}, []);
const renderDescriptionModal = useCallback(
() => (
<Modal
title={t('供应商介绍')}
visible={descModalVisible}
onCancel={handleCloseDescModal}
footer={null}
width={isMobile ? '95%' : 600}
bodyStyle={{
maxHeight: isMobile ? '70vh' : '60vh',
overflowY: 'auto',
}}
>
<div className='text-sm mb-4'>{descModalContent}</div>
</Modal>
),
[descModalVisible, descModalContent, handleCloseDescModal, isMobile, t],
);
const vendorInfo = useMemo(() => {
const vendors = new Map();
let unknownCount = 0;
const sourceModels =
Array.isArray(allModels) && allModels.length > 0 ? allModels : models;
sourceModels.forEach((model) => {
if (model.vendor_name) {
const existing = vendors.get(model.vendor_name);
if (existing) {
existing.count++;
} else {
vendors.set(model.vendor_name, {
name: model.vendor_name,
icon: model.vendor_icon,
description: model.vendor_description,
count: 1,
});
}
} else {
unknownCount++;
}
});
const vendorList = Array.from(vendors.values()).sort((a, b) =>
a.name.localeCompare(b.name),
);
if (unknownCount > 0) {
vendorList.push({
name: CONFIG.UNKNOWN_VENDOR,
icon: null,
description: CONTENT_TEXTS.unknown.description(t),
count: unknownCount,
});
}
return vendorList;
}, [allModels, models, t]);
const currentModelCount = models.length;
useEffect(() => {
if (filterVendor !== 'all' || vendorInfo.length <= 1) {
setCurrentOffset(0);
return;
}
const interval = setInterval(() => {
setCurrentOffset((prev) => (prev + 1) % vendorInfo.length);
}, CONFIG.CAROUSEL_INTERVAL);
return () => clearInterval(interval);
}, [filterVendor, vendorInfo.length]);
const getVendorDescription = useCallback(
(vendorKey) => {
if (vendorKey === 'all') {
return CONTENT_TEXTS.all.description(t);
}
if (vendorKey === CONFIG.UNKNOWN_VENDOR) {
return CONTENT_TEXTS.unknown.description(t);
}
const vendor = vendorInfo.find((v) => v.name === vendorKey);
return vendor?.description || CONTENT_TEXTS.fallback.description(t);
},
[vendorInfo, t],
);
const createCoverStyle = useCallback(
(primaryColor) => ({
'--palette-primary-darkerChannel': primaryColor,
backgroundImage: `linear-gradient(0deg, rgba(var(--palette-primary-darkerChannel) / 80%), rgba(var(--palette-primary-darkerChannel) / 80%)), url('/cover-4.webp')`,
backgroundSize: 'cover',
backgroundPosition: 'center',
backgroundRepeat: 'no-repeat',
}),
[],
);
const renderSearchActions = useCallback(
() => (
<SearchActions
selectedRowKeys={selectedRowKeys}
copyText={copyText}
handleChange={handleChange}
handleCompositionStart={handleCompositionStart}
handleCompositionEnd={handleCompositionEnd}
isMobile={isMobile}
searchValue={searchValue}
setShowFilterModal={setShowFilterModal}
showWithRecharge={showWithRecharge}
setShowWithRecharge={setShowWithRecharge}
currency={currency}
setCurrency={setCurrency}
showRatio={showRatio}
setShowRatio={setShowRatio}
viewMode={viewMode}
setViewMode={setViewMode}
tokenUnit={tokenUnit}
setTokenUnit={setTokenUnit}
t={t}
/>
),
[
selectedRowKeys,
copyText,
handleChange,
handleCompositionStart,
handleCompositionEnd,
isMobile,
searchValue,
setShowFilterModal,
showWithRecharge,
setShowWithRecharge,
currency,
setCurrency,
showRatio,
setShowRatio,
viewMode,
setViewMode,
tokenUnit,
setTokenUnit,
t,
],
);
const renderHeaderCard = useCallback(
({ title, count, description, rightContent, primaryDarkerChannel }) => (
<Card
className='!rounded-2xl shadow-sm border-0'
cover={
<div
className='relative h-full'
style={createCoverStyle(primaryDarkerChannel)}
>
<div className='relative z-10 h-full flex items-center justify-between p-4'>
<div className='flex-1 min-w-0 mr-4'>
<div className='flex flex-row flex-wrap items-center gap-2 sm:gap-3 mb-2'>
<h2
className='text-lg sm:text-xl font-bold truncate'
style={COMPONENT_STYLES.titleText}
>
{title}
</h2>
<Tag
style={COMPONENT_STYLES.tag}
shape='circle'
size='small'
className='self-center'
>
{t('共 {{count}} 个模型', { count })}
</Tag>
</div>
<Paragraph
className='text-xs sm:text-sm leading-relaxed !mb-0 cursor-pointer'
style={COMPONENT_STYLES.descriptionText}
ellipsis={{ rows: 2 }}
onClick={() => handleOpenDescModal(description)}
>
{description}
</Paragraph>
</div>
<div className='flex-shrink-0'>{rightContent}</div>
</div>
</div>
}
>
{renderSearchActions()}
</Card>
),
[renderSearchActions, createCoverStyle, handleOpenDescModal, t],
);
const renderAllVendorsAvatar = useCallback(() => {
const currentVendor =
vendorInfo.length > 0
? vendorInfo[currentOffset % vendorInfo.length]
: null;
return renderVendorAvatar(currentVendor, t, true);
}, [vendorInfo, currentOffset, t]);
if (filterVendor === 'all') {
const headerCard = renderHeaderCard({
title: t('全部供应商'),
count: currentModelCount,
description: getVendorDescription('all'),
rightContent: renderAllVendorsAvatar(),
primaryDarkerChannel: THEME_COLORS.allVendors.primary,
});
return (
<>
{headerCard}
{renderDescriptionModal()}
</>
);
}
const currentVendor = vendorInfo.find((v) => v.name === filterVendor);
if (!currentVendor) {
return null;
}
const vendorDisplayName = getVendorDisplayName(currentVendor.name, t);
const headerCard = renderHeaderCard({
title: vendorDisplayName,
count: currentModelCount,
description:
currentVendor.description || getVendorDescription(currentVendor.name),
rightContent: renderVendorAvatar(currentVendor, t, false),
primaryDarkerChannel: THEME_COLORS.specific.primary,
});
return (
<>
{headerCard}
{renderDescriptionModal()}
</>
);
},
);
PricingVendorIntro.displayName = 'PricingVendorIntro';
export default PricingVendorIntro;
@@ -0,0 +1,212 @@
/*
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 React, { memo } from 'react';
import { Card, Skeleton } from '@douyinfe/semi-ui';
const THEME_COLORS = {
allVendors: {
primary: '37 99 235',
background: 'rgba(59, 130, 246, 0.1)',
border: 'rgba(59, 130, 246, 0.2)',
},
specific: {
primary: '16 185 129',
background: 'rgba(16, 185, 129, 0.1)',
border: 'rgba(16, 185, 129, 0.2)',
},
neutral: {
background: 'rgba(156, 163, 175, 0.1)',
border: 'rgba(156, 163, 175, 0.2)',
},
};
const SIZES = {
title: { width: { all: 120, specific: 100 }, height: 24 },
tag: { width: 80, height: 20 },
description: { height: 14 },
avatar: { width: 40, height: 40 },
searchInput: { height: 32 },
button: { width: 80, height: 32 },
};
const SKELETON_STYLES = {
cover: (primaryColor) => ({
'--palette-primary-darkerChannel': primaryColor,
backgroundImage: `linear-gradient(0deg, rgba(var(--palette-primary-darkerChannel) / 80%), rgba(var(--palette-primary-darkerChannel) / 80%)), url('/cover-4.webp')`,
backgroundSize: 'cover',
backgroundPosition: 'center',
backgroundRepeat: 'no-repeat',
}),
title: {
backgroundColor: 'rgba(255, 255, 255, 0.25)',
borderRadius: 8,
backdropFilter: 'blur(4px)',
},
tag: {
backgroundColor: 'rgba(255, 255, 255, 0.2)',
borderRadius: 9999,
backdropFilter: 'blur(4px)',
border: '1px solid rgba(255,255,255,0.3)',
},
description: {
backgroundColor: 'rgba(255, 255, 255, 0.2)',
borderRadius: 4,
backdropFilter: 'blur(4px)',
},
avatar: (isAllVendors) => {
const colors = isAllVendors
? THEME_COLORS.allVendors
: THEME_COLORS.specific;
return {
backgroundColor: colors.background,
borderRadius: 12,
border: `1px solid ${colors.border}`,
};
},
searchInput: {
backgroundColor: THEME_COLORS.neutral.background,
borderRadius: 8,
border: `1px solid ${THEME_COLORS.neutral.border}`,
},
button: {
backgroundColor: THEME_COLORS.neutral.background,
borderRadius: 8,
border: `1px solid ${THEME_COLORS.neutral.border}`,
},
};
const createSkeletonRect = (style = {}, key = null) => (
<div key={key} className='animate-pulse' style={style} />
);
const PricingVendorIntroSkeleton = memo(
({ isAllVendors = false, isMobile = false }) => {
const placeholder = (
<Card
className='!rounded-2xl shadow-sm border-0'
cover={
<div
className='relative h-full'
style={SKELETON_STYLES.cover(
isAllVendors
? THEME_COLORS.allVendors.primary
: THEME_COLORS.specific.primary,
)}
>
<div className='relative z-10 h-full flex items-center justify-between p-4'>
<div className='flex-1 min-w-0 mr-4'>
<div className='flex flex-row flex-wrap items-center gap-2 sm:gap-3 mb-2'>
{createSkeletonRect(
{
...SKELETON_STYLES.title,
width: isAllVendors
? SIZES.title.width.all
: SIZES.title.width.specific,
height: SIZES.title.height,
},
'title',
)}
{createSkeletonRect(
{
...SKELETON_STYLES.tag,
width: SIZES.tag.width,
height: SIZES.tag.height,
},
'tag',
)}
</div>
<div className='space-y-2'>
{createSkeletonRect(
{
...SKELETON_STYLES.description,
width: '100%',
height: SIZES.description.height,
},
'desc1',
)}
{createSkeletonRect(
{
...SKELETON_STYLES.description,
backgroundColor: 'rgba(255, 255, 255, 0.15)',
width: '75%',
height: SIZES.description.height,
},
'desc2',
)}
</div>
</div>
<div className='flex-shrink-0 w-16 h-16 rounded-2xl bg-white/90 shadow-md backdrop-blur-sm flex items-center justify-center'>
{createSkeletonRect(
{
...SKELETON_STYLES.avatar(isAllVendors),
width: SIZES.avatar.width,
height: SIZES.avatar.height,
},
'avatar',
)}
</div>
</div>
</div>
}
>
<div className='flex items-center gap-2 w-full'>
<div className='flex-1'>
{createSkeletonRect(
{
...SKELETON_STYLES.searchInput,
width: '100%',
height: SIZES.searchInput.height,
},
'search',
)}
</div>
{createSkeletonRect(
{
...SKELETON_STYLES.button,
width: SIZES.button.width,
height: SIZES.button.height,
},
'copy-button',
)}
{isMobile &&
createSkeletonRect(
{
...SKELETON_STYLES.button,
width: SIZES.button.width,
height: SIZES.button.height,
},
'filter-button',
)}
</div>
</Card>
);
return (
<Skeleton loading={true} active placeholder={placeholder}></Skeleton>
);
},
);
PricingVendorIntroSkeleton.displayName = 'PricingVendorIntroSkeleton';
export default PricingVendorIntroSkeleton;
@@ -0,0 +1,44 @@
/*
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 React, { memo } from 'react';
import PricingVendorIntro from './PricingVendorIntro';
import PricingVendorIntroSkeleton from './PricingVendorIntroSkeleton';
import { useMinimumLoadingTime } from '../../../../../hooks/common/useMinimumLoadingTime';
const PricingVendorIntroWithSkeleton = memo(
({ loading = false, filterVendor, ...restProps }) => {
const showSkeleton = useMinimumLoadingTime(loading);
if (showSkeleton) {
return (
<PricingVendorIntroSkeleton
isAllVendors={filterVendor === 'all'}
isMobile={restProps.isMobile}
/>
);
}
return <PricingVendorIntro filterVendor={filterVendor} {...restProps} />;
},
);
PricingVendorIntroWithSkeleton.displayName = 'PricingVendorIntroWithSkeleton';
export default PricingVendorIntroWithSkeleton;
@@ -0,0 +1,163 @@
/*
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 React, { memo, useCallback } from 'react';
import { Input, Button, Switch, Select, Divider } from '@douyinfe/semi-ui';
import { IconSearch, IconCopy, IconFilter } from '@douyinfe/semi-icons';
const SearchActions = memo(
({
selectedRowKeys = [],
copyText,
handleChange,
handleCompositionStart,
handleCompositionEnd,
isMobile = false,
searchValue = '',
setShowFilterModal,
showWithRecharge,
setShowWithRecharge,
currency,
setCurrency,
siteDisplayType,
showRatio,
setShowRatio,
viewMode,
setViewMode,
tokenUnit,
setTokenUnit,
t,
}) => {
const supportsCurrencyDisplay = siteDisplayType !== 'TOKENS';
const handleCopyClick = useCallback(() => {
if (copyText && selectedRowKeys.length > 0) {
copyText(selectedRowKeys);
}
}, [copyText, selectedRowKeys]);
const handleFilterClick = useCallback(() => {
setShowFilterModal?.(true);
}, [setShowFilterModal]);
const handleViewModeToggle = useCallback(() => {
setViewMode?.(viewMode === 'table' ? 'card' : 'table');
}, [viewMode, setViewMode]);
const handleTokenUnitToggle = useCallback(() => {
setTokenUnit?.(tokenUnit === 'K' ? 'M' : 'K');
}, [tokenUnit, setTokenUnit]);
return (
<div className='flex items-center gap-2 w-full'>
<div className='flex-1'>
<Input
prefix={<IconSearch />}
placeholder={t('模糊搜索模型名称')}
value={searchValue}
onCompositionStart={handleCompositionStart}
onCompositionEnd={handleCompositionEnd}
onChange={handleChange}
showClear
/>
</div>
<Button
theme='outline'
type='primary'
icon={<IconCopy />}
onClick={handleCopyClick}
disabled={selectedRowKeys.length === 0}
className='!bg-blue-500 hover:!bg-blue-600 !text-white disabled:!bg-gray-300 disabled:!text-gray-500'
>
{t('复制')}
</Button>
{!isMobile && (
<>
<Divider layout='vertical' margin='8px' />
{/* 充值价格显示开关 */}
{supportsCurrencyDisplay && (
<div className='flex items-center gap-2'>
<span className='text-sm text-gray-600'>{t('充值价格显示')}</span>
<Switch
checked={showWithRecharge}
onChange={setShowWithRecharge}
/>
</div>
)}
{/* 货币单位选择 */}
{supportsCurrencyDisplay && showWithRecharge && (
<Select
value={currency}
onChange={setCurrency}
optionList={[
{ value: 'USD', label: 'USD' },
{ value: 'CNY', label: 'CNY' },
{ value: 'CUSTOM', label: t('自定义货币') },
]}
/>
)}
{/* 显示倍率开关 */}
<div className='flex items-center gap-2'>
<span className='text-sm text-gray-600'>{t('倍率')}</span>
<Switch checked={showRatio} onChange={setShowRatio} />
</div>
{/* 视图模式切换按钮 */}
<Button
theme={viewMode === 'table' ? 'solid' : 'outline'}
type={viewMode === 'table' ? 'primary' : 'tertiary'}
onClick={handleViewModeToggle}
>
{t('表格视图')}
</Button>
{/* Token单位切换按钮 */}
<Button
theme={tokenUnit === 'K' ? 'solid' : 'outline'}
type={tokenUnit === 'K' ? 'primary' : 'tertiary'}
onClick={handleTokenUnitToggle}
>
{tokenUnit}
</Button>
</>
)}
{isMobile && (
<Button
theme='outline'
type='tertiary'
icon={<IconFilter />}
onClick={handleFilterClick}
>
{t('筛选')}
</Button>
)}
</div>
);
},
);
SearchActions.displayName = 'SearchActions';
export default SearchActions;
@@ -0,0 +1,132 @@
/*
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 React from 'react';
import { SideSheet, Typography, Button, Divider } from '@douyinfe/semi-ui';
import { IconClose } from '@douyinfe/semi-icons';
import { useIsMobile } from '../../../../hooks/common/useIsMobile';
import ModelHeader from './components/ModelHeader';
import ModelBasicInfo from './components/ModelBasicInfo';
import ModelEndpoints from './components/ModelEndpoints';
import ModelPricingTable from './components/ModelPricingTable';
import DynamicPricingBreakdown from './components/DynamicPricingBreakdown';
const { Text } = Typography;
const ModelDetailSideSheet = ({
visible,
onClose,
modelData,
groupRatio,
currency,
siteDisplayType,
tokenUnit,
displayPrice,
showRatio,
usableGroup,
vendorsMap,
endpointMap,
autoGroups,
t,
}) => {
const isMobile = useIsMobile();
return (
<SideSheet
placement='right'
title={
<ModelHeader modelData={modelData} vendorsMap={vendorsMap} t={t} />
}
bodyStyle={{
padding: '0',
display: 'flex',
flexDirection: 'column',
borderBottom: '1px solid var(--semi-color-border)',
}}
visible={visible}
width={isMobile ? '100%' : 600}
closeIcon={
<Button
className='semi-button-tertiary semi-button-size-small semi-button-borderless'
type='button'
icon={<IconClose />}
onClick={onClose}
/>
}
onCancel={onClose}
>
<div style={{ paddingTop: 16, paddingBottom: 16 }}>
{!modelData && (
<div className='flex justify-center items-center py-10'>
<Text type='secondary'>{t('加载中...')}</Text>
</div>
)}
{modelData && (
<>
<div style={{ padding: '0 24px' }}>
<ModelBasicInfo
modelData={modelData}
vendorsMap={vendorsMap}
t={t}
/>
</div>
<Divider margin={16} />
<div style={{ padding: '0 24px' }}>
<ModelEndpoints
modelData={modelData}
endpointMap={endpointMap}
t={t}
/>
</div>
{modelData.billing_mode === 'tiered_expr' && modelData.billing_expr && (
<>
<Divider margin={16} />
<div style={{ padding: '0 24px' }}>
<DynamicPricingBreakdown
billingExpr={modelData.billing_expr}
t={t}
/>
</div>
</>
)}
<Divider margin={16} />
<div style={{ padding: '0 24px' }}>
<ModelPricingTable
modelData={modelData}
groupRatio={groupRatio}
currency={currency}
siteDisplayType={siteDisplayType}
tokenUnit={tokenUnit}
displayPrice={displayPrice}
showRatio={showRatio}
usableGroup={usableGroup}
autoGroups={autoGroups}
t={t}
/>
</div>
<Divider margin={16} />
</>
)}
</div>
</SideSheet>
);
};
export default ModelDetailSideSheet;
@@ -0,0 +1,67 @@
/*
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 React from 'react';
import { Modal } from '@douyinfe/semi-ui';
import { resetPricingFilters } from '../../../../helpers/utils';
import FilterModalContent from './components/FilterModalContent';
import FilterModalFooter from './components/FilterModalFooter';
const PricingFilterModal = ({ visible, onClose, sidebarProps, t }) => {
const handleResetFilters = () =>
resetPricingFilters({
handleChange: sidebarProps.handleChange,
setShowWithRecharge: sidebarProps.setShowWithRecharge,
setCurrency: sidebarProps.setCurrency,
setShowRatio: sidebarProps.setShowRatio,
setViewMode: sidebarProps.setViewMode,
setFilterGroup: sidebarProps.setFilterGroup,
setFilterQuotaType: sidebarProps.setFilterQuotaType,
setFilterEndpointType: sidebarProps.setFilterEndpointType,
setFilterVendor: sidebarProps.setFilterVendor,
setFilterTag: sidebarProps.setFilterTag,
setCurrentPage: sidebarProps.setCurrentPage,
setTokenUnit: sidebarProps.setTokenUnit,
});
const footer = (
<FilterModalFooter onReset={handleResetFilters} onConfirm={onClose} t={t} />
);
return (
<Modal
title={t('筛选')}
visible={visible}
onCancel={onClose}
footer={footer}
style={{ width: '100%', height: '100%', margin: 0 }}
bodyStyle={{
padding: 0,
height: 'calc(100vh - 160px)',
overflowY: 'auto',
scrollbarWidth: 'none',
msOverflowStyle: 'none',
}}
>
<FilterModalContent sidebarProps={sidebarProps} t={t} />
</Modal>
);
};
export default PricingFilterModal;
@@ -0,0 +1,206 @@
/*
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 React from 'react';
import { Avatar, Tag, Table, Typography } from '@douyinfe/semi-ui';
import { IconPriceTag } from '@douyinfe/semi-icons';
import { parseTiersFromExpr, getCurrencyConfig } from '../../../../../helpers';
import { BILLING_PRICING_VARS } from '../../../../../constants';
import {
splitBillingExprAndRequestRules,
tryParseRequestRuleExpr,
SOURCE_TIME,
MATCH_RANGE,
MATCH_EQ,
MATCH_GTE,
MATCH_LT,
MATCH_CONTAINS,
MATCH_EXISTS,
} from '../../../../../pages/Setting/Ratio/components/requestRuleExpr';
const { Text } = Typography;
const VAR_LABELS = { p: '输入', c: '输出' };
const OP_LABELS = { '<': '<', '<=': '≤', '>': '>', '>=': '≥' };
const TIME_FUNC_LABELS = { hour: '小时', minute: '分钟', weekday: '星期', month: '月份', day: '日期' };
function formatTokenHint(value) {
const n = Number(value);
if (!Number.isFinite(n) || n === 0) return '';
if (n >= 1000000) return `${(n / 1000000).toFixed(n % 1000000 === 0 ? 0 : 1)}M`;
if (n >= 1000) return `${(n / 1000).toFixed(n % 1000 === 0 ? 0 : 1)}K`;
return String(n);
}
function formatConditionSummary(conditions, t) {
return conditions
.map((c) => {
if (c.var && c.op) {
const varLabel = t(VAR_LABELS[c.var] || c.var);
const hint = formatTokenHint(c.value);
return `${varLabel} ${OP_LABELS[c.op] || c.op} ${hint || c.value}`;
}
return '';
})
.filter(Boolean)
.join(' && ');
}
function describeCondition(cond, t) {
if (cond.source === SOURCE_TIME) {
const fn = t(TIME_FUNC_LABELS[cond.timeFunc] || cond.timeFunc);
const tz = cond.timezone || 'UTC';
if (cond.mode === MATCH_RANGE) {
return `${fn} ${cond.rangeStart}:00~${cond.rangeEnd}:00 (${tz})`;
}
const opMap = { [MATCH_EQ]: '=', [MATCH_GTE]: '≥', [MATCH_LT]: '<' };
return `${fn} ${opMap[cond.mode] || '='} ${cond.value} (${tz})`;
}
const src = cond.source === 'header' ? t('请求头') : t('请求参数');
const path = cond.path || '';
if (cond.mode === MATCH_EXISTS) return `${src} ${path} ${t('存在')}`;
if (cond.mode === MATCH_CONTAINS) return `${src} ${path} ${t('包含')} "${cond.value}"`;
const opMap = { eq: '=', gt: '>', gte: '≥', lt: '<', lte: '≤' };
return `${src} ${path} ${opMap[cond.mode] || '='} ${cond.value}`;
}
function describeGroup(group, t) {
const parts = (group.conditions || []).map((c) => describeCondition(c, t));
return parts.join(' && ');
}
export default function DynamicPricingBreakdown({ billingExpr, t }) {
const { symbol, rate } = getCurrencyConfig();
const { billingExpr: baseExpr, requestRuleExpr: ruleExpr } =
splitBillingExprAndRequestRules(billingExpr || '');
const tiers = parseTiersFromExpr(baseExpr);
const ruleGroups = tryParseRequestRuleExpr(ruleExpr || '');
const hasTiers = tiers && tiers.length > 0;
const hasRules = ruleGroups && ruleGroups.length > 0;
if (!hasTiers && !hasRules) {
return (
<div>
<div className='flex items-center mb-3'>
<Avatar size='small' color='amber' className='mr-2 shadow-md'>
<IconPriceTag size={16} />
</Avatar>
<Text className='text-lg font-medium'>{t('动态计费')}</Text>
</div>
<div className='text-sm text-gray-500'>
<code style={{ fontSize: 12, wordBreak: 'break-all' }}>{billingExpr}</code>
</div>
</div>
);
}
const priceFields = BILLING_PRICING_VARS.map((v) => [v.field, v.shortLabel]);
const tierColumns = [
{
title: t('档位'),
dataIndex: 'label',
render: (text, record) => (
<div>
<Tag color='blue' size='small'>{text || t('默认')}</Tag>
{record.condSummary && (
<div className='text-xs text-gray-500 mt-1'>{record.condSummary}</div>
)}
</div>
),
},
...priceFields
.filter(([field]) => hasTiers && tiers.some((tier) => tier[field] > 0))
.map(([field, label]) => ({
title: `${t(label)} (${symbol}/1M tokens)`,
dataIndex: field,
render: (v) => v > 0 ? <Text strong>{`${symbol}${(v * rate).toFixed(4)}`}</Text> : '-',
})),
];
const tierData = hasTiers
? tiers.map((tier, i) => ({
key: `tier-${i}`,
label: tier.label,
condSummary: formatConditionSummary(tier.conditions, t),
...Object.fromEntries(priceFields.map(([field]) => [field, tier[field] || 0])),
}))
: [];
return (
<div>
<div className='flex items-center mb-4'>
<Avatar size='small' color='amber' className='mr-2 shadow-md'>
<IconPriceTag size={16} />
</Avatar>
<div>
<Text className='text-lg font-medium'>{t('动态计费')}</Text>
<div className='text-xs text-gray-600'>
{t('价格根据用量档位和请求条件动态调整')}
</div>
</div>
</div>
{hasTiers && (
<div style={{ marginBottom: 16 }}>
<Text strong className='text-sm' style={{ display: 'block', marginBottom: 8 }}>
{t('分档价格表')}
</Text>
<Table
dataSource={tierData}
columns={tierColumns}
pagination={false}
size='small'
bordered={false}
className='!rounded-lg'
/>
</div>
)}
{hasRules && (
<div style={{ marginBottom: 16 }}>
<Text strong className='text-sm' style={{ display: 'block', marginBottom: 8 }}>
{t('条件乘数')}
</Text>
{ruleGroups.map((group, gi) => (
<div
key={`group-${gi}`}
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '8px 12px',
borderRadius: 6,
background: 'var(--semi-color-fill-0)',
marginBottom: 4,
}}
>
<Text size='small'>{describeGroup(group, t)}</Text>
<Tag color='orange' size='small'>{group.multiplier}x</Tag>
</div>
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,140 @@
/*
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 React from 'react';
import PricingDisplaySettings from '../../filter/PricingDisplaySettings';
import PricingGroups from '../../filter/PricingGroups';
import PricingQuotaTypes from '../../filter/PricingQuotaTypes';
import PricingEndpointTypes from '../../filter/PricingEndpointTypes';
import PricingVendors from '../../filter/PricingVendors';
import PricingTags from '../../filter/PricingTags';
import { usePricingFilterCounts } from '../../../../../hooks/model-pricing/usePricingFilterCounts';
const FilterModalContent = ({ sidebarProps, t }) => {
const {
showWithRecharge,
setShowWithRecharge,
currency,
setCurrency,
siteDisplayType,
handleChange,
setActiveKey,
showRatio,
setShowRatio,
viewMode,
setViewMode,
filterGroup,
setFilterGroup,
filterQuotaType,
setFilterQuotaType,
filterEndpointType,
setFilterEndpointType,
filterVendor,
setFilterVendor,
filterTag,
setFilterTag,
tokenUnit,
setTokenUnit,
loading,
...categoryProps
} = sidebarProps;
const {
quotaTypeModels,
endpointTypeModels,
vendorModels,
tagModels,
groupCountModels,
} = usePricingFilterCounts({
models: categoryProps.models,
filterGroup,
filterQuotaType,
filterEndpointType,
filterVendor,
filterTag,
searchValue: sidebarProps.searchValue,
});
return (
<>
<PricingDisplaySettings
showWithRecharge={showWithRecharge}
setShowWithRecharge={setShowWithRecharge}
currency={currency}
setCurrency={setCurrency}
siteDisplayType={siteDisplayType}
showRatio={showRatio}
setShowRatio={setShowRatio}
viewMode={viewMode}
setViewMode={setViewMode}
tokenUnit={tokenUnit}
setTokenUnit={setTokenUnit}
loading={loading}
t={t}
/>
<PricingVendors
filterVendor={filterVendor}
setFilterVendor={setFilterVendor}
models={vendorModels}
allModels={categoryProps.models}
loading={loading}
t={t}
/>
<PricingGroups
filterGroup={filterGroup}
setFilterGroup={setFilterGroup}
usableGroup={categoryProps.usableGroup}
groupRatio={categoryProps.groupRatio}
models={groupCountModels}
loading={loading}
t={t}
/>
<PricingQuotaTypes
filterQuotaType={filterQuotaType}
setFilterQuotaType={setFilterQuotaType}
models={quotaTypeModels}
loading={loading}
t={t}
/>
<PricingTags
filterTag={filterTag}
setFilterTag={setFilterTag}
models={tagModels}
allModels={categoryProps.models}
loading={loading}
t={t}
/>
<PricingEndpointTypes
filterEndpointType={filterEndpointType}
setFilterEndpointType={setFilterEndpointType}
models={endpointTypeModels}
allModels={categoryProps.models}
loading={loading}
t={t}
/>
</>
);
};
export default FilterModalContent;
@@ -0,0 +1,36 @@
/*
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 React from 'react';
import { Button } from '@douyinfe/semi-ui';
const FilterModalFooter = ({ onReset, onConfirm, t }) => {
return (
<div className='flex justify-end'>
<Button theme='outline' type='tertiary' onClick={onReset}>
{t('重置')}
</Button>
<Button theme='solid' type='primary' onClick={onConfirm}>
{t('确定')}
</Button>
</div>
);
};
export default FilterModalFooter;
@@ -0,0 +1,89 @@
/*
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 React from 'react';
import { Avatar, Typography, Tag, Space } from '@douyinfe/semi-ui';
import { IconInfoCircle } from '@douyinfe/semi-icons';
import { stringToColor } from '../../../../../helpers';
const { Text } = Typography;
const ModelBasicInfo = ({ modelData, vendorsMap = {}, t }) => {
// 获取模型描述(使用后端真实数据)
const getModelDescription = () => {
if (!modelData) return t('暂无模型描述');
// 优先使用后端提供的描述
if (modelData.description) {
return modelData.description;
}
// 如果没有描述但有供应商描述,显示供应商信息
if (modelData.vendor_description) {
return t('供应商信息:') + modelData.vendor_description;
}
return t('暂无模型描述');
};
// 获取模型标签
const getModelTags = () => {
const tags = [];
if (modelData?.tags) {
const customTags = modelData.tags.split(',').filter((tag) => tag.trim());
customTags.forEach((tag) => {
const tagText = tag.trim();
tags.push({ text: tagText, color: stringToColor(tagText) });
});
}
return tags;
};
return (
<div>
<div className='flex items-center mb-4'>
<Avatar size='small' color='blue' className='mr-2 shadow-md'>
<IconInfoCircle size={16} />
</Avatar>
<div>
<Text className='text-lg font-medium'>{t('基本信息')}</Text>
<div className='text-xs text-gray-600'>
{t('模型的详细描述和基本特性')}
</div>
</div>
</div>
<div className='text-gray-600'>
<p className='mb-4'>{getModelDescription()}</p>
{getModelTags().length > 0 && (
<Space wrap>
{getModelTags().map((tag, index) => (
<Tag key={index} color={tag.color} shape='circle' size='small'>
{tag.text}
</Tag>
))}
</Space>
)}
</div>
</div>
);
};
export default ModelBasicInfo;
@@ -0,0 +1,82 @@
/*
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 React from 'react';
import { Avatar, Typography, Badge } from '@douyinfe/semi-ui';
import { IconLink } from '@douyinfe/semi-icons';
const { Text } = Typography;
const ModelEndpoints = ({ modelData, endpointMap = {}, t }) => {
const renderAPIEndpoints = () => {
if (!modelData) return null;
const mapping = endpointMap;
const types = modelData.supported_endpoint_types || [];
return types.map((type) => {
const info = mapping[type] || {};
let path = info.path || '';
// 如果路径中包含 {model} 占位符,替换为真实模型名称
if (path.includes('{model}')) {
const modelName = modelData.model_name || modelData.modelName || '';
path = path.replaceAll('{model}', modelName);
}
const method = info.method || 'POST';
return (
<div
key={type}
className='flex justify-between border-b border-dashed last:border-0 py-2 last:pb-0'
style={{ borderColor: 'var(--semi-color-border)' }}
>
<span className='flex items-center pr-5'>
<Badge dot type='success' className='mr-2' />
{type}
{path && ''}
{path && (
<span className='text-gray-500 md:ml-1 break-all'>{path}</span>
)}
</span>
{path && (
<span className='text-gray-500 text-xs md:ml-1'>{method}</span>
)}
</div>
);
});
};
return (
<div>
<div className='flex items-center mb-4'>
<Avatar size='small' color='purple' className='mr-2 shadow-md'>
<IconLink size={16} />
</Avatar>
<div>
<Text className='text-lg font-medium'>{t('API端点')}</Text>
<div className='text-xs text-gray-600'>
{t('模型支持的接口端点信息')}
</div>
</div>
</div>
{renderAPIEndpoints()}
</div>
);
};
export default ModelEndpoints;
@@ -0,0 +1,96 @@
/*
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 React from 'react';
import { Typography, Toast, Avatar } from '@douyinfe/semi-ui';
import { getLobeHubIcon } from '../../../../../helpers';
const { Paragraph } = Typography;
const CARD_STYLES = {
container:
'w-12 h-12 rounded-2xl flex items-center justify-center relative shadow-md',
icon: 'w-8 h-8 flex items-center justify-center',
};
const ModelHeader = ({ modelData, vendorsMap = {}, t }) => {
// 获取模型图标(优先模型图标,其次供应商图标)
const getModelIcon = () => {
// 1) 优先使用模型自定义图标
if (modelData?.icon) {
return (
<div className={CARD_STYLES.container}>
<div className={CARD_STYLES.icon}>
{getLobeHubIcon(modelData.icon, 32)}
</div>
</div>
);
}
// 2) 退化为供应商图标
if (modelData?.vendor_icon) {
return (
<div className={CARD_STYLES.container}>
<div className={CARD_STYLES.icon}>
{getLobeHubIcon(modelData.vendor_icon, 32)}
</div>
</div>
);
}
// 如果没有供应商图标,使用模型名称的前两个字符
const avatarText = modelData?.model_name?.slice(0, 2).toUpperCase() || 'AI';
return (
<div className={CARD_STYLES.container}>
<Avatar
size='large'
style={{
width: 48,
height: 48,
borderRadius: 16,
fontSize: 16,
fontWeight: 'bold',
}}
>
{avatarText}
</Avatar>
</div>
);
};
return (
<div className='flex items-center'>
{getModelIcon()}
<div className='ml-3 font-normal'>
<Paragraph
className='!mb-0 !text-lg !font-medium'
copyable={{
content: modelData?.model_name || '',
onCopy: () => Toast.success({ content: t('已复制模型名称') }),
}}
>
<span className='truncate max-w-60 font-bold'>
{modelData?.model_name || t('未知模型')}
</span>
</Paragraph>
</div>
</div>
);
};
export default ModelHeader;
@@ -0,0 +1,201 @@
/*
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 React from 'react';
import { Avatar, Typography, Table, Tag } from '@douyinfe/semi-ui';
import { IconCoinMoneyStroked } from '@douyinfe/semi-icons';
import { calculateModelPrice, getModelPriceItems } from '../../../../../helpers';
const { Text } = Typography;
const ModelPricingTable = ({
modelData,
groupRatio,
currency,
siteDisplayType,
tokenUnit,
displayPrice,
showRatio,
usableGroup,
autoGroups = [],
t,
}) => {
const modelEnableGroups = Array.isArray(modelData?.enable_groups)
? modelData.enable_groups
: [];
const autoChain = autoGroups.filter((g) => modelEnableGroups.includes(g));
const renderGroupPriceTable = () => {
// 仅展示模型可用的分组:模型 enable_groups 与用户可用分组的交集
const availableGroups = Object.keys(usableGroup || {})
.filter((g) => g !== '')
.filter((g) => g !== 'auto')
.filter((g) => modelEnableGroups.includes(g));
// 准备表格数据
const tableData = availableGroups.map((group) => {
const priceData = modelData
? calculateModelPrice({
record: modelData,
selectedGroup: group,
groupRatio,
tokenUnit,
displayPrice,
currency,
quotaDisplayType: siteDisplayType,
})
: { inputPrice: '-', outputPrice: '-', price: '-' };
// 获取分组倍率
const groupRatioValue =
groupRatio && groupRatio[group] ? groupRatio[group] : 1;
return {
key: group,
group: group,
ratio: groupRatioValue,
billingType:
modelData?.billing_mode === 'tiered_expr'
? t('动态计费')
: modelData?.quota_type === 0
? t('按量计费')
: modelData?.quota_type === 1
? t('按次计费')
: '-',
priceItems: getModelPriceItems(priceData, t, siteDisplayType),
};
});
// 定义表格列
const columns = [
{
title: t('分组'),
dataIndex: 'group',
render: (text) => (
<Tag color='white' size='small' shape='circle'>
{text}
{t('分组')}
</Tag>
),
},
];
const isDynamic = modelData?.billing_mode === 'tiered_expr';
// 动态计费时始终显示倍率列,否则根据设置
if (showRatio || isDynamic) {
columns.push({
title: t('分组倍率'),
dataIndex: 'ratio',
render: (text) => (
<Tag color='blue' size='small' shape='circle'>
{text}x
</Tag>
),
});
}
columns.push({
title: t('计费类型'),
dataIndex: 'billingType',
render: (text) => {
let color = 'white';
if (text === t('按量计费')) color = 'violet';
else if (text === t('按次计费')) color = 'teal';
else if (text === t('动态计费')) color = 'amber';
return (
<Tag color={color} size='small' shape='circle'>
{text || '-'}
</Tag>
);
},
});
columns.push({
title: siteDisplayType === 'TOKENS' ? t('计费摘要') : t('价格摘要'),
dataIndex: 'priceItems',
render: (items) => {
if (items.length === 1 && items[0].isDynamic) {
return (
<Text type='tertiary' size='small'>
{t('见上方动态计费详情')}
</Text>
);
}
return (
<div className='space-y-1'>
{items.map((item) => (
<div key={item.key}>
<div className='font-semibold text-orange-600'>
{item.label} {item.value}
</div>
<div className='text-xs text-gray-500'>{item.suffix}</div>
</div>
))}
</div>
);
},
});
return (
<Table
dataSource={tableData}
columns={columns}
pagination={false}
size='small'
bordered={false}
className='!rounded-lg'
/>
);
};
return (
<div>
<div className='flex items-center mb-4'>
<Avatar size='small' color='orange' className='mr-2 shadow-md'>
<IconCoinMoneyStroked size={16} />
</Avatar>
<div>
<Text className='text-lg font-medium'>{t('分组价格')}</Text>
<div className='text-xs text-gray-600'>
{t('不同用户分组的价格信息')}
</div>
</div>
</div>
{autoChain.length > 0 && (
<div className='flex flex-wrap items-center gap-1 mb-4'>
<span className='text-sm text-gray-600'>{t('auto分组调用链路')}</span>
<span className='text-sm'></span>
{autoChain.map((g, idx) => (
<React.Fragment key={g}>
<Tag color='white' size='small' shape='circle'>
{g}
{t('分组')}
</Tag>
{idx < autoChain.length - 1 && <span className='text-sm'></span>}
</React.Fragment>
))}
</div>
)}
{renderGroupPriceTable()}
</div>
);
};
export default ModelPricingTable;
@@ -0,0 +1,144 @@
/*
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 React from 'react';
import { Card, Skeleton } from '@douyinfe/semi-ui';
const PricingCardSkeleton = ({
skeletonCount = 100,
rowSelection = false,
showRatio = false,
}) => {
const placeholder = (
<div className='px-2 pt-2'>
<div className='grid grid-cols-1 xl:grid-cols-2 2xl:grid-cols-3 gap-4'>
{Array.from({ length: skeletonCount }).map((_, index) => (
<Card
key={index}
className='!rounded-2xl border border-gray-200'
bodyStyle={{ padding: '24px' }}
>
{/* 头部:图标 + 模型名称 + 操作按钮 */}
<div className='flex items-start justify-between mb-3'>
<div className='flex items-start space-x-3 flex-1 min-w-0'>
{/* 模型图标骨架 */}
<div className='w-12 h-12 rounded-2xl flex items-center justify-center relative shadow-sm'>
<Skeleton.Avatar
size='large'
style={{ width: 48, height: 48, borderRadius: 16 }}
/>
</div>
{/* 模型名称和价格区域 */}
<div className='flex-1 min-w-0'>
{/* 模型名称骨架 */}
<Skeleton.Title
style={{
width: `${120 + (index % 3) * 30}px`,
height: 20,
marginBottom: 8,
}}
/>
{/* 价格信息骨架 */}
<Skeleton.Title
style={{
width: `${160 + (index % 4) * 20}px`,
height: 20,
marginBottom: 0,
}}
/>
</div>
</div>
<div className='flex items-center space-x-2 ml-3'>
{/* 复制按钮骨架 */}
<Skeleton.Button
size='small'
style={{ width: 16, height: 16, borderRadius: 4 }}
/>
{/* 勾选框骨架 */}
{rowSelection && (
<Skeleton.Button
size='small'
style={{ width: 16, height: 16, borderRadius: 2 }}
/>
)}
</div>
</div>
{/* 模型描述骨架 */}
<div className='mb-4'>
<Skeleton.Paragraph
rows={2}
style={{ marginBottom: 0 }}
title={false}
/>
</div>
{/* 标签区域骨架 */}
<div className='flex flex-wrap gap-2'>
{Array.from({ length: 2 + (index % 3) }).map((_, tagIndex) => (
<Skeleton.Button
key={tagIndex}
size='small'
style={{
width: 64,
height: 18,
borderRadius: 10,
}}
/>
))}
</div>
{/* 倍率信息骨架(可选) */}
{showRatio && (
<div className='mt-4 pt-3 border-t border-gray-100'>
<div className='flex items-center space-x-1 mb-2'>
<Skeleton.Title
style={{ width: 60, height: 12, marginBottom: 0 }}
/>
<Skeleton.Button
size='small'
style={{ width: 14, height: 14, borderRadius: 7 }}
/>
</div>
<div className='grid grid-cols-3 gap-2'>
{Array.from({ length: 3 }).map((_, ratioIndex) => (
<Skeleton.Title
key={ratioIndex}
style={{ width: '100%', height: 12, marginBottom: 0 }}
/>
))}
</div>
</div>
)}
</Card>
))}
</div>
{/* 分页骨架 */}
<div className='flex justify-center mt-6 py-4 border-t pricing-pagination-divider'>
<Skeleton.Button style={{ width: 300, height: 32 }} />
</div>
</div>
);
return <Skeleton loading={true} active placeholder={placeholder}></Skeleton>;
};
export default PricingCardSkeleton;
@@ -0,0 +1,389 @@
/*
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 React from 'react';
import {
Card,
Tag,
Tooltip,
Checkbox,
Empty,
Pagination,
Button,
Avatar,
} from '@douyinfe/semi-ui';
import { IconHelpCircle } from '@douyinfe/semi-icons';
import { Copy } from 'lucide-react';
import {
IllustrationNoResult,
IllustrationNoResultDark,
} from '@douyinfe/semi-illustrations';
import {
stringToColor,
calculateModelPrice,
formatPriceInfo,
formatDynamicPriceSummary,
getLobeHubIcon,
} from '../../../../../helpers';
import PricingCardSkeleton from './PricingCardSkeleton';
import { useMinimumLoadingTime } from '../../../../../hooks/common/useMinimumLoadingTime';
import { renderLimitedItems } from '../../../../common/ui/RenderUtils';
import { useIsMobile } from '../../../../../hooks/common/useIsMobile';
const CARD_STYLES = {
container:
'w-12 h-12 rounded-2xl flex items-center justify-center relative shadow-md',
icon: 'w-8 h-8 flex items-center justify-center',
selected: 'border-blue-500 bg-blue-50',
default: 'border-gray-200 hover:border-gray-300',
};
const PricingCardView = ({
filteredModels,
loading,
rowSelection,
pageSize,
setPageSize,
currentPage,
setCurrentPage,
selectedGroup,
groupRatio,
copyText,
setModalImageUrl,
setIsModalOpenurl,
currency,
siteDisplayType,
tokenUnit,
displayPrice,
showRatio,
t,
selectedRowKeys = [],
setSelectedRowKeys,
openModelDetail,
}) => {
const showSkeleton = useMinimumLoadingTime(loading);
const startIndex = (currentPage - 1) * pageSize;
const paginatedModels = filteredModels.slice(
startIndex,
startIndex + pageSize,
);
const getModelKey = (model) => model.key ?? model.model_name ?? model.id;
const isMobile = useIsMobile();
const handleCheckboxChange = (model, checked) => {
if (!setSelectedRowKeys) return;
const modelKey = getModelKey(model);
const newKeys = checked
? Array.from(new Set([...selectedRowKeys, modelKey]))
: selectedRowKeys.filter((key) => key !== modelKey);
setSelectedRowKeys(newKeys);
rowSelection?.onChange?.(newKeys, null);
};
// 获取模型图标
const getModelIcon = (model) => {
if (!model || !model.model_name) {
return (
<div className={CARD_STYLES.container}>
<Avatar size='large'>?</Avatar>
</div>
);
}
// 1) 优先使用模型自定义图标
if (model.icon) {
return (
<div className={CARD_STYLES.container}>
<div className={CARD_STYLES.icon}>
{getLobeHubIcon(model.icon, 32)}
</div>
</div>
);
}
// 2) 退化为供应商图标
if (model.vendor_icon) {
return (
<div className={CARD_STYLES.container}>
<div className={CARD_STYLES.icon}>
{getLobeHubIcon(model.vendor_icon, 32)}
</div>
</div>
);
}
// 如果没有供应商图标,使用模型名称生成头像
const avatarText = model.model_name.slice(0, 2).toUpperCase();
return (
<div className={CARD_STYLES.container}>
<Avatar
size='large'
style={{
width: 48,
height: 48,
borderRadius: 16,
fontSize: 16,
fontWeight: 'bold',
}}
>
{avatarText}
</Avatar>
</div>
);
};
// 获取模型描述
const getModelDescription = (record) => {
return record.description || '';
};
// 渲染标签
const renderTags = (record) => {
// 计费类型标签(左边)
let billingTag = (
<Tag key='billing' shape='circle' color='white' size='small'>
-
</Tag>
);
if (record.quota_type === 1) {
billingTag = (
<Tag key='billing' shape='circle' color='teal' size='small'>
{t('按次计费')}
</Tag>
);
} else if (record.quota_type === 0) {
billingTag = (
<Tag key='billing' shape='circle' color='violet' size='small'>
{t('按量计费')}
</Tag>
);
}
// 自定义标签(右边)
const customTags = [];
if (record.tags) {
const tagArr = record.tags.split(',').filter(Boolean);
tagArr.forEach((tg, idx) => {
customTags.push(
<Tag
key={`custom-${idx}`}
shape='circle'
color={stringToColor(tg)}
size='small'
>
{tg}
</Tag>,
);
});
}
return (
<div className='flex items-center justify-between'>
<div className='flex items-center gap-2'>{billingTag}</div>
<div className='flex items-center gap-1'>
{customTags.length > 0 &&
renderLimitedItems({
items: customTags.map((tag, idx) => ({
key: `custom-${idx}`,
element: tag,
})),
renderItem: (item, idx) => item.element,
maxDisplay: 3,
})}
</div>
</div>
);
};
// 显示骨架屏
if (showSkeleton) {
return (
<PricingCardSkeleton
rowSelection={!!rowSelection}
showRatio={showRatio}
/>
);
}
if (!filteredModels || filteredModels.length === 0) {
return (
<div className='flex justify-center items-center py-20'>
<Empty
image={<IllustrationNoResult style={{ width: 150, height: 150 }} />}
darkModeImage={
<IllustrationNoResultDark style={{ width: 150, height: 150 }} />
}
description={t('搜索无结果')}
/>
</div>
);
}
return (
<div className='px-2 pt-2'>
<div className='grid grid-cols-1 xl:grid-cols-2 2xl:grid-cols-3 gap-4'>
{paginatedModels.map((model, index) => {
const modelKey = getModelKey(model);
const isSelected = selectedRowKeys.includes(modelKey);
const priceData = calculateModelPrice({
record: model,
selectedGroup,
groupRatio,
tokenUnit,
displayPrice,
currency,
quotaDisplayType: siteDisplayType,
});
return (
<Card
key={modelKey || index}
className={`!rounded-2xl transition-all duration-200 hover:shadow-lg border cursor-pointer ${isSelected ? CARD_STYLES.selected : CARD_STYLES.default}`}
bodyStyle={{ height: '100%' }}
onClick={() => openModelDetail && openModelDetail(model)}
>
<div className='flex flex-col h-full'>
{/* 头部:图标 + 模型名称 + 操作按钮 */}
<div className='flex items-start justify-between mb-3'>
<div className='flex items-start space-x-3 flex-1 min-w-0'>
{getModelIcon(model)}
<div className='flex-1 min-w-0'>
<h3 className='text-lg font-bold text-gray-900 truncate'>
{model.model_name}
</h3>
<div className='flex flex-col gap-1 text-xs mt-1'>
{priceData.isDynamicPricing ? (
formatDynamicPriceSummary(priceData.billingExpr, t, priceData.usedGroupRatio)
) : (
formatPriceInfo(priceData, t, siteDisplayType)
)}
</div>
</div>
</div>
<div className='flex items-center space-x-2 ml-3'>
{/* 复制按钮 */}
<Button
size='small'
theme='outline'
type='tertiary'
icon={<Copy size={12} />}
onClick={(e) => {
e.stopPropagation();
copyText(model.model_name);
}}
/>
{/* 选择框 */}
{rowSelection && (
<Checkbox
checked={isSelected}
onChange={(e) => {
e.stopPropagation();
handleCheckboxChange(model, e.target.checked);
}}
/>
)}
</div>
</div>
{/* 模型描述 - 占据剩余空间 */}
<div className='flex-1 mb-4'>
<p
className='text-xs line-clamp-2 leading-relaxed'
style={{ color: 'var(--semi-color-text-2)' }}
>
{getModelDescription(model)}
</p>
</div>
{/* 底部区域 */}
<div className='mt-auto'>
{/* 标签区域 */}
{renderTags(model)}
{/* 倍率信息(可选) */}
{showRatio && (
<div className='pt-3'>
<div className='flex items-center space-x-1 mb-2'>
<span className='text-xs font-medium text-gray-700'>
{t('倍率信息')}
</span>
<Tooltip
content={t('倍率是为了方便换算不同价格的模型')}
>
<IconHelpCircle
className='text-blue-500 cursor-pointer'
size='small'
onClick={(e) => {
e.stopPropagation();
setModalImageUrl('/ratio.png');
setIsModalOpenurl(true);
}}
/>
</Tooltip>
</div>
<div className='grid grid-cols-3 gap-2 text-xs text-gray-600'>
<div>
{t('模型')}:{' '}
{model.quota_type === 0 ? model.model_ratio : t('无')}
</div>
<div>
{t('补全')}:{' '}
{model.quota_type === 0
? parseFloat(model.completion_ratio.toFixed(3))
: t('无')}
</div>
<div>
{t('分组')}: {priceData?.usedGroupRatio ?? '-'}
</div>
</div>
</div>
)}
</div>
</div>
</Card>
);
})}
</div>
{/* 分页 */}
{filteredModels.length > 0 && (
<div className='flex justify-center mt-6 py-4 border-t pricing-pagination-divider'>
<Pagination
currentPage={currentPage}
pageSize={pageSize}
total={filteredModels.length}
showSizeChanger={true}
pageSizeOptions={[10, 20, 50, 100]}
size={isMobile ? 'small' : 'default'}
showQuickJumper={isMobile}
onPageChange={(page) => setCurrentPage(page)}
onPageSizeChange={(size) => {
setPageSize(size);
setCurrentPage(1);
}}
/>
</div>
)}
</div>
);
};
export default PricingCardView;
@@ -0,0 +1,147 @@
/*
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 React, { useMemo } from 'react';
import { Card, Table, Empty } from '@douyinfe/semi-ui';
import {
IllustrationNoResult,
IllustrationNoResultDark,
} from '@douyinfe/semi-illustrations';
import { getPricingTableColumns } from './PricingTableColumns';
const PricingTable = ({
filteredModels,
loading,
rowSelection,
pageSize,
setPageSize,
selectedGroup,
groupRatio,
copyText,
setModalImageUrl,
setIsModalOpenurl,
currency,
siteDisplayType,
tokenUnit,
displayPrice,
searchValue,
showRatio,
compactMode = false,
openModelDetail,
t,
}) => {
const columns = useMemo(() => {
return getPricingTableColumns({
t,
selectedGroup,
groupRatio,
copyText,
setModalImageUrl,
setIsModalOpenurl,
currency,
siteDisplayType,
tokenUnit,
displayPrice,
showRatio,
});
}, [
t,
selectedGroup,
groupRatio,
copyText,
setModalImageUrl,
setIsModalOpenurl,
currency,
siteDisplayType,
tokenUnit,
displayPrice,
showRatio,
]);
// 更新列定义中的 searchValue
const processedColumns = useMemo(() => {
const cols = columns.map((column) => {
if (column.dataIndex === 'model_name') {
return {
...column,
filteredValue: searchValue ? [searchValue] : [],
};
}
return column;
});
// Remove fixed property when in compact mode (mobile view)
if (compactMode) {
return cols.map(({ fixed, ...rest }) => rest);
}
return cols;
}, [columns, searchValue, compactMode]);
const ModelTable = useMemo(
() => (
<Card className='!rounded-xl overflow-hidden' bordered={false}>
<Table
columns={processedColumns}
dataSource={filteredModels}
loading={loading}
rowSelection={rowSelection}
scroll={compactMode ? undefined : { x: 'max-content' }}
onRow={(record) => ({
onClick: () => openModelDetail && openModelDetail(record),
style: { cursor: 'pointer' },
})}
empty={
<Empty
image={
<IllustrationNoResult style={{ width: 150, height: 150 }} />
}
darkModeImage={
<IllustrationNoResultDark style={{ width: 150, height: 150 }} />
}
description={t('搜索无结果')}
style={{ padding: 30 }}
/>
}
pagination={{
defaultPageSize: 20,
pageSize: pageSize,
showSizeChanger: true,
pageSizeOptions: [10, 20, 50, 100],
onPageSizeChange: (size) => setPageSize(size),
}}
/>
</Card>
),
[
filteredModels,
loading,
processedColumns,
rowSelection,
pageSize,
setPageSize,
openModelDetail,
t,
compactMode,
],
);
return ModelTable;
};
export default PricingTable;
@@ -0,0 +1,259 @@
/*
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 React from 'react';
import { Tag, Space, Tooltip } from '@douyinfe/semi-ui';
import { IconHelpCircle } from '@douyinfe/semi-icons';
import {
renderModelTag,
stringToColor,
calculateModelPrice,
getModelPriceItems,
getLobeHubIcon,
} from '../../../../../helpers';
import {
renderLimitedItems,
renderDescription,
} from '../../../../common/ui/RenderUtils';
import { useIsMobile } from '../../../../../hooks/common/useIsMobile';
function renderQuotaType(type, t) {
switch (type) {
case 1:
return (
<Tag color='teal' shape='circle'>
{t('按次计费')}
</Tag>
);
case 0:
return (
<Tag color='violet' shape='circle'>
{t('按量计费')}
</Tag>
);
default:
return t('未知');
}
}
// Render vendor name
const renderVendor = (vendorName, vendorIcon, t) => {
if (!vendorName) return '-';
return (
<Tag
color='white'
shape='circle'
prefixIcon={getLobeHubIcon(vendorIcon || 'Layers', 14)}
>
{vendorName}
</Tag>
);
};
// Render tags list using RenderUtils
const renderTags = (text) => {
if (!text) return '-';
const tagsArr = text.split(',').filter((tag) => tag.trim());
return renderLimitedItems({
items: tagsArr,
renderItem: (tag, idx) => (
<Tag
key={idx}
color={stringToColor(tag.trim())}
shape='circle'
size='small'
>
{tag.trim()}
</Tag>
),
maxDisplay: 3,
});
};
function renderSupportedEndpoints(endpoints) {
if (!endpoints || endpoints.length === 0) {
return null;
}
return (
<Space wrap>
{endpoints.map((endpoint, idx) => (
<Tag key={endpoint} color={stringToColor(endpoint)} shape='circle'>
{endpoint}
</Tag>
))}
</Space>
);
}
export const getPricingTableColumns = ({
t,
selectedGroup,
groupRatio,
copyText,
setModalImageUrl,
setIsModalOpenurl,
currency,
siteDisplayType,
tokenUnit,
displayPrice,
showRatio,
}) => {
const isMobile = useIsMobile();
const priceDataCache = new WeakMap();
const getPriceData = (record) => {
let cache = priceDataCache.get(record);
if (!cache) {
cache = calculateModelPrice({
record,
selectedGroup,
groupRatio,
tokenUnit,
displayPrice,
currency,
quotaDisplayType: siteDisplayType,
});
priceDataCache.set(record, cache);
}
return cache;
};
const endpointColumn = {
title: t('可用端点类型'),
dataIndex: 'supported_endpoint_types',
render: (text, record, index) => {
return renderSupportedEndpoints(text);
},
};
const modelNameColumn = {
title: t('模型名称'),
dataIndex: 'model_name',
render: (text, record, index) => {
return renderModelTag(text, {
onClick: () => {
copyText(text);
},
});
},
onFilter: (value, record) =>
record.model_name.toLowerCase().includes(value.toLowerCase()),
};
const quotaColumn = {
title: t('计费类型'),
dataIndex: 'quota_type',
render: (text, record, index) => {
return renderQuotaType(parseInt(text), t);
},
sorter: (a, b) => a.quota_type - b.quota_type,
};
const descriptionColumn = {
title: t('描述'),
dataIndex: 'description',
render: (text) => renderDescription(text, 200),
};
const tagsColumn = {
title: t('标签'),
dataIndex: 'tags',
render: renderTags,
};
const vendorColumn = {
title: t('供应商'),
dataIndex: 'vendor_name',
render: (text, record) => renderVendor(text, record.vendor_icon, t),
};
const baseColumns = [
modelNameColumn,
vendorColumn,
descriptionColumn,
tagsColumn,
quotaColumn,
];
const ratioColumn = {
title: () => (
<div className='flex items-center space-x-1'>
<span>{t('倍率')}</span>
<Tooltip content={t('倍率是为了方便换算不同价格的模型')}>
<IconHelpCircle
className='text-blue-500 cursor-pointer'
onClick={() => {
setModalImageUrl('/ratio.png');
setIsModalOpenurl(true);
}}
/>
</Tooltip>
</div>
),
dataIndex: 'model_ratio',
render: (text, record, index) => {
const completionRatio = parseFloat(record.completion_ratio.toFixed(3));
const priceData = getPriceData(record);
return (
<div className='space-y-1'>
<div className='text-gray-700'>
{t('模型倍率')}{record.quota_type === 0 ? text : t('无')}
</div>
<div className='text-gray-700'>
{t('补全倍率')}
{record.quota_type === 0 ? completionRatio : t('无')}
</div>
<div className='text-gray-700'>
{t('分组倍率')}{priceData?.usedGroupRatio ?? '-'}
</div>
</div>
);
},
};
const priceColumn = {
title: siteDisplayType === 'TOKENS' ? t('计费摘要') : t('模型价格'),
dataIndex: 'model_price',
...(isMobile ? {} : { fixed: 'right' }),
render: (text, record, index) => {
const priceData = getPriceData(record);
const priceItems = getModelPriceItems(priceData, t, siteDisplayType);
return (
<div className='space-y-1'>
{priceItems.map((item) => (
<div key={item.key} className='text-gray-700'>
{item.label} {item.value}
{item.suffix}
</div>
))}
</div>
);
},
};
const columns = [...baseColumns];
columns.push(endpointColumn);
if (showRatio) {
columns.push(ratioColumn);
}
columns.push(priceColumn);
return columns;
};
@@ -0,0 +1,259 @@
/*
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 React, { useState } from 'react';
import MissingModelsModal from './modals/MissingModelsModal';
import PrefillGroupManagement from './modals/PrefillGroupManagement';
import EditPrefillGroupModal from './modals/EditPrefillGroupModal';
import { Button, Modal, Popover, RadioGroup, Radio } from '@douyinfe/semi-ui';
import { showSuccess, showError, copy } from '../../../helpers';
import CompactModeToggle from '../../common/ui/CompactModeToggle';
import SelectionNotification from './components/SelectionNotification';
import UpstreamConflictModal from './modals/UpstreamConflictModal';
import SyncWizardModal from './modals/SyncWizardModal';
const ModelsActions = ({
selectedKeys,
setSelectedKeys,
setEditingModel,
setShowEdit,
batchDeleteModels,
syncing,
previewing,
syncUpstream,
previewUpstreamDiff,
applyUpstreamOverwrite,
compactMode,
setCompactMode,
t,
}) => {
// Modal states
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [showMissingModal, setShowMissingModal] = useState(false);
const [showGroupManagement, setShowGroupManagement] = useState(false);
const [showAddPrefill, setShowAddPrefill] = useState(false);
const [prefillInit, setPrefillInit] = useState({ id: undefined });
const [showConflict, setShowConflict] = useState(false);
const [conflicts, setConflicts] = useState([]);
const [showSyncModal, setShowSyncModal] = useState(false);
const [syncLocale, setSyncLocale] = useState('zh');
const handleSyncUpstream = async (locale) => {
// 先预览
const data = await previewUpstreamDiff?.({ locale });
const conflictItems = data?.conflicts || [];
if (conflictItems.length > 0) {
setConflicts(conflictItems);
setShowConflict(true);
return;
}
// 无冲突,直接同步缺失
await syncUpstream?.({ locale });
};
// Handle delete selected models with confirmation
const handleDeleteSelectedModels = () => {
setShowDeleteModal(true);
};
// Handle delete confirmation
const handleConfirmDelete = () => {
batchDeleteModels();
setShowDeleteModal(false);
};
// Handle clear selection
const handleClearSelected = () => {
setSelectedKeys([]);
};
// Handle add selected models to prefill group
const handleCopyNames = async () => {
const text = selectedKeys.map((m) => m.model_name).join(',');
if (!text) return;
const ok = await copy(text);
if (ok) {
showSuccess(t('已复制模型名称'));
} else {
showError(t('复制失败'));
}
};
const handleAddToPrefill = () => {
// Prepare initial data
const items = selectedKeys.map((m) => m.model_name);
setPrefillInit({ id: undefined, type: 'model', items });
setShowAddPrefill(true);
};
return (
<>
<div className='flex flex-wrap gap-2 w-full md:w-auto order-2 md:order-1'>
<Button
type='primary'
className='flex-1 md:flex-initial'
onClick={() => {
setEditingModel({
id: undefined,
});
setShowEdit(true);
}}
size='small'
>
{t('添加模型')}
</Button>
<Button
type='secondary'
className='flex-1 md:flex-initial'
size='small'
onClick={() => setShowMissingModal(true)}
>
{t('未配置模型')}
</Button>
<Popover
position='bottom'
trigger='hover'
content={
<div className='p-2 max-w-[360px]'>
<div className='text-[var(--semi-color-text-2)] text-sm'>
{t(
'模型社区需要大家的共同维护,如发现数据有误或想贡献新的模型数据,请访问:',
)}
</div>
<a
href='https://github.com/basellm/llm-metadata'
target='_blank'
rel='noreferrer'
className='text-blue-600 underline'
>
https://github.com/basellm/llm-metadata
</a>
</div>
}
>
<Button
type='secondary'
className='flex-1 md:flex-initial'
size='small'
loading={syncing || previewing}
onClick={() => {
setSyncLocale('zh');
setShowSyncModal(true);
}}
>
{t('同步')}
</Button>
</Popover>
<Button
type='secondary'
className='flex-1 md:flex-initial'
size='small'
onClick={() => setShowGroupManagement(true)}
>
{t('预填组管理')}
</Button>
<CompactModeToggle
compactMode={compactMode}
setCompactMode={setCompactMode}
t={t}
/>
</div>
<SelectionNotification
selectedKeys={selectedKeys}
t={t}
onDelete={handleDeleteSelectedModels}
onAddPrefill={handleAddToPrefill}
onClear={handleClearSelected}
onCopy={handleCopyNames}
/>
<Modal
title={t('批量删除模型')}
visible={showDeleteModal}
onCancel={() => setShowDeleteModal(false)}
onOk={handleConfirmDelete}
type='warning'
>
<div>
{t('确定要删除所选的 {{count}} 个模型吗?', {
count: selectedKeys.length,
})}
</div>
</Modal>
<SyncWizardModal
visible={showSyncModal}
onClose={() => setShowSyncModal(false)}
loading={syncing || previewing}
t={t}
onConfirm={async ({ option, locale }) => {
setSyncLocale(locale);
if (option === 'official') {
await handleSyncUpstream(locale);
}
setShowSyncModal(false);
}}
/>
<MissingModelsModal
visible={showMissingModal}
onClose={() => setShowMissingModal(false)}
onConfigureModel={(name) => {
setEditingModel({ id: undefined, model_name: name });
setShowEdit(true);
setShowMissingModal(false);
}}
t={t}
/>
<PrefillGroupManagement
visible={showGroupManagement}
onClose={() => setShowGroupManagement(false)}
/>
<EditPrefillGroupModal
visible={showAddPrefill}
onClose={() => setShowAddPrefill(false)}
editingGroup={prefillInit}
onSuccess={() => setShowAddPrefill(false)}
/>
<UpstreamConflictModal
visible={showConflict}
onClose={() => setShowConflict(false)}
conflicts={conflicts}
onSubmit={async (payload) => {
return await applyUpstreamOverwrite?.({
overwrite: payload,
locale: syncLocale,
});
}}
t={t}
loading={syncing}
/>
</>
);
};
export default ModelsActions;
@@ -0,0 +1,380 @@
/*
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 React from 'react';
import {
Button,
Space,
Tag,
Typography,
Modal,
Tooltip,
} from '@douyinfe/semi-ui';
import {
timestamp2string,
getLobeHubIcon,
stringToColor,
} from '../../../helpers';
import {
renderLimitedItems,
renderDescription,
} from '../../common/ui/RenderUtils';
const { Text } = Typography;
// Render timestamp
function renderTimestamp(timestamp) {
return <>{timestamp2string(timestamp)}</>;
}
// Render model icon column: prefer model.icon, then fallback to vendor icon
const renderModelIconCol = (record, vendorMap) => {
const iconKey = record?.icon || vendorMap[record?.vendor_id]?.icon;
if (!iconKey) return '-';
return (
<div className='flex items-center justify-center'>
{getLobeHubIcon(iconKey, 20)}
</div>
);
};
// Render vendor column with icon
const renderVendorTag = (vendorId, vendorMap, t) => {
if (!vendorId || !vendorMap[vendorId]) return '-';
const v = vendorMap[vendorId];
return (
<Tag
color='white'
shape='circle'
prefixIcon={getLobeHubIcon(v.icon || 'Layers', 14)}
>
{v.name}
</Tag>
);
};
// Render groups (enable_groups)
const renderGroups = (groups) => {
if (!groups || groups.length === 0) return '-';
return renderLimitedItems({
items: groups,
renderItem: (g, idx) => (
<Tag key={idx} size='small' shape='circle' color={stringToColor(g)}>
{g}
</Tag>
),
});
};
// Render tags
const renderTags = (text) => {
if (!text) return '-';
const tagsArr = text.split(',').filter(Boolean);
return renderLimitedItems({
items: tagsArr,
renderItem: (tag, idx) => (
<Tag key={idx} size='small' shape='circle' color={stringToColor(tag)}>
{tag}
</Tag>
),
});
};
// Render endpoints (supports object map or legacy array)
const renderEndpoints = (value) => {
try {
const parsed = typeof value === 'string' ? JSON.parse(value) : value;
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
const keys = Object.keys(parsed || {});
if (keys.length === 0) return '-';
return renderLimitedItems({
items: keys,
renderItem: (key, idx) => (
<Tag key={idx} size='small' shape='circle' color={stringToColor(key)}>
{key}
</Tag>
),
maxDisplay: 3,
});
}
if (Array.isArray(parsed)) {
if (parsed.length === 0) return '-';
return renderLimitedItems({
items: parsed,
renderItem: (ep, idx) => (
<Tag key={idx} color='white' size='small' shape='circle'>
{ep}
</Tag>
),
maxDisplay: 3,
});
}
return value || '-';
} catch (_) {
return value || '-';
}
};
// Render quota types (array) using common limited items renderer
const renderQuotaTypes = (arr, t) => {
if (!Array.isArray(arr) || arr.length === 0) return '-';
return renderLimitedItems({
items: arr,
renderItem: (qt, idx) => {
if (qt === 1) {
return (
<Tag key={`${qt}-${idx}`} color='teal' size='small' shape='circle'>
{t('按次计费')}
</Tag>
);
}
if (qt === 0) {
return (
<Tag key={`${qt}-${idx}`} color='violet' size='small' shape='circle'>
{t('按量计费')}
</Tag>
);
}
return (
<Tag key={`${qt}-${idx}`} color='white' size='small' shape='circle'>
{qt}
</Tag>
);
},
maxDisplay: 3,
});
};
// Render bound channels
const renderBoundChannels = (channels) => {
if (!channels || channels.length === 0) return '-';
return renderLimitedItems({
items: channels,
renderItem: (c, idx) => (
<Tag key={idx} color='white' size='small' shape='circle'>
{c.name}({c.type})
</Tag>
),
});
};
// Render operations column
const renderOperations = (
text,
record,
setEditingModel,
setShowEdit,
manageModel,
refresh,
t,
) => {
return (
<Space wrap>
{record.status === 1 ? (
<Button
type='danger'
size='small'
onClick={() => manageModel(record.id, 'disable', record)}
>
{t('禁用')}
</Button>
) : (
<Button
size='small'
onClick={() => manageModel(record.id, 'enable', record)}
>
{t('启用')}
</Button>
)}
<Button
type='tertiary'
size='small'
onClick={() => {
setEditingModel(record);
setShowEdit(true);
}}
>
{t('编辑')}
</Button>
<Button
type='danger'
size='small'
onClick={() => {
Modal.confirm({
title: t('确定是否要删除此模型?'),
content: t('此修改将不可逆'),
onOk: () => {
(async () => {
await manageModel(record.id, 'delete', record);
await refresh();
})();
},
});
}}
>
{t('删除')}
</Button>
</Space>
);
};
// 名称匹配类型渲染(带匹配数量 Tooltip)
const renderNameRule = (rule, record, t) => {
const map = {
0: { color: 'green', label: t('精确') },
1: { color: 'blue', label: t('前缀') },
2: { color: 'orange', label: t('包含') },
3: { color: 'purple', label: t('后缀') },
};
const cfg = map[rule];
if (!cfg) return '-';
let label = cfg.label;
if (rule !== 0 && record.matched_count) {
label = `${cfg.label} ${record.matched_count}${t('个模型')}`;
}
const tagElement = (
<Tag color={cfg.color} size='small' shape='circle'>
{label}
</Tag>
);
if (
rule === 0 ||
!record.matched_models ||
record.matched_models.length === 0
) {
return tagElement;
}
return (
<Tooltip content={record.matched_models.join(', ')} showArrow>
{tagElement}
</Tooltip>
);
};
export const getModelsColumns = ({
t,
manageModel,
setEditingModel,
setShowEdit,
refresh,
vendorMap,
}) => {
return [
{
title: t('图标'),
dataIndex: 'icon',
width: 70,
align: 'center',
render: (text, record) => renderModelIconCol(record, vendorMap),
},
{
title: t('模型名称'),
dataIndex: 'model_name',
render: (text) => (
<Text copyable onClick={(e) => e.stopPropagation()}>
{text}
</Text>
),
},
{
title: t('匹配类型'),
dataIndex: 'name_rule',
render: (val, record) => renderNameRule(val, record, t),
},
{
title: t('参与官方同步'),
dataIndex: 'sync_official',
render: (val) => (
<Tag size='small' shape='circle' color={val === 1 ? 'green' : 'orange'}>
{val === 1 ? t('是') : t('否')}
</Tag>
),
},
{
title: t('描述'),
dataIndex: 'description',
render: (text) => renderDescription(text, 200),
},
{
title: t('供应商'),
dataIndex: 'vendor_id',
render: (vendorId, record) => renderVendorTag(vendorId, vendorMap, t),
},
{
title: t('标签'),
dataIndex: 'tags',
render: renderTags,
},
{
title: t('端点'),
dataIndex: 'endpoints',
render: renderEndpoints,
},
{
title: t('已绑定渠道'),
dataIndex: 'bound_channels',
render: renderBoundChannels,
},
{
title: t('可用分组'),
dataIndex: 'enable_groups',
render: renderGroups,
},
{
title: t('计费类型'),
dataIndex: 'quota_types',
render: (qts) => renderQuotaTypes(qts, t),
},
{
title: t('创建时间'),
dataIndex: 'created_time',
render: (text, record, index) => {
return <div>{renderTimestamp(text)}</div>;
},
},
{
title: t('更新时间'),
dataIndex: 'updated_time',
render: (text, record, index) => {
return <div>{renderTimestamp(text)}</div>;
},
},
{
title: '',
dataIndex: 'operate',
fixed: 'right',
render: (text, record, index) =>
renderOperations(
text,
record,
setEditingModel,
setShowEdit,
manageModel,
refresh,
t,
),
},
];
};
@@ -0,0 +1,44 @@
/*
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 React from 'react';
import { Typography } from '@douyinfe/semi-ui';
import { Layers } from 'lucide-react';
import CompactModeToggle from '../../common/ui/CompactModeToggle';
const { Text } = Typography;
const ModelsDescription = ({ compactMode, setCompactMode, t }) => {
return (
<div className='flex flex-col md:flex-row justify-between items-start md:items-center gap-2 w-full'>
<div className='flex items-center text-green-500'>
<Layers size={16} className='mr-2' />
<Text>{t('模型管理')}</Text>
</div>
<CompactModeToggle
compactMode={compactMode}
setCompactMode={setCompactMode}
t={t}
/>
</div>
);
};
export default ModelsDescription;
@@ -0,0 +1,106 @@
/*
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 React, { useRef } from 'react';
import { Form, Button } from '@douyinfe/semi-ui';
import { IconSearch } from '@douyinfe/semi-icons';
const ModelsFilters = ({
formInitValues,
setFormApi,
searchModels,
loading,
searching,
t,
}) => {
// Handle form reset and immediate search
const formApiRef = useRef(null);
const handleReset = () => {
if (!formApiRef.current) return;
formApiRef.current.reset();
setTimeout(() => {
searchModels();
}, 100);
};
return (
<Form
initValues={formInitValues}
getFormApi={(api) => {
setFormApi(api);
formApiRef.current = api;
}}
onSubmit={searchModels}
allowEmpty={true}
autoComplete='off'
layout='horizontal'
trigger='change'
stopValidateWithError={false}
className='w-full md:w-auto order-1 md:order-2'
>
<div className='flex flex-col md:flex-row items-center gap-2 w-full md:w-auto'>
<div className='relative w-full md:w-56'>
<Form.Input
field='searchKeyword'
prefix={<IconSearch />}
placeholder={t('搜索模型名称')}
showClear
pure
size='small'
/>
</div>
<div className='relative w-full md:w-56'>
<Form.Input
field='searchVendor'
prefix={<IconSearch />}
placeholder={t('搜索供应商')}
showClear
pure
size='small'
/>
</div>
<div className='flex gap-2 w-full md:w-auto'>
<Button
type='tertiary'
htmlType='submit'
loading={loading || searching}
className='flex-1 md:flex-initial md:w-auto'
size='small'
>
{t('查询')}
</Button>
<Button
type='tertiary'
onClick={handleReset}
className='flex-1 md:flex-initial md:w-auto'
size='small'
>
{t('重置')}
</Button>
</div>
</div>
</Form>
);
};
export default ModelsFilters;
+108
View File
@@ -0,0 +1,108 @@
/*
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 React, { useMemo } from 'react';
import { Empty } from '@douyinfe/semi-ui';
import CardTable from '../../common/ui/CardTable';
import {
IllustrationNoResult,
IllustrationNoResultDark,
} from '@douyinfe/semi-illustrations';
import { getModelsColumns } from './ModelsColumnDefs';
const ModelsTable = (modelsData) => {
const {
models,
loading,
activePage,
pageSize,
modelCount,
compactMode,
handlePageChange,
handlePageSizeChange,
rowSelection,
handleRow,
manageModel,
setEditingModel,
setShowEdit,
refresh,
vendorMap,
t,
} = modelsData;
// Get all columns
const columns = useMemo(() => {
return getModelsColumns({
t,
manageModel,
setEditingModel,
setShowEdit,
refresh,
vendorMap,
});
}, [t, manageModel, setEditingModel, setShowEdit, refresh, vendorMap]);
// Handle compact mode by removing fixed positioning
const tableColumns = useMemo(() => {
return compactMode
? columns.map((col) => {
if (col.dataIndex === 'operate') {
const { fixed, ...rest } = col;
return rest;
}
return col;
})
: columns;
}, [compactMode, columns]);
return (
<CardTable
columns={tableColumns}
dataSource={models}
scroll={compactMode ? undefined : { x: 'max-content' }}
pagination={{
currentPage: activePage,
pageSize: pageSize,
total: modelCount,
showSizeChanger: true,
pageSizeOptions: [10, 20, 50, 100],
onPageSizeChange: handlePageSizeChange,
onPageChange: handlePageChange,
}}
hidePagination={true}
loading={loading}
rowSelection={rowSelection}
onRow={handleRow}
empty={
<Empty
image={<IllustrationNoResult style={{ width: 150, height: 150 }} />}
darkModeImage={
<IllustrationNoResultDark style={{ width: 150, height: 150 }} />
}
description={t('搜索无结果')}
style={{ padding: 30 }}
/>
}
className='rounded-xl overflow-hidden'
size='middle'
/>
);
};
export default ModelsTable;
+178
View File
@@ -0,0 +1,178 @@
/*
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 React from 'react';
import { Tabs, TabPane, Tag, Button, Dropdown, Modal } from '@douyinfe/semi-ui';
import { IconEdit, IconDelete } from '@douyinfe/semi-icons';
import { getLobeHubIcon, showError, showSuccess } from '../../../helpers';
import { API } from '../../../helpers';
const ModelsTabs = ({
activeVendorKey,
setActiveVendorKey,
vendorCounts,
vendors,
loadModels,
activePage,
pageSize,
setActivePage,
setShowAddVendor,
setShowEditVendor,
setEditingVendor,
loadVendors,
t,
}) => {
const handleTabChange = (key) => {
setActiveVendorKey(key);
setActivePage(1);
loadModels(1, pageSize, key);
};
const handleEditVendor = (vendor, e) => {
e.stopPropagation(); // 阻止事件冒泡,避免触发tab切换
setEditingVendor(vendor);
setShowEditVendor(true);
};
const handleDeleteVendor = async (vendor, e) => {
e.stopPropagation(); // 阻止事件冒泡,避免触发tab切换
try {
const res = await API.delete(`/api/vendors/${vendor.id}`);
if (res.data.success) {
showSuccess(t('供应商删除成功'));
// 如果删除的是当前选中的供应商,切换到"全部"
if (activeVendorKey === String(vendor.id)) {
setActiveVendorKey('all');
loadModels(1, pageSize, 'all');
} else {
loadModels(activePage, pageSize, activeVendorKey);
}
loadVendors(); // 重新加载供应商列表
} else {
showError(res.data.message || t('删除失败'));
}
} catch (error) {
showError(error.response?.data?.message || t('删除失败'));
}
};
return (
<Tabs
activeKey={activeVendorKey}
type='card'
collapsible
onChange={handleTabChange}
className='mb-2'
tabBarExtraContent={
<Button
type='primary'
size='small'
onClick={() => setShowAddVendor(true)}
>
{t('新增供应商')}
</Button>
}
>
<TabPane
itemKey='all'
tab={
<span className='flex items-center gap-2'>
{t('全部')}
<Tag
color={activeVendorKey === 'all' ? 'red' : 'grey'}
shape='circle'
>
{vendorCounts['all'] || 0}
</Tag>
</span>
}
/>
{vendors.map((vendor) => {
const key = String(vendor.id);
const count = vendorCounts[vendor.id] || 0;
return (
<TabPane
key={key}
itemKey={key}
tab={
<span className='flex items-center gap-2'>
{getLobeHubIcon(vendor.icon || 'Layers', 14)}
{vendor.name}
<Tag
color={activeVendorKey === key ? 'red' : 'grey'}
shape='circle'
>
{count}
</Tag>
<Dropdown
trigger='click'
position='bottomRight'
render={
<Dropdown.Menu>
<Dropdown.Item
icon={<IconEdit />}
onClick={(e) => handleEditVendor(vendor, e)}
>
{t('编辑')}
</Dropdown.Item>
<Dropdown.Item
type='danger'
icon={<IconDelete />}
onClick={(e) => {
e.stopPropagation();
Modal.confirm({
title: t('确认删除'),
content: t(
'确定要删除供应商 "{{name}}" 吗?此操作不可撤销。',
{ name: vendor.name },
),
onOk: () => handleDeleteVendor(vendor, e),
okText: t('删除'),
cancelText: t('取消'),
type: 'warning',
okType: 'danger',
});
}}
>
{t('删除')}
</Dropdown.Item>
</Dropdown.Menu>
}
onClickOutSide={(e) => e.stopPropagation()}
>
<Button
size='small'
type='tertiary'
theme='outline'
onClick={(e) => e.stopPropagation()}
>
{t('操作')}
</Button>
</Dropdown>
</span>
}
/>
);
})}
</Tabs>
);
};
export default ModelsTabs;
@@ -0,0 +1,100 @@
/*
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 React, { useEffect } from 'react';
import { Notification, Button, Space, Typography } from '@douyinfe/semi-ui';
// 固定通知 ID,保持同一个实例即可避免闪烁
const NOTICE_ID = 'models-batch-actions';
/**
* SelectionNotification 选择通知组件
* 1. 当 selectedKeys.length > 0 时,使用固定 id 创建/更新通知
* 2. 当 selectedKeys 清空时关闭通知
*/
const SelectionNotification = ({
selectedKeys = [],
t,
onDelete,
onAddPrefill,
onClear,
onCopy,
}) => {
// 根据选中数量决定显示/隐藏或更新通知
useEffect(() => {
const selectedCount = selectedKeys.length;
if (selectedCount > 0) {
const titleNode = (
<Space wrap>
<span>{t('批量操作')}</span>
<Typography.Text type='tertiary' size='small'>
{t('已选择 {{count}} 个模型', { count: selectedCount })}
</Typography.Text>
</Space>
);
const content = (
<Space wrap>
<Button size='small' type='tertiary' theme='solid' onClick={onClear}>
{t('取消全选')}
</Button>
<Button
size='small'
type='primary'
theme='solid'
onClick={onAddPrefill}
>
{t('加入预填组')}
</Button>
<Button size='small' type='secondary' theme='solid' onClick={onCopy}>
{t('复制名称')}
</Button>
<Button size='small' type='danger' theme='solid' onClick={onDelete}>
{t('删除所选')}
</Button>
</Space>
);
// 使用相同 id 更新通知(若已存在则就地更新,不存在则创建)
Notification.info({
id: NOTICE_ID,
title: titleNode,
content,
duration: 0, // 不自动关闭
position: 'bottom',
showClose: false,
});
} else {
// 取消全部勾选时关闭通知
Notification.close(NOTICE_ID);
}
}, [selectedKeys, t, onDelete, onAddPrefill, onClear, onCopy]);
// 卸载时确保关闭通知
useEffect(() => {
return () => {
Notification.close(NOTICE_ID);
};
}, []);
return null; // 该组件不渲染可见内容
};
export default SelectionNotification;
+210
View File
@@ -0,0 +1,210 @@
/*
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 React, { useState } from 'react';
import { Banner, Button, Modal } from '@douyinfe/semi-ui';
import { IconAlertTriangle, IconClose } from '@douyinfe/semi-icons';
import CardPro from '../../common/ui/CardPro';
import ModelsTable from './ModelsTable';
import ModelsActions from './ModelsActions';
import ModelsFilters from './ModelsFilters';
import ModelsTabs from './ModelsTabs';
import EditModelModal from './modals/EditModelModal';
import EditVendorModal from './modals/EditVendorModal';
import { useModelsData } from '../../../hooks/models/useModelsData';
import { useIsMobile } from '../../../hooks/common/useIsMobile';
import { createCardProPagination } from '../../../helpers/utils';
const MARKETPLACE_DISPLAY_NOTICE_STORAGE_KEY =
'models_marketplace_display_notice_dismissed';
const ModelsPage = () => {
const modelsData = useModelsData();
const isMobile = useIsMobile();
const {
// Edit state
showEdit,
editingModel,
closeEdit,
refresh,
// Actions state
selectedKeys,
setSelectedKeys,
setEditingModel,
setShowEdit,
batchDeleteModels,
// Filters state
formInitValues,
setFormApi,
searchModels,
loading,
searching,
// Description state
compactMode,
setCompactMode,
// Vendor state
showAddVendor,
setShowAddVendor,
showEditVendor,
setShowEditVendor,
editingVendor,
setEditingVendor,
loadVendors,
// Translation
t,
} = modelsData;
const [showMarketplaceDisplayNotice, setShowMarketplaceDisplayNotice] =
useState(() => {
try {
return (
localStorage.getItem(MARKETPLACE_DISPLAY_NOTICE_STORAGE_KEY) !== '1'
);
} catch (_) {
return true;
}
});
const confirmCloseMarketplaceDisplayNotice = () => {
Modal.confirm({
title: t('确认关闭提示'),
content: t(
'关闭后将不再显示此提示(仅对当前浏览器生效)。确定要关闭吗?',
),
okText: t('关闭提示'),
cancelText: t('取消'),
okButtonProps: {
type: 'danger',
},
onOk: () => {
try {
localStorage.setItem(MARKETPLACE_DISPLAY_NOTICE_STORAGE_KEY, '1');
} catch (_) {}
setShowMarketplaceDisplayNotice(false);
},
});
};
return (
<>
<EditModelModal
refresh={refresh}
editingModel={editingModel}
visiable={showEdit}
handleClose={closeEdit}
/>
<EditVendorModal
visible={showAddVendor || showEditVendor}
handleClose={() => {
setShowAddVendor(false);
setShowEditVendor(false);
setEditingVendor({ id: undefined });
}}
editingVendor={showEditVendor ? editingVendor : { id: undefined }}
refresh={() => {
loadVendors();
refresh();
}}
/>
{showMarketplaceDisplayNotice ? (
<div style={{ position: 'relative', marginBottom: 12 }}>
<Banner
type='warning'
closeIcon={null}
icon={
<IconAlertTriangle
size='large'
style={{ color: 'var(--semi-color-warning)' }}
/>
}
description={t(
'提示:此处配置仅用于控制「模型广场」对用户的展示效果,不会影响模型的实际调用与路由。若需配置真实调用行为,请前往「渠道管理」进行设置。',
)}
style={{ marginBottom: 0 }}
/>
<Button
theme='borderless'
size='small'
type='tertiary'
icon={<IconClose aria-hidden={true} />}
onClick={confirmCloseMarketplaceDisplayNotice}
style={{ position: 'absolute', top: 8, right: 8 }}
aria-label={t('关闭')}
/>
</div>
) : null}
<CardPro
type='type3'
tabsArea={<ModelsTabs {...modelsData} />}
actionsArea={
<div className='flex flex-col md:flex-row justify-between items-center gap-2 w-full'>
<ModelsActions
selectedKeys={selectedKeys}
setSelectedKeys={setSelectedKeys}
setEditingModel={setEditingModel}
setShowEdit={setShowEdit}
batchDeleteModels={batchDeleteModels}
syncing={modelsData.syncing}
syncUpstream={modelsData.syncUpstream}
previewing={modelsData.previewing}
previewUpstreamDiff={modelsData.previewUpstreamDiff}
applyUpstreamOverwrite={modelsData.applyUpstreamOverwrite}
compactMode={compactMode}
setCompactMode={setCompactMode}
t={t}
/>
<div className='w-full md:w-full lg:w-auto order-1 md:order-2'>
<ModelsFilters
formInitValues={formInitValues}
setFormApi={setFormApi}
searchModels={searchModels}
loading={loading}
searching={searching}
t={t}
/>
</div>
</div>
}
paginationArea={createCardProPagination({
currentPage: modelsData.activePage,
pageSize: modelsData.pageSize,
total: modelsData.modelCount,
onPageChange: modelsData.handlePageChange,
onPageSizeChange: modelsData.handlePageSizeChange,
isMobile: isMobile,
t: modelsData.t,
})}
t={modelsData.t}
>
<ModelsTable {...modelsData} />
</CardPro>
</>
);
};
export default ModelsPage;
@@ -0,0 +1,554 @@
/*
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 React, { useState, useEffect, useRef, useMemo } from 'react';
import JSONEditor from '../../../common/ui/JSONEditor';
import {
Banner,
SideSheet,
Form,
Button,
Space,
Spin,
Typography,
Card,
Tag,
Avatar,
Col,
Row,
} from '@douyinfe/semi-ui';
import { Save, X, FileText } from 'lucide-react';
import { IconAlertTriangle, IconLink } from '@douyinfe/semi-icons';
import { API, showError, showSuccess } from '../../../../helpers';
import { useTranslation } from 'react-i18next';
import { useIsMobile } from '../../../../hooks/common/useIsMobile';
const { Text, Title } = Typography;
// Example endpoint template for quick fill
const ENDPOINT_TEMPLATE = {
openai: { path: '/v1/chat/completions', method: 'POST' },
'openai-response': { path: '/v1/responses', method: 'POST' },
'openai-response-compact': { path: '/v1/responses/compact', method: 'POST' },
anthropic: { path: '/v1/messages', method: 'POST' },
gemini: { path: '/v1beta/models/{model}:generateContent', method: 'POST' },
'jina-rerank': { path: '/v1/rerank', method: 'POST' },
'image-generation': { path: '/v1/images/generations', method: 'POST' },
};
const nameRuleOptions = [
{ label: '精确名称匹配', value: 0 },
{ label: '前缀名称匹配', value: 1 },
{ label: '包含名称匹配', value: 2 },
{ label: '后缀名称匹配', value: 3 },
];
const EditModelModal = (props) => {
const { t } = useTranslation();
const [loading, setLoading] = useState(false);
const isMobile = useIsMobile();
const formApiRef = useRef(null);
const isEdit = props.editingModel && props.editingModel.id !== undefined;
const placement = useMemo(() => (isEdit ? 'right' : 'left'), [isEdit]);
// 供应商列表
const [vendors, setVendors] = useState([]);
// 预填组(标签、端点)
const [tagGroups, setTagGroups] = useState([]);
const [endpointGroups, setEndpointGroups] = useState([]);
// 获取供应商列表
const fetchVendors = async () => {
try {
const res = await API.get('/api/vendors/?page_size=1000'); // 获取全部供应商
if (res.data.success) {
const items = res.data.data.items || res.data.data || [];
setVendors(Array.isArray(items) ? items : []);
}
} catch (error) {
// ignore
}
};
// 获取预填组(标签、端点)
const fetchPrefillGroups = async () => {
try {
const [tagRes, endpointRes] = await Promise.all([
API.get('/api/prefill_group?type=tag'),
API.get('/api/prefill_group?type=endpoint'),
]);
if (tagRes?.data?.success) {
setTagGroups(tagRes.data.data || []);
}
if (endpointRes?.data?.success) {
setEndpointGroups(endpointRes.data.data || []);
}
} catch (error) {
// ignore
}
};
useEffect(() => {
if (props.visiable) {
fetchVendors();
fetchPrefillGroups();
}
}, [props.visiable]);
const getInitValues = () => ({
model_name: props.editingModel?.model_name || '',
description: '',
icon: '',
tags: [],
vendor_id: undefined,
vendor: '',
vendor_icon: '',
endpoints: '',
name_rule: props.editingModel?.model_name ? 0 : undefined, // 通过未配置模型过来的固定为精确匹配
status: true,
sync_official: true,
});
const handleCancel = () => {
props.handleClose();
};
const loadModel = async () => {
if (!isEdit || !props.editingModel.id) return;
setLoading(true);
try {
const res = await API.get(`/api/models/${props.editingModel.id}`);
const { success, message, data } = res.data;
if (success) {
// 处理tags
if (data.tags) {
data.tags = data.tags.split(',').filter(Boolean);
} else {
data.tags = [];
}
// endpoints 保持原始 JSON 字符串,若为空设为空串
if (!data.endpoints) {
data.endpoints = '';
}
// 处理status/sync_official,将数字转为布尔值
data.status = data.status === 1;
data.sync_official = (data.sync_official ?? 1) === 1;
if (formApiRef.current) {
formApiRef.current.setValues({ ...getInitValues(), ...data });
}
} else {
showError(message);
}
} catch (error) {
showError(t('加载模型信息失败'));
}
setLoading(false);
};
useEffect(() => {
if (formApiRef.current) {
if (!isEdit) {
formApiRef.current.setValues({
...getInitValues(),
model_name: props.editingModel?.model_name || '',
});
}
}
}, [props.editingModel?.id, props.editingModel?.model_name]);
useEffect(() => {
if (props.visiable) {
if (isEdit) {
loadModel();
} else {
formApiRef.current?.setValues({
...getInitValues(),
model_name: props.editingModel?.model_name || '',
});
}
} else {
formApiRef.current?.reset();
}
}, [props.visiable, props.editingModel?.id, props.editingModel?.model_name]);
const submit = async (values) => {
setLoading(true);
try {
const submitData = {
...values,
tags: Array.isArray(values.tags) ? values.tags.join(',') : values.tags,
endpoints: values.endpoints || '',
status: values.status ? 1 : 0,
sync_official: values.sync_official ? 1 : 0,
};
if (isEdit) {
submitData.id = props.editingModel.id;
const res = await API.put('/api/models/', submitData);
const { success, message } = res.data;
if (success) {
showSuccess(t('模型更新成功!'));
props.refresh();
props.handleClose();
} else {
showError(t(message));
}
} else {
const res = await API.post('/api/models/', submitData);
const { success, message } = res.data;
if (success) {
showSuccess(t('模型创建成功!'));
props.refresh();
props.handleClose();
} else {
showError(t(message));
}
}
} catch (error) {
showError(error.response?.data?.message || t('操作失败'));
}
setLoading(false);
formApiRef.current?.setValues(getInitValues());
};
return (
<SideSheet
placement={placement}
title={
<Space>
{isEdit ? (
<Tag color='blue' shape='circle'>
{t('更新')}
</Tag>
) : (
<Tag color='green' shape='circle'>
{t('新建')}
</Tag>
)}
<Title heading={4} className='m-0'>
{isEdit ? t('更新模型信息') : t('创建新的模型')}
</Title>
</Space>
}
bodyStyle={{ padding: '0' }}
visible={props.visiable}
width={isMobile ? '100%' : 600}
footer={
<div className='flex justify-end bg-white'>
<Space>
<Button
theme='solid'
className='!rounded-lg'
onClick={() => formApiRef.current?.submitForm()}
icon={<Save size={16} />}
loading={loading}
>
{t('提交')}
</Button>
<Button
theme='light'
className='!rounded-lg'
type='primary'
onClick={handleCancel}
icon={<X size={16} />}
>
{t('取消')}
</Button>
</Space>
</div>
}
closeIcon={null}
onCancel={() => handleCancel()}
>
<Spin spinning={loading}>
<Form
key={isEdit ? 'edit' : 'new'}
initValues={getInitValues()}
getFormApi={(api) => (formApiRef.current = api)}
onSubmit={submit}
>
{({ values }) => (
<div className='p-2'>
{/* 基本信息 */}
<Card className='!rounded-2xl shadow-sm border-0'>
<div className='flex items-center mb-2'>
<Avatar size='small' color='green' className='mr-2 shadow-md'>
<FileText size={16} />
</Avatar>
<div>
<Text className='text-lg font-medium'>{t('基本信息')}</Text>
<div className='text-xs text-gray-600'>
{t('设置模型的基本信息')}
</div>
</div>
</div>
<Row gutter={12}>
<Col span={24}>
<Form.Input
field='model_name'
label={t('模型名称')}
placeholder={t('请输入模型名称,如:gpt-4')}
rules={[{ required: true, message: t('请输入模型名称') }]}
showClear
/>
</Col>
<Col span={24}>
<Form.Select
field='name_rule'
label={t('名称匹配类型')}
placeholder={t('请选择名称匹配类型')}
optionList={nameRuleOptions.map((o) => ({
label: t(o.label),
value: o.value,
}))}
rules={[
{ required: true, message: t('请选择名称匹配类型') },
]}
extraText={t(
'根据模型名称和匹配规则查找模型元数据,优先级:精确 > 前缀 > 后缀 > 包含',
)}
style={{ width: '100%' }}
/>
</Col>
<Col span={24}>
<Form.Input
field='icon'
label={t('模型图标')}
placeholder={t('请输入图标名称')}
extraText={
<span>
{t(
"图标使用@lobehub/icons库,如:OpenAI、Claude.Color,支持链式参数:OpenAI.Avatar.type={'platform'}、OpenRouter.Avatar.shape={'square'},查询所有可用图标请 ",
)}
<Typography.Text
link={{
href: 'https://icons.lobehub.com/components/lobe-hub',
target: '_blank',
}}
icon={<IconLink />}
underline
>
{t('请点击我')}
</Typography.Text>
</span>
}
showClear
/>
</Col>
<Col span={24}>
<Form.TextArea
field='description'
label={t('描述')}
placeholder={t('请输入模型描述')}
rows={3}
showClear
/>
</Col>
<Col span={24}>
<Form.TagInput
field='tags'
label={t('标签')}
placeholder={t('输入标签或使用","分隔多个标签')}
addOnBlur
showClear
onChange={(newTags) => {
if (!formApiRef.current) return;
const normalize = (tags) => {
if (!Array.isArray(tags)) return [];
return [
...new Set(
tags.flatMap((tag) =>
tag
.split(',')
.map((t) => t.trim())
.filter(Boolean),
),
),
];
};
const normalized = normalize(newTags);
formApiRef.current.setValue('tags', normalized);
}}
style={{ width: '100%' }}
{...(tagGroups.length > 0 && {
extraText: (
<Space wrap>
{tagGroups.map((group) => (
<Button
key={group.id}
size='small'
type='primary'
onClick={() => {
if (formApiRef.current) {
const currentTags =
formApiRef.current.getValue('tags') || [];
const newTags = [
...currentTags,
...(group.items || []),
];
const uniqueTags = [...new Set(newTags)];
formApiRef.current.setValue(
'tags',
uniqueTags,
);
}
}}
>
{group.name}
</Button>
))}
</Space>
),
})}
/>
</Col>
<Col span={24}>
<Form.Select
field='vendor_id'
label={t('供应商')}
placeholder={t('选择模型供应商')}
optionList={vendors.map((v) => ({
label: v.name,
value: v.id,
}))}
filter
showClear
onChange={(value) => {
const vendorInfo = vendors.find((v) => v.id === value);
if (vendorInfo && formApiRef.current) {
formApiRef.current.setValue(
'vendor',
vendorInfo.name,
);
}
}}
style={{ width: '100%' }}
/>
</Col>
<Col span={24}>
<Banner
type='warning'
closeIcon={null}
icon={
<IconAlertTriangle
size='large'
style={{ color: 'var(--semi-color-warning)' }}
/>
}
description={t(
'提示:此处配置仅用于控制「模型广场」对用户的展示效果,不会影响模型的实际调用与路由。若需配置真实调用行为,请前往「渠道管理」进行设置。',
)}
style={{ marginBottom: 12 }}
/>
<JSONEditor
field='endpoints'
label={t('在模型广场向用户展示的端点')}
placeholder={
'{\n "openai": {"path": "/v1/chat/completions", "method": "POST"}\n}'
}
value={values.endpoints}
onChange={(val) =>
formApiRef.current?.setValue('endpoints', val)
}
formApi={formApiRef.current}
editorType='object'
template={ENDPOINT_TEMPLATE}
templateLabel={t('填入模板')}
extraText={t('留空则使用默认端点;支持 {path, method}')}
extraFooter={
endpointGroups.length > 0 && (
<Space wrap>
{endpointGroups.map((group) => (
<Button
key={group.id}
size='small'
type='primary'
onClick={() => {
try {
const current =
formApiRef.current?.getValue(
'endpoints',
) || '';
let base = {};
if (current && current.trim())
base = JSON.parse(current);
const groupObj =
typeof group.items === 'string'
? JSON.parse(group.items || '{}')
: group.items || {};
const merged = { ...base, ...groupObj };
formApiRef.current?.setValue(
'endpoints',
JSON.stringify(merged, null, 2),
);
} catch (e) {
try {
const groupObj =
typeof group.items === 'string'
? JSON.parse(group.items || '{}')
: group.items || {};
formApiRef.current?.setValue(
'endpoints',
JSON.stringify(groupObj, null, 2),
);
} catch {}
}
}}
>
{group.name}
</Button>
))}
</Space>
)
}
/>
</Col>
<Col span={24}>
<Form.Switch
field='sync_official'
label={t('参与官方同步')}
extraText={t(
'关闭后,此模型将不会被“同步官方”自动覆盖或创建',
)}
size='large'
/>
</Col>
<Col span={24}>
<Form.Switch
field='status'
label={t('状态')}
size='large'
/>
</Col>
</Row>
</Card>
</div>
)}
</Form>
</Spin>
</SideSheet>
);
};
export default EditModelModal;
@@ -0,0 +1,275 @@
/*
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 React, { useState, useRef, useEffect } from 'react';
import JSONEditor from '../../../common/ui/JSONEditor';
import {
SideSheet,
Button,
Form,
Typography,
Space,
Tag,
Row,
Col,
Card,
Avatar,
Spin,
} from '@douyinfe/semi-ui';
import { IconLayers, IconSave, IconClose } from '@douyinfe/semi-icons';
import { API, showError, showSuccess } from '../../../../helpers';
import { useTranslation } from 'react-i18next';
import { useIsMobile } from '../../../../hooks/common/useIsMobile';
const { Text, Title } = Typography;
// Example endpoint template for quick fill
const ENDPOINT_TEMPLATE = {
openai: { path: '/v1/chat/completions', method: 'POST' },
'openai-response': { path: '/v1/responses', method: 'POST' },
'openai-response-compact': { path: '/v1/responses/compact', method: 'POST' },
anthropic: { path: '/v1/messages', method: 'POST' },
gemini: { path: '/v1beta/models/{model}:generateContent', method: 'POST' },
'jina-rerank': { path: '/v1/rerank', method: 'POST' },
'image-generation': { path: '/v1/images/generations', method: 'POST' },
};
const EditPrefillGroupModal = ({
visible,
onClose,
editingGroup,
onSuccess,
}) => {
const { t } = useTranslation();
const isMobile = useIsMobile();
const [loading, setLoading] = useState(false);
const formRef = useRef(null);
const isEdit = editingGroup && editingGroup.id !== undefined;
const [selectedType, setSelectedType] = useState(editingGroup?.type || 'tag');
// 当外部传入的编辑组类型变化时同步 selectedType
useEffect(() => {
setSelectedType(editingGroup?.type || 'tag');
}, [editingGroup?.type]);
const typeOptions = [
{ label: t('模型组'), value: 'model' },
{ label: t('标签组'), value: 'tag' },
{ label: t('端点组'), value: 'endpoint' },
];
// 提交表单
const handleSubmit = async (values) => {
setLoading(true);
try {
const submitData = {
...values,
};
if (values.type === 'endpoint') {
submitData.items = values.items || '';
} else {
submitData.items = Array.isArray(values.items) ? values.items : [];
}
if (editingGroup.id) {
submitData.id = editingGroup.id;
const res = await API.put('/api/prefill_group', submitData);
if (res.data.success) {
showSuccess(t('更新成功'));
onSuccess();
} else {
showError(res.data.message || t('更新失败'));
}
} else {
const res = await API.post('/api/prefill_group', submitData);
if (res.data.success) {
showSuccess(t('创建成功'));
onSuccess();
} else {
showError(res.data.message || t('创建失败'));
}
}
} catch (error) {
showError(t('操作失败'));
}
setLoading(false);
};
return (
<SideSheet
placement='left'
title={
<Space>
{isEdit ? (
<Tag color='blue' shape='circle'>
{t('更新')}
</Tag>
) : (
<Tag color='green' shape='circle'>
{t('新建')}
</Tag>
)}
<Title heading={4} className='m-0'>
{isEdit ? t('更新预填组') : t('创建新的预填组')}
</Title>
</Space>
}
visible={visible}
onCancel={onClose}
width={isMobile ? '100%' : 600}
bodyStyle={{ padding: '0' }}
footer={
<div className='flex justify-end bg-white'>
<Space>
<Button
theme='solid'
className='!rounded-lg'
onClick={() => formRef.current?.submitForm()}
icon={<IconSave />}
loading={loading}
>
{t('提交')}
</Button>
<Button
theme='light'
className='!rounded-lg'
type='primary'
onClick={onClose}
icon={<IconClose />}
>
{t('取消')}
</Button>
</Space>
</div>
}
closeIcon={null}
>
<Spin spinning={loading}>
<Form
getFormApi={(api) => (formRef.current = api)}
initValues={{
name: editingGroup?.name || '',
type: editingGroup?.type || 'tag',
description: editingGroup?.description || '',
items: (() => {
try {
if (editingGroup?.type === 'endpoint') {
// 保持原始字符串
return typeof editingGroup?.items === 'string'
? editingGroup.items
: JSON.stringify(editingGroup.items || {}, null, 2);
}
return Array.isArray(editingGroup?.items)
? editingGroup.items
: [];
} catch {
return editingGroup?.type === 'endpoint' ? '' : [];
}
})(),
}}
onSubmit={handleSubmit}
>
<div className='p-2'>
{/* 基本信息 */}
<Card className='!rounded-2xl shadow-sm border-0'>
<div className='flex items-center mb-2'>
<Avatar size='small' color='green' className='mr-2 shadow-md'>
<IconLayers size={16} />
</Avatar>
<div>
<Text className='text-lg font-medium'>{t('基本信息')}</Text>
<div className='text-xs text-gray-600'>
{t('设置预填组的基本信息')}
</div>
</div>
</div>
<Row gutter={12}>
<Col span={24}>
<Form.Input
field='name'
label={t('组名')}
placeholder={t('请输入组名')}
rules={[{ required: true, message: t('请输入组名') }]}
showClear
/>
</Col>
<Col span={24}>
<Form.Select
field='type'
label={t('类型')}
placeholder={t('选择组类型')}
optionList={typeOptions}
rules={[{ required: true, message: t('请选择组类型') }]}
style={{ width: '100%' }}
onChange={(val) => setSelectedType(val)}
/>
</Col>
<Col span={24}>
<Form.TextArea
field='description'
label={t('描述')}
placeholder={t('请输入组描述')}
rows={3}
showClear
/>
</Col>
<Col span={24}>
{selectedType === 'endpoint' ? (
<JSONEditor
field='items'
label={t('端点映射')}
value={
formRef.current?.getValue('items') ??
(typeof editingGroup?.items === 'string'
? editingGroup.items
: JSON.stringify(editingGroup.items || {}, null, 2))
}
onChange={(val) =>
formRef.current?.setValue('items', val)
}
editorType='object'
placeholder={
'{\n "openai": {"path": "/v1/chat/completions", "method": "POST"}\n}'
}
template={ENDPOINT_TEMPLATE}
templateLabel={t('填入模板')}
extraText={t('键为端点类型,值为路径和方法对象')}
/>
) : (
<Form.TagInput
field='items'
label={t('项目')}
placeholder={t('输入项目名称,按回车添加')}
addOnBlur
showClear
style={{ width: '100%' }}
/>
)}
</Col>
</Row>
</Card>
</div>
</Form>
</Spin>
</SideSheet>
);
};
export default EditPrefillGroupModal;
@@ -0,0 +1,186 @@
/*
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 React, { useState, useRef, useEffect } from 'react';
import { Modal, Form, Col, Row } from '@douyinfe/semi-ui';
import { API, showError, showSuccess } from '../../../../helpers';
import { Typography } from '@douyinfe/semi-ui';
import { IconLink } from '@douyinfe/semi-icons';
import { useTranslation } from 'react-i18next';
import { useIsMobile } from '../../../../hooks/common/useIsMobile';
const EditVendorModal = ({ visible, handleClose, refresh, editingVendor }) => {
const { t } = useTranslation();
const [loading, setLoading] = useState(false);
const formApiRef = useRef(null);
const isMobile = useIsMobile();
const isEdit = editingVendor && editingVendor.id !== undefined;
const getInitValues = () => ({
name: '',
description: '',
icon: '',
status: true,
});
const handleCancel = () => {
handleClose();
formApiRef.current?.reset();
};
const loadVendor = async () => {
if (!isEdit || !editingVendor.id) return;
setLoading(true);
try {
const res = await API.get(`/api/vendors/${editingVendor.id}`);
const { success, message, data } = res.data;
if (success) {
// 将数字状态转为布尔值
data.status = data.status === 1;
if (formApiRef.current) {
formApiRef.current.setValues({ ...getInitValues(), ...data });
}
} else {
showError(message);
}
} catch (error) {
showError(t('加载供应商信息失败'));
}
setLoading(false);
};
useEffect(() => {
if (visible) {
if (isEdit) {
loadVendor();
} else {
formApiRef.current?.setValues(getInitValues());
}
} else {
formApiRef.current?.reset();
}
}, [visible, editingVendor?.id]);
const submit = async (values) => {
setLoading(true);
try {
// 转换 status 为数字
const submitData = {
...values,
status: values.status ? 1 : 0,
};
if (isEdit) {
submitData.id = editingVendor.id;
const res = await API.put('/api/vendors/', submitData);
const { success, message } = res.data;
if (success) {
showSuccess(t('供应商更新成功!'));
refresh();
handleClose();
} else {
showError(t(message));
}
} else {
const res = await API.post('/api/vendors/', submitData);
const { success, message } = res.data;
if (success) {
showSuccess(t('供应商创建成功!'));
refresh();
handleClose();
} else {
showError(t(message));
}
}
} catch (error) {
showError(error.response?.data?.message || t('操作失败'));
}
setLoading(false);
};
return (
<Modal
title={isEdit ? t('编辑供应商') : t('新增供应商')}
visible={visible}
onOk={() => formApiRef.current?.submitForm()}
onCancel={handleCancel}
confirmLoading={loading}
size={isMobile ? 'full-width' : 'small'}
>
<Form
initValues={getInitValues()}
getFormApi={(api) => (formApiRef.current = api)}
onSubmit={submit}
>
<Row gutter={12}>
<Col span={24}>
<Form.Input
field='name'
label={t('供应商名称')}
placeholder={t('请输入供应商名称,如:OpenAI')}
rules={[{ required: true, message: t('请输入供应商名称') }]}
showClear
/>
</Col>
<Col span={24}>
<Form.TextArea
field='description'
label={t('描述')}
placeholder={t('请输入供应商描述')}
rows={3}
showClear
/>
</Col>
<Col span={24}>
<Form.Input
field='icon'
label={t('供应商图标')}
placeholder={t('请输入图标名称')}
extraText={
<span>
{t(
"图标使用@lobehub/icons库,如:OpenAI、Claude.Color,支持链式参数:OpenAI.Avatar.type={'platform'}、OpenRouter.Avatar.shape={'square'},查询所有可用图标请 ",
)}
<Typography.Text
link={{
href: 'https://icons.lobehub.com/components/lobe-hub',
target: '_blank',
}}
icon={<IconLink />}
underline
>
{t('请点击我')}
</Typography.Text>
</span>
}
showClear
/>
</Col>
<Col span={24}>
<Form.Switch field='status' label={t('状态')} initValue={true} />
</Col>
</Row>
</Form>
</Modal>
);
};
export default EditVendorModal;
@@ -0,0 +1,198 @@
/*
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 React, { useEffect, useState } from 'react';
import {
Modal,
Table,
Spin,
Button,
Typography,
Empty,
Input,
} from '@douyinfe/semi-ui';
import {
IllustrationNoResult,
IllustrationNoResultDark,
} from '@douyinfe/semi-illustrations';
import { IconSearch } from '@douyinfe/semi-icons';
import { API, showError } from '../../../../helpers';
import { MODEL_TABLE_PAGE_SIZE } from '../../../../constants';
import { useIsMobile } from '../../../../hooks/common/useIsMobile';
const MissingModelsModal = ({ visible, onClose, onConfigureModel, t }) => {
const [loading, setLoading] = useState(false);
const [missingModels, setMissingModels] = useState([]);
const [searchKeyword, setSearchKeyword] = useState('');
const [currentPage, setCurrentPage] = useState(1);
const isMobile = useIsMobile();
const fetchMissing = async () => {
setLoading(true);
try {
const res = await API.get('/api/models/missing');
if (res.data.success) {
setMissingModels(res.data.data || []);
} else {
showError(res.data.message);
}
} catch (_) {
showError(t('获取未配置模型失败'));
}
setLoading(false);
};
useEffect(() => {
if (visible) {
fetchMissing();
setSearchKeyword('');
setCurrentPage(1);
} else {
setMissingModels([]);
}
}, [visible]);
// 过滤和分页逻辑
const filteredModels = missingModels.filter((model) =>
model.toLowerCase().includes(searchKeyword.toLowerCase()),
);
const dataSource = (() => {
const start = (currentPage - 1) * MODEL_TABLE_PAGE_SIZE;
const end = start + MODEL_TABLE_PAGE_SIZE;
return filteredModels.slice(start, end).map((model) => ({
model,
key: model,
}));
})();
const columns = [
{
title: t('模型名称'),
dataIndex: 'model',
render: (text) => (
<div className='flex items-center'>
<Typography.Text strong>{text}</Typography.Text>
</div>
),
},
{
title: '',
dataIndex: 'operate',
fixed: 'right',
width: 120,
render: (text, record) => (
<Button
type='primary'
size='small'
onClick={() => onConfigureModel(record.model)}
>
{t('配置')}
</Button>
),
},
];
return (
<Modal
title={
<div className='flex flex-col gap-2 w-full'>
<div className='flex items-center gap-2'>
<Typography.Text
strong
className='!text-[var(--semi-color-text-0)] !text-base'
>
{t('未配置的模型列表')}
</Typography.Text>
<Typography.Text type='tertiary' size='small'>
{t('共')} {missingModels.length} {t('个未配置模型')}
</Typography.Text>
</div>
</div>
}
visible={visible}
onCancel={onClose}
footer={null}
size={isMobile ? 'full-width' : 'medium'}
className='!rounded-lg'
>
<Spin spinning={loading}>
{missingModels.length === 0 && !loading ? (
<Empty
image={<IllustrationNoResult style={{ width: 150, height: 150 }} />}
darkModeImage={
<IllustrationNoResultDark style={{ width: 150, height: 150 }} />
}
description={t('暂无缺失模型')}
style={{ padding: 30 }}
/>
) : (
<div className='missing-models-content'>
{/* 搜索框 */}
<div className='flex items-center justify-end gap-2 w-full mb-4'>
<Input
placeholder={t('搜索模型...')}
value={searchKeyword}
onChange={(v) => {
setSearchKeyword(v);
setCurrentPage(1);
}}
className='!w-full'
prefix={<IconSearch />}
showClear
/>
</div>
{/* 表格 */}
{filteredModels.length > 0 ? (
<Table
columns={columns}
dataSource={dataSource}
pagination={{
currentPage: currentPage,
pageSize: MODEL_TABLE_PAGE_SIZE,
total: filteredModels.length,
showSizeChanger: false,
onPageChange: (page) => setCurrentPage(page),
}}
/>
) : (
<Empty
image={
<IllustrationNoResult style={{ width: 100, height: 100 }} />
}
darkModeImage={
<IllustrationNoResultDark
style={{ width: 100, height: 100 }}
/>
}
description={
searchKeyword ? t('未找到匹配的模型') : t('暂无缺失模型')
}
style={{ padding: 20 }}
/>
)}
</div>
)}
</Spin>
</Modal>
);
};
export default MissingModelsModal;
@@ -0,0 +1,308 @@
/*
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 React, { useState, useEffect } from 'react';
import {
SideSheet,
Button,
Typography,
Space,
Tag,
Popconfirm,
Card,
Avatar,
Spin,
Empty,
} from '@douyinfe/semi-ui';
import { IconPlus, IconLayers } from '@douyinfe/semi-icons';
import {
IllustrationNoResult,
IllustrationNoResultDark,
} from '@douyinfe/semi-illustrations';
import {
API,
showError,
showSuccess,
stringToColor,
} from '../../../../helpers';
import { useTranslation } from 'react-i18next';
import { useIsMobile } from '../../../../hooks/common/useIsMobile';
import CardTable from '../../../common/ui/CardTable';
import EditPrefillGroupModal from './EditPrefillGroupModal';
import {
renderLimitedItems,
renderDescription,
} from '../../../common/ui/RenderUtils';
const { Text, Title } = Typography;
const PrefillGroupManagement = ({ visible, onClose }) => {
const { t } = useTranslation();
const isMobile = useIsMobile();
const [loading, setLoading] = useState(false);
const [groups, setGroups] = useState([]);
const [showEdit, setShowEdit] = useState(false);
const [editingGroup, setEditingGroup] = useState({ id: undefined });
const typeOptions = [
{ label: t('模型组'), value: 'model' },
{ label: t('标签组'), value: 'tag' },
{ label: t('端点组'), value: 'endpoint' },
];
// 加载组列表
const loadGroups = async () => {
setLoading(true);
try {
const res = await API.get('/api/prefill_group');
if (res.data.success) {
setGroups(res.data.data || []);
} else {
showError(res.data.message || t('获取组列表失败'));
}
} catch (error) {
showError(t('获取组列表失败'));
}
setLoading(false);
};
// 删除组
const deleteGroup = async (id) => {
try {
const res = await API.delete(`/api/prefill_group/${id}`);
if (res.data.success) {
showSuccess(t('删除成功'));
loadGroups();
} else {
showError(res.data.message || t('删除失败'));
}
} catch (error) {
showError(t('删除失败'));
}
};
// 编辑组
const handleEdit = (group = {}) => {
setEditingGroup(group);
setShowEdit(true);
};
// 关闭编辑
const closeEdit = () => {
setShowEdit(false);
setTimeout(() => {
setEditingGroup({ id: undefined });
}, 300);
};
// 编辑成功回调
const handleEditSuccess = () => {
closeEdit();
loadGroups();
};
// 表格列定义
const columns = [
{
title: t('组名'),
dataIndex: 'name',
key: 'name',
render: (text, record) => (
<Space>
<Text strong>{text}</Text>
<Tag color='white' shape='circle' size='small'>
{typeOptions.find((opt) => opt.value === record.type)?.label ||
record.type}
</Tag>
</Space>
),
},
{
title: t('描述'),
dataIndex: 'description',
key: 'description',
render: (text) => renderDescription(text, 150),
},
{
title: t('项目内容'),
dataIndex: 'items',
key: 'items',
render: (items, record) => {
try {
if (record.type === 'endpoint') {
const obj =
typeof items === 'string'
? JSON.parse(items || '{}')
: items || {};
const keys = Object.keys(obj);
if (keys.length === 0)
return <Text type='tertiary'>{t('暂无项目')}</Text>;
return renderLimitedItems({
items: keys,
renderItem: (key, idx) => (
<Tag
key={idx}
size='small'
shape='circle'
color={stringToColor(key)}
>
{key}
</Tag>
),
maxDisplay: 3,
});
}
const itemsArray =
typeof items === 'string' ? JSON.parse(items) : items;
if (!Array.isArray(itemsArray) || itemsArray.length === 0) {
return <Text type='tertiary'>{t('暂无项目')}</Text>;
}
return renderLimitedItems({
items: itemsArray,
renderItem: (item, idx) => (
<Tag
key={idx}
size='small'
shape='circle'
color={stringToColor(item)}
>
{item}
</Tag>
),
maxDisplay: 3,
});
} catch {
return <Text type='tertiary'>{t('数据格式错误')}</Text>;
}
},
},
{
title: '',
key: 'action',
fixed: 'right',
width: 140,
render: (_, record) => (
<Space>
<Button size='small' onClick={() => handleEdit(record)}>
{t('编辑')}
</Button>
<Popconfirm
title={t('确定删除此组?')}
onConfirm={() => deleteGroup(record.id)}
>
<Button size='small' type='danger'>
{t('删除')}
</Button>
</Popconfirm>
</Space>
),
},
];
useEffect(() => {
if (visible) {
loadGroups();
}
}, [visible]);
return (
<>
<SideSheet
placement='left'
title={
<Space>
<Tag color='blue' shape='circle'>
{t('管理')}
</Tag>
<Title heading={4} className='m-0'>
{t('预填组管理')}
</Title>
</Space>
}
visible={visible}
onCancel={onClose}
width={isMobile ? '100%' : 800}
bodyStyle={{ padding: '0' }}
closeIcon={null}
>
<Spin spinning={loading}>
<div className='p-2'>
<Card className='!rounded-2xl shadow-sm border-0'>
<div className='flex items-center mb-2'>
<Avatar size='small' color='blue' className='mr-2 shadow-md'>
<IconLayers size={16} />
</Avatar>
<div>
<Text className='text-lg font-medium'>{t('组列表')}</Text>
<div className='text-xs text-gray-600'>
{t('管理模型、标签、端点等预填组')}
</div>
</div>
</div>
<div className='flex justify-end mb-4'>
<Button
type='primary'
theme='solid'
size='small'
icon={<IconPlus />}
onClick={() => handleEdit()}
>
{t('新建组')}
</Button>
</div>
{groups.length > 0 ? (
<CardTable
columns={columns}
dataSource={groups}
rowKey='id'
hidePagination={true}
size='small'
scroll={{ x: 'max-content' }}
/>
) : (
<Empty
image={
<IllustrationNoResult style={{ width: 150, height: 150 }} />
}
darkModeImage={
<IllustrationNoResultDark
style={{ width: 150, height: 150 }}
/>
}
description={t('暂无预填组')}
style={{ padding: 30 }}
/>
)}
</Card>
</div>
</Spin>
</SideSheet>
{/* 编辑组件 */}
<EditPrefillGroupModal
visible={showEdit}
onClose={closeEdit}
editingGroup={editingGroup}
onSuccess={handleEditSuccess}
/>
</>
);
};
export default PrefillGroupManagement;
@@ -0,0 +1,135 @@
/*
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 React, { useEffect, useState } from 'react';
import { Modal, RadioGroup, Radio, Steps, Button } from '@douyinfe/semi-ui';
import { useIsMobile } from '../../../../hooks/common/useIsMobile';
const SyncWizardModal = ({ visible, onClose, onConfirm, loading, t }) => {
const [step, setStep] = useState(0);
const [option, setOption] = useState('official');
const [locale, setLocale] = useState('zh-CN');
const isMobile = useIsMobile();
useEffect(() => {
if (visible) {
setStep(0);
setOption('official');
setLocale('zh-CN');
}
}, [visible]);
return (
<Modal
title={t('同步向导')}
visible={visible}
onCancel={onClose}
footer={
<div className='flex justify-end'>
{step === 1 && (
<Button onClick={() => setStep(0)}>{t('上一步')}</Button>
)}
<Button onClick={onClose}>{t('取消')}</Button>
{step === 0 && (
<Button
type='primary'
onClick={() => setStep(1)}
disabled={option !== 'official'}
>
{t('下一步')}
</Button>
)}
{step === 1 && (
<Button
type='primary'
theme='solid'
loading={loading}
onClick={async () => {
await onConfirm?.({ option, locale });
}}
>
{t('开始同步')}
</Button>
)}
</div>
}
width={isMobile ? '100%' : 'small'}
>
<div className='mb-3'>
<Steps type='basic' current={step} size='small'>
<Steps.Step title={t('选择方式')} description={t('选择同步来源')} />
<Steps.Step title={t('选择语言')} description={t('选择同步语言')} />
</Steps>
</div>
{step === 0 && (
<div className='mt-2 flex justify-center'>
<RadioGroup
value={option}
onChange={(e) => setOption(e?.target?.value ?? e)}
type='card'
direction='horizontal'
aria-label='同步方式选择'
name='sync-mode-selection'
>
<Radio value='official' extra={t('从官方模型库同步')}>
{t('官方模型同步')}
</Radio>
<Radio value='config' extra={t('从配置文件同步')} disabled>
{t('配置文件同步')}
</Radio>
</RadioGroup>
</div>
)}
{step === 1 && (
<div className='mt-2'>
<div className='mb-2 text-[var(--semi-color-text-2)]'>
{t('请选择同步语言')}
</div>
<div className='flex justify-center'>
<RadioGroup
value={locale}
onChange={(e) => setLocale(e?.target?.value ?? e)}
type='card'
direction='horizontal'
aria-label='语言选择'
name='sync-locale-selection'
>
<Radio value='en' extra='English'>
en
</Radio>
<Radio value='zh-CN' extra='简体中文'>
zh-CN
</Radio>
<Radio value='zh-TW' extra='繁體中文'>
zh-TW
</Radio>
<Radio value='ja' extra='日本語'>
ja
</Radio>
</RadioGroup>
</div>
</div>
)}
</Modal>
);
};
export default SyncWizardModal;
@@ -0,0 +1,324 @@
/*
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 React, { useEffect, useMemo, useState, useCallback } from 'react';
import {
Modal,
Table,
Checkbox,
Typography,
Empty,
Tag,
Popover,
Input,
} from '@douyinfe/semi-ui';
import { MousePointerClick } from 'lucide-react';
import { useIsMobile } from '../../../../hooks/common/useIsMobile';
import { MODEL_TABLE_PAGE_SIZE } from '../../../../constants';
import { IconSearch } from '@douyinfe/semi-icons';
const { Text } = Typography;
const FIELD_LABELS = {
description: '描述',
icon: '图标',
tags: '标签',
vendor: '供应商',
name_rule: '命名规则',
status: '状态',
};
const FIELD_KEYS = Object.keys(FIELD_LABELS);
const UpstreamConflictModal = ({
visible,
onClose,
conflicts = [],
onSubmit,
t,
loading = false,
}) => {
const [selections, setSelections] = useState({});
const isMobile = useIsMobile();
const [currentPage, setCurrentPage] = useState(1);
const [searchKeyword, setSearchKeyword] = useState('');
const formatValue = (v) => {
if (v === null || v === undefined) return '-';
if (typeof v === 'string') return v || '-';
try {
return JSON.stringify(v, null, 2);
} catch (_) {
return String(v);
}
};
useEffect(() => {
if (visible) {
const init = {};
conflicts.forEach((item) => {
init[item.model_name] = new Set();
});
setSelections(init);
setCurrentPage(1);
setSearchKeyword('');
} else {
setSelections({});
}
}, [visible, conflicts]);
const toggleField = useCallback((modelName, field, checked) => {
setSelections((prev) => {
const next = { ...prev };
const set = new Set(next[modelName] || []);
if (checked) set.add(field);
else set.delete(field);
next[modelName] = set;
return next;
});
}, []);
// 构造数据源与过滤后的数据源
const dataSource = useMemo(
() =>
(conflicts || []).map((c) => ({
key: c.model_name,
model_name: c.model_name,
fields: c.fields || [],
})),
[conflicts],
);
const filteredDataSource = useMemo(() => {
const kw = (searchKeyword || '').toLowerCase();
if (!kw) return dataSource;
return dataSource.filter((item) =>
(item.model_name || '').toLowerCase().includes(kw),
);
}, [dataSource, searchKeyword]);
// 列头工具:当前过滤范围内可操作的行集合/勾选状态/批量设置
const getPresentRowsForField = useCallback(
(fieldKey) =>
(filteredDataSource || []).filter((row) =>
(row.fields || []).some((f) => f.field === fieldKey),
),
[filteredDataSource],
);
const getHeaderState = useCallback(
(fieldKey) => {
const presentRows = getPresentRowsForField(fieldKey);
const selectedCount = presentRows.filter((row) =>
selections[row.model_name]?.has(fieldKey),
).length;
const allCount = presentRows.length;
return {
headerChecked: allCount > 0 && selectedCount === allCount,
headerIndeterminate: selectedCount > 0 && selectedCount < allCount,
hasAny: allCount > 0,
};
},
[getPresentRowsForField, selections],
);
const applyHeaderChange = useCallback(
(fieldKey, checked) => {
setSelections((prev) => {
const next = { ...prev };
getPresentRowsForField(fieldKey).forEach((row) => {
const set = new Set(next[row.model_name] || []);
if (checked) set.add(fieldKey);
else set.delete(fieldKey);
next[row.model_name] = set;
});
return next;
});
},
[getPresentRowsForField],
);
const columns = useMemo(() => {
const base = [
{
title: t('模型'),
dataIndex: 'model_name',
fixed: 'left',
render: (text) => <Text strong>{text}</Text>,
},
];
const cols = FIELD_KEYS.map((fieldKey) => {
const rawLabel = FIELD_LABELS[fieldKey] || fieldKey;
const label = t(rawLabel);
const { headerChecked, headerIndeterminate, hasAny } =
getHeaderState(fieldKey);
if (!hasAny) return null;
const onHeaderChange = (e) =>
applyHeaderChange(fieldKey, e?.target?.checked);
return {
title: (
<div className='flex items-center gap-2'>
<Checkbox
checked={headerChecked}
indeterminate={headerIndeterminate}
onChange={onHeaderChange}
/>
<Text>{label}</Text>
</div>
),
dataIndex: fieldKey,
render: (_, record) => {
const f = (record.fields || []).find((x) => x.field === fieldKey);
if (!f) return <Text type='tertiary'>-</Text>;
const checked = selections[record.model_name]?.has(fieldKey) || false;
return (
<Checkbox
checked={checked}
onChange={(e) =>
toggleField(record.model_name, fieldKey, e?.target?.checked)
}
>
<Popover
trigger='hover'
position='top'
content={
<div className='p-2 max-w-[520px]'>
<div className='mb-2'>
<Text type='tertiary' size='small'>
{t('本地')}
</Text>
<pre className='whitespace-pre-wrap m-0'>
{formatValue(f.local)}
</pre>
</div>
<div>
<Text type='tertiary' size='small'>
{t('官方')}
</Text>
<pre className='whitespace-pre-wrap m-0'>
{formatValue(f.upstream)}
</pre>
</div>
</div>
}
>
<Tag
color='white'
size='small'
prefixIcon={<MousePointerClick size={14} />}
>
{t('点击查看差异')}
</Tag>
</Popover>
</Checkbox>
);
},
};
});
return [...base, ...cols.filter(Boolean)];
}, [
t,
selections,
filteredDataSource,
getHeaderState,
applyHeaderChange,
toggleField,
]);
const pagedDataSource = useMemo(() => {
const start = (currentPage - 1) * MODEL_TABLE_PAGE_SIZE;
const end = start + MODEL_TABLE_PAGE_SIZE;
return filteredDataSource.slice(start, end);
}, [filteredDataSource, currentPage]);
const handleOk = async () => {
const payload = Object.entries(selections)
.map(([modelName, set]) => ({
model_name: modelName,
fields: Array.from(set || []),
}))
.filter((x) => x.fields.length > 0);
const ok = await onSubmit?.(payload);
if (ok) onClose?.();
};
return (
<Modal
title={t('选择要覆盖的冲突项')}
visible={visible}
onCancel={onClose}
onOk={handleOk}
confirmLoading={loading}
okText={t('应用覆盖')}
cancelText={t('取消')}
width={isMobile ? '100%' : 1000}
>
{dataSource.length === 0 ? (
<Empty description={t('无冲突项')} className='p-6' />
) : (
<>
<div className='mb-3 text-[var(--semi-color-text-2)]'>
{t('仅会覆盖你勾选的字段,未勾选的字段保持本地不变。')}
</div>
{/* 搜索框 */}
<div className='flex items-center justify-end gap-2 w-full mb-4'>
<Input
placeholder={t('搜索模型...')}
value={searchKeyword}
onChange={(v) => {
setSearchKeyword(v);
setCurrentPage(1);
}}
className='!w-full'
prefix={<IconSearch />}
showClear
/>
</div>
{filteredDataSource.length > 0 ? (
<Table
columns={columns}
dataSource={pagedDataSource}
pagination={{
currentPage: currentPage,
pageSize: MODEL_TABLE_PAGE_SIZE,
total: filteredDataSource.length,
showSizeChanger: false,
onPageChange: (page) => setCurrentPage(page),
}}
scroll={{ x: 'max-content' }}
/>
) : (
<Empty
description={
searchKeyword ? t('未找到匹配的模型') : t('无冲突项')
}
className='p-6'
/>
)}
</>
)}
</Modal>
);
};
export default UpstreamConflictModal;
@@ -0,0 +1,71 @@
/*
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 React from 'react';
import { Button } from '@douyinfe/semi-ui';
const RedemptionsActions = ({
selectedKeys,
setEditingRedemption,
setShowEdit,
batchCopyRedemptions,
batchDeleteRedemptions,
t,
}) => {
// Add new redemption code
const handleAddRedemption = () => {
setEditingRedemption({
id: undefined,
});
setShowEdit(true);
};
return (
<div className='flex flex-wrap gap-2 w-full md:w-auto order-2 md:order-1'>
<Button
type='primary'
className='flex-1 md:flex-initial'
onClick={handleAddRedemption}
size='small'
>
{t('添加兑换码')}
</Button>
<Button
type='tertiary'
className='flex-1 md:flex-initial'
onClick={batchCopyRedemptions}
size='small'
>
{t('复制所选兑换码到剪贴板')}
</Button>
<Button
type='danger'
className='w-full md:w-auto'
onClick={batchDeleteRedemptions}
size='small'
>
{t('清除失效兑换码')}
</Button>
</div>
);
};
export default RedemptionsActions;
@@ -0,0 +1,222 @@
/*
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 React from 'react';
import { Tag, Button, Space, Popover, Dropdown } from '@douyinfe/semi-ui';
import { IconMore } from '@douyinfe/semi-icons';
import { renderQuota, timestamp2string } from '../../../helpers';
import {
REDEMPTION_STATUS,
REDEMPTION_STATUS_MAP,
REDEMPTION_ACTIONS,
} from '../../../constants/redemption.constants';
/**
* Check if redemption code is expired
*/
export const isExpired = (record) => {
return (
record.status === REDEMPTION_STATUS.UNUSED &&
record.expired_time !== 0 &&
record.expired_time < Math.floor(Date.now() / 1000)
);
};
/**
* Render timestamp
*/
const renderTimestamp = (timestamp) => {
return <>{timestamp2string(timestamp)}</>;
};
/**
* Render redemption code status
*/
const renderStatus = (status, record, t) => {
if (isExpired(record)) {
return (
<Tag color='orange' shape='circle'>
{t('已过期')}
</Tag>
);
}
const statusConfig = REDEMPTION_STATUS_MAP[status];
if (statusConfig) {
return (
<Tag color={statusConfig.color} shape='circle'>
{t(statusConfig.text)}
</Tag>
);
}
return (
<Tag color='black' shape='circle'>
{t('未知状态')}
</Tag>
);
};
/**
* Get redemption code table column definitions
*/
export const getRedemptionsColumns = ({
t,
manageRedemption,
copyText,
setEditingRedemption,
setShowEdit,
refresh,
redemptions,
activePage,
showDeleteRedemptionModal,
}) => {
return [
{
title: t('ID'),
dataIndex: 'id',
},
{
title: t('名称'),
dataIndex: 'name',
},
{
title: t('状态'),
dataIndex: 'status',
key: 'status',
render: (text, record) => {
return <div>{renderStatus(text, record, t)}</div>;
},
},
{
title: t('额度'),
dataIndex: 'quota',
render: (text) => {
return (
<div>
<Tag color='grey' shape='circle'>
{renderQuota(parseInt(text))}
</Tag>
</div>
);
},
},
{
title: t('创建时间'),
dataIndex: 'created_time',
render: (text) => {
return <div>{renderTimestamp(text)}</div>;
},
},
{
title: t('过期时间'),
dataIndex: 'expired_time',
render: (text) => {
return <div>{text === 0 ? t('永不过期') : renderTimestamp(text)}</div>;
},
},
{
title: t('兑换人ID'),
dataIndex: 'used_user_id',
render: (text) => {
return <div>{text === 0 ? t('无') : text}</div>;
},
},
{
title: '',
dataIndex: 'operate',
fixed: 'right',
width: 205,
render: (text, record) => {
// Create dropdown menu items for more operations
const moreMenuItems = [
{
node: 'item',
name: t('删除'),
type: 'danger',
onClick: () => {
showDeleteRedemptionModal(record);
},
},
];
if (record.status === REDEMPTION_STATUS.UNUSED && !isExpired(record)) {
moreMenuItems.push({
node: 'item',
name: t('禁用'),
type: 'warning',
onClick: () => {
manageRedemption(record.id, REDEMPTION_ACTIONS.DISABLE, record);
},
});
} else if (!isExpired(record)) {
moreMenuItems.push({
node: 'item',
name: t('启用'),
type: 'secondary',
onClick: () => {
manageRedemption(record.id, REDEMPTION_ACTIONS.ENABLE, record);
},
disabled: record.status === REDEMPTION_STATUS.USED,
});
}
return (
<Space>
<Popover
content={record.key}
style={{ padding: 20 }}
position='top'
>
<Button type='tertiary' size='small'>
{t('查看')}
</Button>
</Popover>
<Button
size='small'
onClick={async () => {
await copyText(record.key);
}}
>
{t('复制')}
</Button>
<Button
type='tertiary'
size='small'
onClick={() => {
setEditingRedemption(record);
setShowEdit(true);
}}
disabled={record.status !== REDEMPTION_STATUS.UNUSED}
>
{t('编辑')}
</Button>
<Dropdown
trigger='click'
position='bottomRight'
menu={moreMenuItems}
>
<Button type='tertiary' size='small' icon={<IconMore />} />
</Dropdown>
</Space>
);
},
},
];
};
@@ -0,0 +1,44 @@
/*
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 React from 'react';
import { Typography } from '@douyinfe/semi-ui';
import { Ticket } from 'lucide-react';
import CompactModeToggle from '../../common/ui/CompactModeToggle';
const { Text } = Typography;
const RedemptionsDescription = ({ compactMode, setCompactMode, t }) => {
return (
<div className='flex flex-col md:flex-row justify-between items-start md:items-center gap-2 w-full'>
<div className='flex items-center text-orange-500'>
<Ticket size={16} className='mr-2' />
<Text>{t('兑换码管理')}</Text>
</div>
<CompactModeToggle
compactMode={compactMode}
setCompactMode={setCompactMode}
t={t}
/>
</div>
);
};
export default RedemptionsDescription;
@@ -0,0 +1,93 @@
/*
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 React, { useRef } from 'react';
import { Form, Button } from '@douyinfe/semi-ui';
import { IconSearch } from '@douyinfe/semi-icons';
const RedemptionsFilters = ({
formInitValues,
setFormApi,
searchRedemptions,
loading,
searching,
t,
}) => {
// Handle form reset and immediate search
const formApiRef = useRef(null);
const handleReset = () => {
if (!formApiRef.current) return;
formApiRef.current.reset();
setTimeout(() => {
searchRedemptions();
}, 100);
};
return (
<Form
initValues={formInitValues}
getFormApi={(api) => {
setFormApi(api);
formApiRef.current = api;
}}
onSubmit={searchRedemptions}
allowEmpty={true}
autoComplete='off'
layout='horizontal'
trigger='change'
stopValidateWithError={false}
className='w-full md:w-auto order-1 md:order-2'
>
<div className='flex flex-col md:flex-row items-center gap-2 w-full md:w-auto'>
<div className='relative w-full md:w-64'>
<Form.Input
field='searchKeyword'
prefix={<IconSearch />}
placeholder={t('关键字(id或者名称)')}
showClear
pure
size='small'
/>
</div>
<div className='flex gap-2 w-full md:w-auto'>
<Button
type='tertiary'
htmlType='submit'
loading={loading || searching}
className='flex-1 md:flex-initial md:w-auto'
size='small'
>
{t('查询')}
</Button>
<Button
type='tertiary'
onClick={handleReset}
className='flex-1 md:flex-initial md:w-auto'
size='small'
>
{t('重置')}
</Button>
</div>
</div>
</Form>
);
};
export default RedemptionsFilters;
@@ -0,0 +1,144 @@
/*
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 React, { useMemo, useState } from 'react';
import { Empty } from '@douyinfe/semi-ui';
import CardTable from '../../common/ui/CardTable';
import {
IllustrationNoResult,
IllustrationNoResultDark,
} from '@douyinfe/semi-illustrations';
import { getRedemptionsColumns, isExpired } from './RedemptionsColumnDefs';
import DeleteRedemptionModal from './modals/DeleteRedemptionModal';
const RedemptionsTable = (redemptionsData) => {
const {
redemptions,
loading,
activePage,
pageSize,
tokenCount,
compactMode,
handlePageChange,
rowSelection,
handleRow,
manageRedemption,
copyText,
setEditingRedemption,
setShowEdit,
refresh,
t,
} = redemptionsData;
// Modal states
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [deletingRecord, setDeletingRecord] = useState(null);
// Handle show delete modal
const showDeleteRedemptionModal = (record) => {
setDeletingRecord(record);
setShowDeleteModal(true);
};
// Get all columns
const columns = useMemo(() => {
return getRedemptionsColumns({
t,
manageRedemption,
copyText,
setEditingRedemption,
setShowEdit,
refresh,
redemptions,
activePage,
showDeleteRedemptionModal,
});
}, [
t,
manageRedemption,
copyText,
setEditingRedemption,
setShowEdit,
refresh,
redemptions,
activePage,
showDeleteRedemptionModal,
]);
// Handle compact mode by removing fixed positioning
const tableColumns = useMemo(() => {
return compactMode
? columns.map((col) => {
if (col.dataIndex === 'operate') {
const { fixed, ...rest } = col;
return rest;
}
return col;
})
: columns;
}, [compactMode, columns]);
return (
<>
<CardTable
columns={tableColumns}
dataSource={redemptions}
scroll={compactMode ? undefined : { x: 'max-content' }}
pagination={{
currentPage: activePage,
pageSize: pageSize,
total: tokenCount,
showSizeChanger: true,
pageSizeOptions: [10, 20, 50, 100],
onPageSizeChange: redemptionsData.handlePageSizeChange,
onPageChange: handlePageChange,
}}
hidePagination={true}
loading={loading}
rowSelection={rowSelection}
onRow={handleRow}
empty={
<Empty
image={<IllustrationNoResult style={{ width: 150, height: 150 }} />}
darkModeImage={
<IllustrationNoResultDark style={{ width: 150, height: 150 }} />
}
description={t('搜索无结果')}
style={{ padding: 30 }}
/>
}
className='rounded-xl overflow-hidden'
size='middle'
/>
<DeleteRedemptionModal
visible={showDeleteModal}
onCancel={() => setShowDeleteModal(false)}
record={deletingRecord}
manageRedemption={manageRedemption}
refresh={refresh}
redemptions={redemptions}
activePage={activePage}
t={t}
/>
</>
);
};
export default RedemptionsTable;
+122
View File
@@ -0,0 +1,122 @@
/*
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 React from 'react';
import CardPro from '../../common/ui/CardPro';
import RedemptionsTable from './RedemptionsTable';
import RedemptionsActions from './RedemptionsActions';
import RedemptionsFilters from './RedemptionsFilters';
import RedemptionsDescription from './RedemptionsDescription';
import EditRedemptionModal from './modals/EditRedemptionModal';
import { useRedemptionsData } from '../../../hooks/redemptions/useRedemptionsData';
import { useIsMobile } from '../../../hooks/common/useIsMobile';
import { createCardProPagination } from '../../../helpers/utils';
const RedemptionsPage = () => {
const redemptionsData = useRedemptionsData();
const isMobile = useIsMobile();
const {
// Edit state
showEdit,
editingRedemption,
closeEdit,
refresh,
// Actions state
selectedKeys,
setEditingRedemption,
setShowEdit,
batchCopyRedemptions,
batchDeleteRedemptions,
// Filters state
formInitValues,
setFormApi,
searchRedemptions,
loading,
searching,
// UI state
compactMode,
setCompactMode,
// Translation
t,
} = redemptionsData;
return (
<>
<EditRedemptionModal
refresh={refresh}
editingRedemption={editingRedemption}
visiable={showEdit}
handleClose={closeEdit}
/>
<CardPro
type='type1'
descriptionArea={
<RedemptionsDescription
compactMode={compactMode}
setCompactMode={setCompactMode}
t={t}
/>
}
actionsArea={
<div className='flex flex-col md:flex-row justify-between items-center gap-2 w-full'>
<RedemptionsActions
selectedKeys={selectedKeys}
setEditingRedemption={setEditingRedemption}
setShowEdit={setShowEdit}
batchCopyRedemptions={batchCopyRedemptions}
batchDeleteRedemptions={batchDeleteRedemptions}
t={t}
/>
<div className='w-full md:w-full lg:w-auto order-1 md:order-2'>
<RedemptionsFilters
formInitValues={formInitValues}
setFormApi={setFormApi}
searchRedemptions={searchRedemptions}
loading={loading}
searching={searching}
t={t}
/>
</div>
</div>
}
paginationArea={createCardProPagination({
currentPage: redemptionsData.activePage,
pageSize: redemptionsData.pageSize,
total: redemptionsData.tokenCount,
onPageChange: redemptionsData.handlePageChange,
onPageSizeChange: redemptionsData.handlePageSizeChange,
isMobile: isMobile,
t: redemptionsData.t,
})}
t={redemptionsData.t}
>
<RedemptionsTable {...redemptionsData} />
</CardPro>
</>
);
};
export default RedemptionsPage;
@@ -0,0 +1,58 @@
/*
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 React from 'react';
import { Modal } from '@douyinfe/semi-ui';
import { REDEMPTION_ACTIONS } from '../../../../constants/redemption.constants';
const DeleteRedemptionModal = ({
visible,
onCancel,
record,
manageRedemption,
refresh,
redemptions,
activePage,
t,
}) => {
const handleConfirm = async () => {
await manageRedemption(record.id, REDEMPTION_ACTIONS.DELETE, record);
await refresh();
setTimeout(() => {
if (redemptions.length === 0 && activePage > 1) {
refresh(activePage - 1);
}
}, 100);
onCancel(); // Close modal after success
};
return (
<Modal
title={t('确定是否要删除此兑换码?')}
visible={visible}
onCancel={onCancel}
onOk={handleConfirm}
type='warning'
>
{t('此修改将不可逆')}
</Modal>
);
};
export default DeleteRedemptionModal;
@@ -0,0 +1,392 @@
/*
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 React, { useEffect, useState, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import {
API,
downloadTextAsFile,
showError,
showSuccess,
renderQuota,
getCurrencyConfig,
} from '../../../../helpers';
import {
quotaToDisplayAmount,
displayAmountToQuota,
} from '../../../../helpers/quota';
import { useIsMobile } from '../../../../hooks/common/useIsMobile';
import {
Button,
Modal,
SideSheet,
Space,
Spin,
Typography,
Card,
Tag,
Form,
Avatar,
Row,
Col,
InputNumber,
} from '@douyinfe/semi-ui';
import {
IconCreditCard,
IconSave,
IconClose,
IconGift,
} from '@douyinfe/semi-icons';
const { Text, Title } = Typography;
const EditRedemptionModal = (props) => {
const { t } = useTranslation();
const isEdit = props.editingRedemption.id !== undefined;
const [loading, setLoading] = useState(isEdit);
const isMobile = useIsMobile();
const formApiRef = useRef(null);
const [showQuotaInput, setShowQuotaInput] = useState(false);
const getInitValues = () => ({
name: '',
quota: 100000,
amount: Number(quotaToDisplayAmount(100000).toFixed(6)),
count: 1,
expired_time: null,
});
const handleCancel = () => {
props.handleClose();
};
const loadRedemption = async () => {
setLoading(true);
let res = await API.get(`/api/redemption/${props.editingRedemption.id}`);
const { success, message, data } = res.data;
if (success) {
if (data.expired_time === 0) {
data.expired_time = null;
} else {
data.expired_time = new Date(data.expired_time * 1000);
}
data.amount = Number(quotaToDisplayAmount(data.quota || 0).toFixed(6));
formApiRef.current?.setValues({ ...getInitValues(), ...data });
} else {
showError(message);
}
setLoading(false);
};
useEffect(() => {
if (formApiRef.current) {
if (isEdit) {
loadRedemption();
} else {
formApiRef.current.setValues(getInitValues());
}
}
}, [props.editingRedemption.id]);
const submit = async (values) => {
let name = values.name;
if (!isEdit && (!name || name === '')) {
name = renderQuota(values.quota);
}
setLoading(true);
let localInputs = { ...values };
localInputs.count = parseInt(localInputs.count) || 0;
localInputs.quota = displayAmountToQuota(localInputs.amount);
if (localInputs.quota <= 0) {
showError(t('请输入金额'));
setLoading(false);
return;
}
localInputs.name = name;
if (!localInputs.expired_time) {
localInputs.expired_time = 0;
} else {
localInputs.expired_time = Math.floor(
localInputs.expired_time.getTime() / 1000,
);
}
let res;
if (isEdit) {
res = await API.put(`/api/redemption/`, {
...localInputs,
id: parseInt(props.editingRedemption.id),
});
} else {
res = await API.post(`/api/redemption/`, {
...localInputs,
});
}
const { success, message, data } = res.data;
if (success) {
if (isEdit) {
showSuccess(t('兑换码更新成功!'));
props.refresh();
props.handleClose();
} else {
showSuccess(t('兑换码创建成功!'));
props.refresh();
formApiRef.current?.setValues(getInitValues());
props.handleClose();
}
} else {
showError(message);
}
if (!isEdit && data) {
let text = '';
for (let i = 0; i < data.length; i++) {
text += data[i] + '\n';
}
Modal.confirm({
title: t('兑换码创建成功'),
content: (
<div>
<p>{t('兑换码创建成功,是否下载兑换码?')}</p>
<p>{t('兑换码将以文本文件的形式下载,文件名为兑换码的名称。')}</p>
</div>
),
onOk: () => {
downloadTextAsFile(text, `${localInputs.name}.txt`);
},
});
}
setLoading(false);
};
return (
<>
<SideSheet
placement={isEdit ? 'right' : 'left'}
title={
<Space>
{isEdit ? (
<Tag color='blue' shape='circle'>
{t('更新')}
</Tag>
) : (
<Tag color='green' shape='circle'>
{t('新建')}
</Tag>
)}
<Title heading={4} className='m-0'>
{isEdit ? t('更新兑换码信息') : t('创建新的兑换码')}
</Title>
</Space>
}
bodyStyle={{ padding: '0' }}
visible={props.visiable}
width={isMobile ? '100%' : 600}
footer={
<div className='flex justify-end bg-white'>
<Space>
<Button
theme='solid'
onClick={() => formApiRef.current?.submitForm()}
icon={<IconSave />}
loading={loading}
>
{t('提交')}
</Button>
<Button
theme='light'
type='primary'
onClick={handleCancel}
icon={<IconClose />}
>
{t('取消')}
</Button>
</Space>
</div>
}
closeIcon={null}
onCancel={() => handleCancel()}
>
<Spin spinning={loading}>
<Form
initValues={getInitValues()}
getFormApi={(api) => (formApiRef.current = api)}
onSubmit={submit}
>
{({ values }) => (
<div className='p-2'>
<Card className='!rounded-2xl shadow-sm border-0 mb-6'>
{/* Header: Basic Info */}
<div className='flex items-center mb-2'>
<Avatar
size='small'
color='blue'
className='mr-2 shadow-md'
>
<IconGift size={16} />
</Avatar>
<div>
<Text className='text-lg font-medium'>
{t('基本信息')}
</Text>
<div className='text-xs text-gray-600'>
{t('设置兑换码的基本信息')}
</div>
</div>
</div>
<Row gutter={12}>
<Col span={24}>
<Form.Input
field='name'
label={t('名称')}
placeholder={t('请输入名称')}
style={{ width: '100%' }}
rules={
!isEdit
? []
: [{ required: true, message: t('请输入名称') }]
}
showClear
/>
</Col>
<Col span={24}>
<Form.DatePicker
field='expired_time'
label={t('过期时间')}
type='dateTime'
placeholder={t('选择过期时间(可选,留空为永久)')}
style={{ width: '100%' }}
showClear
/>
</Col>
</Row>
</Card>
<Card className='!rounded-2xl shadow-sm border-0'>
{/* Header: Quota Settings */}
<div className='flex items-center mb-2'>
<Avatar
size='small'
color='green'
className='mr-2 shadow-md'
>
<IconCreditCard size={16} />
</Avatar>
<div>
<Text className='text-lg font-medium'>
{t('额度设置')}
</Text>
<div className='text-xs text-gray-600'>
{t('设置兑换码的额度和数量')}
</div>
</div>
</div>
<Row gutter={12}>
<Col span={24}>
<Form.InputNumber
field='amount'
label={t('金额')}
prefix={getCurrencyConfig().symbol}
placeholder={t('输入金额')}
precision={6}
min={0}
step={0.000001}
style={{ width: '100%' }}
onChange={(val) => {
const amount = val === '' || val == null ? 0 : val;
formApiRef.current?.setValue('amount', amount);
formApiRef.current?.setValue(
'quota',
displayAmountToQuota(amount),
);
}}
showClear
/>
<div
className='text-xs cursor-pointer mt-1'
style={{ color: 'var(--semi-color-text-2)' }}
onClick={() => setShowQuotaInput((v) => !v)}
>
{showQuotaInput
? `${t('收起原生额度输入')}`
: `${t('使用原生额度输入')}`}
</div>
<div style={{ display: showQuotaInput ? 'block' : 'none' }} className='mt-2'>
<Form.InputNumber
field='quota'
label={t('额度')}
placeholder={t('输入额度')}
rules={[
{ required: true, message: t('请输入额度') },
{
validator: (rule, v) => {
const num = parseInt(v, 10);
return num > 0
? Promise.resolve()
: Promise.reject(t('额度必须大于0'));
},
},
]}
onChange={(val) => {
const quota = val === '' || val == null ? 0 : val;
formApiRef.current?.setValue('quota', quota);
formApiRef.current?.setValue(
'amount',
Number(quotaToDisplayAmount(quota).toFixed(6)),
);
}}
style={{ width: '100%' }}
showClear
/>
</div>
</Col>
{!isEdit && (
<Col span={12}>
<Form.InputNumber
field='count'
label={t('生成数量')}
min={1}
rules={[
{ required: true, message: t('请输入生成数量') },
{
validator: (rule, v) => {
const num = parseInt(v, 10);
return num > 0
? Promise.resolve()
: Promise.reject(t('生成数量必须大于0'));
},
},
]}
style={{ width: '100%' }}
showClear
/>
</Col>
)}
</Row>
</Card>
</div>
)}
</Form>
</Spin>
</SideSheet>
</>
);
};
export default EditRedemptionModal;
@@ -0,0 +1,38 @@
/*
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 React from 'react';
import { Button } from '@douyinfe/semi-ui';
const SubscriptionsActions = ({ openCreate, t }) => {
return (
<div className='flex gap-2 w-full md:w-auto'>
<Button
type='primary'
className='w-full md:w-auto'
onClick={openCreate}
size='small'
>
{t('新建套餐')}
</Button>
</div>
);
};
export default SubscriptionsActions;
@@ -0,0 +1,357 @@
/*
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 React from 'react';
import {
Button,
Modal,
Space,
Tag,
Typography,
Popover,
Divider,
Badge,
Tooltip,
} from '@douyinfe/semi-ui';
import { renderQuota } from '../../../helpers';
import { convertUSDToCurrency } from '../../../helpers/render';
const { Text } = Typography;
function formatDuration(plan, t) {
if (!plan) return '';
const u = plan.duration_unit || 'month';
if (u === 'custom') {
return `${t('自定义')} ${plan.custom_seconds || 0}s`;
}
const unitMap = {
year: t('年'),
month: t('月'),
day: t('日'),
hour: t('小时'),
};
return `${plan.duration_value || 0}${unitMap[u] || u}`;
}
function formatResetPeriod(plan, t) {
const period = plan?.quota_reset_period || 'never';
if (period === 'daily') return t('每天');
if (period === 'weekly') return t('每周');
if (period === 'monthly') return t('每月');
if (period === 'custom') {
const seconds = Number(plan?.quota_reset_custom_seconds || 0);
if (seconds >= 86400) return `${Math.floor(seconds / 86400)} ${t('天')}`;
if (seconds >= 3600) return `${Math.floor(seconds / 3600)} ${t('小时')}`;
if (seconds >= 60) return `${Math.floor(seconds / 60)} ${t('分钟')}`;
return `${seconds} ${t('秒')}`;
}
return t('不重置');
}
const renderPlanTitle = (text, record, t) => {
const subtitle = record?.plan?.subtitle;
const plan = record?.plan;
const popoverContent = (
<div style={{ width: 260 }}>
<Text strong>{text}</Text>
{subtitle && (
<Text type='tertiary' style={{ display: 'block', marginTop: 4 }}>
{subtitle}
</Text>
)}
<Divider margin={12} />
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
<Text type='tertiary'>{t('价格')}</Text>
<Text strong style={{ color: 'var(--semi-color-success)' }}>
{convertUSDToCurrency(Number(plan?.price_amount || 0), 2)}
</Text>
<Text type='tertiary'>{t('总额度')}</Text>
{plan?.total_amount > 0 ? (
<Tooltip content={`${t('原生额度')}${plan.total_amount}`}>
<Text>{renderQuota(plan.total_amount)}</Text>
</Tooltip>
) : (
<Text>{t('不限')}</Text>
)}
<Text type='tertiary'>{t('升级分组')}</Text>
<Text>{plan?.upgrade_group ? plan.upgrade_group : t('不升级')}</Text>
<Text type='tertiary'>{t('购买上限')}</Text>
<Text>
{plan?.max_purchase_per_user > 0
? plan.max_purchase_per_user
: t('不限')}
</Text>
<Text type='tertiary'>{t('有效期')}</Text>
<Text>{formatDuration(plan, t)}</Text>
<Text type='tertiary'>{t('重置')}</Text>
<Text>{formatResetPeriod(plan, t)}</Text>
</div>
</div>
);
return (
<Popover content={popoverContent} position='rightTop' showArrow>
<div style={{ cursor: 'pointer', maxWidth: 180 }}>
<Text strong ellipsis={{ showTooltip: false }}>
{text}
</Text>
{subtitle && (
<Text
type='tertiary'
ellipsis={{ showTooltip: false }}
style={{ display: 'block' }}
>
{subtitle}
</Text>
)}
</div>
</Popover>
);
};
const renderPrice = (text) => {
return (
<Text strong style={{ color: 'var(--semi-color-success)' }}>
{convertUSDToCurrency(Number(text || 0), 2)}
</Text>
);
};
const renderPurchaseLimit = (text, record, t) => {
const limit = Number(record?.plan?.max_purchase_per_user || 0);
return (
<Text type={limit > 0 ? 'secondary' : 'tertiary'}>
{limit > 0 ? limit : t('不限')}
</Text>
);
};
const renderDuration = (text, record, t) => {
return <Text type='secondary'>{formatDuration(record?.plan, t)}</Text>;
};
const renderEnabled = (text, record, t) => {
return text ? (
<Tag
color='white'
shape='circle'
type='light'
prefixIcon={<Badge dot type='success' />}
>
{t('启用')}
</Tag>
) : (
<Tag
color='white'
shape='circle'
type='light'
prefixIcon={<Badge dot type='danger' />}
>
{t('禁用')}
</Tag>
);
};
const renderTotalAmount = (text, record, t) => {
const total = Number(record?.plan?.total_amount || 0);
return (
<Text type={total > 0 ? 'secondary' : 'tertiary'}>
{total > 0 ? (
<Tooltip content={`${t('原生额度')}${total}`}>
<span>{renderQuota(total)}</span>
</Tooltip>
) : (
t('不限')
)}
</Text>
);
};
const renderUpgradeGroup = (text, record, t) => {
const group = record?.plan?.upgrade_group || '';
return (
<Text type={group ? 'secondary' : 'tertiary'}>
{group ? group : t('不升级')}
</Text>
);
};
const renderResetPeriod = (text, record, t) => {
const period = record?.plan?.quota_reset_period || 'never';
const isNever = period === 'never';
return (
<Text type={isNever ? 'tertiary' : 'secondary'}>
{formatResetPeriod(record?.plan, t)}
</Text>
);
};
const renderPaymentConfig = (text, record, t, enableEpay) => {
const hasStripe = !!record?.plan?.stripe_price_id;
const hasCreem = !!record?.plan?.creem_product_id;
const hasEpay = !!enableEpay;
return (
<Space spacing={4}>
{hasStripe && (
<Tag color='violet' shape='circle'>
Stripe
</Tag>
)}
{hasCreem && (
<Tag color='cyan' shape='circle'>
Creem
</Tag>
)}
{hasEpay && (
<Tag color='light-green' shape='circle'>
{t('易支付')}
</Tag>
)}
</Space>
);
};
const renderOperations = (text, record, { openEdit, setPlanEnabled, t }) => {
const isEnabled = record?.plan?.enabled;
const handleToggle = () => {
if (isEnabled) {
Modal.confirm({
title: t('确认禁用'),
content: t('禁用后用户端不再展示,但历史订单不受影响。是否继续?'),
centered: true,
onOk: () => setPlanEnabled(record, false),
});
} else {
Modal.confirm({
title: t('确认启用'),
content: t('启用后套餐将在用户端展示。是否继续?'),
centered: true,
onOk: () => setPlanEnabled(record, true),
});
}
};
return (
<Space spacing={8}>
<Button
theme='light'
type='tertiary'
size='small'
onClick={() => openEdit(record)}
>
{t('编辑')}
</Button>
{isEnabled ? (
<Button theme='light' type='danger' size='small' onClick={handleToggle}>
{t('禁用')}
</Button>
) : (
<Button
theme='light'
type='primary'
size='small'
onClick={handleToggle}
>
{t('启用')}
</Button>
)}
</Space>
);
};
export const getSubscriptionsColumns = ({
t,
openEdit,
setPlanEnabled,
enableEpay,
}) => {
return [
{
title: 'ID',
dataIndex: ['plan', 'id'],
width: 60,
render: (text) => <Text type='tertiary'>#{text}</Text>,
},
{
title: t('套餐'),
dataIndex: ['plan', 'title'],
width: 200,
render: (text, record) => renderPlanTitle(text, record, t),
},
{
title: t('价格'),
dataIndex: ['plan', 'price_amount'],
width: 100,
render: (text) => renderPrice(text),
},
{
title: t('购买上限'),
width: 90,
render: (text, record) => renderPurchaseLimit(text, record, t),
},
{
title: t('优先级'),
dataIndex: ['plan', 'sort_order'],
width: 80,
render: (text) => <Text type='tertiary'>{Number(text || 0)}</Text>,
},
{
title: t('有效期'),
width: 100,
render: (text, record) => renderDuration(text, record, t),
},
{
title: t('重置'),
width: 80,
render: (text, record) => renderResetPeriod(text, record, t),
},
{
title: t('状态'),
dataIndex: ['plan', 'enabled'],
width: 80,
render: (text, record) => renderEnabled(text, record, t),
},
{
title: t('支付渠道'),
width: 180,
render: (text, record) =>
renderPaymentConfig(text, record, t, enableEpay),
},
{
title: t('总额度'),
width: 100,
render: (text, record) => renderTotalAmount(text, record, t),
},
{
title: t('升级分组'),
width: 100,
render: (text, record) => renderUpgradeGroup(text, record, t),
},
{
title: t('操作'),
dataIndex: 'operate',
fixed: 'right',
width: 160,
render: (text, record) =>
renderOperations(text, record, { openEdit, setPlanEnabled, t }),
},
];
};
@@ -0,0 +1,44 @@
/*
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 React from 'react';
import { Typography } from '@douyinfe/semi-ui';
import { CalendarClock } from 'lucide-react';
import CompactModeToggle from '../../common/ui/CompactModeToggle';
const { Text } = Typography;
const SubscriptionsDescription = ({ compactMode, setCompactMode, t }) => {
return (
<div className='flex flex-col md:flex-row justify-between items-start md:items-center gap-2 w-full'>
<div className='flex items-center text-blue-500'>
<CalendarClock size={16} className='mr-2' />
<Text>{t('订阅管理')}</Text>
</div>
<CompactModeToggle
compactMode={compactMode}
setCompactMode={setCompactMode}
t={t}
/>
</div>
);
};
export default SubscriptionsDescription;
@@ -0,0 +1,86 @@
/*
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 React, { useMemo } from 'react';
import { Empty } from '@douyinfe/semi-ui';
import CardTable from '../../common/ui/CardTable';
import {
IllustrationNoResult,
IllustrationNoResultDark,
} from '@douyinfe/semi-illustrations';
import { getSubscriptionsColumns } from './SubscriptionsColumnDefs';
const SubscriptionsTable = (subscriptionsData) => {
const {
plans,
loading,
compactMode,
openEdit,
setPlanEnabled,
t,
enableEpay,
} = subscriptionsData;
const columns = useMemo(() => {
return getSubscriptionsColumns({
t,
openEdit,
setPlanEnabled,
enableEpay,
});
}, [t, openEdit, setPlanEnabled, enableEpay]);
const tableColumns = useMemo(() => {
return compactMode
? columns.map((col) => {
if (col.dataIndex === 'operate') {
const { fixed, ...rest } = col;
return rest;
}
return col;
})
: columns;
}, [compactMode, columns]);
return (
<CardTable
columns={tableColumns}
dataSource={plans}
scroll={compactMode ? undefined : { x: 'max-content' }}
pagination={false}
hidePagination={true}
loading={loading}
rowKey={(row) => row?.plan?.id}
empty={
<Empty
image={<IllustrationNoResult style={{ width: 150, height: 150 }} />}
darkModeImage={
<IllustrationNoResultDark style={{ width: 150, height: 150 }} />
}
description={t('暂无订阅套餐')}
style={{ padding: 30 }}
/>
}
className='overflow-hidden'
size='middle'
/>
);
};
export default SubscriptionsTable;
+103
View File
@@ -0,0 +1,103 @@
/*
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 React, { useContext } from 'react';
import { Banner } from '@douyinfe/semi-ui';
import CardPro from '../../common/ui/CardPro';
import SubscriptionsTable from './SubscriptionsTable';
import SubscriptionsActions from './SubscriptionsActions';
import SubscriptionsDescription from './SubscriptionsDescription';
import AddEditSubscriptionModal from './modals/AddEditSubscriptionModal';
import { useSubscriptionsData } from '../../../hooks/subscriptions/useSubscriptionsData';
import { useIsMobile } from '../../../hooks/common/useIsMobile';
import { createCardProPagination } from '../../../helpers/utils';
import { StatusContext } from '../../../context/Status';
const SubscriptionsPage = () => {
const subscriptionsData = useSubscriptionsData();
const isMobile = useIsMobile();
const [statusState] = useContext(StatusContext);
const enableEpay = !!statusState?.status?.enable_online_topup;
const {
showEdit,
editingPlan,
sheetPlacement,
closeEdit,
refresh,
openCreate,
compactMode,
setCompactMode,
t,
} = subscriptionsData;
return (
<>
<AddEditSubscriptionModal
visible={showEdit}
handleClose={closeEdit}
editingPlan={editingPlan}
placement={sheetPlacement}
refresh={refresh}
t={t}
/>
<CardPro
type='type1'
descriptionArea={
<SubscriptionsDescription
compactMode={compactMode}
setCompactMode={setCompactMode}
t={t}
/>
}
actionsArea={
<div className='flex flex-col md:flex-row justify-between items-start md:items-center gap-2 w-full'>
{/* Mobile: actions first; Desktop: actions left */}
<div className='order-1 md:order-0 w-full md:w-auto'>
<SubscriptionsActions openCreate={openCreate} t={t} />
</div>
<Banner
type='info'
description={t('Stripe/Creem 需在第三方平台创建商品并填入 ID')}
closeIcon={null}
// Mobile: banner below; Desktop: banner right
className='!rounded-lg order-2 md:order-1'
style={{ maxWidth: '100%' }}
/>
</div>
}
paginationArea={createCardProPagination({
currentPage: subscriptionsData.activePage,
pageSize: subscriptionsData.pageSize,
total: subscriptionsData.planCount,
onPageChange: subscriptionsData.handlePageChange,
onPageSizeChange: subscriptionsData.handlePageSizeChange,
isMobile,
t: subscriptionsData.t,
})}
t={t}
>
<SubscriptionsTable {...subscriptionsData} enableEpay={enableEpay} />
</CardPro>
</>
);
};
export default SubscriptionsPage;
@@ -0,0 +1,553 @@
/*
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 React, { useEffect, useState, useRef } from 'react';
import {
Avatar,
Button,
Card,
Col,
Form,
Row,
Select,
SideSheet,
Space,
Spin,
Tag,
Typography,
} from '@douyinfe/semi-ui';
import {
IconCalendarClock,
IconClose,
IconCreditCard,
IconSave,
} from '@douyinfe/semi-icons';
import { Clock, RefreshCw } from 'lucide-react';
import { API, showError, showSuccess } from '../../../../helpers';
import {
quotaToDisplayAmount,
displayAmountToQuota,
} from '../../../../helpers/quota';
import { useIsMobile } from '../../../../hooks/common/useIsMobile';
const { Text, Title } = Typography;
const durationUnitOptions = [
{ value: 'year', label: '年' },
{ value: 'month', label: '月' },
{ value: 'day', label: '日' },
{ value: 'hour', label: '小时' },
{ value: 'custom', label: '自定义(秒)' },
];
const resetPeriodOptions = [
{ value: 'never', label: '不重置' },
{ value: 'daily', label: '每天' },
{ value: 'weekly', label: '每周' },
{ value: 'monthly', label: '每月' },
{ value: 'custom', label: '自定义(秒)' },
];
const AddEditSubscriptionModal = ({
visible,
handleClose,
editingPlan,
placement = 'left',
refresh,
t,
}) => {
const [loading, setLoading] = useState(false);
const [groupOptions, setGroupOptions] = useState([]);
const [groupLoading, setGroupLoading] = useState(false);
const isMobile = useIsMobile();
const formApiRef = useRef(null);
const isEdit = editingPlan?.plan?.id !== undefined;
const formKey = isEdit ? `edit-${editingPlan?.plan?.id}` : 'create';
const getInitValues = () => ({
title: '',
subtitle: '',
price_amount: 0,
currency: 'USD',
duration_unit: 'month',
duration_value: 1,
custom_seconds: 0,
quota_reset_period: 'never',
quota_reset_custom_seconds: 0,
enabled: true,
sort_order: 0,
max_purchase_per_user: 0,
total_amount: 0,
upgrade_group: '',
stripe_price_id: '',
creem_product_id: '',
});
const buildFormValues = () => {
const base = getInitValues();
if (editingPlan?.plan?.id === undefined) return base;
const p = editingPlan.plan || {};
return {
...base,
title: p.title || '',
subtitle: p.subtitle || '',
price_amount: Number(p.price_amount || 0),
currency: 'USD',
duration_unit: p.duration_unit || 'month',
duration_value: Number(p.duration_value || 1),
custom_seconds: Number(p.custom_seconds || 0),
quota_reset_period: p.quota_reset_period || 'never',
quota_reset_custom_seconds: Number(p.quota_reset_custom_seconds || 0),
enabled: p.enabled !== false,
sort_order: Number(p.sort_order || 0),
max_purchase_per_user: Number(p.max_purchase_per_user || 0),
total_amount: Number(
quotaToDisplayAmount(p.total_amount || 0).toFixed(2),
),
upgrade_group: p.upgrade_group || '',
stripe_price_id: p.stripe_price_id || '',
creem_product_id: p.creem_product_id || '',
};
};
useEffect(() => {
if (!visible) return;
setGroupLoading(true);
API.get('/api/group')
.then((res) => {
if (res.data?.success) {
setGroupOptions(res.data?.data || []);
} else {
setGroupOptions([]);
}
})
.catch(() => setGroupOptions([]))
.finally(() => setGroupLoading(false));
}, [visible]);
const submit = async (values) => {
if (!values.title || values.title.trim() === '') {
showError(t('套餐标题不能为空'));
return;
}
setLoading(true);
try {
const payload = {
plan: {
...values,
price_amount: Number(values.price_amount || 0),
currency: 'USD',
duration_value: Number(values.duration_value || 0),
custom_seconds: Number(values.custom_seconds || 0),
quota_reset_period: values.quota_reset_period || 'never',
quota_reset_custom_seconds:
values.quota_reset_period === 'custom'
? Number(values.quota_reset_custom_seconds || 0)
: 0,
sort_order: Number(values.sort_order || 0),
max_purchase_per_user: Number(values.max_purchase_per_user || 0),
total_amount: displayAmountToQuota(values.total_amount),
upgrade_group: values.upgrade_group || '',
},
};
if (editingPlan?.plan?.id) {
const res = await API.put(
`/api/subscription/admin/plans/${editingPlan.plan.id}`,
payload,
);
if (res.data?.success) {
showSuccess(t('更新成功'));
handleClose();
refresh?.();
} else {
showError(res.data?.message || t('更新失败'));
}
} else {
const res = await API.post('/api/subscription/admin/plans', payload);
if (res.data?.success) {
showSuccess(t('创建成功'));
handleClose();
refresh?.();
} else {
showError(res.data?.message || t('创建失败'));
}
}
} catch (e) {
showError(t('请求失败'));
} finally {
setLoading(false);
}
};
return (
<>
<SideSheet
placement={placement}
title={
<Space>
{isEdit ? (
<Tag color='blue' shape='circle'>
{t('更新')}
</Tag>
) : (
<Tag color='green' shape='circle'>
{t('新建')}
</Tag>
)}
<Title heading={4} className='m-0'>
{isEdit ? t('更新套餐信息') : t('创建新的订阅套餐')}
</Title>
</Space>
}
bodyStyle={{ padding: '0' }}
visible={visible}
width={isMobile ? '100%' : 600}
footer={
<div className='flex justify-end bg-white'>
<Space>
<Button
theme='solid'
onClick={() => formApiRef.current?.submitForm()}
icon={<IconSave />}
loading={loading}
>
{t('提交')}
</Button>
<Button
theme='light'
type='primary'
onClick={handleClose}
icon={<IconClose />}
>
{t('取消')}
</Button>
</Space>
</div>
}
closeIcon={null}
onCancel={handleClose}
>
<Spin spinning={loading}>
<Form
key={formKey}
initValues={buildFormValues()}
getFormApi={(api) => (formApiRef.current = api)}
onSubmit={submit}
>
{({ values }) => (
<div className='p-2'>
{/* 基本信息 */}
<Card className='!rounded-2xl shadow-sm border-0 mb-4'>
<div className='flex items-center mb-2'>
<Avatar
size='small'
color='blue'
className='mr-2 shadow-md'
>
<IconCalendarClock size={16} />
</Avatar>
<div>
<Text className='text-lg font-medium'>
{t('基本信息')}
</Text>
<div className='text-xs text-gray-600'>
{t('套餐的基本信息和定价')}
</div>
</div>
</div>
<Row gutter={12}>
<Col span={24}>
<Form.Input
field='title'
label={t('套餐标题')}
placeholder={t('例如:基础套餐')}
required
rules={[
{ required: true, message: t('请输入套餐标题') },
]}
showClear
/>
</Col>
<Col span={24}>
<Form.Input
field='subtitle'
label={t('套餐副标题')}
placeholder={t('例如:适合轻度使用')}
showClear
/>
</Col>
<Col span={12}>
<Form.InputNumber
field='price_amount'
label={t('实付金额')}
required
min={0}
precision={2}
rules={[{ required: true, message: t('请输入金额') }]}
style={{ width: '100%' }}
/>
</Col>
<Col span={12}>
<Form.InputNumber
field='total_amount'
label={t('总额度')}
required
min={0}
precision={2}
rules={[{ required: true, message: t('请输入总额度') }]}
extraText={`${t('0 表示不限')} · ${t('原生额度')}${displayAmountToQuota(
values.total_amount,
)}`}
style={{ width: '100%' }}
/>
</Col>
<Col span={12}>
<Form.Select
field='upgrade_group'
label={t('升级分组')}
showClear
loading={groupLoading}
placeholder={t('不升级')}
extraText={t(
'购买或手动新增订阅会升级到该分组;当套餐失效/过期或手动作废/删除后,将回退到升级前分组。回退不会立即生效,通常会有几分钟延迟。',
)}
>
<Select.Option value=''>{t('不升级')}</Select.Option>
{(groupOptions || []).map((g) => (
<Select.Option key={g} value={g}>
{g}
</Select.Option>
))}
</Form.Select>
</Col>
<Col span={12}>
<Form.Input
field='currency'
label={t('币种')}
disabled
extraText={t('由全站货币展示设置统一控制')}
/>
</Col>
<Col span={12}>
<Form.InputNumber
field='sort_order'
label={t('排序')}
precision={0}
style={{ width: '100%' }}
/>
</Col>
<Col span={12}>
<Form.InputNumber
field='max_purchase_per_user'
label={t('购买上限')}
min={0}
precision={0}
extraText={t('0 表示不限')}
style={{ width: '100%' }}
/>
</Col>
<Col span={12}>
<Form.Switch
field='enabled'
label={t('启用状态')}
size='large'
/>
</Col>
</Row>
</Card>
{/* 有效期设置 */}
<Card className='!rounded-2xl shadow-sm border-0 mb-4'>
<div className='flex items-center mb-2'>
<Avatar
size='small'
color='green'
className='mr-2 shadow-md'
>
<Clock size={16} />
</Avatar>
<div>
<Text className='text-lg font-medium'>
{t('有效期设置')}
</Text>
<div className='text-xs text-gray-600'>
{t('配置套餐的有效时长')}
</div>
</div>
</div>
<Row gutter={12}>
<Col span={12}>
<Form.Select
field='duration_unit'
label={t('有效期单位')}
required
rules={[{ required: true }]}
>
{durationUnitOptions.map((o) => (
<Select.Option key={o.value} value={o.value}>
{o.label}
</Select.Option>
))}
</Form.Select>
</Col>
<Col span={12}>
{values.duration_unit === 'custom' ? (
<Form.InputNumber
field='custom_seconds'
label={t('自定义秒数')}
required
min={1}
precision={0}
rules={[{ required: true, message: t('请输入秒数') }]}
style={{ width: '100%' }}
/>
) : (
<Form.InputNumber
field='duration_value'
label={t('有效期数值')}
required
min={1}
precision={0}
rules={[{ required: true, message: t('请输入数值') }]}
style={{ width: '100%' }}
/>
)}
</Col>
</Row>
</Card>
{/* 额度重置 */}
<Card className='!rounded-2xl shadow-sm border-0 mb-4'>
<div className='flex items-center mb-2'>
<Avatar
size='small'
color='orange'
className='mr-2 shadow-md'
>
<RefreshCw size={16} />
</Avatar>
<div>
<Text className='text-lg font-medium'>
{t('额度重置')}
</Text>
<div className='text-xs text-gray-600'>
{t('支持周期性重置套餐权益额度')}
</div>
</div>
</div>
<Row gutter={12}>
<Col span={12}>
<Form.Select
field='quota_reset_period'
label={t('重置周期')}
>
{resetPeriodOptions.map((o) => (
<Select.Option key={o.value} value={o.value}>
{o.label}
</Select.Option>
))}
</Form.Select>
</Col>
<Col span={12}>
{values.quota_reset_period === 'custom' ? (
<Form.InputNumber
field='quota_reset_custom_seconds'
label={t('自定义秒数')}
required
min={60}
precision={0}
rules={[{ required: true, message: t('请输入秒数') }]}
style={{ width: '100%' }}
/>
) : (
<Form.InputNumber
field='quota_reset_custom_seconds'
label={t('自定义秒数')}
min={0}
precision={0}
style={{ width: '100%' }}
disabled
/>
)}
</Col>
</Row>
</Card>
{/* 第三方支付配置 */}
<Card className='!rounded-2xl shadow-sm border-0 mb-4'>
<div className='flex items-center mb-2'>
<Avatar
size='small'
color='purple'
className='mr-2 shadow-md'
>
<IconCreditCard size={16} />
</Avatar>
<div>
<Text className='text-lg font-medium'>
{t('第三方支付配置')}
</Text>
<div className='text-xs text-gray-600'>
{t('Stripe/Creem 商品ID(可选)')}
</div>
</div>
</div>
<Row gutter={12}>
<Col span={24}>
<Form.Input
field='stripe_price_id'
label='Stripe PriceId'
placeholder='price_...'
showClear
/>
</Col>
<Col span={24}>
<Form.Input
field='creem_product_id'
label='Creem ProductId'
placeholder='prod_...'
showClear
/>
</Col>
</Row>
</Card>
</div>
)}
</Form>
</Spin>
</SideSheet>
</>
);
};
export default AddEditSubscriptionModal;
@@ -0,0 +1,43 @@
/*
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 React from 'react';
import { Typography } from '@douyinfe/semi-ui';
import { IconEyeOpened } from '@douyinfe/semi-icons';
import CompactModeToggle from '../../common/ui/CompactModeToggle';
const { Text } = Typography;
const TaskLogsActions = ({ compactMode, setCompactMode, t }) => {
return (
<div className='flex flex-col md:flex-row justify-between items-start md:items-center gap-2 w-full'>
<div className='flex items-center text-orange-500 mb-2 md:mb-0'>
<IconEyeOpened className='mr-2' />
<Text>{t('任务记录')}</Text>
</div>
<CompactModeToggle
compactMode={compactMode}
setCompactMode={setCompactMode}
t={t}
/>
</div>
);
};
export default TaskLogsActions;
@@ -0,0 +1,450 @@
/*
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 React from 'react';
import { Progress, Tag, Tooltip, Typography } from '@douyinfe/semi-ui';
import {
Music,
FileText,
HelpCircle,
CheckCircle,
Pause,
Clock,
Play,
XCircle,
Loader,
List,
Hash,
Video,
Sparkles,
} from 'lucide-react';
import {
TASK_ACTION_FIRST_TAIL_GENERATE,
TASK_ACTION_GENERATE,
TASK_ACTION_REFERENCE_GENERATE,
TASK_ACTION_TEXT_GENERATE,
TASK_ACTION_REMIX_GENERATE,
} from '../../../constants/common.constant';
import { CHANNEL_OPTIONS } from '../../../constants/channel.constants';
import { stringToColor } from '../../../helpers/render';
import { Avatar, Space } from '@douyinfe/semi-ui';
const colors = [
'amber',
'blue',
'cyan',
'green',
'grey',
'indigo',
'light-blue',
'lime',
'orange',
'pink',
'purple',
'red',
'teal',
'violet',
'yellow',
];
// Render functions
const renderTimestamp = (timestampInSeconds) => {
const date = new Date(timestampInSeconds * 1000); // 从秒转换为毫秒
const year = date.getFullYear(); // 获取年份
const month = ('0' + (date.getMonth() + 1)).slice(-2); // 获取月份,从0开始需要+1,并保证两位数
const day = ('0' + date.getDate()).slice(-2); // 获取日期,并保证两位数
const hours = ('0' + date.getHours()).slice(-2); // 获取小时,并保证两位数
const minutes = ('0' + date.getMinutes()).slice(-2); // 获取分钟,并保证两位数
const seconds = ('0' + date.getSeconds()).slice(-2); // 获取秒钟,并保证两位数
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`; // 格式化输出
};
function renderDuration(submit_time, finishTime) {
if (!submit_time || !finishTime) return 'N/A';
const durationSec = finishTime - submit_time;
const color = durationSec > 60 ? 'red' : 'green';
// 返回带有样式的颜色标签
return (
<Tag color={color} shape='circle'>
{durationSec} s
</Tag>
);
}
const renderType = (type, t) => {
switch (type) {
case 'MUSIC':
return (
<Tag color='grey' shape='circle' prefixIcon={<Music size={14} />}>
{t('生成音乐')}
</Tag>
);
case 'LYRICS':
return (
<Tag color='pink' shape='circle' prefixIcon={<FileText size={14} />}>
{t('生成歌词')}
</Tag>
);
case TASK_ACTION_GENERATE:
return (
<Tag color='blue' shape='circle' prefixIcon={<Sparkles size={14} />}>
{t('图生视频')}
</Tag>
);
case TASK_ACTION_TEXT_GENERATE:
return (
<Tag color='blue' shape='circle' prefixIcon={<Sparkles size={14} />}>
{t('文生视频')}
</Tag>
);
case TASK_ACTION_FIRST_TAIL_GENERATE:
return (
<Tag color='blue' shape='circle' prefixIcon={<Sparkles size={14} />}>
{t('首尾生视频')}
</Tag>
);
case TASK_ACTION_REFERENCE_GENERATE:
return (
<Tag color='blue' shape='circle' prefixIcon={<Sparkles size={14} />}>
{t('参照生视频')}
</Tag>
);
case TASK_ACTION_REMIX_GENERATE:
return (
<Tag color='blue' shape='circle' prefixIcon={<Sparkles size={14} />}>
{t('视频Remix')}
</Tag>
);
default:
return (
<Tag color='white' shape='circle' prefixIcon={<HelpCircle size={14} />}>
{t('未知')}
</Tag>
);
}
};
const renderPlatform = (platform, t) => {
let option = CHANNEL_OPTIONS.find(
(opt) => String(opt.value) === String(platform),
);
if (option) {
return (
<Tag color={option.color} shape='circle'>
{option.label}
</Tag>
);
}
switch (platform) {
case 'suno':
return (
<Tag color='green' shape='circle'>
Suno
</Tag>
);
default:
return (
<Tag color='white' shape='circle'>
{t('未知')}
</Tag>
);
}
};
const renderStatus = (type, t) => {
switch (type) {
case 'SUCCESS':
return (
<Tag
color='green'
shape='circle'
prefixIcon={<CheckCircle size={14} />}
>
{t('成功')}
</Tag>
);
case 'NOT_START':
return (
<Tag color='grey' shape='circle' prefixIcon={<Pause size={14} />}>
{t('未启动')}
</Tag>
);
case 'SUBMITTED':
return (
<Tag color='yellow' shape='circle' prefixIcon={<Clock size={14} />}>
{t('队列中')}
</Tag>
);
case 'IN_PROGRESS':
return (
<Tag color='blue' shape='circle' prefixIcon={<Play size={14} />}>
{t('执行中')}
</Tag>
);
case 'FAILURE':
return (
<Tag color='red' shape='circle' prefixIcon={<XCircle size={14} />}>
{t('失败')}
</Tag>
);
case 'QUEUED':
return (
<Tag color='orange' shape='circle' prefixIcon={<List size={14} />}>
{t('排队中')}
</Tag>
);
case 'UNKNOWN':
return (
<Tag color='white' shape='circle' prefixIcon={<HelpCircle size={14} />}>
{t('未知')}
</Tag>
);
case '':
return (
<Tag color='grey' shape='circle' prefixIcon={<Loader size={14} />}>
{t('正在提交')}
</Tag>
);
default:
return (
<Tag color='white' shape='circle' prefixIcon={<HelpCircle size={14} />}>
{t('未知')}
</Tag>
);
}
};
export const getTaskLogsColumns = ({
t,
COLUMN_KEYS,
copyText,
openContentModal,
isAdminUser,
openVideoModal,
openAudioModal,
}) => {
return [
{
key: COLUMN_KEYS.SUBMIT_TIME,
title: t('提交时间'),
dataIndex: 'submit_time',
render: (text, record, index) => {
return <div>{text ? renderTimestamp(text) : '-'}</div>;
},
},
{
key: COLUMN_KEYS.FINISH_TIME,
title: t('结束时间'),
dataIndex: 'finish_time',
render: (text, record, index) => {
return <div>{text ? renderTimestamp(text) : '-'}</div>;
},
},
{
key: COLUMN_KEYS.DURATION,
title: t('花费时间'),
dataIndex: 'finish_time',
render: (finish, record) => {
return <>{finish ? renderDuration(record.submit_time, finish) : '-'}</>;
},
},
{
key: COLUMN_KEYS.CHANNEL,
title: t('渠道'),
dataIndex: 'channel_id',
render: (text, record, index) => {
return isAdminUser ? (
<div>
<Tag
color={colors[parseInt(text) % colors.length]}
size='large'
shape='circle'
onClick={() => {
copyText(text);
}}
>
{text}
</Tag>
</div>
) : (
<></>
);
},
},
{
key: COLUMN_KEYS.USERNAME,
title: t('用户'),
dataIndex: 'username',
render: (userId, record, index) => {
if (!isAdminUser) {
return <></>;
}
const displayText = String(record.username || userId || '?');
return (
<Space>
<Avatar
size='extra-small'
color={stringToColor(displayText)}
>
{displayText.slice(0, 1)}
</Avatar>
<Typography.Text>
{displayText}
</Typography.Text>
</Space>
);
},
},
{
key: COLUMN_KEYS.PLATFORM,
title: t('平台'),
dataIndex: 'platform',
render: (text, record, index) => {
return <div>{renderPlatform(text, t)}</div>;
},
},
{
key: COLUMN_KEYS.TYPE,
title: t('类型'),
dataIndex: 'action',
render: (text, record, index) => {
return <div>{renderType(text, t)}</div>;
},
},
{
key: COLUMN_KEYS.TASK_ID,
title: t('任务ID'),
dataIndex: 'task_id',
render: (text, record, index) => {
return (
<Typography.Text
ellipsis={{ showTooltip: true }}
onClick={() => {
openContentModal(JSON.stringify(record, null, 2));
}}
>
<div>{text}</div>
</Typography.Text>
);
},
},
{
key: COLUMN_KEYS.TASK_STATUS,
title: t('任务状态'),
dataIndex: 'status',
render: (text, record, index) => {
return <div>{renderStatus(text, t)}</div>;
},
},
{
key: COLUMN_KEYS.PROGRESS,
title: t('进度'),
dataIndex: 'progress',
render: (text, record, index) => {
return (
<div>
{isNaN(text?.replace('%', '')) ? (
text || '-'
) : (
<Progress
stroke={
record.status === 'FAILURE'
? 'var(--semi-color-warning)'
: null
}
percent={text ? parseInt(text.replace('%', '')) : 0}
showInfo={true}
aria-label='task progress'
style={{ minWidth: '160px' }}
/>
)}
</div>
);
},
},
{
key: COLUMN_KEYS.FAIL_REASON,
title: t('详情'),
dataIndex: 'fail_reason',
fixed: 'right',
render: (text, record, index) => {
// Suno audio preview
const isSunoSuccess =
record.platform === 'suno' &&
record.status === 'SUCCESS' &&
Array.isArray(record.data) &&
record.data.some((c) => c.audio_url);
if (isSunoSuccess) {
return (
<a
href='#'
onClick={(e) => {
e.preventDefault();
openAudioModal(record.data);
}}
>
{t('点击预览音乐')}
</a>
);
}
// 视频预览:优先使用 result_url,兼容旧数据 fail_reason 中的 URL
const isVideoTask =
record.action === TASK_ACTION_GENERATE ||
record.action === TASK_ACTION_TEXT_GENERATE ||
record.action === TASK_ACTION_FIRST_TAIL_GENERATE ||
record.action === TASK_ACTION_REFERENCE_GENERATE ||
record.action === TASK_ACTION_REMIX_GENERATE;
const isSuccess = record.status === 'SUCCESS';
const resultUrl = record.result_url;
const hasResultUrl = typeof resultUrl === 'string' && /^https?:\/\//.test(resultUrl);
if (isSuccess && isVideoTask && hasResultUrl) {
return (
<a
href='#'
onClick={(e) => {
e.preventDefault();
openVideoModal(resultUrl);
}}
>
{t('点击预览视频')}
</a>
);
}
if (!text) {
return t('无');
}
return (
<Typography.Text
ellipsis={{ showTooltip: true }}
style={{ width: 100 }}
onClick={() => {
openContentModal(text);
}}
>
{text}
</Typography.Text>
);
},
},
];
};

Some files were not shown because too many files have changed in this diff Show More