🚀 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 of9f8a4ec05) - 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 ofbee339d27) - 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 fix4e93148d9- 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:
co-authored by
Seefs
Seefs
feitianbubu
Calcium-Ion
Xyfacai
xiangsx
郑伯涛
RedwindA
dean
QuentinHsu
Bliod
Apple\Apple <zeraturing@foxmail.com>
parent
9f8a4ec050
commit
a42b397607
+173
@@ -0,0 +1,173 @@
|
||||
/*
|
||||
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 { API, showError } from '../../helpers';
|
||||
import { marked } from 'marked';
|
||||
import { Empty } from '@douyinfe/semi-ui';
|
||||
import {
|
||||
IllustrationConstruction,
|
||||
IllustrationConstructionDark,
|
||||
} from '@douyinfe/semi-illustrations';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const About = () => {
|
||||
const { t } = useTranslation();
|
||||
const [about, setAbout] = useState('');
|
||||
const [aboutLoaded, setAboutLoaded] = useState(false);
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
||||
const displayAbout = async () => {
|
||||
setAbout(localStorage.getItem('about') || '');
|
||||
const res = await API.get('/api/about');
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
let aboutContent = data;
|
||||
if (!data.startsWith('https://')) {
|
||||
aboutContent = marked.parse(data);
|
||||
}
|
||||
setAbout(aboutContent);
|
||||
localStorage.setItem('about', aboutContent);
|
||||
} else {
|
||||
showError(message);
|
||||
setAbout(t('加载关于内容失败...'));
|
||||
}
|
||||
setAboutLoaded(true);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
displayAbout().then();
|
||||
}, []);
|
||||
|
||||
const emptyStyle = {
|
||||
padding: '24px',
|
||||
};
|
||||
|
||||
const customDescription = (
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<p>{t('可在设置页面设置关于内容,支持 HTML & Markdown')}</p>
|
||||
{t('New API项目仓库地址:')}
|
||||
<a
|
||||
href='https://github.com/QuantumNous/new-api'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='!text-semi-color-primary'
|
||||
>
|
||||
https://github.com/QuantumNous/new-api
|
||||
</a>
|
||||
<p>
|
||||
<a
|
||||
href='https://github.com/QuantumNous/new-api'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='!text-semi-color-primary'
|
||||
>
|
||||
NewAPI
|
||||
</a>{' '}
|
||||
{t('© {{currentYear}}', { currentYear })}{' '}
|
||||
<a
|
||||
href='https://github.com/QuantumNous'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='!text-semi-color-primary'
|
||||
>
|
||||
QuantumNous
|
||||
</a>{' '}
|
||||
{t('| 基于')}{' '}
|
||||
<a
|
||||
href='https://github.com/songquanpeng/one-api/releases/tag/v0.5.4'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='!text-semi-color-primary'
|
||||
>
|
||||
One API v0.5.4
|
||||
</a>{' '}
|
||||
© 2023{' '}
|
||||
<a
|
||||
href='https://github.com/songquanpeng'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='!text-semi-color-primary'
|
||||
>
|
||||
JustSong
|
||||
</a>
|
||||
</p>
|
||||
<p>
|
||||
{t('本项目根据')}
|
||||
<a
|
||||
href='https://github.com/songquanpeng/one-api/blob/v0.5.4/LICENSE'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='!text-semi-color-primary'
|
||||
>
|
||||
{t('MIT许可证')}
|
||||
</a>
|
||||
{t('授权,需在遵守')}
|
||||
<a
|
||||
href='https://www.gnu.org/licenses/agpl-3.0.html'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='!text-semi-color-primary'
|
||||
>
|
||||
{t('AGPL v3.0协议')}
|
||||
</a>
|
||||
{t('的前提下使用。')}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className='mt-[60px] px-2'>
|
||||
{aboutLoaded && about === '' ? (
|
||||
<div className='flex justify-center items-center h-screen p-8'>
|
||||
<Empty
|
||||
image={
|
||||
<IllustrationConstruction style={{ width: 150, height: 150 }} />
|
||||
}
|
||||
darkModeImage={
|
||||
<IllustrationConstructionDark
|
||||
style={{ width: 150, height: 150 }}
|
||||
/>
|
||||
}
|
||||
description={t('管理员暂时未设置任何关于内容')}
|
||||
style={emptyStyle}
|
||||
>
|
||||
{customDescription}
|
||||
</Empty>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{about.startsWith('https://') ? (
|
||||
<iframe
|
||||
src={about}
|
||||
style={{ width: '100%', height: '100vh', border: 'none' }}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
style={{ fontSize: 'larger' }}
|
||||
dangerouslySetInnerHTML={{ __html: about }}
|
||||
></div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default About;
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
/*
|
||||
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 ChannelsTable from '../../components/table/channels';
|
||||
|
||||
const File = () => {
|
||||
return (
|
||||
<div className='mt-[60px] px-2'>
|
||||
<ChannelsTable />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default File;
|
||||
Vendored
+83
@@ -0,0 +1,83 @@
|
||||
/*
|
||||
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 { useTokenKeys } from '../../hooks/chat/useTokenKeys';
|
||||
import { Spin } from '@douyinfe/semi-ui';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const ChatPage = () => {
|
||||
const { t } = useTranslation();
|
||||
const { id } = useParams();
|
||||
const { keys, serverAddress, isLoading } = useTokenKeys(id);
|
||||
|
||||
const comLink = (key) => {
|
||||
// console.log('chatLink:', chatLink);
|
||||
if (!serverAddress || !key) return '';
|
||||
let link = '';
|
||||
if (id) {
|
||||
let chats = localStorage.getItem('chats');
|
||||
if (chats) {
|
||||
chats = JSON.parse(chats);
|
||||
if (Array.isArray(chats) && chats.length > 0) {
|
||||
for (let k in chats[id]) {
|
||||
link = chats[id][k];
|
||||
link = link.replaceAll(
|
||||
'{address}',
|
||||
encodeURIComponent(serverAddress),
|
||||
);
|
||||
link = link.replaceAll('{key}', 'sk-' + key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return link;
|
||||
};
|
||||
|
||||
const iframeSrc = keys.length > 0 ? comLink(keys[0]) : '';
|
||||
|
||||
return !isLoading && iframeSrc ? (
|
||||
<iframe
|
||||
src={iframeSrc}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: 'calc(100vh - 64px)',
|
||||
border: 'none',
|
||||
marginTop: '64px',
|
||||
}}
|
||||
title='Token Frame'
|
||||
allow='camera;microphone'
|
||||
/>
|
||||
) : (
|
||||
<div className='fixed inset-0 w-screen h-screen flex items-center justify-center bg-white/80 z-[1000] mt-[60px]'>
|
||||
<div className='flex flex-col items-center'>
|
||||
<Spin size='large' spinning={true} tip={null} />
|
||||
<span
|
||||
className='whitespace-nowrap mt-2 text-center'
|
||||
style={{ color: 'var(--semi-color-primary)' }}
|
||||
>
|
||||
{t('正在跳转...')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatPage;
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
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 { useTokenKeys } from '../../hooks/chat/useTokenKeys';
|
||||
|
||||
const chat2page = () => {
|
||||
const { keys, chatLink, serverAddress, isLoading } = useTokenKeys();
|
||||
|
||||
const comLink = (key) => {
|
||||
if (!chatLink || !serverAddress || !key) return '';
|
||||
return `${chatLink}/#/?settings={"key":"sk-${key}","url":"${encodeURIComponent(serverAddress)}"}`;
|
||||
};
|
||||
|
||||
if (keys.length > 0) {
|
||||
const redirectLink = comLink(keys[0]);
|
||||
if (redirectLink) {
|
||||
window.location.href = redirectLink;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='mt-[60px] px-2'>
|
||||
<h3>正在加载,请稍候...</h3>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default chat2page;
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
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 Dashboard from '../../components/dashboard';
|
||||
|
||||
const Detail = () => (
|
||||
<div className='mt-[60px] px-2'>
|
||||
<Dashboard />
|
||||
</div>
|
||||
);
|
||||
|
||||
export default Detail;
|
||||
+43
@@ -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 { Empty } from '@douyinfe/semi-ui';
|
||||
import {
|
||||
IllustrationNoAccess,
|
||||
IllustrationNoAccessDark,
|
||||
} from '@douyinfe/semi-illustrations';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const Forbidden = () => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className='flex justify-center items-center h-screen p-8'>
|
||||
<Empty
|
||||
image={<IllustrationNoAccess style={{ width: 250, height: 250 }} />}
|
||||
darkModeImage={
|
||||
<IllustrationNoAccessDark style={{ width: 250, height: 250 }} />
|
||||
}
|
||||
description={t('您无权访问此页面,请联系管理员')}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Forbidden;
|
||||
Vendored
+356
@@ -0,0 +1,356 @@
|
||||
/*
|
||||
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, useEffect, useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Typography,
|
||||
Input,
|
||||
ScrollList,
|
||||
ScrollItem,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { API, showError, copy, showSuccess } from '../../helpers';
|
||||
import { useIsMobile } from '../../hooks/common/useIsMobile';
|
||||
import { API_ENDPOINTS } from '../../constants/common.constant';
|
||||
import { StatusContext } from '../../context/Status';
|
||||
import { useActualTheme } from '../../context/Theme';
|
||||
import { marked } from 'marked';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
IconGithubLogo,
|
||||
IconPlay,
|
||||
IconFile,
|
||||
IconCopy,
|
||||
} from '@douyinfe/semi-icons';
|
||||
import { Link } from 'react-router-dom';
|
||||
import NoticeModal from '../../components/layout/NoticeModal';
|
||||
import {
|
||||
Moonshot,
|
||||
OpenAI,
|
||||
XAI,
|
||||
Zhipu,
|
||||
Volcengine,
|
||||
Cohere,
|
||||
Claude,
|
||||
Gemini,
|
||||
Suno,
|
||||
Minimax,
|
||||
Wenxin,
|
||||
Spark,
|
||||
Qingyan,
|
||||
DeepSeek,
|
||||
Qwen,
|
||||
Midjourney,
|
||||
Grok,
|
||||
AzureAI,
|
||||
Hunyuan,
|
||||
Xinference,
|
||||
} from '@lobehub/icons';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const Home = () => {
|
||||
const { t, i18n } = useTranslation();
|
||||
const [statusState] = useContext(StatusContext);
|
||||
const actualTheme = useActualTheme();
|
||||
const [homePageContentLoaded, setHomePageContentLoaded] = useState(false);
|
||||
const [homePageContent, setHomePageContent] = useState('');
|
||||
const [noticeVisible, setNoticeVisible] = useState(false);
|
||||
const isMobile = useIsMobile();
|
||||
const isDemoSiteMode = statusState?.status?.demo_site_enabled || false;
|
||||
const docsLink = statusState?.status?.docs_link || '';
|
||||
const serverAddress =
|
||||
statusState?.status?.server_address || `${window.location.origin}`;
|
||||
const endpointItems = API_ENDPOINTS.map((e) => ({ value: e }));
|
||||
const [endpointIndex, setEndpointIndex] = useState(0);
|
||||
const isChinese = i18n.language.startsWith('zh');
|
||||
|
||||
const displayHomePageContent = async () => {
|
||||
setHomePageContent(localStorage.getItem('home_page_content') || '');
|
||||
const res = await API.get('/api/home_page_content');
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
let content = data;
|
||||
if (!data.startsWith('https://')) {
|
||||
content = marked.parse(data);
|
||||
}
|
||||
setHomePageContent(content);
|
||||
localStorage.setItem('home_page_content', content);
|
||||
|
||||
// 如果内容是 URL,则发送主题模式
|
||||
if (data.startsWith('https://')) {
|
||||
const iframe = document.querySelector('iframe');
|
||||
if (iframe) {
|
||||
iframe.onload = () => {
|
||||
iframe.contentWindow.postMessage({ themeMode: actualTheme }, '*');
|
||||
iframe.contentWindow.postMessage({ lang: i18n.language }, '*');
|
||||
};
|
||||
}
|
||||
}
|
||||
} else {
|
||||
showError(message);
|
||||
setHomePageContent('加载首页内容失败...');
|
||||
}
|
||||
setHomePageContentLoaded(true);
|
||||
};
|
||||
|
||||
const handleCopyBaseURL = async () => {
|
||||
const ok = await copy(serverAddress);
|
||||
if (ok) {
|
||||
showSuccess(t('已复制到剪切板'));
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const checkNoticeAndShow = async () => {
|
||||
const lastCloseDate = localStorage.getItem('notice_close_date');
|
||||
const today = new Date().toDateString();
|
||||
if (lastCloseDate !== today) {
|
||||
try {
|
||||
const res = await API.get('/api/notice');
|
||||
const { success, data } = res.data;
|
||||
if (success && data && data.trim() !== '') {
|
||||
setNoticeVisible(true);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取公告失败:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
checkNoticeAndShow();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
displayHomePageContent().then();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
setEndpointIndex((prev) => (prev + 1) % endpointItems.length);
|
||||
}, 3000);
|
||||
return () => clearInterval(timer);
|
||||
}, [endpointItems.length]);
|
||||
|
||||
return (
|
||||
<div className='w-full overflow-x-hidden'>
|
||||
<NoticeModal
|
||||
visible={noticeVisible}
|
||||
onClose={() => setNoticeVisible(false)}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
{homePageContentLoaded && homePageContent === '' ? (
|
||||
<div className='w-full overflow-x-hidden'>
|
||||
{/* Banner 部分 */}
|
||||
<div className='w-full border-b border-semi-color-border min-h-[500px] md:min-h-[600px] lg:min-h-[700px] relative overflow-x-hidden'>
|
||||
{/* 背景模糊晕染球 */}
|
||||
<div className='blur-ball blur-ball-indigo' />
|
||||
<div className='blur-ball blur-ball-teal' />
|
||||
<div className='flex items-center justify-center h-full px-4 py-20 md:py-24 lg:py-32 mt-10'>
|
||||
{/* 居中内容区 */}
|
||||
<div className='flex flex-col items-center justify-center text-center max-w-4xl mx-auto'>
|
||||
<div className='flex flex-col items-center justify-center mb-6 md:mb-8'>
|
||||
<h1
|
||||
className={`text-4xl md:text-5xl lg:text-6xl xl:text-7xl font-bold text-semi-color-text-0 leading-tight ${isChinese ? 'tracking-wide md:tracking-wider' : ''}`}
|
||||
>
|
||||
<>
|
||||
{t('统一的')}
|
||||
<br />
|
||||
<span className='shine-text'>{t('大模型接口网关')}</span>
|
||||
</>
|
||||
</h1>
|
||||
<p className='text-base md:text-lg lg:text-xl text-semi-color-text-1 mt-4 md:mt-6 max-w-xl'>
|
||||
{t('更好的价格,更好的稳定性,只需要将模型基址替换为:')}
|
||||
</p>
|
||||
{/* BASE URL 与端点选择 */}
|
||||
<div className='flex flex-col md:flex-row items-center justify-center gap-4 w-full mt-4 md:mt-6 max-w-md'>
|
||||
<Input
|
||||
readonly
|
||||
value={serverAddress}
|
||||
className='flex-1 !rounded-full'
|
||||
size={isMobile ? 'default' : 'large'}
|
||||
suffix={
|
||||
<div className='flex items-center gap-2'>
|
||||
<ScrollList
|
||||
bodyHeight={32}
|
||||
style={{ border: 'unset', boxShadow: 'unset' }}
|
||||
>
|
||||
<ScrollItem
|
||||
mode='wheel'
|
||||
cycled={true}
|
||||
list={endpointItems}
|
||||
selectedIndex={endpointIndex}
|
||||
onSelect={({ index }) => setEndpointIndex(index)}
|
||||
/>
|
||||
</ScrollList>
|
||||
<Button
|
||||
type='primary'
|
||||
onClick={handleCopyBaseURL}
|
||||
icon={<IconCopy />}
|
||||
className='!rounded-full'
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className='flex flex-row gap-4 justify-center items-center'>
|
||||
<Link to='/console'>
|
||||
<Button
|
||||
theme='solid'
|
||||
type='primary'
|
||||
size={isMobile ? 'default' : 'large'}
|
||||
className='!rounded-3xl px-8 py-2'
|
||||
icon={<IconPlay />}
|
||||
>
|
||||
{t('获取密钥')}
|
||||
</Button>
|
||||
</Link>
|
||||
{isDemoSiteMode && statusState?.status?.version ? (
|
||||
<Button
|
||||
size={isMobile ? 'default' : 'large'}
|
||||
className='flex items-center !rounded-3xl px-6 py-2'
|
||||
icon={<IconGithubLogo />}
|
||||
onClick={() =>
|
||||
window.open(
|
||||
'https://github.com/QuantumNous/new-api',
|
||||
'_blank',
|
||||
)
|
||||
}
|
||||
>
|
||||
{statusState.status.version}
|
||||
</Button>
|
||||
) : (
|
||||
docsLink && (
|
||||
<Button
|
||||
size={isMobile ? 'default' : 'large'}
|
||||
className='flex items-center !rounded-3xl px-6 py-2'
|
||||
icon={<IconFile />}
|
||||
onClick={() => window.open(docsLink, '_blank')}
|
||||
>
|
||||
{t('文档')}
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 框架兼容性图标 */}
|
||||
<div className='mt-12 md:mt-16 lg:mt-20 w-full'>
|
||||
<div className='flex items-center mb-6 md:mb-8 justify-center'>
|
||||
<Text
|
||||
type='tertiary'
|
||||
className='text-lg md:text-xl lg:text-2xl font-light'
|
||||
>
|
||||
{t('支持众多的大模型供应商')}
|
||||
</Text>
|
||||
</div>
|
||||
<div className='flex flex-wrap items-center justify-center gap-3 sm:gap-4 md:gap-6 lg:gap-8 max-w-5xl mx-auto px-4'>
|
||||
<div className='w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center'>
|
||||
<Moonshot size={40} />
|
||||
</div>
|
||||
<div className='w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center'>
|
||||
<OpenAI size={40} />
|
||||
</div>
|
||||
<div className='w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center'>
|
||||
<XAI size={40} />
|
||||
</div>
|
||||
<div className='w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center'>
|
||||
<Zhipu.Color size={40} />
|
||||
</div>
|
||||
<div className='w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center'>
|
||||
<Volcengine.Color size={40} />
|
||||
</div>
|
||||
<div className='w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center'>
|
||||
<Cohere.Color size={40} />
|
||||
</div>
|
||||
<div className='w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center'>
|
||||
<Claude.Color size={40} />
|
||||
</div>
|
||||
<div className='w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center'>
|
||||
<Gemini.Color size={40} />
|
||||
</div>
|
||||
<div className='w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center'>
|
||||
<Suno size={40} />
|
||||
</div>
|
||||
<div className='w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center'>
|
||||
<Minimax.Color size={40} />
|
||||
</div>
|
||||
<div className='w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center'>
|
||||
<Wenxin.Color size={40} />
|
||||
</div>
|
||||
<div className='w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center'>
|
||||
<Spark.Color size={40} />
|
||||
</div>
|
||||
<div className='w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center'>
|
||||
<Qingyan.Color size={40} />
|
||||
</div>
|
||||
<div className='w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center'>
|
||||
<DeepSeek.Color size={40} />
|
||||
</div>
|
||||
<div className='w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center'>
|
||||
<Qwen.Color size={40} />
|
||||
</div>
|
||||
<div className='w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center'>
|
||||
<Midjourney size={40} />
|
||||
</div>
|
||||
<div className='w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center'>
|
||||
<Grok size={40} />
|
||||
</div>
|
||||
<div className='w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center'>
|
||||
<AzureAI.Color size={40} />
|
||||
</div>
|
||||
<div className='w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center'>
|
||||
<Hunyuan.Color size={40} />
|
||||
</div>
|
||||
<div className='w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center'>
|
||||
<Xinference.Color size={40} />
|
||||
</div>
|
||||
<div className='w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center'>
|
||||
<Typography.Text className='!text-lg sm:!text-xl md:!text-2xl lg:!text-3xl font-bold'>
|
||||
30+
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className='overflow-x-hidden w-full'>
|
||||
{homePageContent.startsWith('https://') ? (
|
||||
<iframe
|
||||
src={homePageContent}
|
||||
className='w-full h-screen border-none'
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className='mt-[60px]'
|
||||
dangerouslySetInnerHTML={{ __html: homePageContent }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Home;
|
||||
Vendored
+29
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
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 UsageLogsTable from '../../components/table/usage-logs';
|
||||
|
||||
const Token = () => (
|
||||
<div className='mt-[60px] px-2'>
|
||||
<UsageLogsTable />
|
||||
</div>
|
||||
);
|
||||
|
||||
export default Token;
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
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 MjLogsTable from '../../components/table/mj-logs';
|
||||
|
||||
const Midjourney = () => (
|
||||
<div className='mt-[60px] px-2'>
|
||||
<MjLogsTable />
|
||||
</div>
|
||||
);
|
||||
|
||||
export default Midjourney;
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
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 ModelsTable from '../../components/table/models';
|
||||
|
||||
const ModelPage = () => {
|
||||
return (
|
||||
<div className='mt-[60px] px-2'>
|
||||
<ModelsTable />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModelPage;
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
/*
|
||||
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 DeploymentsTable from '../../components/table/model-deployments';
|
||||
import DeploymentAccessGuard from '../../components/model-deployments/DeploymentAccessGuard';
|
||||
import { useModelDeploymentSettings } from '../../hooks/model-deployments/useModelDeploymentSettings';
|
||||
|
||||
const ModelDeploymentPage = () => {
|
||||
const {
|
||||
loading,
|
||||
isIoNetEnabled,
|
||||
connectionLoading,
|
||||
connectionOk,
|
||||
connectionError,
|
||||
testConnection,
|
||||
} = useModelDeploymentSettings();
|
||||
|
||||
return (
|
||||
<DeploymentAccessGuard
|
||||
loading={loading}
|
||||
isEnabled={isIoNetEnabled}
|
||||
connectionLoading={connectionLoading}
|
||||
connectionOk={connectionOk}
|
||||
connectionError={connectionError}
|
||||
onRetry={() => testConnection()}
|
||||
>
|
||||
<div className='mt-[60px] px-2'>
|
||||
<DeploymentsTable />
|
||||
</div>
|
||||
</DeploymentAccessGuard>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModelDeploymentPage;
|
||||
+43
@@ -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 { Empty } from '@douyinfe/semi-ui';
|
||||
import {
|
||||
IllustrationNotFound,
|
||||
IllustrationNotFoundDark,
|
||||
} from '@douyinfe/semi-illustrations';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const NotFound = () => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className='flex justify-center items-center h-screen p-8'>
|
||||
<Empty
|
||||
image={<IllustrationNotFound style={{ width: 250, height: 250 }} />}
|
||||
darkModeImage={
|
||||
<IllustrationNotFoundDark style={{ width: 250, height: 250 }} />
|
||||
}
|
||||
description={t('页面未找到,请检查您的浏览器地址是否正确')}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NotFound;
|
||||
+565
@@ -0,0 +1,565 @@
|
||||
/*
|
||||
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, useEffect, useCallback, useRef } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Layout, Toast, Modal } from '@douyinfe/semi-ui';
|
||||
|
||||
// Context
|
||||
import { UserContext } from '../../context/User';
|
||||
import { useIsMobile } from '../../hooks/common/useIsMobile';
|
||||
|
||||
// hooks
|
||||
import { usePlaygroundState } from '../../hooks/playground/usePlaygroundState';
|
||||
import { useMessageActions } from '../../hooks/playground/useMessageActions';
|
||||
import { useApiRequest } from '../../hooks/playground/useApiRequest';
|
||||
import { useSyncMessageAndCustomBody } from '../../hooks/playground/useSyncMessageAndCustomBody';
|
||||
import { useMessageEdit } from '../../hooks/playground/useMessageEdit';
|
||||
import { useDataLoader } from '../../hooks/playground/useDataLoader';
|
||||
|
||||
// Constants and utils
|
||||
import {
|
||||
MESSAGE_ROLES,
|
||||
ERROR_MESSAGES,
|
||||
} from '../../constants/playground.constants';
|
||||
import {
|
||||
getLogo,
|
||||
stringToColor,
|
||||
buildMessageContent,
|
||||
createMessage,
|
||||
createLoadingAssistantMessage,
|
||||
getTextContent,
|
||||
buildApiPayload,
|
||||
encodeToBase64,
|
||||
} from '../../helpers';
|
||||
|
||||
// Components
|
||||
import {
|
||||
OptimizedSettingsPanel,
|
||||
OptimizedDebugPanel,
|
||||
OptimizedMessageContent,
|
||||
OptimizedMessageActions,
|
||||
} from '../../components/playground/OptimizedComponents';
|
||||
import ChatArea from '../../components/playground/ChatArea';
|
||||
import FloatingButtons from '../../components/playground/FloatingButtons';
|
||||
import { PlaygroundProvider } from '../../contexts/PlaygroundContext';
|
||||
|
||||
// 生成头像
|
||||
const generateAvatarDataUrl = (username) => {
|
||||
if (!username) {
|
||||
return 'https://lf3-static.bytednsdoc.com/obj/eden-cn/ptlz_zlp/ljhwZthlaukjlkulzlp/docs-icon.png';
|
||||
}
|
||||
const firstLetter = username[0].toUpperCase();
|
||||
const bgColor = stringToColor(username);
|
||||
const svg = `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
|
||||
<circle cx="16" cy="16" r="16" fill="${bgColor}" />
|
||||
<text x="50%" y="50%" dominant-baseline="central" text-anchor="middle" font-size="16" fill="#ffffff" font-family="sans-serif">${firstLetter}</text>
|
||||
</svg>
|
||||
`;
|
||||
return `data:image/svg+xml;base64,${encodeToBase64(svg)}`;
|
||||
};
|
||||
|
||||
const Playground = () => {
|
||||
const { t } = useTranslation();
|
||||
const [userState] = useContext(UserContext);
|
||||
const isMobile = useIsMobile();
|
||||
const styleState = { isMobile };
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
const state = usePlaygroundState();
|
||||
const {
|
||||
inputs,
|
||||
parameterEnabled,
|
||||
showDebugPanel,
|
||||
customRequestMode,
|
||||
customRequestBody,
|
||||
showSettings,
|
||||
models,
|
||||
groups,
|
||||
status,
|
||||
message,
|
||||
debugData,
|
||||
activeDebugTab,
|
||||
previewPayload,
|
||||
sseSourceRef,
|
||||
chatRef,
|
||||
handleInputChange,
|
||||
handleParameterToggle,
|
||||
debouncedSaveConfig,
|
||||
saveMessagesImmediately,
|
||||
handleConfigImport,
|
||||
handleConfigReset,
|
||||
setShowSettings,
|
||||
setModels,
|
||||
setGroups,
|
||||
setStatus,
|
||||
setMessage,
|
||||
setDebugData,
|
||||
setActiveDebugTab,
|
||||
setPreviewPayload,
|
||||
setShowDebugPanel,
|
||||
setCustomRequestMode,
|
||||
setCustomRequestBody,
|
||||
} = state;
|
||||
|
||||
// API 请求相关
|
||||
const { sendRequest, onStopGenerator } = useApiRequest(
|
||||
setMessage,
|
||||
setDebugData,
|
||||
setActiveDebugTab,
|
||||
sseSourceRef,
|
||||
saveMessagesImmediately,
|
||||
);
|
||||
|
||||
// 数据加载
|
||||
useDataLoader(userState, inputs, handleInputChange, setModels, setGroups);
|
||||
|
||||
// 消息编辑
|
||||
const {
|
||||
editingMessageId,
|
||||
editValue,
|
||||
setEditValue,
|
||||
handleMessageEdit,
|
||||
handleEditSave,
|
||||
handleEditCancel,
|
||||
} = useMessageEdit(
|
||||
setMessage,
|
||||
inputs,
|
||||
parameterEnabled,
|
||||
sendRequest,
|
||||
saveMessagesImmediately,
|
||||
);
|
||||
|
||||
// 消息和自定义请求体同步
|
||||
const { syncMessageToCustomBody, syncCustomBodyToMessage } =
|
||||
useSyncMessageAndCustomBody(
|
||||
customRequestMode,
|
||||
customRequestBody,
|
||||
message,
|
||||
inputs,
|
||||
setCustomRequestBody,
|
||||
setMessage,
|
||||
debouncedSaveConfig,
|
||||
);
|
||||
|
||||
// 角色信息
|
||||
const roleInfo = {
|
||||
user: {
|
||||
name: userState?.user?.username || 'User',
|
||||
avatar: generateAvatarDataUrl(userState?.user?.username),
|
||||
},
|
||||
assistant: {
|
||||
name: 'Assistant',
|
||||
avatar: getLogo(),
|
||||
},
|
||||
system: {
|
||||
name: 'System',
|
||||
avatar: getLogo(),
|
||||
},
|
||||
};
|
||||
|
||||
// 消息操作
|
||||
const messageActions = useMessageActions(
|
||||
message,
|
||||
setMessage,
|
||||
onMessageSend,
|
||||
saveMessagesImmediately,
|
||||
);
|
||||
|
||||
// 构建预览请求体
|
||||
const constructPreviewPayload = useCallback(() => {
|
||||
try {
|
||||
// 如果是自定义请求体模式且有自定义内容,直接返回解析后的自定义请求体
|
||||
if (customRequestMode && customRequestBody && customRequestBody.trim()) {
|
||||
try {
|
||||
return JSON.parse(customRequestBody);
|
||||
} catch (parseError) {
|
||||
console.warn('自定义请求体JSON解析失败,回退到默认预览:', parseError);
|
||||
}
|
||||
}
|
||||
|
||||
// 默认预览逻辑
|
||||
let messages = [...message];
|
||||
|
||||
// 如果存在用户消息
|
||||
if (
|
||||
!(
|
||||
messages.length === 0 ||
|
||||
messages.every((msg) => msg.role !== MESSAGE_ROLES.USER)
|
||||
)
|
||||
) {
|
||||
// 处理最后一个用户消息的图片
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
if (messages[i].role === MESSAGE_ROLES.USER) {
|
||||
if (inputs.imageEnabled && inputs.imageUrls) {
|
||||
const validImageUrls = inputs.imageUrls.filter(
|
||||
(url) => url.trim() !== '',
|
||||
);
|
||||
if (validImageUrls.length > 0) {
|
||||
const textContent = getTextContent(messages[i]) || '示例消息';
|
||||
const content = buildMessageContent(
|
||||
textContent,
|
||||
validImageUrls,
|
||||
true,
|
||||
);
|
||||
messages[i] = { ...messages[i], content };
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return buildApiPayload(messages, null, inputs, parameterEnabled);
|
||||
} catch (error) {
|
||||
console.error('构造预览请求体失败:', error);
|
||||
return null;
|
||||
}
|
||||
}, [inputs, parameterEnabled, message, customRequestMode, customRequestBody]);
|
||||
|
||||
// 发送消息
|
||||
function onMessageSend(content, attachment) {
|
||||
console.log('attachment: ', attachment);
|
||||
|
||||
// 创建用户消息和加载消息
|
||||
const userMessage = createMessage(MESSAGE_ROLES.USER, content);
|
||||
const loadingMessage = createLoadingAssistantMessage();
|
||||
|
||||
// 如果是自定义请求体模式
|
||||
if (customRequestMode && customRequestBody) {
|
||||
try {
|
||||
const customPayload = JSON.parse(customRequestBody);
|
||||
|
||||
setMessage((prevMessage) => {
|
||||
const newMessages = [...prevMessage, userMessage, loadingMessage];
|
||||
|
||||
// 发送自定义请求体
|
||||
sendRequest(customPayload, customPayload.stream !== false);
|
||||
|
||||
// 发送消息后保存,传入新消息列表
|
||||
setTimeout(() => saveMessagesImmediately(newMessages), 0);
|
||||
|
||||
return newMessages;
|
||||
});
|
||||
return;
|
||||
} catch (error) {
|
||||
console.error('自定义请求体JSON解析失败:', error);
|
||||
Toast.error(ERROR_MESSAGES.JSON_PARSE_ERROR);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 默认模式
|
||||
const validImageUrls = inputs.imageUrls.filter((url) => url.trim() !== '');
|
||||
const messageContent = buildMessageContent(
|
||||
content,
|
||||
validImageUrls,
|
||||
inputs.imageEnabled,
|
||||
);
|
||||
const userMessageWithImages = createMessage(
|
||||
MESSAGE_ROLES.USER,
|
||||
messageContent,
|
||||
);
|
||||
|
||||
setMessage((prevMessage) => {
|
||||
const newMessages = [...prevMessage, userMessageWithImages];
|
||||
|
||||
const payload = buildApiPayload(
|
||||
newMessages,
|
||||
null,
|
||||
inputs,
|
||||
parameterEnabled,
|
||||
);
|
||||
sendRequest(payload, inputs.stream);
|
||||
|
||||
// 禁用图片模式
|
||||
if (inputs.imageEnabled) {
|
||||
setTimeout(() => {
|
||||
handleInputChange('imageEnabled', false);
|
||||
}, 100);
|
||||
}
|
||||
|
||||
// 发送消息后保存,传入新消息列表(包含用户消息和加载消息)
|
||||
const messagesWithLoading = [...newMessages, loadingMessage];
|
||||
setTimeout(() => saveMessagesImmediately(messagesWithLoading), 0);
|
||||
|
||||
return messagesWithLoading;
|
||||
});
|
||||
}
|
||||
|
||||
// 切换推理展开状态
|
||||
const toggleReasoningExpansion = useCallback(
|
||||
(messageId) => {
|
||||
setMessage((prevMessages) =>
|
||||
prevMessages.map((msg) =>
|
||||
msg.id === messageId && msg.role === MESSAGE_ROLES.ASSISTANT
|
||||
? { ...msg, isReasoningExpanded: !msg.isReasoningExpanded }
|
||||
: msg,
|
||||
),
|
||||
);
|
||||
},
|
||||
[setMessage],
|
||||
);
|
||||
|
||||
// 渲染函数
|
||||
const renderCustomChatContent = useCallback(
|
||||
({ message, className }) => {
|
||||
const isCurrentlyEditing = editingMessageId === message.id;
|
||||
|
||||
return (
|
||||
<OptimizedMessageContent
|
||||
message={message}
|
||||
className={className}
|
||||
styleState={styleState}
|
||||
onToggleReasoningExpansion={toggleReasoningExpansion}
|
||||
isEditing={isCurrentlyEditing}
|
||||
onEditSave={handleEditSave}
|
||||
onEditCancel={handleEditCancel}
|
||||
editValue={editValue}
|
||||
onEditValueChange={setEditValue}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[
|
||||
styleState,
|
||||
editingMessageId,
|
||||
editValue,
|
||||
handleEditSave,
|
||||
handleEditCancel,
|
||||
setEditValue,
|
||||
toggleReasoningExpansion,
|
||||
],
|
||||
);
|
||||
|
||||
const renderChatBoxAction = useCallback(
|
||||
(props) => {
|
||||
const { message: currentMessage } = props;
|
||||
const isAnyMessageGenerating = message.some(
|
||||
(msg) => msg.status === 'loading' || msg.status === 'incomplete',
|
||||
);
|
||||
const isCurrentlyEditing = editingMessageId === currentMessage.id;
|
||||
|
||||
return (
|
||||
<OptimizedMessageActions
|
||||
message={currentMessage}
|
||||
styleState={styleState}
|
||||
onMessageReset={messageActions.handleMessageReset}
|
||||
onMessageCopy={messageActions.handleMessageCopy}
|
||||
onMessageDelete={messageActions.handleMessageDelete}
|
||||
onRoleToggle={messageActions.handleRoleToggle}
|
||||
onMessageEdit={handleMessageEdit}
|
||||
isAnyMessageGenerating={isAnyMessageGenerating}
|
||||
isEditing={isCurrentlyEditing}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[messageActions, styleState, message, editingMessageId, handleMessageEdit],
|
||||
);
|
||||
|
||||
// Effects
|
||||
|
||||
// 同步消息和自定义请求体
|
||||
useEffect(() => {
|
||||
syncMessageToCustomBody();
|
||||
}, [message, syncMessageToCustomBody]);
|
||||
|
||||
useEffect(() => {
|
||||
syncCustomBodyToMessage();
|
||||
}, [customRequestBody, syncCustomBodyToMessage]);
|
||||
|
||||
// 处理URL参数
|
||||
useEffect(() => {
|
||||
if (searchParams.get('expired')) {
|
||||
Toast.warning(t('登录过期,请重新登录!'));
|
||||
}
|
||||
}, [searchParams, t]);
|
||||
|
||||
// Playground 组件无需再监听窗口变化,isMobile 由 useIsMobile Hook 自动更新
|
||||
|
||||
// 构建预览payload
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
const preview = constructPreviewPayload();
|
||||
setPreviewPayload(preview);
|
||||
setDebugData((prev) => ({
|
||||
...prev,
|
||||
previewRequest: preview ? JSON.stringify(preview, null, 2) : null,
|
||||
previewTimestamp: preview ? new Date().toISOString() : null,
|
||||
}));
|
||||
}, 300);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [
|
||||
message,
|
||||
inputs,
|
||||
parameterEnabled,
|
||||
customRequestMode,
|
||||
customRequestBody,
|
||||
constructPreviewPayload,
|
||||
setPreviewPayload,
|
||||
setDebugData,
|
||||
]);
|
||||
|
||||
// 自动保存配置
|
||||
useEffect(() => {
|
||||
debouncedSaveConfig();
|
||||
}, [
|
||||
inputs,
|
||||
parameterEnabled,
|
||||
showDebugPanel,
|
||||
customRequestMode,
|
||||
customRequestBody,
|
||||
debouncedSaveConfig,
|
||||
]);
|
||||
|
||||
// 清空对话的处理函数
|
||||
const handleClearMessages = useCallback(() => {
|
||||
setMessage([]);
|
||||
// 清空对话后保存,传入空数组
|
||||
setTimeout(() => saveMessagesImmediately([]), 0);
|
||||
}, [setMessage, saveMessagesImmediately]);
|
||||
|
||||
// 处理粘贴图片
|
||||
const handlePasteImage = useCallback(
|
||||
(base64Data) => {
|
||||
if (!inputs.imageEnabled) {
|
||||
return;
|
||||
}
|
||||
// 添加图片到 imageUrls 数组
|
||||
const newUrls = [...(inputs.imageUrls || []), base64Data];
|
||||
handleInputChange('imageUrls', newUrls);
|
||||
},
|
||||
[inputs.imageEnabled, inputs.imageUrls, handleInputChange],
|
||||
);
|
||||
|
||||
// Playground Context 值
|
||||
const playgroundContextValue = {
|
||||
onPasteImage: handlePasteImage,
|
||||
imageUrls: inputs.imageUrls || [],
|
||||
imageEnabled: inputs.imageEnabled || false,
|
||||
};
|
||||
|
||||
return (
|
||||
<PlaygroundProvider value={playgroundContextValue}>
|
||||
<div className='h-full'>
|
||||
<Layout className='h-full bg-transparent flex flex-col md:flex-row'>
|
||||
{(showSettings || !isMobile) && (
|
||||
<Layout.Sider
|
||||
className={`
|
||||
bg-transparent border-r-0 flex-shrink-0 overflow-auto mt-[60px]
|
||||
${
|
||||
isMobile
|
||||
? 'fixed top-0 left-0 right-0 bottom-0 z-[1000] w-full h-auto bg-white shadow-lg'
|
||||
: 'relative z-[1] w-80 h-[calc(100vh-66px)]'
|
||||
}
|
||||
`}
|
||||
width={isMobile ? '100%' : 320}
|
||||
>
|
||||
<OptimizedSettingsPanel
|
||||
inputs={inputs}
|
||||
parameterEnabled={parameterEnabled}
|
||||
models={models}
|
||||
groups={groups}
|
||||
styleState={styleState}
|
||||
showSettings={showSettings}
|
||||
showDebugPanel={showDebugPanel}
|
||||
customRequestMode={customRequestMode}
|
||||
customRequestBody={customRequestBody}
|
||||
onInputChange={handleInputChange}
|
||||
onParameterToggle={handleParameterToggle}
|
||||
onCloseSettings={() => setShowSettings(false)}
|
||||
onConfigImport={handleConfigImport}
|
||||
onConfigReset={handleConfigReset}
|
||||
onCustomRequestModeChange={setCustomRequestMode}
|
||||
onCustomRequestBodyChange={setCustomRequestBody}
|
||||
previewPayload={previewPayload}
|
||||
messages={message}
|
||||
/>
|
||||
</Layout.Sider>
|
||||
)}
|
||||
|
||||
<Layout.Content className='relative flex-1 overflow-hidden'>
|
||||
<div className='overflow-hidden flex flex-col lg:flex-row h-[calc(100vh-66px)] mt-[60px]'>
|
||||
<div className='flex-1 flex flex-col'>
|
||||
<ChatArea
|
||||
chatRef={chatRef}
|
||||
message={message}
|
||||
inputs={inputs}
|
||||
styleState={styleState}
|
||||
showDebugPanel={showDebugPanel}
|
||||
roleInfo={roleInfo}
|
||||
onMessageSend={onMessageSend}
|
||||
onMessageCopy={messageActions.handleMessageCopy}
|
||||
onMessageReset={messageActions.handleMessageReset}
|
||||
onMessageDelete={messageActions.handleMessageDelete}
|
||||
onStopGenerator={onStopGenerator}
|
||||
onClearMessages={handleClearMessages}
|
||||
onToggleDebugPanel={() => setShowDebugPanel(!showDebugPanel)}
|
||||
renderCustomChatContent={renderCustomChatContent}
|
||||
renderChatBoxAction={renderChatBoxAction}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 调试面板 - 桌面端 */}
|
||||
{showDebugPanel && !isMobile && (
|
||||
<div className='w-96 flex-shrink-0 h-full'>
|
||||
<OptimizedDebugPanel
|
||||
debugData={debugData}
|
||||
activeDebugTab={activeDebugTab}
|
||||
onActiveDebugTabChange={setActiveDebugTab}
|
||||
styleState={styleState}
|
||||
customRequestMode={customRequestMode}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 调试面板 - 移动端覆盖层 */}
|
||||
{showDebugPanel && isMobile && (
|
||||
<div className='fixed top-0 left-0 right-0 bottom-0 z-[1000] bg-white overflow-auto shadow-lg'>
|
||||
<OptimizedDebugPanel
|
||||
debugData={debugData}
|
||||
activeDebugTab={activeDebugTab}
|
||||
onActiveDebugTabChange={setActiveDebugTab}
|
||||
styleState={styleState}
|
||||
showDebugPanel={showDebugPanel}
|
||||
onCloseDebugPanel={() => setShowDebugPanel(false)}
|
||||
customRequestMode={customRequestMode}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 浮动按钮 */}
|
||||
<FloatingButtons
|
||||
styleState={styleState}
|
||||
showSettings={showSettings}
|
||||
showDebugPanel={showDebugPanel}
|
||||
onToggleSettings={() => setShowSettings(!showSettings)}
|
||||
onToggleDebugPanel={() => setShowDebugPanel(!showDebugPanel)}
|
||||
/>
|
||||
</Layout.Content>
|
||||
</Layout>
|
||||
</div>
|
||||
</PlaygroundProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default Playground;
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
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 ModelPricingPage from '../../components/table/model-pricing/layout/PricingPage';
|
||||
|
||||
const Pricing = () => (
|
||||
<>
|
||||
<ModelPricingPage />
|
||||
</>
|
||||
);
|
||||
|
||||
export default Pricing;
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
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 { useTranslation } from 'react-i18next';
|
||||
import DocumentRenderer from '../../components/common/DocumentRenderer';
|
||||
|
||||
const PrivacyPolicy = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<DocumentRenderer
|
||||
apiEndpoint='/api/privacy-policy'
|
||||
title={t('隐私政策')}
|
||||
cacheKey='privacy_policy'
|
||||
emptyMessage={t('加载隐私政策内容失败...')}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default PrivacyPolicy;
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
/*
|
||||
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 RedemptionsTable from '../../components/table/redemptions';
|
||||
|
||||
const Redemption = () => {
|
||||
return (
|
||||
<div className='mt-[60px] px-2'>
|
||||
<RedemptionsTable />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Redemption;
|
||||
@@ -0,0 +1,562 @@
|
||||
/*
|
||||
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 {
|
||||
Banner,
|
||||
Button,
|
||||
Dropdown,
|
||||
Form,
|
||||
Space,
|
||||
Spin,
|
||||
RadioGroup,
|
||||
Radio,
|
||||
Table,
|
||||
Modal,
|
||||
Input,
|
||||
Divider,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
IconPlus,
|
||||
IconEdit,
|
||||
IconDelete,
|
||||
IconSearch,
|
||||
IconSaveStroked,
|
||||
IconBolt,
|
||||
} from '@douyinfe/semi-icons';
|
||||
import {
|
||||
compareObjects,
|
||||
API,
|
||||
showError,
|
||||
showSuccess,
|
||||
showWarning,
|
||||
verifyJSON,
|
||||
} from '../../../helpers';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export default function SettingsChats(props) {
|
||||
const { t } = useTranslation();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [inputs, setInputs] = useState({
|
||||
Chats: '[]',
|
||||
});
|
||||
const refForm = useRef();
|
||||
const [inputsRow, setInputsRow] = useState(inputs);
|
||||
const [editMode, setEditMode] = useState('visual');
|
||||
const [chatConfigs, setChatConfigs] = useState([]);
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const [editingConfig, setEditingConfig] = useState(null);
|
||||
const [isEdit, setIsEdit] = useState(false);
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const modalFormRef = useRef();
|
||||
|
||||
const BUILTIN_TEMPLATES = [
|
||||
{ name: 'Cherry Studio', url: 'cherrystudio://providers/api-keys?v=1&data={cherryConfig}' },
|
||||
{ name: 'AionUI', url: 'aionui://provider/add?v=1&data={aionuiConfig}' },
|
||||
{ name: '流畅阅读', url: 'fluentread' },
|
||||
{ name: 'CC Switch', url: 'ccswitch' },
|
||||
{ name: 'Lobe Chat', url: 'https://chat-preview.lobehub.com/?settings={"keyVaults":{"openai":{"apiKey":"{key}","baseURL":"{address}/v1"}}}' },
|
||||
{ name: 'AI as Workspace', url: 'https://aiaw.app/set-provider?provider={"type":"openai","settings":{"apiKey":"{key}","baseURL":"{address}/v1","compatibility":"strict"}}' },
|
||||
{ name: 'AMA 问天', url: 'ama://set-api-key?server={address}&key={key}' },
|
||||
{ name: 'OpenCat', url: 'opencat://team/join?domain={address}&token={key}' },
|
||||
];
|
||||
|
||||
const addTemplates = (templates) => {
|
||||
const existingNames = new Set(chatConfigs.map((c) => c.name));
|
||||
const toAdd = templates.filter((tpl) => !existingNames.has(tpl.name));
|
||||
if (toAdd.length === 0) {
|
||||
showWarning(t('所选模板已存在'));
|
||||
return;
|
||||
}
|
||||
let maxId = chatConfigs.length > 0
|
||||
? Math.max(...chatConfigs.map((c) => c.id))
|
||||
: -1;
|
||||
const newItems = toAdd.map((tpl) => ({
|
||||
id: ++maxId,
|
||||
name: tpl.name,
|
||||
url: tpl.url,
|
||||
}));
|
||||
const newConfigs = [...chatConfigs, ...newItems];
|
||||
setChatConfigs(newConfigs);
|
||||
syncConfigsToJson(newConfigs);
|
||||
showSuccess(t('已添加 {{count}} 个模板', { count: toAdd.length }));
|
||||
};
|
||||
|
||||
const jsonToConfigs = (jsonString) => {
|
||||
try {
|
||||
const configs = JSON.parse(jsonString);
|
||||
return Array.isArray(configs)
|
||||
? configs.map((config, index) => ({
|
||||
id: index,
|
||||
name: Object.keys(config)[0] || '',
|
||||
url: Object.values(config)[0] || '',
|
||||
}))
|
||||
: [];
|
||||
} catch (error) {
|
||||
console.error('JSON parse error:', error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const configsToJson = (configs) => {
|
||||
const jsonArray = configs.map((config) => ({
|
||||
[config.name]: config.url,
|
||||
}));
|
||||
return JSON.stringify(jsonArray, null, 2);
|
||||
};
|
||||
|
||||
const syncJsonToConfigs = () => {
|
||||
const configs = jsonToConfigs(inputs.Chats);
|
||||
setChatConfigs(configs);
|
||||
};
|
||||
|
||||
const syncConfigsToJson = (configs) => {
|
||||
const jsonString = configsToJson(configs);
|
||||
setInputs((prev) => ({
|
||||
...prev,
|
||||
Chats: jsonString,
|
||||
}));
|
||||
if (refForm.current && editMode === 'json') {
|
||||
refForm.current.setValues({ Chats: jsonString });
|
||||
}
|
||||
};
|
||||
|
||||
async function onSubmit() {
|
||||
try {
|
||||
if (editMode === 'json' && refForm.current) {
|
||||
try {
|
||||
await refForm.current.validate();
|
||||
} catch (error) {
|
||||
console.error('Validation failed:', error);
|
||||
showError(t('请检查输入'));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const updateArray = compareObjects(inputs, inputsRow);
|
||||
if (!updateArray.length)
|
||||
return showWarning(t('你似乎并没有修改什么'));
|
||||
const requestQueue = updateArray.map((item) => {
|
||||
let value = '';
|
||||
if (typeof inputs[item.key] === 'boolean') {
|
||||
value = String(inputs[item.key]);
|
||||
} else {
|
||||
value = inputs[item.key];
|
||||
}
|
||||
return API.put('/api/option/', {
|
||||
key: item.key,
|
||||
value,
|
||||
});
|
||||
});
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await Promise.all(requestQueue);
|
||||
if (res.includes(undefined)) {
|
||||
if (requestQueue.length > 1) {
|
||||
showError(t('部分保存失败,请重试'));
|
||||
}
|
||||
return;
|
||||
}
|
||||
showSuccess(t('保存成功'));
|
||||
props.refresh();
|
||||
} catch {
|
||||
showError(t('保存失败,请重试'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
} catch (error) {
|
||||
showError(t('请检查输入'));
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const currentInputs = {};
|
||||
for (let key in props.options) {
|
||||
if (Object.keys(inputs).includes(key)) {
|
||||
if (key === 'Chats') {
|
||||
const obj = JSON.parse(props.options[key]);
|
||||
currentInputs[key] = JSON.stringify(obj, null, 2);
|
||||
} else {
|
||||
currentInputs[key] = props.options[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
setInputs(currentInputs);
|
||||
setInputsRow(structuredClone(currentInputs));
|
||||
if (refForm.current) {
|
||||
refForm.current.setValues(currentInputs);
|
||||
}
|
||||
|
||||
// 同步到可视化配置
|
||||
const configs = jsonToConfigs(currentInputs.Chats || '[]');
|
||||
setChatConfigs(configs);
|
||||
}, [props.options]);
|
||||
|
||||
useEffect(() => {
|
||||
if (editMode === 'visual') {
|
||||
syncJsonToConfigs();
|
||||
}
|
||||
}, [inputs.Chats, editMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (refForm.current && editMode === 'json') {
|
||||
refForm.current.setValues(inputs);
|
||||
}
|
||||
}, [editMode, inputs]);
|
||||
|
||||
const handleAddConfig = () => {
|
||||
setEditingConfig({ name: '', url: '' });
|
||||
setIsEdit(false);
|
||||
setModalVisible(true);
|
||||
setTimeout(() => {
|
||||
if (modalFormRef.current) {
|
||||
modalFormRef.current.setValues({ name: '', url: '' });
|
||||
}
|
||||
}, 100);
|
||||
};
|
||||
|
||||
const handleEditConfig = (config) => {
|
||||
setEditingConfig({ ...config });
|
||||
setIsEdit(true);
|
||||
setModalVisible(true);
|
||||
setTimeout(() => {
|
||||
if (modalFormRef.current) {
|
||||
modalFormRef.current.setValues(config);
|
||||
}
|
||||
}, 100);
|
||||
};
|
||||
|
||||
const handleDeleteConfig = (id) => {
|
||||
const newConfigs = chatConfigs.filter((config) => config.id !== id);
|
||||
setChatConfigs(newConfigs);
|
||||
syncConfigsToJson(newConfigs);
|
||||
showSuccess(t('删除成功'));
|
||||
};
|
||||
|
||||
const handleModalOk = () => {
|
||||
if (modalFormRef.current) {
|
||||
modalFormRef.current
|
||||
.validate()
|
||||
.then((values) => {
|
||||
// 检查名称是否重复
|
||||
const isDuplicate = chatConfigs.some(
|
||||
(config) =>
|
||||
config.name === values.name &&
|
||||
(!isEdit || config.id !== editingConfig.id),
|
||||
);
|
||||
|
||||
if (isDuplicate) {
|
||||
showError(t('聊天应用名称已存在,请使用其他名称'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (isEdit) {
|
||||
const newConfigs = chatConfigs.map((config) =>
|
||||
config.id === editingConfig.id
|
||||
? { ...editingConfig, name: values.name, url: values.url }
|
||||
: config,
|
||||
);
|
||||
setChatConfigs(newConfigs);
|
||||
syncConfigsToJson(newConfigs);
|
||||
} else {
|
||||
const maxId =
|
||||
chatConfigs.length > 0
|
||||
? Math.max(...chatConfigs.map((c) => c.id))
|
||||
: -1;
|
||||
const newConfig = {
|
||||
id: maxId + 1,
|
||||
name: values.name,
|
||||
url: values.url,
|
||||
};
|
||||
const newConfigs = [...chatConfigs, newConfig];
|
||||
setChatConfigs(newConfigs);
|
||||
syncConfigsToJson(newConfigs);
|
||||
}
|
||||
setModalVisible(false);
|
||||
setEditingConfig(null);
|
||||
showSuccess(isEdit ? t('编辑成功') : t('添加成功'));
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Modal form validation error:', error);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleModalCancel = () => {
|
||||
setModalVisible(false);
|
||||
setEditingConfig(null);
|
||||
};
|
||||
|
||||
const filteredConfigs = chatConfigs.filter(
|
||||
(config) =>
|
||||
!searchText ||
|
||||
config.name.toLowerCase().includes(searchText.toLowerCase()),
|
||||
);
|
||||
|
||||
const highlightKeywords = (text) => {
|
||||
if (!text) return text;
|
||||
|
||||
const parts = text.split(/(\{address\}|\{key\})/g);
|
||||
return parts.map((part, index) => {
|
||||
if (part === '{address}') {
|
||||
return (
|
||||
<span key={index} style={{ color: '#0077cc', fontWeight: 600 }}>
|
||||
{part}
|
||||
</span>
|
||||
);
|
||||
} else if (part === '{key}') {
|
||||
return (
|
||||
<span key={index} style={{ color: '#ff6b35', fontWeight: 600 }}>
|
||||
{part}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return part;
|
||||
});
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: t('聊天应用名称'),
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
render: (text) => text || t('未命名'),
|
||||
},
|
||||
{
|
||||
title: t('URL链接'),
|
||||
dataIndex: 'url',
|
||||
key: 'url',
|
||||
render: (text) => (
|
||||
<div style={{ maxWidth: 300, wordBreak: 'break-all' }}>
|
||||
{highlightKeywords(text)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('操作'),
|
||||
key: 'action',
|
||||
render: (_, record) => (
|
||||
<Space>
|
||||
<Button
|
||||
type='primary'
|
||||
icon={<IconEdit />}
|
||||
size='small'
|
||||
onClick={() => handleEditConfig(record)}
|
||||
>
|
||||
{t('编辑')}
|
||||
</Button>
|
||||
<Button
|
||||
type='danger'
|
||||
icon={<IconDelete />}
|
||||
size='small'
|
||||
onClick={() => handleDeleteConfig(record.id)}
|
||||
>
|
||||
{t('删除')}
|
||||
</Button>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Spin spinning={loading}>
|
||||
<Space vertical style={{ width: '100%' }}>
|
||||
<Form.Section text={t('聊天设置')}>
|
||||
<Banner
|
||||
type='info'
|
||||
description={t(
|
||||
'链接中的{key}将自动替换为sk-xxxx,{address}将自动替换为系统设置的服务器地址,末尾不带/和/v1',
|
||||
)}
|
||||
/>
|
||||
|
||||
<Divider />
|
||||
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<span style={{ marginRight: 16, fontWeight: 600 }}>
|
||||
{t('编辑模式')}:
|
||||
</span>
|
||||
<RadioGroup
|
||||
type='button'
|
||||
value={editMode}
|
||||
onChange={(e) => {
|
||||
const newMode = e.target.value;
|
||||
setEditMode(newMode);
|
||||
|
||||
// 确保模式切换时数据正确同步
|
||||
setTimeout(() => {
|
||||
if (newMode === 'json' && refForm.current) {
|
||||
refForm.current.setValues(inputs);
|
||||
}
|
||||
}, 100);
|
||||
}}
|
||||
>
|
||||
<Radio value='visual'>{t('可视化编辑')}</Radio>
|
||||
<Radio value='json'>{t('JSON编辑')}</Radio>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
{editMode === 'visual' ? (
|
||||
<div>
|
||||
<Space style={{ marginBottom: 16 }}>
|
||||
<Button
|
||||
type='primary'
|
||||
icon={<IconPlus />}
|
||||
onClick={handleAddConfig}
|
||||
>
|
||||
{t('添加聊天配置')}
|
||||
</Button>
|
||||
<Dropdown
|
||||
trigger='click'
|
||||
position='bottomLeft'
|
||||
menu={[
|
||||
...BUILTIN_TEMPLATES.map((tpl, idx) => ({
|
||||
node: 'item',
|
||||
key: String(idx),
|
||||
name: tpl.name,
|
||||
onClick: () => addTemplates([tpl]),
|
||||
})),
|
||||
{ node: 'divider', key: 'divider' },
|
||||
{
|
||||
node: 'item',
|
||||
key: 'all',
|
||||
name: t('全部填入'),
|
||||
onClick: () => addTemplates(BUILTIN_TEMPLATES),
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Button icon={<IconBolt />}>
|
||||
{t('填入模板')}
|
||||
</Button>
|
||||
</Dropdown>
|
||||
<Button
|
||||
type='primary'
|
||||
theme='solid'
|
||||
icon={<IconSaveStroked />}
|
||||
onClick={onSubmit}
|
||||
>
|
||||
{t('保存聊天设置')}
|
||||
</Button>
|
||||
<Input
|
||||
prefix={<IconSearch />}
|
||||
placeholder={t('搜索聊天应用名称')}
|
||||
value={searchText}
|
||||
onChange={(value) => setSearchText(value)}
|
||||
style={{ width: 250 }}
|
||||
showClear
|
||||
/>
|
||||
</Space>
|
||||
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={filteredConfigs}
|
||||
rowKey='id'
|
||||
pagination={{
|
||||
pageSize: 10,
|
||||
showSizeChanger: false,
|
||||
showQuickJumper: true,
|
||||
showTotal: (total, range) =>
|
||||
t('共 {{total}} 项,当前显示 {{start}}-{{end}} 项', {
|
||||
total,
|
||||
start: range[0],
|
||||
end: range[1],
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<Form
|
||||
values={inputs}
|
||||
getFormApi={(formAPI) => (refForm.current = formAPI)}
|
||||
>
|
||||
<Form.TextArea
|
||||
label={t('聊天配置')}
|
||||
extraText={''}
|
||||
placeholder={t('为一个 JSON 文本')}
|
||||
field={'Chats'}
|
||||
autosize={{ minRows: 6, maxRows: 12 }}
|
||||
trigger='blur'
|
||||
stopValidateWithError
|
||||
rules={[
|
||||
{
|
||||
validator: (rule, value) => {
|
||||
return verifyJSON(value);
|
||||
},
|
||||
message: t('不是合法的 JSON 字符串'),
|
||||
},
|
||||
]}
|
||||
onChange={(value) =>
|
||||
setInputs({
|
||||
...inputs,
|
||||
Chats: value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Form>
|
||||
)}
|
||||
</Form.Section>
|
||||
|
||||
{editMode === 'json' && (
|
||||
<Space>
|
||||
<Button
|
||||
type='primary'
|
||||
icon={<IconSaveStroked />}
|
||||
onClick={onSubmit}
|
||||
>
|
||||
{t('保存聊天设置')}
|
||||
</Button>
|
||||
</Space>
|
||||
)}
|
||||
</Space>
|
||||
|
||||
<Modal
|
||||
title={isEdit ? t('编辑聊天配置') : t('添加聊天配置')}
|
||||
visible={modalVisible}
|
||||
onOk={handleModalOk}
|
||||
onCancel={handleModalCancel}
|
||||
width={600}
|
||||
>
|
||||
<Form getFormApi={(api) => (modalFormRef.current = api)}>
|
||||
<Form.Input
|
||||
field='name'
|
||||
label={t('聊天应用名称')}
|
||||
placeholder={t('请输入聊天应用名称')}
|
||||
rules={[
|
||||
{ required: true, message: t('请输入聊天应用名称') },
|
||||
{ min: 1, message: t('名称不能为空') },
|
||||
]}
|
||||
/>
|
||||
<Form.Input
|
||||
field='url'
|
||||
label={t('URL链接')}
|
||||
placeholder={t('请输入完整的URL链接')}
|
||||
rules={[{ required: true, message: t('请输入URL链接') }]}
|
||||
/>
|
||||
<Banner
|
||||
type='info'
|
||||
description={t(
|
||||
'提示:链接中的{key}将被替换为API密钥,{address}将被替换为服务器地址',
|
||||
)}
|
||||
style={{ marginTop: 16 }}
|
||||
/>
|
||||
</Form>
|
||||
</Modal>
|
||||
</Spin>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,514 @@
|
||||
/*
|
||||
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 {
|
||||
Button,
|
||||
Space,
|
||||
Table,
|
||||
Form,
|
||||
Typography,
|
||||
Empty,
|
||||
Divider,
|
||||
Avatar,
|
||||
Modal,
|
||||
Tag,
|
||||
Switch,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
IllustrationNoResult,
|
||||
IllustrationNoResultDark,
|
||||
} from '@douyinfe/semi-illustrations';
|
||||
import { Plus, Edit, Trash2, Save, Settings } from 'lucide-react';
|
||||
import { API, showError, showSuccess } from '../../../helpers';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const SettingsAPIInfo = ({ options, refresh }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [apiInfoList, setApiInfoList] = useState([]);
|
||||
const [showApiModal, setShowApiModal] = useState(false);
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
const [deletingApi, setDeletingApi] = useState(null);
|
||||
const [editingApi, setEditingApi] = useState(null);
|
||||
const [modalLoading, setModalLoading] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
const [apiForm, setApiForm] = useState({
|
||||
url: '',
|
||||
description: '',
|
||||
route: '',
|
||||
color: 'blue',
|
||||
});
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
const [selectedRowKeys, setSelectedRowKeys] = useState([]);
|
||||
|
||||
// 面板启用状态 state
|
||||
const [panelEnabled, setPanelEnabled] = useState(true);
|
||||
|
||||
const colorOptions = [
|
||||
{ value: 'blue', label: 'blue' },
|
||||
{ value: 'green', label: 'green' },
|
||||
{ value: 'cyan', label: 'cyan' },
|
||||
{ value: 'purple', label: 'purple' },
|
||||
{ value: 'pink', label: 'pink' },
|
||||
{ value: 'red', label: 'red' },
|
||||
{ value: 'orange', label: 'orange' },
|
||||
{ value: 'amber', label: 'amber' },
|
||||
{ value: 'yellow', label: 'yellow' },
|
||||
{ value: 'lime', label: 'lime' },
|
||||
{ value: 'light-green', label: 'light-green' },
|
||||
{ value: 'teal', label: 'teal' },
|
||||
{ value: 'light-blue', label: 'light-blue' },
|
||||
{ value: 'indigo', label: 'indigo' },
|
||||
{ value: 'violet', label: 'violet' },
|
||||
{ value: 'grey', label: 'grey' },
|
||||
];
|
||||
|
||||
const updateOption = async (key, value) => {
|
||||
const res = await API.put('/api/option/', {
|
||||
key,
|
||||
value,
|
||||
});
|
||||
const { success, message } = res.data;
|
||||
if (success) {
|
||||
showSuccess('API信息已更新');
|
||||
if (refresh) refresh();
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
};
|
||||
|
||||
const submitApiInfo = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const apiInfoJson = JSON.stringify(apiInfoList);
|
||||
await updateOption('console_setting.api_info', apiInfoJson);
|
||||
setHasChanges(false);
|
||||
} catch (error) {
|
||||
console.error('API信息更新失败', error);
|
||||
showError('API信息更新失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddApi = () => {
|
||||
setEditingApi(null);
|
||||
setApiForm({
|
||||
url: '',
|
||||
description: '',
|
||||
route: '',
|
||||
color: 'blue',
|
||||
});
|
||||
setShowApiModal(true);
|
||||
};
|
||||
|
||||
const handleEditApi = (api) => {
|
||||
setEditingApi(api);
|
||||
setApiForm({
|
||||
url: api.url,
|
||||
description: api.description,
|
||||
route: api.route,
|
||||
color: api.color,
|
||||
});
|
||||
setShowApiModal(true);
|
||||
};
|
||||
|
||||
const handleDeleteApi = (api) => {
|
||||
setDeletingApi(api);
|
||||
setShowDeleteModal(true);
|
||||
};
|
||||
|
||||
const confirmDeleteApi = () => {
|
||||
if (deletingApi) {
|
||||
const newList = apiInfoList.filter((api) => api.id !== deletingApi.id);
|
||||
setApiInfoList(newList);
|
||||
setHasChanges(true);
|
||||
showSuccess('API信息已删除,请及时点击“保存设置”进行保存');
|
||||
}
|
||||
setShowDeleteModal(false);
|
||||
setDeletingApi(null);
|
||||
};
|
||||
|
||||
const handleSaveApi = async () => {
|
||||
if (!apiForm.url || !apiForm.route || !apiForm.description) {
|
||||
showError('请填写完整的API信息');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setModalLoading(true);
|
||||
|
||||
let newList;
|
||||
if (editingApi) {
|
||||
newList = apiInfoList.map((api) =>
|
||||
api.id === editingApi.id ? { ...api, ...apiForm } : api,
|
||||
);
|
||||
} else {
|
||||
const newId = Math.max(...apiInfoList.map((api) => api.id), 0) + 1;
|
||||
const newApi = {
|
||||
id: newId,
|
||||
...apiForm,
|
||||
};
|
||||
newList = [...apiInfoList, newApi];
|
||||
}
|
||||
|
||||
setApiInfoList(newList);
|
||||
setHasChanges(true);
|
||||
setShowApiModal(false);
|
||||
showSuccess(
|
||||
editingApi
|
||||
? 'API信息已更新,请及时点击“保存设置”进行保存'
|
||||
: 'API信息已添加,请及时点击“保存设置”进行保存',
|
||||
);
|
||||
} catch (error) {
|
||||
showError('操作失败: ' + error.message);
|
||||
} finally {
|
||||
setModalLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const parseApiInfo = (apiInfoStr) => {
|
||||
if (!apiInfoStr) {
|
||||
setApiInfoList([]);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(apiInfoStr);
|
||||
setApiInfoList(Array.isArray(parsed) ? parsed : []);
|
||||
} catch (error) {
|
||||
console.error('解析API信息失败:', error);
|
||||
setApiInfoList([]);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const apiInfoStr = options['console_setting.api_info'] ?? options.ApiInfo;
|
||||
if (apiInfoStr !== undefined) {
|
||||
parseApiInfo(apiInfoStr);
|
||||
}
|
||||
}, [options['console_setting.api_info'], options.ApiInfo]);
|
||||
|
||||
useEffect(() => {
|
||||
const enabledStr = options['console_setting.api_info_enabled'];
|
||||
setPanelEnabled(
|
||||
enabledStr === undefined
|
||||
? true
|
||||
: enabledStr === 'true' || enabledStr === true,
|
||||
);
|
||||
}, [options['console_setting.api_info_enabled']]);
|
||||
|
||||
const handleToggleEnabled = async (checked) => {
|
||||
const newValue = checked ? 'true' : 'false';
|
||||
try {
|
||||
const res = await API.put('/api/option/', {
|
||||
key: 'console_setting.api_info_enabled',
|
||||
value: newValue,
|
||||
});
|
||||
if (res.data.success) {
|
||||
setPanelEnabled(checked);
|
||||
showSuccess(t('设置已保存'));
|
||||
refresh?.();
|
||||
} else {
|
||||
showError(res.data.message);
|
||||
}
|
||||
} catch (err) {
|
||||
showError(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: 'ID',
|
||||
dataIndex: 'id',
|
||||
},
|
||||
{
|
||||
title: t('API地址'),
|
||||
dataIndex: 'url',
|
||||
render: (text, record) => (
|
||||
<Tag color={record.color} shape='circle' style={{ maxWidth: '280px' }}>
|
||||
{text}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('线路描述'),
|
||||
dataIndex: 'route',
|
||||
render: (text, record) => <Tag shape='circle'>{text}</Tag>,
|
||||
},
|
||||
{
|
||||
title: t('说明'),
|
||||
dataIndex: 'description',
|
||||
ellipsis: true,
|
||||
render: (text, record) => <Tag shape='circle'>{text || '-'}</Tag>,
|
||||
},
|
||||
{
|
||||
title: t('颜色'),
|
||||
dataIndex: 'color',
|
||||
render: (color) => <Avatar size='extra-extra-small' color={color} />,
|
||||
},
|
||||
{
|
||||
title: t('操作'),
|
||||
fixed: 'right',
|
||||
width: 150,
|
||||
render: (_, record) => (
|
||||
<Space>
|
||||
<Button
|
||||
icon={<Edit size={14} />}
|
||||
theme='light'
|
||||
type='tertiary'
|
||||
size='small'
|
||||
onClick={() => handleEditApi(record)}
|
||||
>
|
||||
{t('编辑')}
|
||||
</Button>
|
||||
<Button
|
||||
icon={<Trash2 size={14} />}
|
||||
type='danger'
|
||||
theme='light'
|
||||
size='small'
|
||||
onClick={() => handleDeleteApi(record)}
|
||||
>
|
||||
{t('删除')}
|
||||
</Button>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const handleBatchDelete = () => {
|
||||
if (selectedRowKeys.length === 0) {
|
||||
showError('请先选择要删除的API信息');
|
||||
return;
|
||||
}
|
||||
|
||||
const newList = apiInfoList.filter(
|
||||
(api) => !selectedRowKeys.includes(api.id),
|
||||
);
|
||||
setApiInfoList(newList);
|
||||
setSelectedRowKeys([]);
|
||||
setHasChanges(true);
|
||||
showSuccess(
|
||||
`已删除 ${selectedRowKeys.length} 个API信息,请及时点击“保存设置”进行保存`,
|
||||
);
|
||||
};
|
||||
|
||||
const renderHeader = () => (
|
||||
<div className='flex flex-col w-full'>
|
||||
<div className='mb-2'>
|
||||
<div className='flex items-center text-blue-500'>
|
||||
<Settings size={16} className='mr-2' />
|
||||
<Text>
|
||||
{t(
|
||||
'API信息管理,可以配置多个API地址用于状态展示和负载均衡(最多50个)',
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Divider margin='12px' />
|
||||
|
||||
<div className='flex flex-col md:flex-row justify-between items-center gap-4 w-full'>
|
||||
<div className='flex gap-2 w-full md:w-auto order-2 md:order-1'>
|
||||
<Button
|
||||
theme='light'
|
||||
type='primary'
|
||||
icon={<Plus size={14} />}
|
||||
className='w-full md:w-auto'
|
||||
onClick={handleAddApi}
|
||||
>
|
||||
{t('添加API')}
|
||||
</Button>
|
||||
<Button
|
||||
icon={<Trash2 size={14} />}
|
||||
type='danger'
|
||||
theme='light'
|
||||
onClick={handleBatchDelete}
|
||||
disabled={selectedRowKeys.length === 0}
|
||||
className='w-full md:w-auto'
|
||||
>
|
||||
{t('批量删除')}{' '}
|
||||
{selectedRowKeys.length > 0 && `(${selectedRowKeys.length})`}
|
||||
</Button>
|
||||
<Button
|
||||
icon={<Save size={14} />}
|
||||
onClick={submitApiInfo}
|
||||
loading={loading}
|
||||
disabled={!hasChanges}
|
||||
type='secondary'
|
||||
className='w-full md:w-auto'
|
||||
>
|
||||
{t('保存设置')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 启用开关 */}
|
||||
<div className='order-1 md:order-2 flex items-center gap-2'>
|
||||
<Switch checked={panelEnabled} onChange={handleToggleEnabled} />
|
||||
<Text>{panelEnabled ? t('已启用') : t('已禁用')}</Text>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// 计算当前页显示的数据
|
||||
const getCurrentPageData = () => {
|
||||
const startIndex = (currentPage - 1) * pageSize;
|
||||
const endIndex = startIndex + pageSize;
|
||||
return apiInfoList.slice(startIndex, endIndex);
|
||||
};
|
||||
|
||||
const rowSelection = {
|
||||
selectedRowKeys,
|
||||
onChange: (selectedRowKeys, selectedRows) => {
|
||||
setSelectedRowKeys(selectedRowKeys);
|
||||
},
|
||||
onSelect: (record, selected, selectedRows) => {
|
||||
console.log(`选择行: ${selected}`, record);
|
||||
},
|
||||
onSelectAll: (selected, selectedRows) => {
|
||||
console.log(`全选: ${selected}`, selectedRows);
|
||||
},
|
||||
getCheckboxProps: (record) => ({
|
||||
disabled: false,
|
||||
name: record.id,
|
||||
}),
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Form.Section text={renderHeader()}>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={getCurrentPageData()}
|
||||
rowSelection={rowSelection}
|
||||
rowKey='id'
|
||||
scroll={{ x: 'max-content' }}
|
||||
pagination={{
|
||||
currentPage: currentPage,
|
||||
pageSize: pageSize,
|
||||
total: apiInfoList.length,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
pageSizeOptions: ['5', '10', '20', '50'],
|
||||
onChange: (page, size) => {
|
||||
setCurrentPage(page);
|
||||
setPageSize(size);
|
||||
},
|
||||
onShowSizeChange: (current, size) => {
|
||||
setCurrentPage(1);
|
||||
setPageSize(size);
|
||||
},
|
||||
}}
|
||||
size='middle'
|
||||
loading={loading}
|
||||
empty={
|
||||
<Empty
|
||||
image={
|
||||
<IllustrationNoResult style={{ width: 150, height: 150 }} />
|
||||
}
|
||||
darkModeImage={
|
||||
<IllustrationNoResultDark style={{ width: 150, height: 150 }} />
|
||||
}
|
||||
description={t('暂无API信息')}
|
||||
style={{ padding: 30 }}
|
||||
/>
|
||||
}
|
||||
className='overflow-hidden'
|
||||
/>
|
||||
</Form.Section>
|
||||
|
||||
<Modal
|
||||
title={editingApi ? t('编辑API') : t('添加API')}
|
||||
visible={showApiModal}
|
||||
onOk={handleSaveApi}
|
||||
onCancel={() => setShowApiModal(false)}
|
||||
okText={t('保存')}
|
||||
cancelText={t('取消')}
|
||||
confirmLoading={modalLoading}
|
||||
>
|
||||
<Form
|
||||
layout='vertical'
|
||||
initValues={apiForm}
|
||||
key={editingApi ? editingApi.id : 'new'}
|
||||
>
|
||||
<Form.Input
|
||||
field='url'
|
||||
label={t('API地址')}
|
||||
placeholder='https://api.example.com'
|
||||
rules={[{ required: true, message: t('请输入API地址') }]}
|
||||
onChange={(value) => setApiForm({ ...apiForm, url: value })}
|
||||
/>
|
||||
<Form.Input
|
||||
field='route'
|
||||
label={t('线路描述')}
|
||||
placeholder={t('如:香港线路')}
|
||||
rules={[{ required: true, message: t('请输入线路描述') }]}
|
||||
onChange={(value) => setApiForm({ ...apiForm, route: value })}
|
||||
/>
|
||||
<Form.Input
|
||||
field='description'
|
||||
label={t('说明')}
|
||||
placeholder={t('如:大带宽批量分析图片推荐')}
|
||||
rules={[{ required: true, message: t('请输入说明') }]}
|
||||
onChange={(value) => setApiForm({ ...apiForm, description: value })}
|
||||
/>
|
||||
<Form.Select
|
||||
field='color'
|
||||
label={t('标识颜色')}
|
||||
optionList={colorOptions}
|
||||
onChange={(value) => setApiForm({ ...apiForm, color: value })}
|
||||
render={(option) => (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<Avatar size='extra-extra-small' color={option.value} />
|
||||
{option.label}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title={t('确认删除')}
|
||||
visible={showDeleteModal}
|
||||
onOk={confirmDeleteApi}
|
||||
onCancel={() => {
|
||||
setShowDeleteModal(false);
|
||||
setDeletingApi(null);
|
||||
}}
|
||||
okText={t('确认删除')}
|
||||
cancelText={t('取消')}
|
||||
type='warning'
|
||||
okButtonProps={{
|
||||
type: 'danger',
|
||||
theme: 'solid',
|
||||
}}
|
||||
>
|
||||
<Text>{t('确定要删除此API信息吗?')}</Text>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingsAPIInfo;
|
||||
@@ -0,0 +1,634 @@
|
||||
/*
|
||||
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 {
|
||||
Button,
|
||||
Space,
|
||||
Table,
|
||||
Form,
|
||||
Typography,
|
||||
Empty,
|
||||
Divider,
|
||||
Modal,
|
||||
Tag,
|
||||
Switch,
|
||||
TextArea,
|
||||
Tooltip,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
IllustrationNoResult,
|
||||
IllustrationNoResultDark,
|
||||
} from '@douyinfe/semi-illustrations';
|
||||
import { Plus, Edit, Trash2, Save, Bell, Maximize2 } from 'lucide-react';
|
||||
import {
|
||||
API,
|
||||
showError,
|
||||
showSuccess,
|
||||
getRelativeTime,
|
||||
formatDateTimeString,
|
||||
} from '../../../helpers';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const SettingsAnnouncements = ({ options, refresh }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [announcementsList, setAnnouncementsList] = useState([]);
|
||||
const [showAnnouncementModal, setShowAnnouncementModal] = useState(false);
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
const [showContentModal, setShowContentModal] = useState(false);
|
||||
const [deletingAnnouncement, setDeletingAnnouncement] = useState(null);
|
||||
const [editingAnnouncement, setEditingAnnouncement] = useState(null);
|
||||
const [modalLoading, setModalLoading] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
const [announcementForm, setAnnouncementForm] = useState({
|
||||
content: '',
|
||||
publishDate: new Date(),
|
||||
type: 'default',
|
||||
extra: '',
|
||||
});
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
const [selectedRowKeys, setSelectedRowKeys] = useState([]);
|
||||
|
||||
// 面板启用状态
|
||||
const [panelEnabled, setPanelEnabled] = useState(true);
|
||||
|
||||
const formApiRef = useRef(null);
|
||||
|
||||
const typeOptions = [
|
||||
{ value: 'default', label: t('默认') },
|
||||
{ value: 'ongoing', label: t('进行中') },
|
||||
{ value: 'success', label: t('成功') },
|
||||
{ value: 'warning', label: t('警告') },
|
||||
{ value: 'error', label: t('错误') },
|
||||
];
|
||||
|
||||
const getTypeColor = (type) => {
|
||||
const colorMap = {
|
||||
default: 'grey',
|
||||
ongoing: 'blue',
|
||||
success: 'green',
|
||||
warning: 'orange',
|
||||
error: 'red',
|
||||
};
|
||||
return colorMap[type] || 'grey';
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: t('内容'),
|
||||
dataIndex: 'content',
|
||||
key: 'content',
|
||||
render: (text) => (
|
||||
<Tooltip content={text} position='topLeft' showArrow>
|
||||
<div
|
||||
style={{
|
||||
maxWidth: '300px',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</div>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('发布时间'),
|
||||
dataIndex: 'publishDate',
|
||||
key: 'publishDate',
|
||||
width: 180,
|
||||
render: (publishDate) => (
|
||||
<div>
|
||||
<div style={{ fontWeight: 'bold' }}>
|
||||
{getRelativeTime(publishDate)}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
color: 'var(--semi-color-text-2)',
|
||||
marginTop: '2px',
|
||||
}}
|
||||
>
|
||||
{publishDate ? formatDateTimeString(new Date(publishDate)) : '-'}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('类型'),
|
||||
dataIndex: 'type',
|
||||
key: 'type',
|
||||
width: 100,
|
||||
render: (type) => (
|
||||
<Tag color={getTypeColor(type)} shape='circle'>
|
||||
{typeOptions.find((opt) => opt.value === type)?.label || type}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('说明'),
|
||||
dataIndex: 'extra',
|
||||
key: 'extra',
|
||||
render: (text) => (
|
||||
<Tooltip content={text || '-'} showArrow>
|
||||
<div
|
||||
style={{
|
||||
maxWidth: '200px',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
color: 'var(--semi-color-text-2)',
|
||||
}}
|
||||
>
|
||||
{text || '-'}
|
||||
</div>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('操作'),
|
||||
key: 'action',
|
||||
fixed: 'right',
|
||||
width: 150,
|
||||
render: (text, record) => (
|
||||
<Space>
|
||||
<Button
|
||||
icon={<Edit size={14} />}
|
||||
theme='light'
|
||||
type='tertiary'
|
||||
size='small'
|
||||
onClick={() => handleEditAnnouncement(record)}
|
||||
>
|
||||
{t('编辑')}
|
||||
</Button>
|
||||
<Button
|
||||
icon={<Trash2 size={14} />}
|
||||
type='danger'
|
||||
theme='light'
|
||||
size='small'
|
||||
onClick={() => handleDeleteAnnouncement(record)}
|
||||
>
|
||||
{t('删除')}
|
||||
</Button>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const updateOption = async (key, value) => {
|
||||
const res = await API.put('/api/option/', {
|
||||
key,
|
||||
value,
|
||||
});
|
||||
const { success, message } = res.data;
|
||||
if (success) {
|
||||
showSuccess('系统公告已更新');
|
||||
if (refresh) refresh();
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
};
|
||||
|
||||
const submitAnnouncements = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const announcementsJson = JSON.stringify(announcementsList);
|
||||
await updateOption('console_setting.announcements', announcementsJson);
|
||||
setHasChanges(false);
|
||||
} catch (error) {
|
||||
console.error('系统公告更新失败', error);
|
||||
showError('系统公告更新失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddAnnouncement = () => {
|
||||
setEditingAnnouncement(null);
|
||||
setAnnouncementForm({
|
||||
content: '',
|
||||
publishDate: new Date(),
|
||||
type: 'default',
|
||||
extra: '',
|
||||
});
|
||||
setShowAnnouncementModal(true);
|
||||
};
|
||||
|
||||
const handleEditAnnouncement = (announcement) => {
|
||||
setEditingAnnouncement(announcement);
|
||||
setAnnouncementForm({
|
||||
content: announcement.content,
|
||||
publishDate: announcement.publishDate
|
||||
? new Date(announcement.publishDate)
|
||||
: new Date(),
|
||||
type: announcement.type || 'default',
|
||||
extra: announcement.extra || '',
|
||||
});
|
||||
setShowAnnouncementModal(true);
|
||||
};
|
||||
|
||||
const handleDeleteAnnouncement = (announcement) => {
|
||||
setDeletingAnnouncement(announcement);
|
||||
setShowDeleteModal(true);
|
||||
};
|
||||
|
||||
const confirmDeleteAnnouncement = () => {
|
||||
if (deletingAnnouncement) {
|
||||
const newList = announcementsList.filter(
|
||||
(item) => item.id !== deletingAnnouncement.id,
|
||||
);
|
||||
setAnnouncementsList(newList);
|
||||
setHasChanges(true);
|
||||
showSuccess('公告已删除,请及时点击“保存设置”进行保存');
|
||||
}
|
||||
setShowDeleteModal(false);
|
||||
setDeletingAnnouncement(null);
|
||||
};
|
||||
|
||||
const handleSaveAnnouncement = async () => {
|
||||
if (!announcementForm.content || !announcementForm.publishDate) {
|
||||
showError('请填写完整的公告信息');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setModalLoading(true);
|
||||
|
||||
// 将publishDate转换为ISO字符串保存
|
||||
const formData = {
|
||||
...announcementForm,
|
||||
publishDate: announcementForm.publishDate.toISOString(),
|
||||
};
|
||||
|
||||
let newList;
|
||||
if (editingAnnouncement) {
|
||||
newList = announcementsList.map((item) =>
|
||||
item.id === editingAnnouncement.id ? { ...item, ...formData } : item,
|
||||
);
|
||||
} else {
|
||||
const newId =
|
||||
Math.max(...announcementsList.map((item) => item.id), 0) + 1;
|
||||
const newAnnouncement = {
|
||||
id: newId,
|
||||
...formData,
|
||||
};
|
||||
newList = [...announcementsList, newAnnouncement];
|
||||
}
|
||||
|
||||
setAnnouncementsList(newList);
|
||||
setHasChanges(true);
|
||||
setShowAnnouncementModal(false);
|
||||
showSuccess(
|
||||
editingAnnouncement
|
||||
? '公告已更新,请及时点击“保存设置”进行保存'
|
||||
: '公告已添加,请及时点击“保存设置”进行保存',
|
||||
);
|
||||
} catch (error) {
|
||||
showError('操作失败: ' + error.message);
|
||||
} finally {
|
||||
setModalLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const parseAnnouncements = (announcementsStr) => {
|
||||
if (!announcementsStr) {
|
||||
setAnnouncementsList([]);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(announcementsStr);
|
||||
const list = Array.isArray(parsed) ? parsed : [];
|
||||
// 确保每个项目都有id
|
||||
const listWithIds = list.map((item, index) => ({
|
||||
...item,
|
||||
id: item.id || index + 1,
|
||||
}));
|
||||
setAnnouncementsList(listWithIds);
|
||||
} catch (error) {
|
||||
console.error('解析系统公告失败:', error);
|
||||
setAnnouncementsList([]);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const annStr =
|
||||
options['console_setting.announcements'] ?? options.Announcements;
|
||||
if (annStr !== undefined) {
|
||||
parseAnnouncements(annStr);
|
||||
}
|
||||
}, [options['console_setting.announcements'], options.Announcements]);
|
||||
|
||||
useEffect(() => {
|
||||
const enabledStr = options['console_setting.announcements_enabled'];
|
||||
setPanelEnabled(
|
||||
enabledStr === undefined
|
||||
? true
|
||||
: enabledStr === 'true' || enabledStr === true,
|
||||
);
|
||||
}, [options['console_setting.announcements_enabled']]);
|
||||
|
||||
const handleToggleEnabled = async (checked) => {
|
||||
const newValue = checked ? 'true' : 'false';
|
||||
try {
|
||||
const res = await API.put('/api/option/', {
|
||||
key: 'console_setting.announcements_enabled',
|
||||
value: newValue,
|
||||
});
|
||||
if (res.data.success) {
|
||||
setPanelEnabled(checked);
|
||||
showSuccess(t('设置已保存'));
|
||||
refresh?.();
|
||||
} else {
|
||||
showError(res.data.message);
|
||||
}
|
||||
} catch (err) {
|
||||
showError(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBatchDelete = () => {
|
||||
if (selectedRowKeys.length === 0) {
|
||||
showError('请先选择要删除的系统公告');
|
||||
return;
|
||||
}
|
||||
|
||||
const newList = announcementsList.filter(
|
||||
(item) => !selectedRowKeys.includes(item.id),
|
||||
);
|
||||
setAnnouncementsList(newList);
|
||||
setSelectedRowKeys([]);
|
||||
setHasChanges(true);
|
||||
showSuccess(
|
||||
`已删除 ${selectedRowKeys.length} 个系统公告,请及时点击“保存设置”进行保存`,
|
||||
);
|
||||
};
|
||||
|
||||
const renderHeader = () => (
|
||||
<div className='flex flex-col w-full'>
|
||||
<div className='mb-2'>
|
||||
<div className='flex items-center text-blue-500'>
|
||||
<Bell size={16} className='mr-2' />
|
||||
<Text>
|
||||
{t(
|
||||
'系统公告管理,可以发布系统通知和重要消息(最多100个,前端显示最新20条)',
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Divider margin='12px' />
|
||||
|
||||
<div className='flex flex-col md:flex-row justify-between items-center gap-4 w-full'>
|
||||
<div className='flex gap-2 w-full md:w-auto order-2 md:order-1'>
|
||||
<Button
|
||||
theme='light'
|
||||
type='primary'
|
||||
icon={<Plus size={14} />}
|
||||
className='w-full md:w-auto'
|
||||
onClick={handleAddAnnouncement}
|
||||
>
|
||||
{t('添加公告')}
|
||||
</Button>
|
||||
<Button
|
||||
icon={<Trash2 size={14} />}
|
||||
type='danger'
|
||||
theme='light'
|
||||
onClick={handleBatchDelete}
|
||||
disabled={selectedRowKeys.length === 0}
|
||||
className='w-full md:w-auto'
|
||||
>
|
||||
{t('批量删除')}{' '}
|
||||
{selectedRowKeys.length > 0 && `(${selectedRowKeys.length})`}
|
||||
</Button>
|
||||
<Button
|
||||
icon={<Save size={14} />}
|
||||
onClick={submitAnnouncements}
|
||||
loading={loading}
|
||||
disabled={!hasChanges}
|
||||
type='secondary'
|
||||
className='w-full md:w-auto'
|
||||
>
|
||||
{t('保存设置')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 启用开关 */}
|
||||
<div className='order-1 md:order-2 flex items-center gap-2'>
|
||||
<Switch checked={panelEnabled} onChange={handleToggleEnabled} />
|
||||
<Text>{panelEnabled ? t('已启用') : t('已禁用')}</Text>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// 计算当前页显示的数据(按发布时间倒序排序,最新优先显示)
|
||||
const getCurrentPageData = () => {
|
||||
const sortedList = [...announcementsList].sort((a, b) => {
|
||||
const dateA = new Date(a.publishDate).getTime();
|
||||
const dateB = new Date(b.publishDate).getTime();
|
||||
return dateB - dateA; // 倒序,最新的排在前面
|
||||
});
|
||||
|
||||
const startIndex = (currentPage - 1) * pageSize;
|
||||
const endIndex = startIndex + pageSize;
|
||||
return sortedList.slice(startIndex, endIndex);
|
||||
};
|
||||
|
||||
const rowSelection = {
|
||||
selectedRowKeys,
|
||||
onChange: (selectedRowKeys, selectedRows) => {
|
||||
setSelectedRowKeys(selectedRowKeys);
|
||||
},
|
||||
onSelect: (record, selected, selectedRows) => {
|
||||
console.log(`选择行: ${selected}`, record);
|
||||
},
|
||||
onSelectAll: (selected, selectedRows) => {
|
||||
console.log(`全选: ${selected}`, selectedRows);
|
||||
},
|
||||
getCheckboxProps: (record) => ({
|
||||
disabled: false,
|
||||
name: record.id,
|
||||
}),
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Form.Section text={renderHeader()}>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={getCurrentPageData()}
|
||||
rowSelection={rowSelection}
|
||||
rowKey='id'
|
||||
scroll={{ x: 'max-content' }}
|
||||
pagination={{
|
||||
currentPage: currentPage,
|
||||
pageSize: pageSize,
|
||||
total: announcementsList.length,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
pageSizeOptions: ['5', '10', '20', '50'],
|
||||
onChange: (page, size) => {
|
||||
setCurrentPage(page);
|
||||
setPageSize(size);
|
||||
},
|
||||
onShowSizeChange: (current, size) => {
|
||||
setCurrentPage(1);
|
||||
setPageSize(size);
|
||||
},
|
||||
}}
|
||||
size='middle'
|
||||
loading={loading}
|
||||
empty={
|
||||
<Empty
|
||||
image={
|
||||
<IllustrationNoResult style={{ width: 150, height: 150 }} />
|
||||
}
|
||||
darkModeImage={
|
||||
<IllustrationNoResultDark style={{ width: 150, height: 150 }} />
|
||||
}
|
||||
description={t('暂无系统公告')}
|
||||
style={{ padding: 30 }}
|
||||
/>
|
||||
}
|
||||
className='overflow-hidden'
|
||||
/>
|
||||
</Form.Section>
|
||||
|
||||
<Modal
|
||||
title={editingAnnouncement ? t('编辑公告') : t('添加公告')}
|
||||
visible={showAnnouncementModal}
|
||||
onOk={handleSaveAnnouncement}
|
||||
onCancel={() => setShowAnnouncementModal(false)}
|
||||
okText={t('保存')}
|
||||
cancelText={t('取消')}
|
||||
confirmLoading={modalLoading}
|
||||
>
|
||||
<Form
|
||||
layout='vertical'
|
||||
initValues={announcementForm}
|
||||
key={editingAnnouncement ? editingAnnouncement.id : 'new'}
|
||||
getFormApi={(api) => (formApiRef.current = api)}
|
||||
>
|
||||
<Form.TextArea
|
||||
field='content'
|
||||
label={t('公告内容')}
|
||||
placeholder={t('请输入公告内容(支持 Markdown/HTML)')}
|
||||
maxCount={500}
|
||||
rows={3}
|
||||
rules={[{ required: true, message: t('请输入公告内容') }]}
|
||||
onChange={(value) =>
|
||||
setAnnouncementForm({ ...announcementForm, content: value })
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
theme='light'
|
||||
type='tertiary'
|
||||
size='small'
|
||||
icon={<Maximize2 size={14} />}
|
||||
style={{ marginBottom: 16 }}
|
||||
onClick={() => setShowContentModal(true)}
|
||||
>
|
||||
{t('放大编辑')}
|
||||
</Button>
|
||||
<Form.DatePicker
|
||||
field='publishDate'
|
||||
label={t('发布日期')}
|
||||
type='dateTime'
|
||||
rules={[{ required: true, message: t('请选择发布日期') }]}
|
||||
onChange={(value) =>
|
||||
setAnnouncementForm({ ...announcementForm, publishDate: value })
|
||||
}
|
||||
/>
|
||||
<Form.Select
|
||||
field='type'
|
||||
label={t('公告类型')}
|
||||
optionList={typeOptions}
|
||||
onChange={(value) =>
|
||||
setAnnouncementForm({ ...announcementForm, type: value })
|
||||
}
|
||||
/>
|
||||
<Form.Input
|
||||
field='extra'
|
||||
label={t('说明信息')}
|
||||
placeholder={t('可选,公告的补充说明')}
|
||||
onChange={(value) =>
|
||||
setAnnouncementForm({ ...announcementForm, extra: value })
|
||||
}
|
||||
/>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title={t('确认删除')}
|
||||
visible={showDeleteModal}
|
||||
onOk={confirmDeleteAnnouncement}
|
||||
onCancel={() => {
|
||||
setShowDeleteModal(false);
|
||||
setDeletingAnnouncement(null);
|
||||
}}
|
||||
okText={t('确认删除')}
|
||||
cancelText={t('取消')}
|
||||
type='warning'
|
||||
okButtonProps={{
|
||||
type: 'danger',
|
||||
theme: 'solid',
|
||||
}}
|
||||
>
|
||||
<Text>{t('确定要删除此公告吗?')}</Text>
|
||||
</Modal>
|
||||
|
||||
{/* 公告内容放大编辑 Modal */}
|
||||
<Modal
|
||||
title={t('编辑公告内容')}
|
||||
visible={showContentModal}
|
||||
onOk={() => {
|
||||
// 将内容同步到表单
|
||||
if (formApiRef.current) {
|
||||
formApiRef.current.setValue('content', announcementForm.content);
|
||||
}
|
||||
setShowContentModal(false);
|
||||
}}
|
||||
onCancel={() => setShowContentModal(false)}
|
||||
okText={t('确定')}
|
||||
cancelText={t('取消')}
|
||||
width={800}
|
||||
>
|
||||
<TextArea
|
||||
value={announcementForm.content}
|
||||
placeholder={t('请输入公告内容(支持 Markdown/HTML)')}
|
||||
maxCount={500}
|
||||
rows={15}
|
||||
style={{ width: '100%' }}
|
||||
onChange={(value) =>
|
||||
setAnnouncementForm({ ...announcementForm, content: value })
|
||||
}
|
||||
/>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingsAnnouncements;
|
||||
@@ -0,0 +1,170 @@
|
||||
/*
|
||||
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 { Button, Col, Form, Row, Spin } from '@douyinfe/semi-ui';
|
||||
import {
|
||||
compareObjects,
|
||||
API,
|
||||
showError,
|
||||
showSuccess,
|
||||
showWarning,
|
||||
} from '../../../helpers';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export default function DataDashboard(props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const optionsDataExportDefaultTime = [
|
||||
{ key: 'hour', label: t('小时'), value: 'hour' },
|
||||
{ key: 'day', label: t('天'), value: 'day' },
|
||||
{ key: 'week', label: t('周'), value: 'week' },
|
||||
];
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [inputs, setInputs] = useState({
|
||||
DataExportEnabled: false,
|
||||
DataExportInterval: '',
|
||||
DataExportDefaultTime: '',
|
||||
});
|
||||
const refForm = useRef();
|
||||
const [inputsRow, setInputsRow] = useState(inputs);
|
||||
|
||||
function onSubmit() {
|
||||
const updateArray = compareObjects(inputs, inputsRow);
|
||||
if (!updateArray.length) return showWarning(t('你似乎并没有修改什么'));
|
||||
const requestQueue = updateArray.map((item) => {
|
||||
let value = '';
|
||||
if (typeof inputs[item.key] === 'boolean') {
|
||||
value = String(inputs[item.key]);
|
||||
} else {
|
||||
value = inputs[item.key];
|
||||
}
|
||||
return API.put('/api/option/', {
|
||||
key: item.key,
|
||||
value,
|
||||
});
|
||||
});
|
||||
setLoading(true);
|
||||
Promise.all(requestQueue)
|
||||
.then((res) => {
|
||||
if (requestQueue.length === 1) {
|
||||
if (res.includes(undefined)) return;
|
||||
} else if (requestQueue.length > 1) {
|
||||
if (res.includes(undefined))
|
||||
return showError(t('部分保存失败,请重试'));
|
||||
}
|
||||
showSuccess(t('保存成功'));
|
||||
props.refresh();
|
||||
})
|
||||
.catch(() => {
|
||||
showError(t('保存失败,请重试'));
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const currentInputs = {};
|
||||
for (let key in props.options) {
|
||||
if (Object.keys(inputs).includes(key)) {
|
||||
currentInputs[key] = props.options[key];
|
||||
}
|
||||
}
|
||||
setInputs(currentInputs);
|
||||
setInputsRow(structuredClone(currentInputs));
|
||||
refForm.current.setValues(currentInputs);
|
||||
localStorage.setItem(
|
||||
'data_export_default_time',
|
||||
String(inputs.DataExportDefaultTime),
|
||||
);
|
||||
}, [props.options]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Spin spinning={loading}>
|
||||
<Form
|
||||
values={inputs}
|
||||
getFormApi={(formAPI) => (refForm.current = formAPI)}
|
||||
style={{ marginBottom: 15 }}
|
||||
>
|
||||
<Form.Section text={t('数据看板设置')}>
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
|
||||
<Form.Switch
|
||||
field={'DataExportEnabled'}
|
||||
label={t('启用数据看板(实验性)')}
|
||||
size='default'
|
||||
checkedText='|'
|
||||
uncheckedText='〇'
|
||||
onChange={(value) => {
|
||||
setInputs({
|
||||
...inputs,
|
||||
DataExportEnabled: value,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row>
|
||||
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
|
||||
<Form.InputNumber
|
||||
label={t('数据看板更新间隔')}
|
||||
step={1}
|
||||
min={1}
|
||||
suffix={t('分钟')}
|
||||
extraText={t('设置过短会影响数据库性能')}
|
||||
placeholder={t('数据看板更新间隔')}
|
||||
field={'DataExportInterval'}
|
||||
onChange={(value) =>
|
||||
setInputs({
|
||||
...inputs,
|
||||
DataExportInterval: String(value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
|
||||
<Form.Select
|
||||
label={t('数据看板默认时间粒度')}
|
||||
optionList={optionsDataExportDefaultTime}
|
||||
field={'DataExportDefaultTime'}
|
||||
extraText={t('仅修改展示粒度,统计精确到小时')}
|
||||
placeholder={t('数据看板默认时间粒度')}
|
||||
style={{ width: 180 }}
|
||||
onChange={(value) =>
|
||||
setInputs({
|
||||
...inputs,
|
||||
DataExportDefaultTime: String(value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row>
|
||||
<Button size='default' onClick={onSubmit}>
|
||||
{t('保存数据看板设置')}
|
||||
</Button>
|
||||
</Row>
|
||||
</Form.Section>
|
||||
</Form>
|
||||
</Spin>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,490 @@
|
||||
/*
|
||||
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 {
|
||||
Button,
|
||||
Space,
|
||||
Table,
|
||||
Form,
|
||||
Typography,
|
||||
Empty,
|
||||
Divider,
|
||||
Modal,
|
||||
Switch,
|
||||
Tooltip,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
IllustrationNoResult,
|
||||
IllustrationNoResultDark,
|
||||
} from '@douyinfe/semi-illustrations';
|
||||
import { Plus, Edit, Trash2, Save, HelpCircle } from 'lucide-react';
|
||||
import { API, showError, showSuccess } from '../../../helpers';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const SettingsFAQ = ({ options, refresh }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [faqList, setFaqList] = useState([]);
|
||||
const [showFaqModal, setShowFaqModal] = useState(false);
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
const [deletingFaq, setDeletingFaq] = useState(null);
|
||||
const [editingFaq, setEditingFaq] = useState(null);
|
||||
const [modalLoading, setModalLoading] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
const [faqForm, setFaqForm] = useState({
|
||||
question: '',
|
||||
answer: '',
|
||||
});
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
const [selectedRowKeys, setSelectedRowKeys] = useState([]);
|
||||
|
||||
// 面板启用状态
|
||||
const [panelEnabled, setPanelEnabled] = useState(true);
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: t('问题标题'),
|
||||
dataIndex: 'question',
|
||||
key: 'question',
|
||||
render: (text) => (
|
||||
<Tooltip content={text} showArrow>
|
||||
<div
|
||||
style={{
|
||||
maxWidth: '300px',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
fontWeight: 'bold',
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</div>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('回答内容'),
|
||||
dataIndex: 'answer',
|
||||
key: 'answer',
|
||||
render: (text) => (
|
||||
<Tooltip content={text} showArrow>
|
||||
<div
|
||||
style={{
|
||||
maxWidth: '400px',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
color: 'var(--semi-color-text-1)',
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</div>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('操作'),
|
||||
key: 'action',
|
||||
fixed: 'right',
|
||||
width: 150,
|
||||
render: (text, record) => (
|
||||
<Space>
|
||||
<Button
|
||||
icon={<Edit size={14} />}
|
||||
theme='light'
|
||||
type='tertiary'
|
||||
size='small'
|
||||
onClick={() => handleEditFaq(record)}
|
||||
>
|
||||
{t('编辑')}
|
||||
</Button>
|
||||
<Button
|
||||
icon={<Trash2 size={14} />}
|
||||
type='danger'
|
||||
theme='light'
|
||||
size='small'
|
||||
onClick={() => handleDeleteFaq(record)}
|
||||
>
|
||||
{t('删除')}
|
||||
</Button>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const updateOption = async (key, value) => {
|
||||
const res = await API.put('/api/option/', {
|
||||
key,
|
||||
value,
|
||||
});
|
||||
const { success, message } = res.data;
|
||||
if (success) {
|
||||
showSuccess('常见问答已更新');
|
||||
if (refresh) refresh();
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
};
|
||||
|
||||
const submitFAQ = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const faqJson = JSON.stringify(faqList);
|
||||
await updateOption('console_setting.faq', faqJson);
|
||||
setHasChanges(false);
|
||||
} catch (error) {
|
||||
console.error('常见问答更新失败', error);
|
||||
showError('常见问答更新失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddFaq = () => {
|
||||
setEditingFaq(null);
|
||||
setFaqForm({
|
||||
question: '',
|
||||
answer: '',
|
||||
});
|
||||
setShowFaqModal(true);
|
||||
};
|
||||
|
||||
const handleEditFaq = (faq) => {
|
||||
setEditingFaq(faq);
|
||||
setFaqForm({
|
||||
question: faq.question,
|
||||
answer: faq.answer,
|
||||
});
|
||||
setShowFaqModal(true);
|
||||
};
|
||||
|
||||
const handleDeleteFaq = (faq) => {
|
||||
setDeletingFaq(faq);
|
||||
setShowDeleteModal(true);
|
||||
};
|
||||
|
||||
const confirmDeleteFaq = () => {
|
||||
if (deletingFaq) {
|
||||
const newList = faqList.filter((item) => item.id !== deletingFaq.id);
|
||||
setFaqList(newList);
|
||||
setHasChanges(true);
|
||||
showSuccess('问答已删除,请及时点击“保存设置”进行保存');
|
||||
}
|
||||
setShowDeleteModal(false);
|
||||
setDeletingFaq(null);
|
||||
};
|
||||
|
||||
const handleSaveFaq = async () => {
|
||||
if (!faqForm.question || !faqForm.answer) {
|
||||
showError('请填写完整的问答信息');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setModalLoading(true);
|
||||
|
||||
let newList;
|
||||
if (editingFaq) {
|
||||
newList = faqList.map((item) =>
|
||||
item.id === editingFaq.id ? { ...item, ...faqForm } : item,
|
||||
);
|
||||
} else {
|
||||
const newId = Math.max(...faqList.map((item) => item.id), 0) + 1;
|
||||
const newFaq = {
|
||||
id: newId,
|
||||
...faqForm,
|
||||
};
|
||||
newList = [...faqList, newFaq];
|
||||
}
|
||||
|
||||
setFaqList(newList);
|
||||
setHasChanges(true);
|
||||
setShowFaqModal(false);
|
||||
showSuccess(
|
||||
editingFaq
|
||||
? '问答已更新,请及时点击“保存设置”进行保存'
|
||||
: '问答已添加,请及时点击“保存设置”进行保存',
|
||||
);
|
||||
} catch (error) {
|
||||
showError('操作失败: ' + error.message);
|
||||
} finally {
|
||||
setModalLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const parseFAQ = (faqStr) => {
|
||||
if (!faqStr) {
|
||||
setFaqList([]);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(faqStr);
|
||||
const list = Array.isArray(parsed) ? parsed : [];
|
||||
// 确保每个项目都有id
|
||||
const listWithIds = list.map((item, index) => ({
|
||||
...item,
|
||||
id: item.id || index + 1,
|
||||
}));
|
||||
setFaqList(listWithIds);
|
||||
} catch (error) {
|
||||
console.error('解析常见问答失败:', error);
|
||||
setFaqList([]);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (options['console_setting.faq'] !== undefined) {
|
||||
parseFAQ(options['console_setting.faq']);
|
||||
}
|
||||
}, [options['console_setting.faq']]);
|
||||
|
||||
useEffect(() => {
|
||||
const enabledStr = options['console_setting.faq_enabled'];
|
||||
setPanelEnabled(
|
||||
enabledStr === undefined
|
||||
? true
|
||||
: enabledStr === 'true' || enabledStr === true,
|
||||
);
|
||||
}, [options['console_setting.faq_enabled']]);
|
||||
|
||||
const handleToggleEnabled = async (checked) => {
|
||||
const newValue = checked ? 'true' : 'false';
|
||||
try {
|
||||
const res = await API.put('/api/option/', {
|
||||
key: 'console_setting.faq_enabled',
|
||||
value: newValue,
|
||||
});
|
||||
if (res.data.success) {
|
||||
setPanelEnabled(checked);
|
||||
showSuccess(t('设置已保存'));
|
||||
refresh?.();
|
||||
} else {
|
||||
showError(res.data.message);
|
||||
}
|
||||
} catch (err) {
|
||||
showError(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBatchDelete = () => {
|
||||
if (selectedRowKeys.length === 0) {
|
||||
showError('请先选择要删除的常见问答');
|
||||
return;
|
||||
}
|
||||
|
||||
const newList = faqList.filter(
|
||||
(item) => !selectedRowKeys.includes(item.id),
|
||||
);
|
||||
setFaqList(newList);
|
||||
setSelectedRowKeys([]);
|
||||
setHasChanges(true);
|
||||
showSuccess(
|
||||
`已删除 ${selectedRowKeys.length} 个常见问答,请及时点击“保存设置”进行保存`,
|
||||
);
|
||||
};
|
||||
|
||||
const renderHeader = () => (
|
||||
<div className='flex flex-col w-full'>
|
||||
<div className='mb-2'>
|
||||
<div className='flex items-center text-blue-500'>
|
||||
<HelpCircle size={16} className='mr-2' />
|
||||
<Text>
|
||||
{t(
|
||||
'常见问答管理,为用户提供常见问题的答案(最多50个,前端显示最新20条)',
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Divider margin='12px' />
|
||||
|
||||
<div className='flex flex-col md:flex-row justify-between items-center gap-4 w-full'>
|
||||
<div className='flex gap-2 w-full md:w-auto order-2 md:order-1'>
|
||||
<Button
|
||||
theme='light'
|
||||
type='primary'
|
||||
icon={<Plus size={14} />}
|
||||
className='w-full md:w-auto'
|
||||
onClick={handleAddFaq}
|
||||
>
|
||||
{t('添加问答')}
|
||||
</Button>
|
||||
<Button
|
||||
icon={<Trash2 size={14} />}
|
||||
type='danger'
|
||||
theme='light'
|
||||
onClick={handleBatchDelete}
|
||||
disabled={selectedRowKeys.length === 0}
|
||||
className='w-full md:w-auto'
|
||||
>
|
||||
{t('批量删除')}{' '}
|
||||
{selectedRowKeys.length > 0 && `(${selectedRowKeys.length})`}
|
||||
</Button>
|
||||
<Button
|
||||
icon={<Save size={14} />}
|
||||
onClick={submitFAQ}
|
||||
loading={loading}
|
||||
disabled={!hasChanges}
|
||||
type='secondary'
|
||||
className='w-full md:w-auto'
|
||||
>
|
||||
{t('保存设置')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 启用开关 */}
|
||||
<div className='order-1 md:order-2 flex items-center gap-2'>
|
||||
<Switch checked={panelEnabled} onChange={handleToggleEnabled} />
|
||||
<Text>{panelEnabled ? t('已启用') : t('已禁用')}</Text>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// 计算当前页显示的数据
|
||||
const getCurrentPageData = () => {
|
||||
const startIndex = (currentPage - 1) * pageSize;
|
||||
const endIndex = startIndex + pageSize;
|
||||
return faqList.slice(startIndex, endIndex);
|
||||
};
|
||||
|
||||
const rowSelection = {
|
||||
selectedRowKeys,
|
||||
onChange: (selectedRowKeys, selectedRows) => {
|
||||
setSelectedRowKeys(selectedRowKeys);
|
||||
},
|
||||
onSelect: (record, selected, selectedRows) => {
|
||||
console.log(`选择行: ${selected}`, record);
|
||||
},
|
||||
onSelectAll: (selected, selectedRows) => {
|
||||
console.log(`全选: ${selected}`, selectedRows);
|
||||
},
|
||||
getCheckboxProps: (record) => ({
|
||||
disabled: false,
|
||||
name: record.id,
|
||||
}),
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Form.Section text={renderHeader()}>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={getCurrentPageData()}
|
||||
rowSelection={rowSelection}
|
||||
rowKey='id'
|
||||
scroll={{ x: 'max-content' }}
|
||||
pagination={{
|
||||
currentPage: currentPage,
|
||||
pageSize: pageSize,
|
||||
total: faqList.length,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
pageSizeOptions: ['5', '10', '20', '50'],
|
||||
onChange: (page, size) => {
|
||||
setCurrentPage(page);
|
||||
setPageSize(size);
|
||||
},
|
||||
onShowSizeChange: (current, size) => {
|
||||
setCurrentPage(1);
|
||||
setPageSize(size);
|
||||
},
|
||||
}}
|
||||
size='middle'
|
||||
loading={loading}
|
||||
empty={
|
||||
<Empty
|
||||
image={
|
||||
<IllustrationNoResult style={{ width: 150, height: 150 }} />
|
||||
}
|
||||
darkModeImage={
|
||||
<IllustrationNoResultDark style={{ width: 150, height: 150 }} />
|
||||
}
|
||||
description={t('暂无常见问答')}
|
||||
style={{ padding: 30 }}
|
||||
/>
|
||||
}
|
||||
className='overflow-hidden'
|
||||
/>
|
||||
</Form.Section>
|
||||
|
||||
<Modal
|
||||
title={editingFaq ? t('编辑问答') : t('添加问答')}
|
||||
visible={showFaqModal}
|
||||
onOk={handleSaveFaq}
|
||||
onCancel={() => setShowFaqModal(false)}
|
||||
okText={t('保存')}
|
||||
cancelText={t('取消')}
|
||||
confirmLoading={modalLoading}
|
||||
width={800}
|
||||
>
|
||||
<Form
|
||||
layout='vertical'
|
||||
initValues={faqForm}
|
||||
key={editingFaq ? editingFaq.id : 'new'}
|
||||
>
|
||||
<Form.Input
|
||||
field='question'
|
||||
label={t('问题标题')}
|
||||
placeholder={t('请输入问题标题')}
|
||||
maxLength={200}
|
||||
rules={[{ required: true, message: t('请输入问题标题') }]}
|
||||
onChange={(value) => setFaqForm({ ...faqForm, question: value })}
|
||||
/>
|
||||
<Form.TextArea
|
||||
field='answer'
|
||||
label={t('回答内容')}
|
||||
placeholder={t('请输入回答内容(支持 Markdown/HTML)')}
|
||||
maxCount={1000}
|
||||
rows={6}
|
||||
rules={[{ required: true, message: t('请输入回答内容') }]}
|
||||
onChange={(value) => setFaqForm({ ...faqForm, answer: value })}
|
||||
/>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title={t('确认删除')}
|
||||
visible={showDeleteModal}
|
||||
onOk={confirmDeleteFaq}
|
||||
onCancel={() => {
|
||||
setShowDeleteModal(false);
|
||||
setDeletingFaq(null);
|
||||
}}
|
||||
okText={t('确认删除')}
|
||||
cancelText={t('取消')}
|
||||
type='warning'
|
||||
okButtonProps={{
|
||||
type: 'danger',
|
||||
theme: 'solid',
|
||||
}}
|
||||
>
|
||||
<Text>{t('确定要删除此问答吗?')}</Text>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingsFAQ;
|
||||
@@ -0,0 +1,522 @@
|
||||
/*
|
||||
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 {
|
||||
Button,
|
||||
Space,
|
||||
Table,
|
||||
Form,
|
||||
Typography,
|
||||
Empty,
|
||||
Divider,
|
||||
Modal,
|
||||
Switch,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
IllustrationNoResult,
|
||||
IllustrationNoResultDark,
|
||||
} from '@douyinfe/semi-illustrations';
|
||||
import { Plus, Edit, Trash2, Save, Activity } from 'lucide-react';
|
||||
import { API, showError, showSuccess } from '../../../helpers';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const SettingsUptimeKuma = ({ options, refresh }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [uptimeGroupsList, setUptimeGroupsList] = useState([]);
|
||||
const [showUptimeModal, setShowUptimeModal] = useState(false);
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
const [deletingGroup, setDeletingGroup] = useState(null);
|
||||
const [editingGroup, setEditingGroup] = useState(null);
|
||||
const [modalLoading, setModalLoading] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
const [uptimeForm, setUptimeForm] = useState({
|
||||
categoryName: '',
|
||||
url: '',
|
||||
slug: '',
|
||||
});
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
const [selectedRowKeys, setSelectedRowKeys] = useState([]);
|
||||
const [panelEnabled, setPanelEnabled] = useState(true);
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: t('分类名称'),
|
||||
dataIndex: 'categoryName',
|
||||
key: 'categoryName',
|
||||
render: (text) => (
|
||||
<div
|
||||
style={{
|
||||
fontWeight: 'bold',
|
||||
color: 'var(--semi-color-text-0)',
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('Uptime Kuma地址'),
|
||||
dataIndex: 'url',
|
||||
key: 'url',
|
||||
render: (text) => (
|
||||
<div
|
||||
style={{
|
||||
maxWidth: '300px',
|
||||
wordBreak: 'break-all',
|
||||
fontFamily: 'monospace',
|
||||
color: 'var(--semi-color-primary)',
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('状态页面Slug'),
|
||||
dataIndex: 'slug',
|
||||
key: 'slug',
|
||||
render: (text) => (
|
||||
<div
|
||||
style={{
|
||||
fontFamily: 'monospace',
|
||||
color: 'var(--semi-color-text-1)',
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('操作'),
|
||||
key: 'action',
|
||||
fixed: 'right',
|
||||
width: 150,
|
||||
render: (text, record) => (
|
||||
<Space>
|
||||
<Button
|
||||
icon={<Edit size={14} />}
|
||||
theme='light'
|
||||
type='tertiary'
|
||||
size='small'
|
||||
onClick={() => handleEditGroup(record)}
|
||||
>
|
||||
{t('编辑')}
|
||||
</Button>
|
||||
<Button
|
||||
icon={<Trash2 size={14} />}
|
||||
type='danger'
|
||||
theme='light'
|
||||
size='small'
|
||||
onClick={() => handleDeleteGroup(record)}
|
||||
>
|
||||
{t('删除')}
|
||||
</Button>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const updateOption = async (key, value) => {
|
||||
const res = await API.put('/api/option/', {
|
||||
key,
|
||||
value,
|
||||
});
|
||||
const { success, message } = res.data;
|
||||
if (success) {
|
||||
showSuccess('Uptime Kuma配置已更新');
|
||||
if (refresh) refresh();
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
};
|
||||
|
||||
const submitUptimeGroups = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const groupsJson = JSON.stringify(uptimeGroupsList);
|
||||
await updateOption('console_setting.uptime_kuma_groups', groupsJson);
|
||||
setHasChanges(false);
|
||||
} catch (error) {
|
||||
console.error('Uptime Kuma配置更新失败', error);
|
||||
showError('Uptime Kuma配置更新失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddGroup = () => {
|
||||
setEditingGroup(null);
|
||||
setUptimeForm({
|
||||
categoryName: '',
|
||||
url: '',
|
||||
slug: '',
|
||||
});
|
||||
setShowUptimeModal(true);
|
||||
};
|
||||
|
||||
const handleEditGroup = (group) => {
|
||||
setEditingGroup(group);
|
||||
setUptimeForm({
|
||||
categoryName: group.categoryName,
|
||||
url: group.url,
|
||||
slug: group.slug,
|
||||
});
|
||||
setShowUptimeModal(true);
|
||||
};
|
||||
|
||||
const handleDeleteGroup = (group) => {
|
||||
setDeletingGroup(group);
|
||||
setShowDeleteModal(true);
|
||||
};
|
||||
|
||||
const confirmDeleteGroup = () => {
|
||||
if (deletingGroup) {
|
||||
const newList = uptimeGroupsList.filter(
|
||||
(item) => item.id !== deletingGroup.id,
|
||||
);
|
||||
setUptimeGroupsList(newList);
|
||||
setHasChanges(true);
|
||||
showSuccess('分类已删除,请及时点击“保存设置”进行保存');
|
||||
}
|
||||
setShowDeleteModal(false);
|
||||
setDeletingGroup(null);
|
||||
};
|
||||
|
||||
const handleSaveGroup = async () => {
|
||||
if (!uptimeForm.categoryName || !uptimeForm.url || !uptimeForm.slug) {
|
||||
showError('请填写完整的分类信息');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
new URL(uptimeForm.url);
|
||||
} catch (error) {
|
||||
showError('请输入有效的URL地址');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(uptimeForm.slug)) {
|
||||
showError('Slug只能包含字母、数字、下划线和连字符');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setModalLoading(true);
|
||||
|
||||
let newList;
|
||||
if (editingGroup) {
|
||||
newList = uptimeGroupsList.map((item) =>
|
||||
item.id === editingGroup.id ? { ...item, ...uptimeForm } : item,
|
||||
);
|
||||
} else {
|
||||
const newId =
|
||||
Math.max(...uptimeGroupsList.map((item) => item.id), 0) + 1;
|
||||
const newGroup = {
|
||||
id: newId,
|
||||
...uptimeForm,
|
||||
};
|
||||
newList = [...uptimeGroupsList, newGroup];
|
||||
}
|
||||
|
||||
setUptimeGroupsList(newList);
|
||||
setHasChanges(true);
|
||||
setShowUptimeModal(false);
|
||||
showSuccess(
|
||||
editingGroup
|
||||
? '分类已更新,请及时点击“保存设置”进行保存'
|
||||
: '分类已添加,请及时点击“保存设置”进行保存',
|
||||
);
|
||||
} catch (error) {
|
||||
showError('操作失败: ' + error.message);
|
||||
} finally {
|
||||
setModalLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const parseUptimeGroups = (groupsStr) => {
|
||||
if (!groupsStr) {
|
||||
setUptimeGroupsList([]);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(groupsStr);
|
||||
const list = Array.isArray(parsed) ? parsed : [];
|
||||
const listWithIds = list.map((item, index) => ({
|
||||
...item,
|
||||
id: item.id || index + 1,
|
||||
}));
|
||||
setUptimeGroupsList(listWithIds);
|
||||
} catch (error) {
|
||||
console.error('解析Uptime Kuma配置失败:', error);
|
||||
setUptimeGroupsList([]);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const groupsStr = options['console_setting.uptime_kuma_groups'];
|
||||
if (groupsStr !== undefined) {
|
||||
parseUptimeGroups(groupsStr);
|
||||
}
|
||||
}, [options['console_setting.uptime_kuma_groups']]);
|
||||
|
||||
useEffect(() => {
|
||||
const enabledStr = options['console_setting.uptime_kuma_enabled'];
|
||||
setPanelEnabled(
|
||||
enabledStr === undefined
|
||||
? true
|
||||
: enabledStr === 'true' || enabledStr === true,
|
||||
);
|
||||
}, [options['console_setting.uptime_kuma_enabled']]);
|
||||
|
||||
const handleToggleEnabled = async (checked) => {
|
||||
const newValue = checked ? 'true' : 'false';
|
||||
try {
|
||||
const res = await API.put('/api/option/', {
|
||||
key: 'console_setting.uptime_kuma_enabled',
|
||||
value: newValue,
|
||||
});
|
||||
if (res.data.success) {
|
||||
setPanelEnabled(checked);
|
||||
showSuccess(t('设置已保存'));
|
||||
refresh?.();
|
||||
} else {
|
||||
showError(res.data.message);
|
||||
}
|
||||
} catch (err) {
|
||||
showError(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBatchDelete = () => {
|
||||
if (selectedRowKeys.length === 0) {
|
||||
showError('请先选择要删除的分类');
|
||||
return;
|
||||
}
|
||||
|
||||
const newList = uptimeGroupsList.filter(
|
||||
(item) => !selectedRowKeys.includes(item.id),
|
||||
);
|
||||
setUptimeGroupsList(newList);
|
||||
setSelectedRowKeys([]);
|
||||
setHasChanges(true);
|
||||
showSuccess(
|
||||
`已删除 ${selectedRowKeys.length} 个分类,请及时点击“保存设置”进行保存`,
|
||||
);
|
||||
};
|
||||
|
||||
const renderHeader = () => (
|
||||
<div className='flex flex-col w-full'>
|
||||
<div className='mb-2'>
|
||||
<div className='flex items-center text-blue-500'>
|
||||
<Activity size={16} className='mr-2' />
|
||||
<Text>
|
||||
{t(
|
||||
'Uptime Kuma监控分类管理,可以配置多个监控分类用于服务状态展示(最多20个)',
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Divider margin='12px' />
|
||||
|
||||
<div className='flex flex-col md:flex-row justify-between items-center gap-4 w-full'>
|
||||
<div className='flex gap-2 w-full md:w-auto order-2 md:order-1'>
|
||||
<Button
|
||||
theme='light'
|
||||
type='primary'
|
||||
icon={<Plus size={14} />}
|
||||
className='w-full md:w-auto'
|
||||
onClick={handleAddGroup}
|
||||
>
|
||||
{t('添加分类')}
|
||||
</Button>
|
||||
<Button
|
||||
icon={<Trash2 size={14} />}
|
||||
type='danger'
|
||||
theme='light'
|
||||
onClick={handleBatchDelete}
|
||||
disabled={selectedRowKeys.length === 0}
|
||||
className='w-full md:w-auto'
|
||||
>
|
||||
{t('批量删除')}{' '}
|
||||
{selectedRowKeys.length > 0 && `(${selectedRowKeys.length})`}
|
||||
</Button>
|
||||
<Button
|
||||
icon={<Save size={14} />}
|
||||
onClick={submitUptimeGroups}
|
||||
loading={loading}
|
||||
disabled={!hasChanges}
|
||||
type='secondary'
|
||||
className='w-full md:w-auto'
|
||||
>
|
||||
{t('保存设置')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 启用开关 */}
|
||||
<div className='order-1 md:order-2 flex items-center gap-2'>
|
||||
<Switch checked={panelEnabled} onChange={handleToggleEnabled} />
|
||||
<Text>{panelEnabled ? t('已启用') : t('已禁用')}</Text>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const getCurrentPageData = () => {
|
||||
const startIndex = (currentPage - 1) * pageSize;
|
||||
const endIndex = startIndex + pageSize;
|
||||
return uptimeGroupsList.slice(startIndex, endIndex);
|
||||
};
|
||||
|
||||
const rowSelection = {
|
||||
selectedRowKeys,
|
||||
onChange: (selectedRowKeys, selectedRows) => {
|
||||
setSelectedRowKeys(selectedRowKeys);
|
||||
},
|
||||
onSelect: (record, selected, selectedRows) => {
|
||||
console.log(`选择行: ${selected}`, record);
|
||||
},
|
||||
onSelectAll: (selected, selectedRows) => {
|
||||
console.log(`全选: ${selected}`, selectedRows);
|
||||
},
|
||||
getCheckboxProps: (record) => ({
|
||||
disabled: false,
|
||||
name: record.id,
|
||||
}),
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Form.Section text={renderHeader()}>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={getCurrentPageData()}
|
||||
rowSelection={rowSelection}
|
||||
rowKey='id'
|
||||
scroll={{ x: 'max-content' }}
|
||||
pagination={{
|
||||
currentPage: currentPage,
|
||||
pageSize: pageSize,
|
||||
total: uptimeGroupsList.length,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
pageSizeOptions: ['5', '10', '20', '50'],
|
||||
onChange: (page, size) => {
|
||||
setCurrentPage(page);
|
||||
setPageSize(size);
|
||||
},
|
||||
onShowSizeChange: (current, size) => {
|
||||
setCurrentPage(1);
|
||||
setPageSize(size);
|
||||
},
|
||||
}}
|
||||
size='middle'
|
||||
loading={loading}
|
||||
empty={
|
||||
<Empty
|
||||
image={
|
||||
<IllustrationNoResult style={{ width: 150, height: 150 }} />
|
||||
}
|
||||
darkModeImage={
|
||||
<IllustrationNoResultDark style={{ width: 150, height: 150 }} />
|
||||
}
|
||||
description={t('暂无监控数据')}
|
||||
style={{ padding: 30 }}
|
||||
/>
|
||||
}
|
||||
className='overflow-hidden'
|
||||
/>
|
||||
</Form.Section>
|
||||
|
||||
<Modal
|
||||
title={editingGroup ? t('编辑分类') : t('添加分类')}
|
||||
visible={showUptimeModal}
|
||||
onOk={handleSaveGroup}
|
||||
onCancel={() => setShowUptimeModal(false)}
|
||||
okText={t('保存')}
|
||||
cancelText={t('取消')}
|
||||
confirmLoading={modalLoading}
|
||||
width={600}
|
||||
>
|
||||
<Form
|
||||
layout='vertical'
|
||||
initValues={uptimeForm}
|
||||
key={editingGroup ? editingGroup.id : 'new'}
|
||||
>
|
||||
<Form.Input
|
||||
field='categoryName'
|
||||
label={t('分类名称')}
|
||||
placeholder={t('请输入分类名称,如:OpenAI、Claude等')}
|
||||
maxLength={50}
|
||||
rules={[{ required: true, message: t('请输入分类名称') }]}
|
||||
onChange={(value) =>
|
||||
setUptimeForm({ ...uptimeForm, categoryName: value })
|
||||
}
|
||||
/>
|
||||
<Form.Input
|
||||
field='url'
|
||||
label={t('Uptime Kuma地址')}
|
||||
placeholder={t(
|
||||
'请输入Uptime Kuma服务地址,如:https://status.example.com',
|
||||
)}
|
||||
maxLength={500}
|
||||
rules={[{ required: true, message: t('请输入Uptime Kuma地址') }]}
|
||||
onChange={(value) => setUptimeForm({ ...uptimeForm, url: value })}
|
||||
/>
|
||||
<Form.Input
|
||||
field='slug'
|
||||
label={t('状态页面Slug')}
|
||||
placeholder={t('请输入状态页面的Slug,如:my-status')}
|
||||
maxLength={100}
|
||||
rules={[{ required: true, message: t('请输入状态页面Slug') }]}
|
||||
onChange={(value) => setUptimeForm({ ...uptimeForm, slug: value })}
|
||||
/>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title={t('确认删除')}
|
||||
visible={showDeleteModal}
|
||||
onOk={confirmDeleteGroup}
|
||||
onCancel={() => {
|
||||
setShowDeleteModal(false);
|
||||
setDeletingGroup(null);
|
||||
}}
|
||||
okText={t('确认删除')}
|
||||
cancelText={t('取消')}
|
||||
type='warning'
|
||||
okButtonProps={{
|
||||
type: 'danger',
|
||||
theme: 'solid',
|
||||
}}
|
||||
>
|
||||
<Text>{t('确定要删除此分类吗?')}</Text>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingsUptimeKuma;
|
||||
@@ -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, { useEffect, useState, useRef } from 'react';
|
||||
import { Button, Col, Form, Row, Spin, Tag } from '@douyinfe/semi-ui';
|
||||
import {
|
||||
compareObjects,
|
||||
API,
|
||||
showError,
|
||||
showSuccess,
|
||||
showWarning,
|
||||
} from '../../../helpers';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export default function SettingsDrawing(props) {
|
||||
const { t } = useTranslation();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [inputs, setInputs] = useState({
|
||||
DrawingEnabled: false,
|
||||
MjNotifyEnabled: false,
|
||||
MjAccountFilterEnabled: false,
|
||||
MjForwardUrlEnabled: false,
|
||||
MjModeClearEnabled: false,
|
||||
MjActionCheckSuccessEnabled: false,
|
||||
});
|
||||
const refForm = useRef();
|
||||
const [inputsRow, setInputsRow] = useState(inputs);
|
||||
|
||||
function onSubmit() {
|
||||
const updateArray = compareObjects(inputs, inputsRow);
|
||||
if (!updateArray.length) return showWarning(t('你似乎并没有修改什么'));
|
||||
const requestQueue = updateArray.map((item) => {
|
||||
let value = '';
|
||||
if (typeof inputs[item.key] === 'boolean') {
|
||||
value = String(inputs[item.key]);
|
||||
} else {
|
||||
value = inputs[item.key];
|
||||
}
|
||||
return API.put('/api/option/', {
|
||||
key: item.key,
|
||||
value,
|
||||
});
|
||||
});
|
||||
setLoading(true);
|
||||
Promise.all(requestQueue)
|
||||
.then((res) => {
|
||||
if (requestQueue.length === 1) {
|
||||
if (res.includes(undefined)) return;
|
||||
} else if (requestQueue.length > 1) {
|
||||
if (res.includes(undefined))
|
||||
return showError(t('部分保存失败,请重试'));
|
||||
}
|
||||
showSuccess(t('保存成功'));
|
||||
props.refresh();
|
||||
})
|
||||
.catch(() => {
|
||||
showError(t('保存失败,请重试'));
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const currentInputs = {};
|
||||
for (let key in props.options) {
|
||||
if (Object.keys(inputs).includes(key)) {
|
||||
currentInputs[key] = props.options[key];
|
||||
}
|
||||
}
|
||||
setInputs(currentInputs);
|
||||
setInputsRow(structuredClone(currentInputs));
|
||||
refForm.current.setValues(currentInputs);
|
||||
localStorage.setItem('mj_notify_enabled', String(inputs.MjNotifyEnabled));
|
||||
}, [props.options]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Spin spinning={loading}>
|
||||
<Form
|
||||
values={inputs}
|
||||
getFormApi={(formAPI) => (refForm.current = formAPI)}
|
||||
style={{ marginBottom: 15 }}
|
||||
>
|
||||
<Form.Section text={t('绘图设置')}>
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
|
||||
<Form.Switch
|
||||
field={'DrawingEnabled'}
|
||||
label={t('启用绘图功能')}
|
||||
size='default'
|
||||
checkedText='|'
|
||||
uncheckedText='〇'
|
||||
onChange={(value) => {
|
||||
setInputs({
|
||||
...inputs,
|
||||
DrawingEnabled: value,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
|
||||
<Form.Switch
|
||||
field={'MjNotifyEnabled'}
|
||||
label={t('允许回调(会泄露服务器 IP 地址)')}
|
||||
size='default'
|
||||
checkedText='|'
|
||||
uncheckedText='〇'
|
||||
onChange={(value) =>
|
||||
setInputs({
|
||||
...inputs,
|
||||
MjNotifyEnabled: value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
|
||||
<Form.Switch
|
||||
field={'MjAccountFilterEnabled'}
|
||||
label={t('允许 AccountFilter 参数')}
|
||||
size='default'
|
||||
checkedText='|'
|
||||
uncheckedText='〇'
|
||||
onChange={(value) =>
|
||||
setInputs({
|
||||
...inputs,
|
||||
MjAccountFilterEnabled: value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
|
||||
<Form.Switch
|
||||
field={'MjForwardUrlEnabled'}
|
||||
label={t('开启之后将上游地址替换为服务器地址')}
|
||||
size='default'
|
||||
checkedText='|'
|
||||
uncheckedText='〇'
|
||||
onChange={(value) =>
|
||||
setInputs({
|
||||
...inputs,
|
||||
MjForwardUrlEnabled: value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
|
||||
<Form.Switch
|
||||
field={'MjModeClearEnabled'}
|
||||
label={
|
||||
<>
|
||||
{t('开启之后会清除用户提示词中的')} <Tag>--fast</Tag> 、
|
||||
<Tag>--relax</Tag> {t('以及')} <Tag>--turbo</Tag>{' '}
|
||||
{t('参数')}
|
||||
</>
|
||||
}
|
||||
size='default'
|
||||
checkedText='|'
|
||||
uncheckedText='〇'
|
||||
onChange={(value) =>
|
||||
setInputs({
|
||||
...inputs,
|
||||
MjModeClearEnabled: value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
|
||||
<Form.Switch
|
||||
field={'MjActionCheckSuccessEnabled'}
|
||||
label={t('检测必须等待绘图成功才能进行放大等操作')}
|
||||
size='default'
|
||||
checkedText='|'
|
||||
uncheckedText='〇'
|
||||
onChange={(value) =>
|
||||
setInputs({
|
||||
...inputs,
|
||||
MjActionCheckSuccessEnabled: value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row>
|
||||
<Button size='default' onClick={onSubmit}>
|
||||
{t('保存绘图设置')}
|
||||
</Button>
|
||||
</Row>
|
||||
</Form.Section>
|
||||
</Form>
|
||||
</Spin>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
/*
|
||||
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 { Button, Col, Form, Row, Spin } from '@douyinfe/semi-ui';
|
||||
import {
|
||||
compareObjects,
|
||||
API,
|
||||
showError,
|
||||
showSuccess,
|
||||
showWarning,
|
||||
verifyJSON,
|
||||
} from '../../../helpers';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import Text from '@douyinfe/semi-ui/lib/es/typography/text';
|
||||
|
||||
const CLAUDE_HEADER = {
|
||||
'claude-3-7-sonnet-20250219-thinking': {
|
||||
'anthropic-beta': [
|
||||
'output-128k-2025-02-19',
|
||||
'token-efficient-tools-2025-02-19',
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const CLAUDE_HEADER_APPEND_CONFIG = {
|
||||
'claude-3-7-sonnet-20250219-thinking': {
|
||||
'anthropic-beta': ['token-efficient-tools-2025-02-19'],
|
||||
},
|
||||
};
|
||||
|
||||
const CLAUDE_HEADER_APPEND_BEFORE = `anthropic-beta: output-128k-2025-02-19`;
|
||||
|
||||
const CLAUDE_HEADER_APPEND_AFTER = `anthropic-beta: output-128k-2025-02-19,token-efficient-tools-2025-02-19`;
|
||||
|
||||
const CLAUDE_DEFAULT_MAX_TOKENS = {
|
||||
default: 8192,
|
||||
'claude-3-haiku-20240307': 4096,
|
||||
'claude-3-opus-20240229': 4096,
|
||||
'claude-3-7-sonnet-20250219-thinking': 8192,
|
||||
};
|
||||
|
||||
export default function SettingClaudeModel(props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [inputs, setInputs] = useState({
|
||||
'claude.model_headers_settings': '',
|
||||
'claude.thinking_adapter_enabled': true,
|
||||
'claude.default_max_tokens': '',
|
||||
'claude.thinking_adapter_budget_tokens_percentage': 0.8,
|
||||
});
|
||||
const refForm = useRef();
|
||||
const [inputsRow, setInputsRow] = useState(inputs);
|
||||
|
||||
function onSubmit() {
|
||||
const updateArray = compareObjects(inputs, inputsRow);
|
||||
if (!updateArray.length) return showWarning(t('你似乎并没有修改什么'));
|
||||
const requestQueue = updateArray.map((item) => {
|
||||
let value = String(inputs[item.key]);
|
||||
|
||||
return API.put('/api/option/', {
|
||||
key: item.key,
|
||||
value,
|
||||
});
|
||||
});
|
||||
setLoading(true);
|
||||
Promise.all(requestQueue)
|
||||
.then((res) => {
|
||||
if (requestQueue.length === 1) {
|
||||
if (res.includes(undefined)) return;
|
||||
} else if (requestQueue.length > 1) {
|
||||
if (res.includes(undefined))
|
||||
return showError(t('部分保存失败,请重试'));
|
||||
}
|
||||
showSuccess(t('保存成功'));
|
||||
props.refresh();
|
||||
})
|
||||
.catch(() => {
|
||||
showError(t('保存失败,请重试'));
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const currentInputs = {};
|
||||
for (let key in props.options) {
|
||||
if (Object.keys(inputs).includes(key)) {
|
||||
currentInputs[key] = props.options[key];
|
||||
}
|
||||
}
|
||||
setInputs(currentInputs);
|
||||
setInputsRow(structuredClone(currentInputs));
|
||||
refForm.current.setValues(currentInputs);
|
||||
}, [props.options]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Spin spinning={loading}>
|
||||
<Form
|
||||
values={inputs}
|
||||
getFormApi={(formAPI) => (refForm.current = formAPI)}
|
||||
style={{ marginBottom: 15 }}
|
||||
>
|
||||
<Form.Section text={t('Claude设置')}>
|
||||
<Row>
|
||||
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
|
||||
<Form.TextArea
|
||||
label={t('Claude请求头追加')}
|
||||
field={'claude.model_headers_settings'}
|
||||
placeholder={
|
||||
t('为一个 JSON 文本,例如:') +
|
||||
'\n' +
|
||||
JSON.stringify(CLAUDE_HEADER, null, 2)
|
||||
}
|
||||
extraText={
|
||||
<div>
|
||||
<div>
|
||||
{t(
|
||||
'Claude会在原有请求头基础上追加这些值,不会覆盖已有同名请求头;重复值会自动忽略。',
|
||||
)}
|
||||
</div>
|
||||
<div className='mt-2 whitespace-pre-wrap font-mono text-xs'>
|
||||
{`${t('前:')}\n${CLAUDE_HEADER_APPEND_BEFORE}\n\n${t('配置:')}\n${JSON.stringify(
|
||||
CLAUDE_HEADER_APPEND_CONFIG,
|
||||
null,
|
||||
2,
|
||||
)}\n\n${t('后:')}\n${CLAUDE_HEADER_APPEND_AFTER}`}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
autosize={{ minRows: 6, maxRows: 12 }}
|
||||
trigger='blur'
|
||||
stopValidateWithError
|
||||
rules={[
|
||||
{
|
||||
validator: (rule, value) => verifyJSON(value),
|
||||
message: t('不是合法的 JSON 字符串'),
|
||||
},
|
||||
]}
|
||||
onChange={(value) =>
|
||||
setInputs({
|
||||
...inputs,
|
||||
'claude.model_headers_settings': value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row>
|
||||
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
|
||||
<Form.TextArea
|
||||
label={t('缺省 MaxTokens')}
|
||||
field={'claude.default_max_tokens'}
|
||||
placeholder={
|
||||
t('为一个 JSON 文本,例如:') +
|
||||
'\n' +
|
||||
JSON.stringify(CLAUDE_DEFAULT_MAX_TOKENS, null, 2)
|
||||
}
|
||||
extraText={
|
||||
t('示例') +
|
||||
'\n' +
|
||||
JSON.stringify(CLAUDE_DEFAULT_MAX_TOKENS, null, 2)
|
||||
}
|
||||
autosize={{ minRows: 6, maxRows: 12 }}
|
||||
trigger='blur'
|
||||
stopValidateWithError
|
||||
rules={[
|
||||
{
|
||||
validator: (rule, value) => verifyJSON(value),
|
||||
message: t('不是合法的 JSON 字符串'),
|
||||
},
|
||||
]}
|
||||
onChange={(value) =>
|
||||
setInputs({ ...inputs, 'claude.default_max_tokens': value })
|
||||
}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row>
|
||||
<Col span={16}>
|
||||
<Form.Switch
|
||||
label={t('启用Claude思考适配(-thinking后缀)')}
|
||||
field={'claude.thinking_adapter_enabled'}
|
||||
onChange={(value) =>
|
||||
setInputs({
|
||||
...inputs,
|
||||
'claude.thinking_adapter_enabled': value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row>
|
||||
<Col span={16}>
|
||||
{/*//展示MaxTokens和BudgetTokens的计算公式, 并展示实际数字*/}
|
||||
<Text>
|
||||
{t(
|
||||
'Claude思考适配 BudgetTokens = MaxTokens * BudgetTokens 百分比',
|
||||
)}
|
||||
</Text>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row>
|
||||
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
|
||||
<Form.InputNumber
|
||||
label={t('思考适配 BudgetTokens 百分比')}
|
||||
field={'claude.thinking_adapter_budget_tokens_percentage'}
|
||||
initValue={''}
|
||||
extraText={t('0.1以上的小数')}
|
||||
min={0.1}
|
||||
onChange={(value) =>
|
||||
setInputs({
|
||||
...inputs,
|
||||
'claude.thinking_adapter_budget_tokens_percentage': value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row>
|
||||
<Button size='default' onClick={onSubmit}>
|
||||
{t('保存')}
|
||||
</Button>
|
||||
</Row>
|
||||
</Form.Section>
|
||||
</Form>
|
||||
</Spin>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
/*
|
||||
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 { Button, Col, Form, Row, Spin } from '@douyinfe/semi-ui';
|
||||
import {
|
||||
compareObjects,
|
||||
API,
|
||||
showError,
|
||||
showSuccess,
|
||||
showWarning,
|
||||
verifyJSON,
|
||||
} from '../../../helpers';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import Text from '@douyinfe/semi-ui/lib/es/typography/text';
|
||||
|
||||
const GEMINI_SETTING_EXAMPLE = {
|
||||
default: 'OFF'
|
||||
};
|
||||
|
||||
const GEMINI_VERSION_EXAMPLE = {
|
||||
default: 'v1beta',
|
||||
};
|
||||
|
||||
const DEFAULT_GEMINI_INPUTS = {
|
||||
'gemini.safety_settings': '',
|
||||
'gemini.version_settings': '',
|
||||
'gemini.supported_imagine_models': '',
|
||||
'gemini.thinking_adapter_enabled': false,
|
||||
'gemini.thinking_adapter_budget_tokens_percentage': 0.6,
|
||||
'gemini.function_call_thought_signature_enabled': true,
|
||||
'gemini.remove_function_response_id_enabled': true,
|
||||
};
|
||||
|
||||
export default function SettingGeminiModel(props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [inputs, setInputs] = useState(DEFAULT_GEMINI_INPUTS);
|
||||
const refForm = useRef();
|
||||
const [inputsRow, setInputsRow] = useState(DEFAULT_GEMINI_INPUTS);
|
||||
|
||||
async function onSubmit() {
|
||||
await refForm.current
|
||||
.validate()
|
||||
.then(() => {
|
||||
const updateArray = compareObjects(inputs, inputsRow);
|
||||
if (!updateArray.length) return showWarning(t('你似乎并没有修改什么'));
|
||||
const requestQueue = updateArray.map((item) => {
|
||||
let value = String(inputs[item.key]);
|
||||
return API.put('/api/option/', {
|
||||
key: item.key,
|
||||
value,
|
||||
});
|
||||
});
|
||||
setLoading(true);
|
||||
Promise.all(requestQueue)
|
||||
.then((res) => {
|
||||
if (requestQueue.length === 1) {
|
||||
if (res.includes(undefined)) return;
|
||||
} else if (requestQueue.length > 1) {
|
||||
if (res.includes(undefined))
|
||||
return showError(t('部分保存失败,请重试'));
|
||||
}
|
||||
showSuccess(t('保存成功'));
|
||||
props.refresh();
|
||||
})
|
||||
.catch(() => {
|
||||
showError(t('保存失败,请重试'));
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Validation failed:', error);
|
||||
showError(t('请检查输入'));
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const currentInputs = { ...DEFAULT_GEMINI_INPUTS };
|
||||
for (let key in props.options) {
|
||||
if (Object.prototype.hasOwnProperty.call(DEFAULT_GEMINI_INPUTS, key)) {
|
||||
currentInputs[key] = props.options[key];
|
||||
}
|
||||
}
|
||||
setInputs(currentInputs);
|
||||
setInputsRow(structuredClone(currentInputs));
|
||||
refForm.current.setValues(currentInputs);
|
||||
}, [props.options]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Spin spinning={loading}>
|
||||
<Form
|
||||
values={inputs}
|
||||
getFormApi={(formAPI) => (refForm.current = formAPI)}
|
||||
style={{ marginBottom: 15 }}
|
||||
>
|
||||
<Form.Section text={t('Gemini设置')}>
|
||||
<Row>
|
||||
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
|
||||
<Form.TextArea
|
||||
label={t('Gemini安全设置')}
|
||||
placeholder={
|
||||
t('为一个 JSON 文本,例如:') +
|
||||
'\n' +
|
||||
JSON.stringify(GEMINI_SETTING_EXAMPLE, null, 2)
|
||||
}
|
||||
field={'gemini.safety_settings'}
|
||||
extraText={t(
|
||||
'default为默认设置,可单独设置每个分类的安全等级',
|
||||
)}
|
||||
autosize={{ minRows: 6, maxRows: 12 }}
|
||||
trigger='blur'
|
||||
stopValidateWithError
|
||||
rules={[
|
||||
{
|
||||
validator: (rule, value) => verifyJSON(value),
|
||||
message: t('不是合法的 JSON 字符串'),
|
||||
},
|
||||
]}
|
||||
onChange={(value) =>
|
||||
setInputs({ ...inputs, 'gemini.safety_settings': value })
|
||||
}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row>
|
||||
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
|
||||
<Form.TextArea
|
||||
label={t('Gemini版本设置')}
|
||||
placeholder={
|
||||
t('为一个 JSON 文本,例如:') +
|
||||
'\n' +
|
||||
JSON.stringify(GEMINI_VERSION_EXAMPLE, null, 2)
|
||||
}
|
||||
field={'gemini.version_settings'}
|
||||
extraText={t('default为默认设置,可单独设置每个模型的版本')}
|
||||
autosize={{ minRows: 6, maxRows: 12 }}
|
||||
trigger='blur'
|
||||
stopValidateWithError
|
||||
rules={[
|
||||
{
|
||||
validator: (rule, value) => verifyJSON(value),
|
||||
message: t('不是合法的 JSON 字符串'),
|
||||
},
|
||||
]}
|
||||
onChange={(value) =>
|
||||
setInputs({ ...inputs, 'gemini.version_settings': value })
|
||||
}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row>
|
||||
<Col span={16}>
|
||||
<Form.Switch
|
||||
label={t('启用FunctionCall思维签名填充')}
|
||||
field={'gemini.function_call_thought_signature_enabled'}
|
||||
extraText={t(
|
||||
'仅为使用OpenAI格式的Gemini/Vertex渠道填充thoughtSignature',
|
||||
)}
|
||||
onChange={(value) =>
|
||||
setInputs({
|
||||
...inputs,
|
||||
'gemini.function_call_thought_signature_enabled': value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row>
|
||||
<Col span={16}>
|
||||
<Form.Switch
|
||||
label={t('移除 functionResponse.id 字段')}
|
||||
field={'gemini.remove_function_response_id_enabled'}
|
||||
extraText={t(
|
||||
'Vertex AI 不支持 functionResponse.id 字段,开启后将自动移除该字段',
|
||||
)}
|
||||
onChange={(value) =>
|
||||
setInputs({
|
||||
...inputs,
|
||||
'gemini.remove_function_response_id_enabled': value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row>
|
||||
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
|
||||
<Form.TextArea
|
||||
field={'gemini.supported_imagine_models'}
|
||||
label={t('支持的图像模型')}
|
||||
placeholder={
|
||||
t('例如:') +
|
||||
'\n' +
|
||||
JSON.stringify(
|
||||
['gemini-2.0-flash-exp-image-generation'],
|
||||
null,
|
||||
2,
|
||||
)
|
||||
}
|
||||
onChange={(value) =>
|
||||
setInputs({
|
||||
...inputs,
|
||||
'gemini.supported_imagine_models': value,
|
||||
})
|
||||
}
|
||||
trigger='blur'
|
||||
stopValidateWithError
|
||||
rules={[
|
||||
{
|
||||
validator: (rule, value) => verifyJSON(value),
|
||||
message: t('不是合法的 JSON 字符串'),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</Form.Section>
|
||||
|
||||
<Form.Section text={t('Gemini思考适配设置')}>
|
||||
<Row>
|
||||
<Col span={16}>
|
||||
<Text>
|
||||
{t(
|
||||
'和Claude不同,默认情况下Gemini的思考模型会自动决定要不要思考,就算不开启适配模型也可以正常使用,' +
|
||||
'如果您需要计费,推荐设置无后缀模型价格按思考价格设置。' +
|
||||
'支持使用 gemini-2.5-pro-preview-06-05-thinking-128 格式来精确传递思考预算。',
|
||||
)}
|
||||
</Text>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row>
|
||||
<Col span={16}>
|
||||
<Form.Switch
|
||||
label={t('启用Gemini思考后缀适配')}
|
||||
field={'gemini.thinking_adapter_enabled'}
|
||||
extraText={t(
|
||||
'适配 -thinking、-thinking-预算数字 和 -nothinking 后缀',
|
||||
)}
|
||||
onChange={(value) =>
|
||||
setInputs({
|
||||
...inputs,
|
||||
'gemini.thinking_adapter_enabled': value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row>
|
||||
<Col span={16}>
|
||||
<Text>
|
||||
{t(
|
||||
'Gemini思考适配 BudgetTokens = MaxTokens * BudgetTokens 百分比',
|
||||
)}
|
||||
</Text>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row>
|
||||
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
|
||||
<Form.InputNumber
|
||||
label={t('思考预算占比')}
|
||||
field={'gemini.thinking_adapter_budget_tokens_percentage'}
|
||||
initValue={''}
|
||||
extraText={t('0.002-1之间的小数')}
|
||||
min={0.002}
|
||||
max={1}
|
||||
onChange={(value) =>
|
||||
setInputs({
|
||||
...inputs,
|
||||
'gemini.thinking_adapter_budget_tokens_percentage': value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</Form.Section>
|
||||
|
||||
<Row>
|
||||
<Button size='default' onClick={onSubmit}>
|
||||
{t('保存')}
|
||||
</Button>
|
||||
</Row>
|
||||
</Form>
|
||||
</Spin>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,416 @@
|
||||
/*
|
||||
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 {
|
||||
Button,
|
||||
Col,
|
||||
Form,
|
||||
Row,
|
||||
Spin,
|
||||
Banner,
|
||||
Tag,
|
||||
Divider,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
compareObjects,
|
||||
API,
|
||||
showError,
|
||||
showSuccess,
|
||||
showWarning,
|
||||
verifyJSON,
|
||||
} from '../../../helpers';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const thinkingExample = JSON.stringify(
|
||||
['moonshotai/kimi-k2-thinking', 'kimi-k2-thinking'],
|
||||
null,
|
||||
2,
|
||||
);
|
||||
|
||||
const chatCompletionsToResponsesPolicyExample = JSON.stringify(
|
||||
{
|
||||
enabled: true,
|
||||
all_channels: false,
|
||||
channel_ids: [1, 2],
|
||||
channel_types: [1],
|
||||
model_patterns: ['^gpt-4o.*$', '^gpt-5.*$'],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
|
||||
const chatCompletionsToResponsesPolicyAllChannelsExample = JSON.stringify(
|
||||
{
|
||||
enabled: true,
|
||||
all_channels: true,
|
||||
model_patterns: ['^gpt-4o.*$', '^gpt-5.*$'],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
|
||||
const defaultGlobalSettingInputs = {
|
||||
'global.pass_through_request_enabled': false,
|
||||
'global.thinking_model_blacklist': '[]',
|
||||
'global.chat_completions_to_responses_policy': '{}',
|
||||
'general_setting.ping_interval_enabled': false,
|
||||
'general_setting.ping_interval_seconds': 60,
|
||||
};
|
||||
|
||||
export default function SettingGlobalModel(props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [inputs, setInputs] = useState(defaultGlobalSettingInputs);
|
||||
const refForm = useRef();
|
||||
const [inputsRow, setInputsRow] = useState(defaultGlobalSettingInputs);
|
||||
const chatCompletionsToResponsesPolicyKey =
|
||||
'global.chat_completions_to_responses_policy';
|
||||
|
||||
const setChatCompletionsToResponsesPolicyValue = (value) => {
|
||||
setInputs((prev) => ({
|
||||
...prev,
|
||||
[chatCompletionsToResponsesPolicyKey]: value,
|
||||
}));
|
||||
if (refForm.current) {
|
||||
refForm.current.setValue(chatCompletionsToResponsesPolicyKey, value);
|
||||
}
|
||||
};
|
||||
|
||||
const normalizeValueBeforeSave = (key, value) => {
|
||||
if (key === 'global.thinking_model_blacklist') {
|
||||
const text = typeof value === 'string' ? value.trim() : '';
|
||||
return text === '' ? '[]' : value;
|
||||
}
|
||||
if (key === 'global.chat_completions_to_responses_policy') {
|
||||
const text = typeof value === 'string' ? value.trim() : '';
|
||||
return text === '' ? '{}' : value;
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
function onSubmit() {
|
||||
const updateArray = compareObjects(inputs, inputsRow);
|
||||
if (!updateArray.length) return showWarning(t('你似乎并没有修改什么'));
|
||||
const requestQueue = updateArray.map((item) => {
|
||||
const normalizedValue = normalizeValueBeforeSave(
|
||||
item.key,
|
||||
inputs[item.key],
|
||||
);
|
||||
let value = String(normalizedValue);
|
||||
|
||||
return API.put('/api/option/', {
|
||||
key: item.key,
|
||||
value,
|
||||
});
|
||||
});
|
||||
setLoading(true);
|
||||
Promise.all(requestQueue)
|
||||
.then((res) => {
|
||||
if (requestQueue.length === 1) {
|
||||
if (res.includes(undefined)) return;
|
||||
} else if (requestQueue.length > 1) {
|
||||
if (res.includes(undefined))
|
||||
return showError(t('部分保存失败,请重试'));
|
||||
}
|
||||
showSuccess(t('保存成功'));
|
||||
props.refresh();
|
||||
})
|
||||
.catch(() => {
|
||||
showError(t('保存失败,请重试'));
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const currentInputs = {};
|
||||
for (const key of Object.keys(defaultGlobalSettingInputs)) {
|
||||
if (props.options[key] !== undefined) {
|
||||
let value = props.options[key];
|
||||
if (key === 'global.thinking_model_blacklist') {
|
||||
try {
|
||||
value =
|
||||
value && String(value).trim() !== ''
|
||||
? JSON.stringify(JSON.parse(value), null, 2)
|
||||
: defaultGlobalSettingInputs[key];
|
||||
} catch (error) {
|
||||
value = defaultGlobalSettingInputs[key];
|
||||
}
|
||||
}
|
||||
if (key === 'global.chat_completions_to_responses_policy') {
|
||||
try {
|
||||
value =
|
||||
value && String(value).trim() !== ''
|
||||
? JSON.stringify(JSON.parse(value), null, 2)
|
||||
: defaultGlobalSettingInputs[key];
|
||||
} catch (error) {
|
||||
value = defaultGlobalSettingInputs[key];
|
||||
}
|
||||
}
|
||||
currentInputs[key] = value;
|
||||
} else {
|
||||
currentInputs[key] = defaultGlobalSettingInputs[key];
|
||||
}
|
||||
}
|
||||
|
||||
setInputs(currentInputs);
|
||||
setInputsRow(structuredClone(currentInputs));
|
||||
if (refForm.current) {
|
||||
refForm.current.setValues(currentInputs);
|
||||
}
|
||||
}, [props.options]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Spin spinning={loading}>
|
||||
<Form
|
||||
values={inputs}
|
||||
getFormApi={(formAPI) => (refForm.current = formAPI)}
|
||||
style={{ marginBottom: 15 }}
|
||||
>
|
||||
<Form.Section text={t('全局设置')}>
|
||||
<Row>
|
||||
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
|
||||
<Form.Switch
|
||||
label={t('启用请求透传')}
|
||||
field={'global.pass_through_request_enabled'}
|
||||
onChange={(value) =>
|
||||
setInputs({
|
||||
...inputs,
|
||||
'global.pass_through_request_enabled': value,
|
||||
})
|
||||
}
|
||||
extraText={t(
|
||||
'开启后,所有请求将直接透传给上游,不会进行任何处理(重定向和渠道适配也将失效),请谨慎开启',
|
||||
)}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row>
|
||||
<Col span={24}>
|
||||
<Form.TextArea
|
||||
label={t('禁用思考处理的模型列表')}
|
||||
field={'global.thinking_model_blacklist'}
|
||||
placeholder={t('例如:') + '\n' + thinkingExample}
|
||||
rows={4}
|
||||
rules={[
|
||||
{
|
||||
validator: (rule, value) => {
|
||||
if (!value || value.trim() === '') return true;
|
||||
return verifyJSON(value);
|
||||
},
|
||||
message: t('不是合法的 JSON 字符串'),
|
||||
},
|
||||
]}
|
||||
extraText={t(
|
||||
'列出的模型将不会自动添加或移除-thinking/-nothinking 后缀',
|
||||
)}
|
||||
onChange={(value) =>
|
||||
setInputs({
|
||||
...inputs,
|
||||
'global.thinking_model_blacklist': value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Form.Section
|
||||
text={
|
||||
<span
|
||||
style={{
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
{t('ChatCompletions→Responses 兼容配置')}
|
||||
<Tag color='orange' size='small'>
|
||||
测试版
|
||||
</Tag>
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<Row style={{ marginTop: 10 }}>
|
||||
<Col span={24}>
|
||||
<Banner
|
||||
type='warning'
|
||||
description={t(
|
||||
'提示:该功能为测试版,未来配置结构与功能行为可能发生变更,请勿在生产环境使用。',
|
||||
)}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row style={{ marginTop: 10 }}>
|
||||
<Col span={24}>
|
||||
<Form.TextArea
|
||||
label={t('参数配置')}
|
||||
field={chatCompletionsToResponsesPolicyKey}
|
||||
placeholder={
|
||||
t('例如(指定渠道):') +
|
||||
'\n' +
|
||||
chatCompletionsToResponsesPolicyExample +
|
||||
'\n\n' +
|
||||
t('例如(全渠道):') +
|
||||
'\n' +
|
||||
chatCompletionsToResponsesPolicyAllChannelsExample
|
||||
}
|
||||
rows={8}
|
||||
rules={[
|
||||
{
|
||||
validator: (rule, value) => {
|
||||
if (!value || value.trim() === '') return true;
|
||||
return verifyJSON(value);
|
||||
},
|
||||
message: t('不是合法的 JSON 字符串'),
|
||||
},
|
||||
]}
|
||||
onChange={(value) =>
|
||||
setInputs((prev) => ({
|
||||
...prev,
|
||||
[chatCompletionsToResponsesPolicyKey]: value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row style={{ marginTop: 10, marginBottom: 16 }}>
|
||||
<Col span={24}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 8,
|
||||
flexWrap: 'wrap',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
type='secondary'
|
||||
size='small'
|
||||
onClick={() =>
|
||||
setChatCompletionsToResponsesPolicyValue(
|
||||
chatCompletionsToResponsesPolicyExample,
|
||||
)
|
||||
}
|
||||
>
|
||||
{t('填充模板(指定渠道)')}
|
||||
</Button>
|
||||
<Button
|
||||
type='secondary'
|
||||
size='small'
|
||||
onClick={() =>
|
||||
setChatCompletionsToResponsesPolicyValue(
|
||||
chatCompletionsToResponsesPolicyAllChannelsExample,
|
||||
)
|
||||
}
|
||||
>
|
||||
{t('填充模板(全渠道)')}
|
||||
</Button>
|
||||
<Button
|
||||
type='secondary'
|
||||
size='small'
|
||||
onClick={() => {
|
||||
const raw = inputs[chatCompletionsToResponsesPolicyKey];
|
||||
if (!raw || String(raw).trim() === '') return;
|
||||
try {
|
||||
const formatted = JSON.stringify(
|
||||
JSON.parse(raw),
|
||||
null,
|
||||
2,
|
||||
);
|
||||
setChatCompletionsToResponsesPolicyValue(formatted);
|
||||
} catch (error) {
|
||||
showError(t('不是合法的 JSON 字符串'));
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t('格式化 JSON')}
|
||||
</Button>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
</Form.Section>
|
||||
|
||||
<Form.Section
|
||||
text={
|
||||
<span style={{ fontSize: 14, fontWeight: 600 }}>
|
||||
{t('连接保活设置')}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<Row style={{ marginTop: 10 }}>
|
||||
<Col span={24}>
|
||||
<Banner
|
||||
type='warning'
|
||||
description={t(
|
||||
'警告:启用保活后,如果已经写入保活数据后渠道出错,系统无法重试,如果必须开启,推荐设置尽可能大的Ping间隔',
|
||||
)}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row>
|
||||
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
|
||||
<Form.Switch
|
||||
label={t('启用Ping间隔')}
|
||||
field={'general_setting.ping_interval_enabled'}
|
||||
onChange={(value) =>
|
||||
setInputs({
|
||||
...inputs,
|
||||
'general_setting.ping_interval_enabled': value,
|
||||
})
|
||||
}
|
||||
extraText={t('开启后,将定期发送ping数据保持连接活跃')}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
|
||||
<Form.InputNumber
|
||||
label={t('Ping间隔(秒)')}
|
||||
field={'general_setting.ping_interval_seconds'}
|
||||
onChange={(value) =>
|
||||
setInputs({
|
||||
...inputs,
|
||||
'general_setting.ping_interval_seconds': value,
|
||||
})
|
||||
}
|
||||
min={1}
|
||||
disabled={!inputs['general_setting.ping_interval_enabled']}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</Form.Section>
|
||||
|
||||
<Row>
|
||||
<Button size='default' onClick={onSubmit}>
|
||||
{t('保存')}
|
||||
</Button>
|
||||
</Row>
|
||||
</Form.Section>
|
||||
</Form>
|
||||
</Spin>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
/*
|
||||
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 { Button, Col, Form, Row, Spin } from '@douyinfe/semi-ui';
|
||||
import {
|
||||
API,
|
||||
compareObjects,
|
||||
showError,
|
||||
showSuccess,
|
||||
showWarning,
|
||||
} from '../../../helpers';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const XAI_VIOLATION_FEE_DOC_URL =
|
||||
'https://docs.x.ai/docs/models#usage-guidelines-violation-fee';
|
||||
|
||||
const DEFAULT_GROK_INPUTS = {
|
||||
'grok.violation_deduction_enabled': true,
|
||||
'grok.violation_deduction_amount': 0.05,
|
||||
};
|
||||
|
||||
export default function SettingGrokModel(props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [inputs, setInputs] = useState(DEFAULT_GROK_INPUTS);
|
||||
const [inputsRow, setInputsRow] = useState(DEFAULT_GROK_INPUTS);
|
||||
const refForm = useRef();
|
||||
|
||||
async function onSubmit() {
|
||||
await refForm.current
|
||||
.validate()
|
||||
.then(() => {
|
||||
const updateArray = compareObjects(inputs, inputsRow);
|
||||
if (!updateArray.length) return showWarning(t('你似乎并没有修改什么'));
|
||||
|
||||
const requestQueue = updateArray.map((item) => {
|
||||
const value = String(inputs[item.key]);
|
||||
return API.put('/api/option/', { key: item.key, value });
|
||||
});
|
||||
|
||||
setLoading(true);
|
||||
Promise.all(requestQueue)
|
||||
.then((res) => {
|
||||
if (requestQueue.length === 1) {
|
||||
if (res.includes(undefined)) return;
|
||||
} else if (requestQueue.length > 1) {
|
||||
if (res.includes(undefined))
|
||||
return showError(t('部分保存失败,请重试'));
|
||||
}
|
||||
showSuccess(t('保存成功'));
|
||||
props.refresh();
|
||||
})
|
||||
.catch(() => {
|
||||
showError(t('保存失败,请重试'));
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Validation failed:', error);
|
||||
showError(t('请检查输入'));
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const currentInputs = { ...DEFAULT_GROK_INPUTS };
|
||||
for (const key of Object.keys(DEFAULT_GROK_INPUTS)) {
|
||||
if (props.options[key] !== undefined) {
|
||||
currentInputs[key] = props.options[key];
|
||||
}
|
||||
}
|
||||
|
||||
setInputs(currentInputs);
|
||||
setInputsRow(structuredClone(currentInputs));
|
||||
if (refForm.current) {
|
||||
refForm.current.setValues(currentInputs);
|
||||
}
|
||||
}, [props.options]);
|
||||
|
||||
return (
|
||||
<Spin spinning={loading}>
|
||||
<Form
|
||||
values={inputs}
|
||||
getFormApi={(formAPI) => (refForm.current = formAPI)}
|
||||
style={{ marginBottom: 15 }}
|
||||
>
|
||||
<Form.Section text={t('Grok设置')}>
|
||||
<Row>
|
||||
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
|
||||
<Form.Switch
|
||||
label={t('启用违规扣费')}
|
||||
field={'grok.violation_deduction_enabled'}
|
||||
onChange={(value) =>
|
||||
setInputs({
|
||||
...inputs,
|
||||
'grok.violation_deduction_enabled': value,
|
||||
})
|
||||
}
|
||||
extraText={
|
||||
<span>
|
||||
{t('开启后,违规请求将额外扣费。')}{' '}
|
||||
<a
|
||||
href={XAI_VIOLATION_FEE_DOC_URL}
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
>
|
||||
{t('官方说明')}
|
||||
</a>
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row>
|
||||
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
|
||||
<Form.InputNumber
|
||||
label={t('违规扣费金额')}
|
||||
field={'grok.violation_deduction_amount'}
|
||||
min={0}
|
||||
step={0.01}
|
||||
precision={4}
|
||||
disabled={!inputs['grok.violation_deduction_enabled']}
|
||||
onChange={(value) =>
|
||||
setInputs({
|
||||
...inputs,
|
||||
'grok.violation_deduction_amount': value,
|
||||
})
|
||||
}
|
||||
extraText={
|
||||
<span>
|
||||
{t('这是基础金额,实际扣费 = 基础金额 x 系统分组倍率。')}{' '}
|
||||
<a
|
||||
href={XAI_VIOLATION_FEE_DOC_URL}
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
>
|
||||
{t('官方说明')}
|
||||
</a>
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row>
|
||||
<Button size='default' onClick={onSubmit}>
|
||||
{t('保存')}
|
||||
</Button>
|
||||
</Row>
|
||||
</Form.Section>
|
||||
</Form>
|
||||
</Spin>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
/*
|
||||
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 {
|
||||
Button,
|
||||
Col,
|
||||
Form,
|
||||
Row,
|
||||
Spin,
|
||||
Card,
|
||||
Typography,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
compareObjects,
|
||||
API,
|
||||
showError,
|
||||
showSuccess,
|
||||
showWarning,
|
||||
} from '../../../helpers';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Server, Cloud, Zap, ArrowUpRight } from 'lucide-react';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
export default function SettingModelDeployment(props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [inputs, setInputs] = useState({
|
||||
'model_deployment.ionet.api_key': '',
|
||||
'model_deployment.ionet.enabled': false,
|
||||
});
|
||||
const refForm = useRef();
|
||||
const [inputsRow, setInputsRow] = useState({
|
||||
'model_deployment.ionet.api_key': '',
|
||||
'model_deployment.ionet.enabled': false,
|
||||
});
|
||||
const [testing, setTesting] = useState(false);
|
||||
|
||||
const testApiKey = async () => {
|
||||
const apiKey = inputs['model_deployment.ionet.api_key'];
|
||||
|
||||
const getLocalizedMessage = (message) => {
|
||||
switch (message) {
|
||||
case 'invalid request payload':
|
||||
return t('请求参数无效');
|
||||
case 'api_key is required':
|
||||
return t('请先填写 API Key');
|
||||
case 'failed to validate api key':
|
||||
return t('API Key 验证失败');
|
||||
default:
|
||||
return message;
|
||||
}
|
||||
};
|
||||
|
||||
setTesting(true);
|
||||
try {
|
||||
const response = await API.post(
|
||||
'/api/deployments/settings/test-connection',
|
||||
apiKey && apiKey.trim() !== '' ? { api_key: apiKey.trim() } : {},
|
||||
{
|
||||
skipErrorHandler: true,
|
||||
},
|
||||
);
|
||||
|
||||
if (response?.data?.success) {
|
||||
showSuccess(t('API Key 验证成功!连接到 io.net 服务正常'));
|
||||
} else {
|
||||
const rawMessage = response?.data?.message;
|
||||
const localizedMessage = rawMessage
|
||||
? getLocalizedMessage(rawMessage)
|
||||
: t('API Key 验证失败');
|
||||
showError(localizedMessage);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('io.net API test error:', error);
|
||||
|
||||
if (error?.code === 'ERR_NETWORK') {
|
||||
showError(t('网络连接失败,请检查网络设置或稍后重试'));
|
||||
} else {
|
||||
const rawMessage =
|
||||
error?.response?.data?.message || error?.message || '';
|
||||
const localizedMessage = rawMessage
|
||||
? getLocalizedMessage(rawMessage)
|
||||
: t('未知错误');
|
||||
showError(t('测试失败:') + localizedMessage);
|
||||
}
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
function onSubmit() {
|
||||
const updateArray = compareObjects(inputs, inputsRow);
|
||||
if (!updateArray.length) return showWarning(t('你似乎并没有修改什么'));
|
||||
|
||||
const requestQueue = updateArray.map((item) => {
|
||||
let value = String(inputs[item.key]);
|
||||
return API.put('/api/option/', {
|
||||
key: item.key,
|
||||
value,
|
||||
});
|
||||
});
|
||||
|
||||
setLoading(true);
|
||||
Promise.all(requestQueue)
|
||||
.then((res) => {
|
||||
if (requestQueue.length === 1) {
|
||||
if (res.includes(undefined)) return;
|
||||
} else if (requestQueue.length > 1) {
|
||||
if (res.includes(undefined))
|
||||
return showError(t('部分保存失败,请重试'));
|
||||
}
|
||||
showSuccess(t('保存成功'));
|
||||
// 更新 inputsRow 以反映已保存的状态
|
||||
setInputsRow(structuredClone(inputs));
|
||||
props.refresh();
|
||||
})
|
||||
.catch(() => {
|
||||
showError(t('保存失败,请重试'));
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (props.options) {
|
||||
const defaultInputs = {
|
||||
'model_deployment.ionet.api_key': '',
|
||||
'model_deployment.ionet.enabled': false,
|
||||
};
|
||||
|
||||
const currentInputs = {};
|
||||
for (let key in defaultInputs) {
|
||||
if (props.options.hasOwnProperty(key)) {
|
||||
currentInputs[key] = props.options[key];
|
||||
} else {
|
||||
currentInputs[key] = defaultInputs[key];
|
||||
}
|
||||
}
|
||||
|
||||
setInputs(currentInputs);
|
||||
setInputsRow(structuredClone(currentInputs));
|
||||
refForm.current?.setValues(currentInputs);
|
||||
}
|
||||
}, [props.options]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Spin spinning={loading}>
|
||||
<Form
|
||||
values={inputs}
|
||||
getFormApi={(formAPI) => (refForm.current = formAPI)}
|
||||
style={{ marginBottom: 15 }}
|
||||
>
|
||||
<Form.Section
|
||||
text={
|
||||
<div
|
||||
style={{ display: 'flex', alignItems: 'center', gap: '8px' }}
|
||||
>
|
||||
<span>{t('模型部署设置')}</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{/*<Text */}
|
||||
{/* type="secondary" */}
|
||||
{/* size="small"*/}
|
||||
{/* style={{ */}
|
||||
{/* display: 'block', */}
|
||||
{/* marginBottom: '20px',*/}
|
||||
{/* color: 'var(--semi-color-text-2)'*/}
|
||||
{/* }}*/}
|
||||
{/*>*/}
|
||||
{/* {t('配置模型部署服务提供商的API密钥和启用状态')}*/}
|
||||
{/*</Text>*/}
|
||||
|
||||
<Card
|
||||
title={
|
||||
<div
|
||||
style={{ display: 'flex', alignItems: 'center', gap: '8px' }}
|
||||
>
|
||||
<Cloud size={18} />
|
||||
<span>io.net</span>
|
||||
</div>
|
||||
}
|
||||
bodyStyle={{ padding: '20px' }}
|
||||
style={{ marginBottom: '16px' }}
|
||||
>
|
||||
<Row gutter={24}>
|
||||
<Col xs={24} lg={14}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '16px',
|
||||
}}
|
||||
>
|
||||
<Form.Switch
|
||||
label={t('启用 io.net 部署')}
|
||||
field={'model_deployment.ionet.enabled'}
|
||||
onChange={(value) =>
|
||||
setInputs({
|
||||
...inputs,
|
||||
'model_deployment.ionet.enabled': value,
|
||||
})
|
||||
}
|
||||
extraText={t('启用后可接入 io.net GPU 资源')}
|
||||
/>
|
||||
<Form.Input
|
||||
label={t('API Key')}
|
||||
field={'model_deployment.ionet.api_key'}
|
||||
placeholder={t('请输入 io.net API Key(敏感信息不显示)')}
|
||||
onChange={(value) =>
|
||||
setInputs({
|
||||
...inputs,
|
||||
'model_deployment.ionet.api_key': value,
|
||||
})
|
||||
}
|
||||
disabled={!inputs['model_deployment.ionet.enabled']}
|
||||
extraText={t('请使用 Project 为 io.cloud 的密钥')}
|
||||
mode='password'
|
||||
/>
|
||||
<div style={{ display: 'flex', gap: '12px' }}>
|
||||
<Button
|
||||
type='outline'
|
||||
size='small'
|
||||
icon={<Zap size={16} />}
|
||||
onClick={testApiKey}
|
||||
loading={testing}
|
||||
disabled={!inputs['model_deployment.ionet.enabled']}
|
||||
style={{
|
||||
height: '32px',
|
||||
fontSize: '13px',
|
||||
borderRadius: '6px',
|
||||
fontWeight: '500',
|
||||
borderColor: testing
|
||||
? 'var(--semi-color-primary)'
|
||||
: 'var(--semi-color-border)',
|
||||
color: testing
|
||||
? 'var(--semi-color-primary)'
|
||||
: 'var(--semi-color-text-0)',
|
||||
}}
|
||||
>
|
||||
{testing ? t('连接测试中...') : t('测试连接')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Col>
|
||||
<Col xs={24} lg={10}>
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--semi-color-fill-0)',
|
||||
padding: '16px',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid var(--semi-color-border)',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '12px',
|
||||
justifyContent: 'space-between',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<Text
|
||||
strong
|
||||
style={{ display: 'block', marginBottom: '8px' }}
|
||||
>
|
||||
{t('获取 io.net API Key')}
|
||||
</Text>
|
||||
<ul
|
||||
style={{
|
||||
margin: 0,
|
||||
paddingLeft: '18px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '6px',
|
||||
color: 'var(--semi-color-text-2)',
|
||||
fontSize: '13px',
|
||||
lineHeight: 1.6,
|
||||
}}
|
||||
>
|
||||
<li>{t('访问 io.net 控制台的 API Keys 页面')}</li>
|
||||
<li>
|
||||
{t('创建或选择密钥时,将 Project 设置为 io.cloud')}
|
||||
</li>
|
||||
<li>{t('复制生成的密钥并粘贴到此处')}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<Button
|
||||
icon={<ArrowUpRight size={16} />}
|
||||
type='primary'
|
||||
theme='solid'
|
||||
style={{ width: '100%' }}
|
||||
onClick={() =>
|
||||
window.open('https://ai.io.net/ai/api-keys', '_blank')
|
||||
}
|
||||
>
|
||||
{t('前往 io.net API Keys')}
|
||||
</Button>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
<Row>
|
||||
<Button size='default' type='primary' onClick={onSubmit}>
|
||||
{t('保存设置')}
|
||||
</Button>
|
||||
</Row>
|
||||
</Form.Section>
|
||||
</Form>
|
||||
</Spin>
|
||||
</>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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, { useEffect, useState, useRef } from 'react';
|
||||
import { Button, Col, Form, Row, Spin, Typography } from '@douyinfe/semi-ui';
|
||||
import {
|
||||
compareObjects,
|
||||
API,
|
||||
showError,
|
||||
showSuccess,
|
||||
showWarning,
|
||||
} from '../../../helpers';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export default function SettingsCheckin(props) {
|
||||
const { t } = useTranslation();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [inputs, setInputs] = useState({
|
||||
'checkin_setting.enabled': false,
|
||||
'checkin_setting.min_quota': 1000,
|
||||
'checkin_setting.max_quota': 10000,
|
||||
});
|
||||
const refForm = useRef();
|
||||
const [inputsRow, setInputsRow] = useState(inputs);
|
||||
|
||||
function handleFieldChange(fieldName) {
|
||||
return (value) => {
|
||||
setInputs((inputs) => ({ ...inputs, [fieldName]: value }));
|
||||
};
|
||||
}
|
||||
|
||||
function onSubmit() {
|
||||
const updateArray = compareObjects(inputs, inputsRow);
|
||||
if (!updateArray.length) return showWarning(t('你似乎并没有修改什么'));
|
||||
const requestQueue = updateArray.map((item) => {
|
||||
let value = '';
|
||||
if (typeof inputs[item.key] === 'boolean') {
|
||||
value = String(inputs[item.key]);
|
||||
} else {
|
||||
value = String(inputs[item.key]);
|
||||
}
|
||||
return API.put('/api/option/', {
|
||||
key: item.key,
|
||||
value,
|
||||
});
|
||||
});
|
||||
setLoading(true);
|
||||
Promise.all(requestQueue)
|
||||
.then((res) => {
|
||||
if (requestQueue.length === 1) {
|
||||
if (res.includes(undefined)) return;
|
||||
} else if (requestQueue.length > 1) {
|
||||
if (res.includes(undefined))
|
||||
return showError(t('部分保存失败,请重试'));
|
||||
}
|
||||
showSuccess(t('保存成功'));
|
||||
props.refresh();
|
||||
})
|
||||
.catch(() => {
|
||||
showError(t('保存失败,请重试'));
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const currentInputs = {};
|
||||
for (let key in props.options) {
|
||||
if (Object.keys(inputs).includes(key)) {
|
||||
currentInputs[key] = props.options[key];
|
||||
}
|
||||
}
|
||||
setInputs(currentInputs);
|
||||
setInputsRow(structuredClone(currentInputs));
|
||||
refForm.current.setValues(currentInputs);
|
||||
}, [props.options]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Spin spinning={loading}>
|
||||
<Form
|
||||
values={inputs}
|
||||
getFormApi={(formAPI) => (refForm.current = formAPI)}
|
||||
style={{ marginBottom: 15 }}
|
||||
>
|
||||
<Form.Section text={t('签到设置')}>
|
||||
<Typography.Text
|
||||
type='tertiary'
|
||||
style={{ marginBottom: 16, display: 'block' }}
|
||||
>
|
||||
{t('签到功能允许用户每日签到获取随机额度奖励')}
|
||||
</Typography.Text>
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
|
||||
<Form.Switch
|
||||
field={'checkin_setting.enabled'}
|
||||
label={t('启用签到功能')}
|
||||
size='default'
|
||||
checkedText='|'
|
||||
uncheckedText='〇'
|
||||
onChange={handleFieldChange('checkin_setting.enabled')}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
|
||||
<Form.InputNumber
|
||||
field={'checkin_setting.min_quota'}
|
||||
label={t('签到最小额度')}
|
||||
placeholder={t('签到奖励的最小额度')}
|
||||
onChange={handleFieldChange('checkin_setting.min_quota')}
|
||||
min={0}
|
||||
disabled={!inputs['checkin_setting.enabled']}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
|
||||
<Form.InputNumber
|
||||
field={'checkin_setting.max_quota'}
|
||||
label={t('签到最大额度')}
|
||||
placeholder={t('签到奖励的最大额度')}
|
||||
onChange={handleFieldChange('checkin_setting.max_quota')}
|
||||
min={0}
|
||||
disabled={!inputs['checkin_setting.enabled']}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row>
|
||||
<Button size='default' onClick={onSubmit}>
|
||||
{t('保存签到设置')}
|
||||
</Button>
|
||||
</Row>
|
||||
</Form.Section>
|
||||
</Form>
|
||||
</Spin>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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, useRef } from 'react';
|
||||
import { Button, Col, Form, Row, Spin } from '@douyinfe/semi-ui';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
compareObjects,
|
||||
API,
|
||||
showError,
|
||||
showSuccess,
|
||||
showWarning,
|
||||
} from '../../../helpers';
|
||||
|
||||
export default function SettingsCreditLimit(props) {
|
||||
const { t } = useTranslation();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [inputs, setInputs] = useState({
|
||||
QuotaForNewUser: '',
|
||||
PreConsumedQuota: '',
|
||||
QuotaForInviter: '',
|
||||
QuotaForInvitee: '',
|
||||
'quota_setting.enable_free_model_pre_consume': true,
|
||||
});
|
||||
const refForm = useRef();
|
||||
const [inputsRow, setInputsRow] = useState(inputs);
|
||||
|
||||
function onSubmit() {
|
||||
const updateArray = compareObjects(inputs, inputsRow);
|
||||
if (!updateArray.length) return showWarning(t('你似乎并没有修改什么'));
|
||||
const requestQueue = updateArray.map((item) => {
|
||||
let value = '';
|
||||
if (typeof inputs[item.key] === 'boolean') {
|
||||
value = String(inputs[item.key]);
|
||||
} else {
|
||||
value = inputs[item.key];
|
||||
}
|
||||
return API.put('/api/option/', {
|
||||
key: item.key,
|
||||
value,
|
||||
});
|
||||
});
|
||||
setLoading(true);
|
||||
Promise.all(requestQueue)
|
||||
.then((res) => {
|
||||
if (requestQueue.length === 1) {
|
||||
if (res.includes(undefined)) return;
|
||||
} else if (requestQueue.length > 1) {
|
||||
if (res.includes(undefined))
|
||||
return showError(t('部分保存失败,请重试'));
|
||||
}
|
||||
showSuccess(t('保存成功'));
|
||||
props.refresh();
|
||||
})
|
||||
.catch(() => {
|
||||
showError(t('保存失败,请重试'));
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const currentInputs = {};
|
||||
for (let key in props.options) {
|
||||
if (Object.keys(inputs).includes(key)) {
|
||||
currentInputs[key] = props.options[key];
|
||||
}
|
||||
}
|
||||
setInputs(currentInputs);
|
||||
setInputsRow(structuredClone(currentInputs));
|
||||
refForm.current.setValues(currentInputs);
|
||||
}, [props.options]);
|
||||
return (
|
||||
<>
|
||||
<Spin spinning={loading}>
|
||||
<Form
|
||||
values={inputs}
|
||||
getFormApi={(formAPI) => (refForm.current = formAPI)}
|
||||
style={{ marginBottom: 15 }}
|
||||
>
|
||||
<Form.Section text={t('额度设置')}>
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
|
||||
<Form.InputNumber
|
||||
label={t('新用户初始额度')}
|
||||
field={'QuotaForNewUser'}
|
||||
step={1}
|
||||
min={0}
|
||||
suffix={'Token'}
|
||||
placeholder={''}
|
||||
onChange={(value) =>
|
||||
setInputs({
|
||||
...inputs,
|
||||
QuotaForNewUser: String(value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
|
||||
<Form.InputNumber
|
||||
label={t('请求预扣费额度')}
|
||||
field={'PreConsumedQuota'}
|
||||
step={1}
|
||||
min={0}
|
||||
suffix={'Token'}
|
||||
extraText={t('请求结束后多退少补')}
|
||||
placeholder={''}
|
||||
onChange={(value) =>
|
||||
setInputs({
|
||||
...inputs,
|
||||
PreConsumedQuota: String(value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
|
||||
<Form.InputNumber
|
||||
label={t('邀请新用户奖励额度')}
|
||||
field={'QuotaForInviter'}
|
||||
step={1}
|
||||
min={0}
|
||||
suffix={'Token'}
|
||||
extraText={''}
|
||||
placeholder={t('例如:2000')}
|
||||
onChange={(value) =>
|
||||
setInputs({
|
||||
...inputs,
|
||||
QuotaForInviter: String(value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row>
|
||||
<Col xs={24} sm={12} md={8} lg={8} xl={6}>
|
||||
<Form.InputNumber
|
||||
label={t('新用户使用邀请码奖励额度')}
|
||||
field={'QuotaForInvitee'}
|
||||
step={1}
|
||||
min={0}
|
||||
suffix={'Token'}
|
||||
extraText={''}
|
||||
placeholder={t('例如:1000')}
|
||||
onChange={(value) =>
|
||||
setInputs({
|
||||
...inputs,
|
||||
QuotaForInvitee: String(value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row>
|
||||
<Col>
|
||||
<Form.Switch
|
||||
label={t('对免费模型启用预消耗')}
|
||||
field={'quota_setting.enable_free_model_pre_consume'}
|
||||
extraText={t(
|
||||
'开启后,对免费模型(倍率为0,或者价格为0)的模型也会预消耗额度',
|
||||
)}
|
||||
onChange={(value) =>
|
||||
setInputs({
|
||||
...inputs,
|
||||
'quota_setting.enable_free_model_pre_consume': value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row>
|
||||
<Button size='default' onClick={onSubmit}>
|
||||
{t('保存额度设置')}
|
||||
</Button>
|
||||
</Row>
|
||||
</Form.Section>
|
||||
</Form>
|
||||
</Spin>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,436 @@
|
||||
/*
|
||||
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, useMemo } from 'react';
|
||||
import {
|
||||
Banner,
|
||||
Button,
|
||||
Col,
|
||||
Form,
|
||||
Row,
|
||||
Spin,
|
||||
Modal,
|
||||
Input,
|
||||
Typography,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
compareObjects,
|
||||
API,
|
||||
showError,
|
||||
showSuccess,
|
||||
showWarning,
|
||||
} from '../../../helpers';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
export default function GeneralSettings(props) {
|
||||
const { t } = useTranslation();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showQuotaWarning, setShowQuotaWarning] = useState(false);
|
||||
const [inputs, setInputs] = useState({
|
||||
TopUpLink: '',
|
||||
'general_setting.docs_link': '',
|
||||
'general_setting.quota_display_type': 'USD',
|
||||
'general_setting.custom_currency_symbol': '¤',
|
||||
'general_setting.custom_currency_exchange_rate': '',
|
||||
QuotaPerUnit: '',
|
||||
RetryTimes: '',
|
||||
USDExchangeRate: '',
|
||||
DisplayTokenStatEnabled: false,
|
||||
DefaultCollapseSidebar: false,
|
||||
DemoSiteEnabled: false,
|
||||
SelfUseModeEnabled: false,
|
||||
'token_setting.max_user_tokens': 1000,
|
||||
});
|
||||
const refForm = useRef();
|
||||
const [inputsRow, setInputsRow] = useState(inputs);
|
||||
|
||||
function handleFieldChange(fieldName) {
|
||||
return (value) => {
|
||||
setInputs((inputs) => ({ ...inputs, [fieldName]: value }));
|
||||
};
|
||||
}
|
||||
|
||||
function onSubmit() {
|
||||
const updateArray = compareObjects(inputs, inputsRow);
|
||||
if (!updateArray.length) return showWarning(t('你似乎并没有修改什么'));
|
||||
const requestQueue = updateArray.map((item) => {
|
||||
let value = '';
|
||||
if (typeof inputs[item.key] === 'boolean') {
|
||||
value = String(inputs[item.key]);
|
||||
} else {
|
||||
value = inputs[item.key];
|
||||
}
|
||||
return API.put('/api/option/', {
|
||||
key: item.key,
|
||||
value,
|
||||
});
|
||||
});
|
||||
setLoading(true);
|
||||
Promise.all(requestQueue)
|
||||
.then((res) => {
|
||||
if (requestQueue.length === 1) {
|
||||
if (res.includes(undefined)) return;
|
||||
} else if (requestQueue.length > 1) {
|
||||
if (res.includes(undefined))
|
||||
return showError(t('部分保存失败,请重试'));
|
||||
}
|
||||
showSuccess(t('保存成功'));
|
||||
props.refresh();
|
||||
})
|
||||
.catch(() => {
|
||||
showError(t('保存失败,请重试'));
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
}
|
||||
|
||||
// 计算展示在输入框中的“1 USD = X <currency>”中的 X
|
||||
const combinedRate = useMemo(() => {
|
||||
const type = inputs['general_setting.quota_display_type'];
|
||||
if (type === 'USD') return '1';
|
||||
if (type === 'CNY') return String(inputs['USDExchangeRate'] || '');
|
||||
if (type === 'TOKENS') return String(inputs['QuotaPerUnit'] || '');
|
||||
if (type === 'CUSTOM')
|
||||
return String(
|
||||
inputs['general_setting.custom_currency_exchange_rate'] || '',
|
||||
);
|
||||
return '';
|
||||
}, [inputs]);
|
||||
|
||||
const onCombinedRateChange = (val) => {
|
||||
const type = inputs['general_setting.quota_display_type'];
|
||||
if (type === 'CNY') {
|
||||
handleFieldChange('USDExchangeRate')(val);
|
||||
} else if (type === 'TOKENS') {
|
||||
handleFieldChange('QuotaPerUnit')(val);
|
||||
} else if (type === 'CUSTOM') {
|
||||
handleFieldChange('general_setting.custom_currency_exchange_rate')(val);
|
||||
}
|
||||
};
|
||||
|
||||
const showTokensOption = useMemo(() => {
|
||||
const initialType = props.options?.['general_setting.quota_display_type'];
|
||||
const initialQuotaPerUnit = parseFloat(props.options?.QuotaPerUnit);
|
||||
const legacyTokensMode =
|
||||
initialType === undefined &&
|
||||
props.options?.DisplayInCurrencyEnabled !== undefined &&
|
||||
!props.options.DisplayInCurrencyEnabled;
|
||||
return (
|
||||
initialType === 'TOKENS' ||
|
||||
legacyTokensMode ||
|
||||
(!isNaN(initialQuotaPerUnit) && initialQuotaPerUnit !== 500000)
|
||||
);
|
||||
}, [props.options]);
|
||||
|
||||
const quotaDisplayType = inputs['general_setting.quota_display_type'];
|
||||
|
||||
const quotaDisplayTypeDesc = useMemo(() => {
|
||||
const descMap = {
|
||||
USD: t('站点所有额度将以美元 ($) 显示'),
|
||||
CNY: t('站点所有额度将按汇率换算为人民币 (¥) 显示'),
|
||||
TOKENS: t('站点所有额度将以原始 Token 数显示,不做货币换算'),
|
||||
CUSTOM: t('站点所有额度将按汇率换算为自定义货币显示'),
|
||||
};
|
||||
return descMap[quotaDisplayType] || '';
|
||||
}, [quotaDisplayType, t]);
|
||||
|
||||
const rateLabel = useMemo(() => {
|
||||
if (quotaDisplayType === 'CNY') return t('汇率');
|
||||
if (quotaDisplayType === 'TOKENS') return t('每美元对应 Token 数');
|
||||
if (quotaDisplayType === 'CUSTOM') return t('汇率');
|
||||
return '';
|
||||
}, [quotaDisplayType, t]);
|
||||
|
||||
const rateSuffix = useMemo(() => {
|
||||
if (quotaDisplayType === 'CNY') return 'CNY (¥)';
|
||||
if (quotaDisplayType === 'TOKENS') return 'Tokens';
|
||||
if (quotaDisplayType === 'CUSTOM')
|
||||
return inputs['general_setting.custom_currency_symbol'] || '¤';
|
||||
return '';
|
||||
}, [quotaDisplayType, inputs]);
|
||||
|
||||
const rateExtraText = useMemo(() => {
|
||||
if (quotaDisplayType === 'CNY')
|
||||
return t(
|
||||
'系统内部以美元 (USD) 为基准计价。用户余额、充值金额、模型定价、用量日志等所有金额显示均按此汇率换算为人民币,不影响内部计费',
|
||||
);
|
||||
if (quotaDisplayType === 'TOKENS')
|
||||
return t(
|
||||
'系统内部计费精度,默认 500000,修改可能导致计费异常,请谨慎操作',
|
||||
);
|
||||
if (quotaDisplayType === 'CUSTOM')
|
||||
return t(
|
||||
'系统内部以美元 (USD) 为基准计价。用户余额、充值金额、模型定价、用量日志等所有金额显示均按此汇率换算为自定义货币,不影响内部计费',
|
||||
);
|
||||
return '';
|
||||
}, [quotaDisplayType, t]);
|
||||
|
||||
const previewText = useMemo(() => {
|
||||
if (quotaDisplayType === 'USD') return '$1.00';
|
||||
const rate = parseFloat(combinedRate);
|
||||
if (!rate || isNaN(rate)) return t('请输入汇率');
|
||||
if (quotaDisplayType === 'CNY') return `$1.00 → ¥${rate.toFixed(2)}`;
|
||||
if (quotaDisplayType === 'TOKENS')
|
||||
return `$1.00 → ${Number(rate).toLocaleString()} Tokens`;
|
||||
if (quotaDisplayType === 'CUSTOM') {
|
||||
const symbol = inputs['general_setting.custom_currency_symbol'] || '¤';
|
||||
return `$1.00 → ${symbol}${rate.toFixed(2)}`;
|
||||
}
|
||||
return '';
|
||||
}, [quotaDisplayType, combinedRate, inputs, t]);
|
||||
|
||||
useEffect(() => {
|
||||
const currentInputs = {};
|
||||
for (let key in props.options) {
|
||||
if (Object.keys(inputs).includes(key)) {
|
||||
currentInputs[key] = props.options[key];
|
||||
}
|
||||
}
|
||||
// 若旧字段存在且新字段缺失,则做一次兜底映射
|
||||
if (
|
||||
currentInputs['general_setting.quota_display_type'] === undefined &&
|
||||
props.options?.DisplayInCurrencyEnabled !== undefined
|
||||
) {
|
||||
currentInputs['general_setting.quota_display_type'] = props.options
|
||||
.DisplayInCurrencyEnabled
|
||||
? 'USD'
|
||||
: 'TOKENS';
|
||||
}
|
||||
// 回填自定义货币相关字段(如果后端已存在)
|
||||
if (props.options['general_setting.custom_currency_symbol'] !== undefined) {
|
||||
currentInputs['general_setting.custom_currency_symbol'] =
|
||||
props.options['general_setting.custom_currency_symbol'];
|
||||
}
|
||||
if (
|
||||
props.options['general_setting.custom_currency_exchange_rate'] !==
|
||||
undefined
|
||||
) {
|
||||
currentInputs['general_setting.custom_currency_exchange_rate'] =
|
||||
props.options['general_setting.custom_currency_exchange_rate'];
|
||||
}
|
||||
setInputs(currentInputs);
|
||||
setInputsRow(structuredClone(currentInputs));
|
||||
refForm.current.setValues(currentInputs);
|
||||
}, [props.options]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Spin spinning={loading}>
|
||||
<Form
|
||||
values={inputs}
|
||||
getFormApi={(formAPI) => (refForm.current = formAPI)}
|
||||
style={{ marginBottom: 15 }}
|
||||
>
|
||||
<Form.Section text={t('通用设置')}>
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
|
||||
<Form.Input
|
||||
field={'TopUpLink'}
|
||||
label={t('充值链接')}
|
||||
initValue={''}
|
||||
placeholder={t('例如发卡网站的购买链接')}
|
||||
onChange={handleFieldChange('TopUpLink')}
|
||||
showClear
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
|
||||
<Form.Input
|
||||
field={'general_setting.docs_link'}
|
||||
label={t('文档地址')}
|
||||
initValue={''}
|
||||
placeholder={t('例如 https://docs.newapi.pro')}
|
||||
onChange={handleFieldChange('general_setting.docs_link')}
|
||||
showClear
|
||||
/>
|
||||
</Col>
|
||||
{/* 单位美元额度已合入汇率组合控件(TOKENS 模式下编辑),不再单独展示 */}
|
||||
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
|
||||
<Form.Input
|
||||
field={'RetryTimes'}
|
||||
label={t('失败重试次数')}
|
||||
initValue={''}
|
||||
placeholder={t('失败重试次数')}
|
||||
onChange={handleFieldChange('RetryTimes')}
|
||||
showClear
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
|
||||
<Form.Select
|
||||
field='general_setting.quota_display_type'
|
||||
label={t('额度展示类型')}
|
||||
extraText={quotaDisplayTypeDesc}
|
||||
onChange={handleFieldChange(
|
||||
'general_setting.quota_display_type',
|
||||
)}
|
||||
>
|
||||
<Form.Select.Option value='USD'>
|
||||
USD ($)
|
||||
</Form.Select.Option>
|
||||
<Form.Select.Option value='CNY'>
|
||||
CNY (¥)
|
||||
</Form.Select.Option>
|
||||
{showTokensOption && (
|
||||
<Form.Select.Option value='TOKENS'>
|
||||
Tokens
|
||||
</Form.Select.Option>
|
||||
)}
|
||||
<Form.Select.Option value='CUSTOM'>
|
||||
{t('自定义货币')}
|
||||
</Form.Select.Option>
|
||||
</Form.Select>
|
||||
</Col>
|
||||
{quotaDisplayType !== 'USD' && (
|
||||
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
|
||||
<Form.Slot label={rateLabel}>
|
||||
<Input
|
||||
prefix='1 USD = '
|
||||
suffix={rateSuffix}
|
||||
value={combinedRate}
|
||||
onChange={onCombinedRateChange}
|
||||
/>
|
||||
<Text
|
||||
type='tertiary'
|
||||
size='small'
|
||||
style={{ marginTop: 4, display: 'block' }}
|
||||
>
|
||||
{rateExtraText}
|
||||
</Text>
|
||||
</Form.Slot>
|
||||
</Col>
|
||||
)}
|
||||
<Col
|
||||
xs={24}
|
||||
sm={12}
|
||||
md={8}
|
||||
lg={8}
|
||||
xl={8}
|
||||
style={
|
||||
quotaDisplayType !== 'CUSTOM'
|
||||
? { display: 'none' }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Form.Input
|
||||
field='general_setting.custom_currency_symbol'
|
||||
label={t('自定义货币符号')}
|
||||
extraText={t(
|
||||
'自定义货币符号将显示在所有额度数值前,例如 €1.50',
|
||||
)}
|
||||
placeholder={t('例如 €, £, Rp, ₩, ₹...')}
|
||||
onChange={handleFieldChange(
|
||||
'general_setting.custom_currency_symbol',
|
||||
)}
|
||||
showClear
|
||||
/>
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
<Text type='tertiary' size='small'>
|
||||
{t('预览效果')}:{previewText}
|
||||
</Text>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
|
||||
<Form.Switch
|
||||
field={'DisplayTokenStatEnabled'}
|
||||
label={t('额度查询接口返回令牌额度而非用户额度')}
|
||||
size='default'
|
||||
checkedText='|'
|
||||
uncheckedText='〇'
|
||||
onChange={handleFieldChange('DisplayTokenStatEnabled')}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
|
||||
<Form.Switch
|
||||
field={'DefaultCollapseSidebar'}
|
||||
label={t('默认折叠侧边栏')}
|
||||
size='default'
|
||||
checkedText='|'
|
||||
uncheckedText='〇'
|
||||
onChange={handleFieldChange('DefaultCollapseSidebar')}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
|
||||
<Form.Switch
|
||||
field={'DemoSiteEnabled'}
|
||||
label={t('演示站点模式')}
|
||||
size='default'
|
||||
checkedText='|'
|
||||
uncheckedText='〇'
|
||||
onChange={handleFieldChange('DemoSiteEnabled')}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
|
||||
<Form.Switch
|
||||
field={'SelfUseModeEnabled'}
|
||||
label={t('自用模式')}
|
||||
extraText={t('开启后不限制:必须设置模型倍率')}
|
||||
size='default'
|
||||
checkedText='|'
|
||||
uncheckedText='〇'
|
||||
onChange={handleFieldChange('SelfUseModeEnabled')}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
|
||||
<Form.InputNumber
|
||||
label={t('用户最大令牌数量')}
|
||||
field={'token_setting.max_user_tokens'}
|
||||
step={1}
|
||||
min={1}
|
||||
extraText={t('每个用户最多可创建的令牌数量,默认 1000,设置过大可能会影响性能')}
|
||||
placeholder={'1000'}
|
||||
onChange={handleFieldChange('token_setting.max_user_tokens')}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row>
|
||||
<Button size='default' onClick={onSubmit}>
|
||||
{t('保存通用设置')}
|
||||
</Button>
|
||||
</Row>
|
||||
</Form.Section>
|
||||
</Form>
|
||||
</Spin>
|
||||
|
||||
<Modal
|
||||
title={t('警告')}
|
||||
visible={showQuotaWarning}
|
||||
onOk={() => setShowQuotaWarning(false)}
|
||||
onCancel={() => setShowQuotaWarning(false)}
|
||||
closeOnEsc={true}
|
||||
width={500}
|
||||
>
|
||||
<Banner
|
||||
type='warning'
|
||||
description={t(
|
||||
'此设置用于系统内部计算,默认值500000是为了精确到6位小数点设计,不推荐修改。',
|
||||
)}
|
||||
bordered
|
||||
fullMode={false}
|
||||
closeIcon={null}
|
||||
/>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
/*
|
||||
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, useContext } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
Form,
|
||||
Row,
|
||||
Switch,
|
||||
Typography,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { API, showError, showSuccess } from '../../../helpers';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { StatusContext } from '../../../context/Status';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
export default function SettingsHeaderNavModules(props) {
|
||||
const { t } = useTranslation();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [statusState, statusDispatch] = useContext(StatusContext);
|
||||
|
||||
// 顶栏模块管理状态
|
||||
const [headerNavModules, setHeaderNavModules] = useState({
|
||||
home: true,
|
||||
console: true,
|
||||
pricing: {
|
||||
enabled: true,
|
||||
requireAuth: false, // 默认不需要登录鉴权
|
||||
},
|
||||
docs: true,
|
||||
about: true,
|
||||
});
|
||||
|
||||
// 处理顶栏模块配置变更
|
||||
function handleHeaderNavModuleChange(moduleKey) {
|
||||
return (checked) => {
|
||||
const newModules = { ...headerNavModules };
|
||||
if (moduleKey === 'pricing') {
|
||||
// 对于pricing模块,只更新enabled属性
|
||||
newModules[moduleKey] = {
|
||||
...newModules[moduleKey],
|
||||
enabled: checked,
|
||||
};
|
||||
} else {
|
||||
newModules[moduleKey] = checked;
|
||||
}
|
||||
setHeaderNavModules(newModules);
|
||||
};
|
||||
}
|
||||
|
||||
// 处理模型广场权限控制变更
|
||||
function handlePricingAuthChange(checked) {
|
||||
const newModules = { ...headerNavModules };
|
||||
newModules.pricing = {
|
||||
...newModules.pricing,
|
||||
requireAuth: checked,
|
||||
};
|
||||
setHeaderNavModules(newModules);
|
||||
}
|
||||
|
||||
// 重置顶栏模块为默认配置
|
||||
function resetHeaderNavModules() {
|
||||
const defaultModules = {
|
||||
home: true,
|
||||
console: true,
|
||||
pricing: {
|
||||
enabled: true,
|
||||
requireAuth: false,
|
||||
},
|
||||
docs: true,
|
||||
about: true,
|
||||
};
|
||||
setHeaderNavModules(defaultModules);
|
||||
showSuccess(t('已重置为默认配置'));
|
||||
}
|
||||
|
||||
// 保存配置
|
||||
async function onSubmit() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await API.put('/api/option/', {
|
||||
key: 'HeaderNavModules',
|
||||
value: JSON.stringify(headerNavModules),
|
||||
});
|
||||
const { success, message } = res.data;
|
||||
if (success) {
|
||||
showSuccess(t('保存成功'));
|
||||
|
||||
// 立即更新StatusContext中的状态
|
||||
statusDispatch({
|
||||
type: 'set',
|
||||
payload: {
|
||||
...statusState.status,
|
||||
HeaderNavModules: JSON.stringify(headerNavModules),
|
||||
},
|
||||
});
|
||||
|
||||
// 刷新父组件状态
|
||||
if (props.refresh) {
|
||||
await props.refresh();
|
||||
}
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
} catch (error) {
|
||||
showError(t('保存失败,请重试'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
// 从 props.options 中获取配置
|
||||
if (props.options && props.options.HeaderNavModules) {
|
||||
try {
|
||||
const modules = JSON.parse(props.options.HeaderNavModules);
|
||||
|
||||
// 处理向后兼容性:如果pricing是boolean,转换为对象格式
|
||||
if (typeof modules.pricing === 'boolean') {
|
||||
modules.pricing = {
|
||||
enabled: modules.pricing,
|
||||
requireAuth: false, // 默认不需要登录鉴权
|
||||
};
|
||||
}
|
||||
|
||||
setHeaderNavModules(modules);
|
||||
} catch (error) {
|
||||
// 使用默认配置
|
||||
const defaultModules = {
|
||||
home: true,
|
||||
console: true,
|
||||
pricing: {
|
||||
enabled: true,
|
||||
requireAuth: false,
|
||||
},
|
||||
docs: true,
|
||||
about: true,
|
||||
};
|
||||
setHeaderNavModules(defaultModules);
|
||||
}
|
||||
}
|
||||
}, [props.options]);
|
||||
|
||||
// 模块配置数据
|
||||
const moduleConfigs = [
|
||||
{
|
||||
key: 'home',
|
||||
title: t('首页'),
|
||||
description: t('用户主页,展示系统信息'),
|
||||
},
|
||||
{
|
||||
key: 'console',
|
||||
title: t('控制台'),
|
||||
description: t('用户控制面板,管理账户'),
|
||||
},
|
||||
{
|
||||
key: 'pricing',
|
||||
title: t('模型广场'),
|
||||
description: t('模型定价,需要登录访问'),
|
||||
hasSubConfig: true, // 标识该模块有子配置
|
||||
},
|
||||
{
|
||||
key: 'docs',
|
||||
title: t('文档'),
|
||||
description: t('系统文档和帮助信息'),
|
||||
},
|
||||
{
|
||||
key: 'about',
|
||||
title: t('关于'),
|
||||
description: t('关于系统的详细信息'),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Form.Section
|
||||
text={t('顶栏管理')}
|
||||
extraText={t('控制顶栏模块显示状态,全局生效')}
|
||||
>
|
||||
<Row gutter={[16, 16]} style={{ marginBottom: '24px' }}>
|
||||
{moduleConfigs.map((module) => (
|
||||
<Col key={module.key} xs={24} sm={12} md={6} lg={6} xl={6}>
|
||||
<Card
|
||||
style={{
|
||||
borderRadius: '8px',
|
||||
border: '1px solid var(--semi-color-border)',
|
||||
transition: 'all 0.2s ease',
|
||||
background: 'var(--semi-color-bg-1)',
|
||||
minHeight: '80px',
|
||||
}}
|
||||
bodyStyle={{ padding: '16px' }}
|
||||
hoverable
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
height: '100%',
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: 1, textAlign: 'left' }}>
|
||||
<div
|
||||
style={{
|
||||
fontWeight: '600',
|
||||
fontSize: '14px',
|
||||
color: 'var(--semi-color-text-0)',
|
||||
marginBottom: '4px',
|
||||
}}
|
||||
>
|
||||
{module.title}
|
||||
</div>
|
||||
<Text
|
||||
type='secondary'
|
||||
size='small'
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
color: 'var(--semi-color-text-2)',
|
||||
lineHeight: '1.4',
|
||||
display: 'block',
|
||||
}}
|
||||
>
|
||||
{module.description}
|
||||
</Text>
|
||||
</div>
|
||||
<div style={{ marginLeft: '16px' }}>
|
||||
<Switch
|
||||
checked={
|
||||
module.key === 'pricing'
|
||||
? headerNavModules[module.key]?.enabled
|
||||
: headerNavModules[module.key]
|
||||
}
|
||||
onChange={handleHeaderNavModuleChange(module.key)}
|
||||
size='default'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 为模型广场添加权限控制子开关 */}
|
||||
{module.key === 'pricing' &&
|
||||
(module.key === 'pricing'
|
||||
? headerNavModules[module.key]?.enabled
|
||||
: headerNavModules[module.key]) && (
|
||||
<div
|
||||
style={{
|
||||
borderTop: '1px solid var(--semi-color-border)',
|
||||
marginTop: '12px',
|
||||
paddingTop: '12px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: 1, textAlign: 'left' }}>
|
||||
<div
|
||||
style={{
|
||||
fontWeight: '500',
|
||||
fontSize: '12px',
|
||||
color: 'var(--semi-color-text-1)',
|
||||
marginBottom: '2px',
|
||||
}}
|
||||
>
|
||||
{t('需要登录访问')}
|
||||
</div>
|
||||
<Text
|
||||
type='secondary'
|
||||
size='small'
|
||||
style={{
|
||||
fontSize: '11px',
|
||||
color: 'var(--semi-color-text-2)',
|
||||
lineHeight: '1.4',
|
||||
display: 'block',
|
||||
}}
|
||||
>
|
||||
{t('开启后未登录用户无法访问模型广场')}
|
||||
</Text>
|
||||
</div>
|
||||
<div style={{ marginLeft: '16px' }}>
|
||||
<Switch
|
||||
checked={
|
||||
headerNavModules.pricing?.requireAuth || false
|
||||
}
|
||||
onChange={handlePricingAuthChange}
|
||||
size='default'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: '12px',
|
||||
justifyContent: 'flex-start',
|
||||
alignItems: 'center',
|
||||
paddingTop: '8px',
|
||||
borderTop: '1px solid var(--semi-color-border)',
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
size='default'
|
||||
type='tertiary'
|
||||
onClick={resetHeaderNavModules}
|
||||
style={{
|
||||
borderRadius: '6px',
|
||||
fontWeight: '500',
|
||||
}}
|
||||
>
|
||||
{t('重置为默认')}
|
||||
</Button>
|
||||
<Button
|
||||
size='default'
|
||||
type='primary'
|
||||
onClick={onSubmit}
|
||||
loading={loading}
|
||||
style={{
|
||||
borderRadius: '6px',
|
||||
fontWeight: '500',
|
||||
minWidth: '100px',
|
||||
}}
|
||||
>
|
||||
{t('保存设置')}
|
||||
</Button>
|
||||
</div>
|
||||
</Form.Section>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
/*
|
||||
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 {
|
||||
Button,
|
||||
Col,
|
||||
Form,
|
||||
Row,
|
||||
Spin,
|
||||
DatePicker,
|
||||
Typography,
|
||||
Modal,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import dayjs from 'dayjs';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
compareObjects,
|
||||
API,
|
||||
showError,
|
||||
showSuccess,
|
||||
showWarning,
|
||||
} from '../../../helpers';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
export default function SettingsLog(props) {
|
||||
const { t } = useTranslation();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loadingCleanHistoryLog, setLoadingCleanHistoryLog] = useState(false);
|
||||
const [inputs, setInputs] = useState({
|
||||
LogConsumeEnabled: false,
|
||||
historyTimestamp: dayjs().subtract(1, 'month').toDate(),
|
||||
});
|
||||
const refForm = useRef();
|
||||
const [inputsRow, setInputsRow] = useState(inputs);
|
||||
|
||||
function onSubmit() {
|
||||
const updateArray = compareObjects(inputs, inputsRow).filter(
|
||||
(item) => item.key !== 'historyTimestamp',
|
||||
);
|
||||
|
||||
if (!updateArray.length) return showWarning(t('你似乎并没有修改什么'));
|
||||
const requestQueue = updateArray.map((item) => {
|
||||
let value = '';
|
||||
if (typeof inputs[item.key] === 'boolean') {
|
||||
value = String(inputs[item.key]);
|
||||
} else {
|
||||
value = inputs[item.key];
|
||||
}
|
||||
return API.put('/api/option/', {
|
||||
key: item.key,
|
||||
value,
|
||||
});
|
||||
});
|
||||
setLoading(true);
|
||||
Promise.all(requestQueue)
|
||||
.then((res) => {
|
||||
if (requestQueue.length === 1) {
|
||||
if (res.includes(undefined)) return;
|
||||
} else if (requestQueue.length > 1) {
|
||||
if (res.includes(undefined))
|
||||
return showError(t('部分保存失败,请重试'));
|
||||
}
|
||||
showSuccess(t('保存成功'));
|
||||
props.refresh();
|
||||
})
|
||||
.catch(() => {
|
||||
showError(t('保存失败,请重试'));
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
}
|
||||
async function onCleanHistoryLog() {
|
||||
if (!inputs.historyTimestamp) {
|
||||
showError(t('请选择日志记录时间'));
|
||||
return;
|
||||
}
|
||||
|
||||
const now = dayjs();
|
||||
const targetDate = dayjs(inputs.historyTimestamp);
|
||||
const targetTime = targetDate.format('YYYY-MM-DD HH:mm:ss');
|
||||
const currentTime = now.format('YYYY-MM-DD HH:mm:ss');
|
||||
const daysDiff = now.diff(targetDate, 'day');
|
||||
|
||||
Modal.confirm({
|
||||
title: t('确认清除历史日志'),
|
||||
content: (
|
||||
<div style={{ lineHeight: '1.8' }}>
|
||||
<p>
|
||||
<Text>{t('当前时间')}:</Text>
|
||||
<Text strong style={{ color: '#52c41a' }}>
|
||||
{currentTime}
|
||||
</Text>
|
||||
</p>
|
||||
<p>
|
||||
<Text>{t('选择时间')}:</Text>
|
||||
<Text strong type='danger'>
|
||||
{targetTime}
|
||||
</Text>
|
||||
{daysDiff > 0 && (
|
||||
<Text type='tertiary'>
|
||||
{' '}
|
||||
({t('约')} {daysDiff} {t('天前')})
|
||||
</Text>
|
||||
)}
|
||||
</p>
|
||||
<div
|
||||
style={{
|
||||
background: '#fff7e6',
|
||||
border: '1px solid #ffd591',
|
||||
padding: '12px',
|
||||
borderRadius: '4px',
|
||||
marginTop: '12px',
|
||||
color: '#333',
|
||||
}}
|
||||
>
|
||||
<Text strong style={{ color: '#d46b08' }}>
|
||||
⚠️ {t('注意')}:
|
||||
</Text>
|
||||
<Text style={{ color: '#333' }}>{t('将删除')} </Text>
|
||||
<Text strong style={{ color: '#cf1322' }}>
|
||||
{targetTime}
|
||||
</Text>
|
||||
{daysDiff > 0 && (
|
||||
<Text style={{ color: '#8c8c8c' }}>
|
||||
{' '}
|
||||
({t('约')} {daysDiff} {t('天前')})
|
||||
</Text>
|
||||
)}
|
||||
<Text style={{ color: '#333' }}> {t('之前的所有日志')}</Text>
|
||||
</div>
|
||||
<p style={{ marginTop: '12px' }}>
|
||||
<Text type='danger'>
|
||||
{t('此操作不可恢复,请仔细确认时间后再操作!')}
|
||||
</Text>
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
okText: t('确认删除'),
|
||||
cancelText: t('取消'),
|
||||
okType: 'danger',
|
||||
onOk: async () => {
|
||||
try {
|
||||
setLoadingCleanHistoryLog(true);
|
||||
const res = await API.delete(
|
||||
`/api/log/?target_timestamp=${Date.parse(inputs.historyTimestamp) / 1000}`,
|
||||
);
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
showSuccess(`${data} ${t('条日志已清理!')}`);
|
||||
return;
|
||||
} else {
|
||||
throw new Error(t('日志清理失败:') + message);
|
||||
}
|
||||
} catch (error) {
|
||||
showError(error.message);
|
||||
} finally {
|
||||
setLoadingCleanHistoryLog(false);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const currentInputs = {};
|
||||
for (let key in props.options) {
|
||||
if (Object.keys(inputs).includes(key)) {
|
||||
currentInputs[key] = props.options[key];
|
||||
}
|
||||
}
|
||||
currentInputs['historyTimestamp'] = inputs.historyTimestamp;
|
||||
setInputs(Object.assign(inputs, currentInputs));
|
||||
setInputsRow(structuredClone(currentInputs));
|
||||
refForm.current.setValues(currentInputs);
|
||||
}, [props.options]);
|
||||
return (
|
||||
<>
|
||||
<Spin spinning={loading}>
|
||||
<Form
|
||||
values={inputs}
|
||||
getFormApi={(formAPI) => (refForm.current = formAPI)}
|
||||
style={{ marginBottom: 15 }}
|
||||
>
|
||||
<Form.Section text={t('日志设置')}>
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
|
||||
<Form.Switch
|
||||
field={'LogConsumeEnabled'}
|
||||
label={t('启用额度消费日志记录')}
|
||||
size='default'
|
||||
checkedText='|'
|
||||
uncheckedText='〇'
|
||||
onChange={(value) => {
|
||||
setInputs({
|
||||
...inputs,
|
||||
LogConsumeEnabled: value,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
|
||||
<Spin spinning={loadingCleanHistoryLog}>
|
||||
<Form.DatePicker
|
||||
label={t('清除历史日志')}
|
||||
field={'historyTimestamp'}
|
||||
type='dateTime'
|
||||
inputReadOnly={true}
|
||||
onChange={(value) => {
|
||||
setInputs({
|
||||
...inputs,
|
||||
historyTimestamp: value,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<Text
|
||||
type='tertiary'
|
||||
size='small'
|
||||
style={{ display: 'block', marginTop: 4, marginBottom: 8 }}
|
||||
>
|
||||
{t('将清除选定时间之前的所有日志')}
|
||||
</Text>
|
||||
<Button
|
||||
size='default'
|
||||
type='danger'
|
||||
onClick={onCleanHistoryLog}
|
||||
>
|
||||
{t('清除历史日志')}
|
||||
</Button>
|
||||
</Spin>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row>
|
||||
<Button size='default' onClick={onSubmit}>
|
||||
{t('保存日志设置')}
|
||||
</Button>
|
||||
</Row>
|
||||
</Form.Section>
|
||||
</Form>
|
||||
</Spin>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
/*
|
||||
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 { Button, Col, Form, Row, Spin } from '@douyinfe/semi-ui';
|
||||
import {
|
||||
compareObjects,
|
||||
API,
|
||||
showError,
|
||||
showSuccess,
|
||||
showWarning,
|
||||
parseHttpStatusCodeRules,
|
||||
} from '../../../helpers';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import HttpStatusCodeRulesInput from '../../../components/settings/HttpStatusCodeRulesInput';
|
||||
|
||||
export default function SettingsMonitoring(props) {
|
||||
const { t } = useTranslation();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [inputs, setInputs] = useState({
|
||||
ChannelDisableThreshold: '',
|
||||
QuotaRemindThreshold: '',
|
||||
AutomaticDisableChannelEnabled: false,
|
||||
AutomaticEnableChannelEnabled: false,
|
||||
AutomaticDisableKeywords: '',
|
||||
AutomaticDisableStatusCodes: '401',
|
||||
AutomaticRetryStatusCodes:
|
||||
'100-199,300-399,401-407,409-499,500-503,505-523,525-599',
|
||||
'monitor_setting.auto_test_channel_enabled': false,
|
||||
'monitor_setting.auto_test_channel_minutes': 10,
|
||||
});
|
||||
const refForm = useRef();
|
||||
const [inputsRow, setInputsRow] = useState(inputs);
|
||||
const parsedAutoDisableStatusCodes = parseHttpStatusCodeRules(
|
||||
inputs.AutomaticDisableStatusCodes || '',
|
||||
);
|
||||
const parsedAutoRetryStatusCodes = parseHttpStatusCodeRules(
|
||||
inputs.AutomaticRetryStatusCodes || '',
|
||||
);
|
||||
|
||||
function onSubmit() {
|
||||
const updateArray = compareObjects(inputs, inputsRow);
|
||||
if (!updateArray.length) return showWarning(t('你似乎并没有修改什么'));
|
||||
if (!parsedAutoDisableStatusCodes.ok) {
|
||||
const details =
|
||||
parsedAutoDisableStatusCodes.invalidTokens &&
|
||||
parsedAutoDisableStatusCodes.invalidTokens.length > 0
|
||||
? `: ${parsedAutoDisableStatusCodes.invalidTokens.join(', ')}`
|
||||
: '';
|
||||
return showError(`${t('自动禁用状态码格式不正确')}${details}`);
|
||||
}
|
||||
if (!parsedAutoRetryStatusCodes.ok) {
|
||||
const details =
|
||||
parsedAutoRetryStatusCodes.invalidTokens &&
|
||||
parsedAutoRetryStatusCodes.invalidTokens.length > 0
|
||||
? `: ${parsedAutoRetryStatusCodes.invalidTokens.join(', ')}`
|
||||
: '';
|
||||
return showError(`${t('自动重试状态码格式不正确')}${details}`);
|
||||
}
|
||||
const requestQueue = updateArray.map((item) => {
|
||||
let value = '';
|
||||
if (typeof inputs[item.key] === 'boolean') {
|
||||
value = String(inputs[item.key]);
|
||||
} else {
|
||||
const normalizedMap = {
|
||||
AutomaticDisableStatusCodes: parsedAutoDisableStatusCodes.normalized,
|
||||
AutomaticRetryStatusCodes: parsedAutoRetryStatusCodes.normalized,
|
||||
};
|
||||
value = normalizedMap[item.key] ?? inputs[item.key];
|
||||
}
|
||||
return API.put('/api/option/', {
|
||||
key: item.key,
|
||||
value,
|
||||
});
|
||||
});
|
||||
setLoading(true);
|
||||
Promise.all(requestQueue)
|
||||
.then((res) => {
|
||||
if (requestQueue.length === 1) {
|
||||
if (res.includes(undefined)) return;
|
||||
} else if (requestQueue.length > 1) {
|
||||
if (res.includes(undefined))
|
||||
return showError(t('部分保存失败,请重试'));
|
||||
}
|
||||
showSuccess(t('保存成功'));
|
||||
props.refresh();
|
||||
})
|
||||
.catch(() => {
|
||||
showError(t('保存失败,请重试'));
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const currentInputs = {};
|
||||
for (let key in props.options) {
|
||||
if (Object.keys(inputs).includes(key)) {
|
||||
currentInputs[key] = props.options[key];
|
||||
}
|
||||
}
|
||||
setInputs(currentInputs);
|
||||
setInputsRow(structuredClone(currentInputs));
|
||||
refForm.current.setValues(currentInputs);
|
||||
}, [props.options]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Spin spinning={loading}>
|
||||
<Form
|
||||
values={inputs}
|
||||
getFormApi={(formAPI) => (refForm.current = formAPI)}
|
||||
style={{ marginBottom: 15 }}
|
||||
>
|
||||
<Form.Section text={t('监控设置')}>
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
|
||||
<Form.Switch
|
||||
field={'monitor_setting.auto_test_channel_enabled'}
|
||||
label={t('定时测试所有通道')}
|
||||
size='default'
|
||||
checkedText='|'
|
||||
uncheckedText='〇'
|
||||
onChange={(value) =>
|
||||
setInputs({
|
||||
...inputs,
|
||||
'monitor_setting.auto_test_channel_enabled': value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
|
||||
<Form.InputNumber
|
||||
label={t('自动测试所有通道间隔时间')}
|
||||
step={1}
|
||||
min={1}
|
||||
suffix={t('分钟')}
|
||||
extraText={t('每隔多少分钟测试一次所有通道')}
|
||||
placeholder={''}
|
||||
field={'monitor_setting.auto_test_channel_minutes'}
|
||||
onChange={(value) =>
|
||||
setInputs({
|
||||
...inputs,
|
||||
'monitor_setting.auto_test_channel_minutes':
|
||||
parseInt(value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
|
||||
<Form.InputNumber
|
||||
label={t('测试所有渠道的最长响应时间')}
|
||||
step={1}
|
||||
min={0}
|
||||
suffix={t('秒')}
|
||||
extraText={t(
|
||||
'当运行通道全部测试时,超过此时间将自动禁用通道',
|
||||
)}
|
||||
placeholder={''}
|
||||
field={'ChannelDisableThreshold'}
|
||||
onChange={(value) =>
|
||||
setInputs({
|
||||
...inputs,
|
||||
ChannelDisableThreshold: String(value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
|
||||
<Form.InputNumber
|
||||
label={t('额度提醒阈值')}
|
||||
step={1}
|
||||
min={0}
|
||||
suffix={'Token'}
|
||||
extraText={t('低于此额度时将发送邮件提醒用户')}
|
||||
placeholder={''}
|
||||
field={'QuotaRemindThreshold'}
|
||||
onChange={(value) =>
|
||||
setInputs({
|
||||
...inputs,
|
||||
QuotaRemindThreshold: String(value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
|
||||
<Form.Switch
|
||||
field={'AutomaticDisableChannelEnabled'}
|
||||
label={t('失败时自动禁用通道')}
|
||||
size='default'
|
||||
checkedText='|'
|
||||
uncheckedText='〇'
|
||||
onChange={(value) => {
|
||||
setInputs({
|
||||
...inputs,
|
||||
AutomaticDisableChannelEnabled: value,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
|
||||
<Form.Switch
|
||||
field={'AutomaticEnableChannelEnabled'}
|
||||
label={t('成功时自动启用通道')}
|
||||
size='default'
|
||||
checkedText='|'
|
||||
uncheckedText='〇'
|
||||
onChange={(value) =>
|
||||
setInputs({
|
||||
...inputs,
|
||||
AutomaticEnableChannelEnabled: value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} sm={16}>
|
||||
<HttpStatusCodeRulesInput
|
||||
label={t('自动禁用状态码')}
|
||||
placeholder={t('例如:401, 403, 429, 500-599')}
|
||||
extraText={t(
|
||||
'支持填写单个状态码或范围(含首尾),使用逗号分隔',
|
||||
)}
|
||||
field={'AutomaticDisableStatusCodes'}
|
||||
onChange={(value) =>
|
||||
setInputs({ ...inputs, AutomaticDisableStatusCodes: value })
|
||||
}
|
||||
parsed={parsedAutoDisableStatusCodes}
|
||||
invalidText={t('自动禁用状态码格式不正确')}
|
||||
/>
|
||||
<HttpStatusCodeRulesInput
|
||||
label={t('自动重试状态码')}
|
||||
placeholder={t('例如:401, 403, 429, 500-599')}
|
||||
extraText={t(
|
||||
'支持填写单个状态码或范围(含首尾),使用逗号分隔;504 和 524 始终不重试,不受此处配置影响',
|
||||
)}
|
||||
field={'AutomaticRetryStatusCodes'}
|
||||
onChange={(value) =>
|
||||
setInputs({ ...inputs, AutomaticRetryStatusCodes: value })
|
||||
}
|
||||
parsed={parsedAutoRetryStatusCodes}
|
||||
invalidText={t('自动重试状态码格式不正确')}
|
||||
/>
|
||||
<Form.TextArea
|
||||
label={t('自动禁用关键词')}
|
||||
placeholder={t('一行一个,不区分大小写')}
|
||||
extraText={t(
|
||||
'当上游通道返回错误中包含这些关键词时(不区分大小写),自动禁用通道',
|
||||
)}
|
||||
field={'AutomaticDisableKeywords'}
|
||||
autosize={{ minRows: 6, maxRows: 12 }}
|
||||
onChange={(value) =>
|
||||
setInputs({ ...inputs, AutomaticDisableKeywords: value })
|
||||
}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row>
|
||||
<Button size='default' onClick={onSubmit}>
|
||||
{t('保存监控设置')}
|
||||
</Button>
|
||||
</Row>
|
||||
</Form.Section>
|
||||
</Form>
|
||||
</Spin>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
/*
|
||||
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 { Button, Col, Form, Row, Spin, Tag } from '@douyinfe/semi-ui';
|
||||
import {
|
||||
compareObjects,
|
||||
API,
|
||||
showError,
|
||||
showSuccess,
|
||||
showWarning,
|
||||
} from '../../../helpers';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export default function SettingsSensitiveWords(props) {
|
||||
const { t } = useTranslation();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [inputs, setInputs] = useState({
|
||||
CheckSensitiveEnabled: false,
|
||||
CheckSensitiveOnPromptEnabled: false,
|
||||
SensitiveWords: '',
|
||||
});
|
||||
const refForm = useRef();
|
||||
const [inputsRow, setInputsRow] = useState(inputs);
|
||||
|
||||
function onSubmit() {
|
||||
const updateArray = compareObjects(inputs, inputsRow);
|
||||
if (!updateArray.length) return showWarning(t('你似乎并没有修改什么'));
|
||||
const requestQueue = updateArray.map((item) => {
|
||||
let value = '';
|
||||
if (typeof inputs[item.key] === 'boolean') {
|
||||
value = String(inputs[item.key]);
|
||||
} else {
|
||||
value = inputs[item.key];
|
||||
}
|
||||
return API.put('/api/option/', {
|
||||
key: item.key,
|
||||
value,
|
||||
});
|
||||
});
|
||||
setLoading(true);
|
||||
Promise.all(requestQueue)
|
||||
.then((res) => {
|
||||
if (requestQueue.length === 1) {
|
||||
if (res.includes(undefined)) return;
|
||||
} else if (requestQueue.length > 1) {
|
||||
if (res.includes(undefined))
|
||||
return showError(t('部分保存失败,请重试'));
|
||||
}
|
||||
showSuccess(t('保存成功'));
|
||||
props.refresh();
|
||||
})
|
||||
.catch(() => {
|
||||
showError(t('保存失败,请重试'));
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const currentInputs = {};
|
||||
for (let key in props.options) {
|
||||
if (Object.keys(inputs).includes(key)) {
|
||||
currentInputs[key] = props.options[key];
|
||||
}
|
||||
}
|
||||
setInputs(currentInputs);
|
||||
setInputsRow(structuredClone(currentInputs));
|
||||
refForm.current.setValues(currentInputs);
|
||||
}, [props.options]);
|
||||
return (
|
||||
<>
|
||||
<Spin spinning={loading}>
|
||||
<Form
|
||||
values={inputs}
|
||||
getFormApi={(formAPI) => (refForm.current = formAPI)}
|
||||
style={{ marginBottom: 15 }}
|
||||
>
|
||||
<Form.Section text={t('屏蔽词过滤设置')}>
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
|
||||
<Form.Switch
|
||||
field={'CheckSensitiveEnabled'}
|
||||
label={t('启用屏蔽词过滤功能')}
|
||||
size='default'
|
||||
checkedText='|'
|
||||
uncheckedText='〇'
|
||||
onChange={(value) => {
|
||||
setInputs({
|
||||
...inputs,
|
||||
CheckSensitiveEnabled: value,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
|
||||
<Form.Switch
|
||||
field={'CheckSensitiveOnPromptEnabled'}
|
||||
label={t('启用 Prompt 检查')}
|
||||
size='default'
|
||||
checkedText='|'
|
||||
uncheckedText='〇'
|
||||
onChange={(value) =>
|
||||
setInputs({
|
||||
...inputs,
|
||||
CheckSensitiveOnPromptEnabled: value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row>
|
||||
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
|
||||
<Form.TextArea
|
||||
label={t('屏蔽词列表')}
|
||||
extraText={t('一行一个屏蔽词,不需要符号分割')}
|
||||
placeholder={t('一行一个屏蔽词,不需要符号分割')}
|
||||
field={'SensitiveWords'}
|
||||
onChange={(value) =>
|
||||
setInputs({
|
||||
...inputs,
|
||||
SensitiveWords: value,
|
||||
})
|
||||
}
|
||||
style={{ fontFamily: 'JetBrains Mono, Consolas' }}
|
||||
autosize={{ minRows: 6, maxRows: 12 }}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row>
|
||||
<Button size='default' onClick={onSubmit}>
|
||||
{t('保存屏蔽词过滤设置')}
|
||||
</Button>
|
||||
</Row>
|
||||
</Form.Section>
|
||||
</Form>
|
||||
</Spin>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,438 @@
|
||||
/*
|
||||
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, useContext } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Card,
|
||||
Form,
|
||||
Button,
|
||||
Switch,
|
||||
Row,
|
||||
Col,
|
||||
Typography,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { API, showSuccess, showError } from '../../../helpers';
|
||||
import { StatusContext } from '../../../context/Status';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
export default function SettingsSidebarModulesAdmin(props) {
|
||||
const { t } = useTranslation();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [statusState, statusDispatch] = useContext(StatusContext);
|
||||
|
||||
// 左侧边栏模块管理状态(管理员全局控制)
|
||||
const [sidebarModulesAdmin, setSidebarModulesAdmin] = useState({
|
||||
chat: {
|
||||
enabled: true,
|
||||
playground: true,
|
||||
chat: true,
|
||||
},
|
||||
console: {
|
||||
enabled: true,
|
||||
detail: true,
|
||||
token: true,
|
||||
log: true,
|
||||
midjourney: true,
|
||||
task: true,
|
||||
},
|
||||
personal: {
|
||||
enabled: true,
|
||||
topup: true,
|
||||
personal: true,
|
||||
},
|
||||
admin: {
|
||||
enabled: true,
|
||||
channel: true,
|
||||
models: true,
|
||||
deployment: true,
|
||||
redemption: true,
|
||||
user: true,
|
||||
subscription: true,
|
||||
setting: true,
|
||||
},
|
||||
});
|
||||
|
||||
// 处理区域级别开关变更
|
||||
function handleSectionChange(sectionKey) {
|
||||
return (checked) => {
|
||||
const newModules = {
|
||||
...sidebarModulesAdmin,
|
||||
[sectionKey]: {
|
||||
...sidebarModulesAdmin[sectionKey],
|
||||
enabled: checked,
|
||||
},
|
||||
};
|
||||
setSidebarModulesAdmin(newModules);
|
||||
};
|
||||
}
|
||||
|
||||
// 处理功能级别开关变更
|
||||
function handleModuleChange(sectionKey, moduleKey) {
|
||||
return (checked) => {
|
||||
const newModules = {
|
||||
...sidebarModulesAdmin,
|
||||
[sectionKey]: {
|
||||
...sidebarModulesAdmin[sectionKey],
|
||||
[moduleKey]: checked,
|
||||
},
|
||||
};
|
||||
setSidebarModulesAdmin(newModules);
|
||||
};
|
||||
}
|
||||
|
||||
// 重置为默认配置
|
||||
function resetSidebarModules() {
|
||||
const defaultModules = {
|
||||
chat: {
|
||||
enabled: true,
|
||||
playground: true,
|
||||
chat: true,
|
||||
},
|
||||
console: {
|
||||
enabled: true,
|
||||
detail: true,
|
||||
token: true,
|
||||
log: true,
|
||||
midjourney: true,
|
||||
task: true,
|
||||
},
|
||||
personal: {
|
||||
enabled: true,
|
||||
topup: true,
|
||||
personal: true,
|
||||
},
|
||||
admin: {
|
||||
enabled: true,
|
||||
channel: true,
|
||||
models: true,
|
||||
deployment: true,
|
||||
redemption: true,
|
||||
user: true,
|
||||
subscription: true,
|
||||
setting: true,
|
||||
},
|
||||
};
|
||||
setSidebarModulesAdmin(defaultModules);
|
||||
showSuccess(t('已重置为默认配置'));
|
||||
}
|
||||
|
||||
// 保存配置
|
||||
async function onSubmit() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await API.put('/api/option/', {
|
||||
key: 'SidebarModulesAdmin',
|
||||
value: JSON.stringify(sidebarModulesAdmin),
|
||||
});
|
||||
const { success, message } = res.data;
|
||||
if (success) {
|
||||
showSuccess(t('保存成功'));
|
||||
|
||||
// 立即更新StatusContext中的状态
|
||||
statusDispatch({
|
||||
type: 'set',
|
||||
payload: {
|
||||
...statusState.status,
|
||||
SidebarModulesAdmin: JSON.stringify(sidebarModulesAdmin),
|
||||
},
|
||||
});
|
||||
|
||||
// 刷新父组件状态
|
||||
if (props.refresh) {
|
||||
await props.refresh();
|
||||
}
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
} catch (error) {
|
||||
showError(t('保存失败,请重试'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
// 从 props.options 中获取配置
|
||||
if (props.options && props.options.SidebarModulesAdmin) {
|
||||
try {
|
||||
const modules = JSON.parse(props.options.SidebarModulesAdmin);
|
||||
setSidebarModulesAdmin(modules);
|
||||
} catch (error) {
|
||||
// 使用默认配置
|
||||
const defaultModules = {
|
||||
chat: { enabled: true, playground: true, chat: true },
|
||||
console: {
|
||||
enabled: true,
|
||||
detail: true,
|
||||
token: true,
|
||||
log: true,
|
||||
midjourney: true,
|
||||
task: true,
|
||||
},
|
||||
personal: { enabled: true, topup: true, personal: true },
|
||||
admin: {
|
||||
enabled: true,
|
||||
channel: true,
|
||||
models: true,
|
||||
deployment: true,
|
||||
redemption: true,
|
||||
user: true,
|
||||
subscription: true,
|
||||
setting: true,
|
||||
},
|
||||
};
|
||||
setSidebarModulesAdmin(defaultModules);
|
||||
}
|
||||
}
|
||||
}, [props.options]);
|
||||
|
||||
// 区域配置数据
|
||||
const sectionConfigs = [
|
||||
{
|
||||
key: 'chat',
|
||||
title: t('聊天区域'),
|
||||
description: t('操练场和聊天功能'),
|
||||
modules: [
|
||||
{
|
||||
key: 'playground',
|
||||
title: t('操练场'),
|
||||
description: t('AI模型测试环境'),
|
||||
},
|
||||
{ key: 'chat', title: t('聊天'), description: t('聊天会话管理') },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'console',
|
||||
title: t('控制台区域'),
|
||||
description: t('数据管理和日志查看'),
|
||||
modules: [
|
||||
{ key: 'detail', title: t('数据看板'), description: t('系统数据统计') },
|
||||
{ key: 'token', title: t('令牌管理'), description: t('API令牌管理') },
|
||||
{ key: 'log', title: t('使用日志'), description: t('API使用记录') },
|
||||
{
|
||||
key: 'midjourney',
|
||||
title: t('绘图日志'),
|
||||
description: t('绘图任务记录'),
|
||||
},
|
||||
{ key: 'task', title: t('任务日志'), description: t('系统任务记录') },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'personal',
|
||||
title: t('个人中心区域'),
|
||||
description: t('用户个人功能'),
|
||||
modules: [
|
||||
{ key: 'topup', title: t('钱包管理'), description: t('余额充值管理') },
|
||||
{
|
||||
key: 'personal',
|
||||
title: t('个人设置'),
|
||||
description: t('个人信息设置'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'admin',
|
||||
title: t('管理员区域'),
|
||||
description: t('系统管理功能'),
|
||||
modules: [
|
||||
{ key: 'channel', title: t('渠道管理'), description: t('API渠道配置') },
|
||||
{ key: 'models', title: t('模型管理'), description: t('AI模型配置') },
|
||||
{
|
||||
key: 'deployment',
|
||||
title: t('模型部署'),
|
||||
description: t('模型部署管理'),
|
||||
},
|
||||
{
|
||||
key: 'subscription',
|
||||
title: t('订阅管理'),
|
||||
description: t('订阅套餐管理'),
|
||||
},
|
||||
{
|
||||
key: 'redemption',
|
||||
title: t('兑换码管理'),
|
||||
description: t('兑换码生成管理'),
|
||||
},
|
||||
{ key: 'user', title: t('用户管理'), description: t('用户账户管理') },
|
||||
{
|
||||
key: 'setting',
|
||||
title: t('系统设置'),
|
||||
description: t('系统参数配置'),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Form.Section
|
||||
text={t('侧边栏管理(全局控制)')}
|
||||
extraText={t(
|
||||
'全局控制侧边栏区域和功能显示,管理员隐藏的功能用户无法启用',
|
||||
)}
|
||||
>
|
||||
{sectionConfigs.map((section) => (
|
||||
<div key={section.key} style={{ marginBottom: '32px' }}>
|
||||
{/* 区域标题和总开关 */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: '16px',
|
||||
padding: '12px 16px',
|
||||
backgroundColor: 'var(--semi-color-fill-0)',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid var(--semi-color-border)',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
fontWeight: '600',
|
||||
fontSize: '16px',
|
||||
color: 'var(--semi-color-text-0)',
|
||||
marginBottom: '4px',
|
||||
}}
|
||||
>
|
||||
{section.title}
|
||||
</div>
|
||||
<Text
|
||||
type='secondary'
|
||||
size='small'
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
color: 'var(--semi-color-text-2)',
|
||||
lineHeight: '1.4',
|
||||
}}
|
||||
>
|
||||
{section.description}
|
||||
</Text>
|
||||
</div>
|
||||
<Switch
|
||||
checked={sidebarModulesAdmin[section.key]?.enabled}
|
||||
onChange={handleSectionChange(section.key)}
|
||||
size='default'
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 功能模块网格 */}
|
||||
<Row gutter={[16, 16]}>
|
||||
{section.modules.map((module) => (
|
||||
<Col key={module.key} xs={24} sm={12} md={8} lg={6} xl={6}>
|
||||
<Card
|
||||
bodyStyle={{ padding: '16px' }}
|
||||
hoverable
|
||||
style={{
|
||||
opacity: sidebarModulesAdmin[section.key]?.enabled
|
||||
? 1
|
||||
: 0.5,
|
||||
transition: 'opacity 0.2s',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
height: '100%',
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: 1, textAlign: 'left' }}>
|
||||
<div
|
||||
style={{
|
||||
fontWeight: '600',
|
||||
fontSize: '14px',
|
||||
color: 'var(--semi-color-text-0)',
|
||||
marginBottom: '4px',
|
||||
}}
|
||||
>
|
||||
{module.title}
|
||||
</div>
|
||||
<Text
|
||||
type='secondary'
|
||||
size='small'
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
color: 'var(--semi-color-text-2)',
|
||||
lineHeight: '1.4',
|
||||
display: 'block',
|
||||
}}
|
||||
>
|
||||
{module.description}
|
||||
</Text>
|
||||
</div>
|
||||
<div style={{ marginLeft: '16px' }}>
|
||||
<Switch
|
||||
checked={
|
||||
sidebarModulesAdmin[section.key]?.[module.key]
|
||||
}
|
||||
onChange={handleModuleChange(section.key, module.key)}
|
||||
size='default'
|
||||
disabled={!sidebarModulesAdmin[section.key]?.enabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: '12px',
|
||||
justifyContent: 'flex-start',
|
||||
alignItems: 'center',
|
||||
paddingTop: '8px',
|
||||
borderTop: '1px solid var(--semi-color-border)',
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
size='default'
|
||||
type='tertiary'
|
||||
onClick={resetSidebarModules}
|
||||
style={{
|
||||
borderRadius: '6px',
|
||||
fontWeight: '500',
|
||||
}}
|
||||
>
|
||||
{t('重置为默认')}
|
||||
</Button>
|
||||
<Button
|
||||
size='default'
|
||||
type='primary'
|
||||
onClick={onSubmit}
|
||||
loading={loading}
|
||||
style={{
|
||||
borderRadius: '6px',
|
||||
fontWeight: '500',
|
||||
minWidth: '100px',
|
||||
}}
|
||||
>
|
||||
{t('保存设置')}
|
||||
</Button>
|
||||
</div>
|
||||
</Form.Section>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
/*
|
||||
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 { Button, Col, Form, Row, Spin } from '@douyinfe/semi-ui';
|
||||
import {
|
||||
API,
|
||||
removeTrailingSlash,
|
||||
showError,
|
||||
showSuccess,
|
||||
verifyJSON,
|
||||
} from '../../../helpers';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export default function SettingsGeneralPayment(props) {
|
||||
const { t } = useTranslation();
|
||||
const sectionTitle = props.hideSectionTitle ? undefined : t('通用设置');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [inputs, setInputs] = useState({
|
||||
ServerAddress: '',
|
||||
CustomCallbackAddress: '',
|
||||
TopupGroupRatio: '',
|
||||
PayMethods: '',
|
||||
AmountOptions: '',
|
||||
AmountDiscount: '',
|
||||
});
|
||||
const [originInputs, setOriginInputs] = useState({});
|
||||
const formApiRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (props.options && formApiRef.current) {
|
||||
const currentInputs = {
|
||||
ServerAddress: props.options.ServerAddress || '',
|
||||
CustomCallbackAddress: props.options.CustomCallbackAddress || '',
|
||||
TopupGroupRatio: props.options.TopupGroupRatio || '',
|
||||
PayMethods: props.options.PayMethods || '',
|
||||
AmountOptions: props.options.AmountOptions || '',
|
||||
AmountDiscount: props.options.AmountDiscount || '',
|
||||
};
|
||||
setInputs(currentInputs);
|
||||
setOriginInputs({ ...currentInputs });
|
||||
formApiRef.current.setValues(currentInputs);
|
||||
}
|
||||
}, [props.options]);
|
||||
|
||||
const handleFormChange = (values) => {
|
||||
setInputs(values);
|
||||
};
|
||||
|
||||
const submitGeneralSettings = async () => {
|
||||
if (
|
||||
originInputs.TopupGroupRatio !== inputs.TopupGroupRatio &&
|
||||
!verifyJSON(inputs.TopupGroupRatio)
|
||||
) {
|
||||
showError(t('充值分组倍率不是合法的 JSON 字符串'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
originInputs.PayMethods !== inputs.PayMethods &&
|
||||
!verifyJSON(inputs.PayMethods)
|
||||
) {
|
||||
showError(t('充值方式设置不是合法的 JSON 字符串'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
originInputs.AmountOptions !== inputs.AmountOptions &&
|
||||
inputs.AmountOptions.trim() !== '' &&
|
||||
!verifyJSON(inputs.AmountOptions)
|
||||
) {
|
||||
showError(t('自定义充值数量选项不是合法的 JSON 数组'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
originInputs.AmountDiscount !== inputs.AmountDiscount &&
|
||||
inputs.AmountDiscount.trim() !== '' &&
|
||||
!verifyJSON(inputs.AmountDiscount)
|
||||
) {
|
||||
showError(t('充值金额折扣配置不是合法的 JSON 对象'));
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const options = [
|
||||
{
|
||||
key: 'ServerAddress',
|
||||
value: removeTrailingSlash(inputs.ServerAddress),
|
||||
},
|
||||
];
|
||||
|
||||
if (inputs.CustomCallbackAddress !== '') {
|
||||
options.push({
|
||||
key: 'CustomCallbackAddress',
|
||||
value: removeTrailingSlash(inputs.CustomCallbackAddress),
|
||||
});
|
||||
}
|
||||
if (originInputs.TopupGroupRatio !== inputs.TopupGroupRatio) {
|
||||
options.push({ key: 'TopupGroupRatio', value: inputs.TopupGroupRatio });
|
||||
}
|
||||
if (originInputs.PayMethods !== inputs.PayMethods) {
|
||||
options.push({ key: 'PayMethods', value: inputs.PayMethods });
|
||||
}
|
||||
if (originInputs.AmountOptions !== inputs.AmountOptions) {
|
||||
options.push({
|
||||
key: 'payment_setting.amount_options',
|
||||
value: inputs.AmountOptions,
|
||||
});
|
||||
}
|
||||
if (originInputs.AmountDiscount !== inputs.AmountDiscount) {
|
||||
options.push({
|
||||
key: 'payment_setting.amount_discount',
|
||||
value: inputs.AmountDiscount,
|
||||
});
|
||||
}
|
||||
|
||||
const results = await Promise.all(
|
||||
options.map((option) =>
|
||||
API.put('/api/option/', {
|
||||
key: option.key,
|
||||
value: option.value,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const errorResults = results.filter((res) => !res.data.success);
|
||||
if (errorResults.length === 0) {
|
||||
showSuccess(t('更新成功'));
|
||||
setOriginInputs({ ...inputs });
|
||||
props.refresh && props.refresh();
|
||||
} else {
|
||||
errorResults.forEach((res) => {
|
||||
showError(res.data.message);
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
showError(t('更新失败'));
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Spin spinning={loading}>
|
||||
<Form
|
||||
initValues={inputs}
|
||||
onValueChange={handleFormChange}
|
||||
getFormApi={(api) => (formApiRef.current = api)}
|
||||
>
|
||||
<Form.Section text={sectionTitle}>
|
||||
<Form.Input
|
||||
field='ServerAddress'
|
||||
label={t('服务器地址')}
|
||||
placeholder={'https://yourdomain.com'}
|
||||
style={{ width: '100%' }}
|
||||
extraText={t(
|
||||
'该服务器地址将影响支付回调地址以及默认首页展示的地址,请确保正确配置',
|
||||
)}
|
||||
/>
|
||||
<Row
|
||||
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
|
||||
style={{ marginTop: 16 }}
|
||||
>
|
||||
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
|
||||
<Form.Input
|
||||
field='CustomCallbackAddress'
|
||||
label={t('回调地址')}
|
||||
placeholder={t('例如:https://yourdomain.com')}
|
||||
extraText={t(
|
||||
'留空时默认使用服务器地址作为回调地址,填写后将覆盖默认值',
|
||||
)}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
|
||||
<Form.TextArea
|
||||
field='TopupGroupRatio'
|
||||
label={t('充值分组倍率')}
|
||||
placeholder={t('为一个 JSON 文本,键为组名称,值为倍率')}
|
||||
autosize
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row
|
||||
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
|
||||
style={{ marginTop: 16 }}
|
||||
>
|
||||
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
|
||||
<Form.TextArea
|
||||
field='PayMethods'
|
||||
label={t('充值方式设置')}
|
||||
placeholder={t('为一个 JSON 文本')}
|
||||
autosize
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
|
||||
<Form.TextArea
|
||||
field='AmountOptions'
|
||||
label={t('自定义充值数量选项')}
|
||||
placeholder={t(
|
||||
'为一个 JSON 数组,例如:[10, 20, 50, 100, 200, 500]',
|
||||
)}
|
||||
autosize
|
||||
extraText={t(
|
||||
'设置用户可选择的充值数量选项,例如:[10, 20, 50, 100, 200, 500]',
|
||||
)}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row style={{ marginTop: 16 }}>
|
||||
<Col span={24}>
|
||||
<Form.TextArea
|
||||
field='AmountDiscount'
|
||||
label={t('充值金额折扣配置')}
|
||||
placeholder={t(
|
||||
'为一个 JSON 对象,例如:{"100": 0.95, "200": 0.9, "500": 0.85}',
|
||||
)}
|
||||
autosize
|
||||
extraText={t(
|
||||
'设置不同充值金额对应的折扣,键为充值金额,值为折扣率,例如:{"100": 0.95, "200": 0.9, "500": 0.85}',
|
||||
)}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Button onClick={submitGeneralSettings} style={{ marginTop: 16 }}>
|
||||
{t('保存通用设置')}
|
||||
</Button>
|
||||
</Form.Section>
|
||||
</Form>
|
||||
</Spin>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
/*
|
||||
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 { Banner, Button, Form, Row, Col, Spin } from '@douyinfe/semi-ui';
|
||||
import {
|
||||
API,
|
||||
removeTrailingSlash,
|
||||
showError,
|
||||
showSuccess,
|
||||
} from '../../../helpers';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Info } from 'lucide-react';
|
||||
|
||||
export default function SettingsPaymentGateway(props) {
|
||||
const { t } = useTranslation();
|
||||
const sectionTitle = props.hideSectionTitle ? undefined : t('易支付设置');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [inputs, setInputs] = useState({
|
||||
PayAddress: '',
|
||||
EpayId: '',
|
||||
EpayKey: '',
|
||||
Price: 7.3,
|
||||
MinTopUp: 1,
|
||||
});
|
||||
const formApiRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (props.options && formApiRef.current) {
|
||||
const currentInputs = {
|
||||
PayAddress: props.options.PayAddress || '',
|
||||
EpayId: props.options.EpayId || '',
|
||||
EpayKey: props.options.EpayKey || '',
|
||||
Price:
|
||||
props.options.Price !== undefined
|
||||
? parseFloat(props.options.Price)
|
||||
: 7.3,
|
||||
MinTopUp:
|
||||
props.options.MinTopUp !== undefined
|
||||
? parseFloat(props.options.MinTopUp)
|
||||
: 1,
|
||||
};
|
||||
|
||||
setInputs(currentInputs);
|
||||
formApiRef.current.setValues(currentInputs);
|
||||
}
|
||||
}, [props.options]);
|
||||
|
||||
const handleFormChange = (values) => {
|
||||
setInputs(values);
|
||||
};
|
||||
|
||||
const submitPayAddress = async () => {
|
||||
if (props.options.ServerAddress === '') {
|
||||
showError(t('请先填写服务器地址'));
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const options = [
|
||||
{ key: 'PayAddress', value: removeTrailingSlash(inputs.PayAddress) },
|
||||
];
|
||||
|
||||
if (inputs.EpayId !== '') {
|
||||
options.push({ key: 'EpayId', value: inputs.EpayId });
|
||||
}
|
||||
if (inputs.EpayKey !== undefined && inputs.EpayKey !== '') {
|
||||
options.push({ key: 'EpayKey', value: inputs.EpayKey });
|
||||
}
|
||||
if (inputs.Price !== '') {
|
||||
options.push({ key: 'Price', value: inputs.Price.toString() });
|
||||
}
|
||||
if (inputs.MinTopUp !== '') {
|
||||
options.push({ key: 'MinTopUp', value: inputs.MinTopUp.toString() });
|
||||
}
|
||||
|
||||
const requestQueue = options.map((opt) =>
|
||||
API.put('/api/option/', {
|
||||
key: opt.key,
|
||||
value: opt.value,
|
||||
}),
|
||||
);
|
||||
|
||||
const results = await Promise.all(requestQueue);
|
||||
|
||||
const errorResults = results.filter((res) => !res.data.success);
|
||||
if (errorResults.length > 0) {
|
||||
errorResults.forEach((res) => {
|
||||
showError(res.data.message);
|
||||
});
|
||||
} else {
|
||||
showSuccess(t('更新成功'));
|
||||
props.refresh && props.refresh();
|
||||
}
|
||||
} catch (error) {
|
||||
showError(t('更新失败'));
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Spin spinning={loading}>
|
||||
<Form
|
||||
initValues={inputs}
|
||||
onValueChange={handleFormChange}
|
||||
getFormApi={(api) => (formApiRef.current = api)}
|
||||
>
|
||||
<Form.Section text={sectionTitle}>
|
||||
<Banner
|
||||
type='info'
|
||||
icon={<Info size={16} />}
|
||||
description={t(
|
||||
'当前仅支持易支付接口,回调地址请在通用设置中配置。',
|
||||
)}
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
<Row gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}>
|
||||
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
|
||||
<Form.Input
|
||||
field='PayAddress'
|
||||
label={t('支付地址')}
|
||||
placeholder={t('例如:https://yourdomain.com')}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
|
||||
<Form.Input
|
||||
field='EpayId'
|
||||
label={t('商户 ID')}
|
||||
placeholder={t('例如:0001')}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
|
||||
<Form.Input
|
||||
field='EpayKey'
|
||||
label={t('API 密钥')}
|
||||
placeholder={t('敏感信息不会发送到前端显示')}
|
||||
type='password'
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row
|
||||
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
|
||||
style={{ marginTop: 16 }}
|
||||
>
|
||||
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
|
||||
<Form.InputNumber
|
||||
field='Price'
|
||||
precision={2}
|
||||
label={t('充值价格(x元/美金)')}
|
||||
placeholder={t('例如:7,就是7元/美金')}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
|
||||
<Form.InputNumber
|
||||
field='MinTopUp'
|
||||
label={t('最低充值美元数量')}
|
||||
placeholder={t('例如:2,就是最低充值2$')}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Button onClick={submitPayAddress} style={{ marginTop: 16 }}>
|
||||
{t('更新易支付设置')}
|
||||
</Button>
|
||||
</Form.Section>
|
||||
</Form>
|
||||
</Spin>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,430 @@
|
||||
/*
|
||||
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 {
|
||||
Banner,
|
||||
Button,
|
||||
Form,
|
||||
Row,
|
||||
Col,
|
||||
Typography,
|
||||
Spin,
|
||||
Table,
|
||||
Modal,
|
||||
Input,
|
||||
InputNumber,
|
||||
Select,
|
||||
} from '@douyinfe/semi-ui';
|
||||
const { Text } = Typography;
|
||||
import { API, showError, showSuccess } from '../../../helpers';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { BookOpen, Plus, Trash2 } from 'lucide-react';
|
||||
|
||||
export default function SettingsPaymentGatewayCreem(props) {
|
||||
const { t } = useTranslation();
|
||||
const sectionTitle = props.hideSectionTitle ? undefined : t('Creem 设置');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [inputs, setInputs] = useState({
|
||||
CreemApiKey: '',
|
||||
CreemWebhookSecret: '',
|
||||
CreemProducts: '[]',
|
||||
CreemTestMode: false,
|
||||
});
|
||||
const [originInputs, setOriginInputs] = useState({});
|
||||
const [products, setProducts] = useState([]);
|
||||
const [showProductModal, setShowProductModal] = useState(false);
|
||||
const [editingProduct, setEditingProduct] = useState(null);
|
||||
const [productForm, setProductForm] = useState({
|
||||
name: '',
|
||||
productId: '',
|
||||
price: 0,
|
||||
quota: 0,
|
||||
currency: 'USD',
|
||||
});
|
||||
const formApiRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (props.options && formApiRef.current) {
|
||||
const currentInputs = {
|
||||
CreemApiKey: props.options.CreemApiKey || '',
|
||||
CreemWebhookSecret: props.options.CreemWebhookSecret || '',
|
||||
CreemProducts: props.options.CreemProducts || '[]',
|
||||
CreemTestMode: props.options.CreemTestMode === 'true',
|
||||
};
|
||||
setInputs(currentInputs);
|
||||
setOriginInputs({ ...currentInputs });
|
||||
formApiRef.current.setValues(currentInputs);
|
||||
|
||||
// Parse products
|
||||
try {
|
||||
const parsedProducts = JSON.parse(currentInputs.CreemProducts);
|
||||
setProducts(parsedProducts);
|
||||
} catch (e) {
|
||||
setProducts([]);
|
||||
}
|
||||
}
|
||||
}, [props.options]);
|
||||
|
||||
const handleFormChange = (values) => {
|
||||
setInputs(values);
|
||||
};
|
||||
|
||||
const submitCreemSetting = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const options = [];
|
||||
|
||||
if (inputs.CreemApiKey && inputs.CreemApiKey !== '') {
|
||||
options.push({ key: 'CreemApiKey', value: inputs.CreemApiKey });
|
||||
}
|
||||
|
||||
if (inputs.CreemWebhookSecret && inputs.CreemWebhookSecret !== '') {
|
||||
options.push({
|
||||
key: 'CreemWebhookSecret',
|
||||
value: inputs.CreemWebhookSecret,
|
||||
});
|
||||
}
|
||||
|
||||
// Save test mode setting
|
||||
options.push({
|
||||
key: 'CreemTestMode',
|
||||
value: inputs.CreemTestMode ? 'true' : 'false',
|
||||
});
|
||||
|
||||
// Save products as JSON string
|
||||
options.push({ key: 'CreemProducts', value: JSON.stringify(products) });
|
||||
|
||||
// 发送请求
|
||||
const requestQueue = options.map((opt) =>
|
||||
API.put('/api/option/', {
|
||||
key: opt.key,
|
||||
value: opt.value,
|
||||
}),
|
||||
);
|
||||
|
||||
const results = await Promise.all(requestQueue);
|
||||
|
||||
// 检查所有请求是否成功
|
||||
const errorResults = results.filter((res) => !res.data.success);
|
||||
if (errorResults.length > 0) {
|
||||
errorResults.forEach((res) => {
|
||||
showError(res.data.message);
|
||||
});
|
||||
} else {
|
||||
showSuccess(t('更新成功'));
|
||||
// 更新本地存储的原始值
|
||||
setOriginInputs({ ...inputs });
|
||||
props.refresh?.();
|
||||
}
|
||||
} catch (error) {
|
||||
showError(t('更新失败'));
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const openProductModal = (product = null) => {
|
||||
if (product) {
|
||||
setEditingProduct(product);
|
||||
setProductForm({ ...product });
|
||||
} else {
|
||||
setEditingProduct(null);
|
||||
setProductForm({
|
||||
name: '',
|
||||
productId: '',
|
||||
price: 0,
|
||||
quota: 0,
|
||||
currency: 'USD',
|
||||
});
|
||||
}
|
||||
setShowProductModal(true);
|
||||
};
|
||||
|
||||
const closeProductModal = () => {
|
||||
setShowProductModal(false);
|
||||
setEditingProduct(null);
|
||||
setProductForm({
|
||||
name: '',
|
||||
productId: '',
|
||||
price: 0,
|
||||
quota: 0,
|
||||
currency: 'USD',
|
||||
});
|
||||
};
|
||||
|
||||
const saveProduct = () => {
|
||||
if (
|
||||
!productForm.name ||
|
||||
!productForm.productId ||
|
||||
productForm.price <= 0 ||
|
||||
productForm.quota <= 0 ||
|
||||
!productForm.currency
|
||||
) {
|
||||
showError(t('请填写完整的产品信息'));
|
||||
return;
|
||||
}
|
||||
|
||||
let newProducts = [...products];
|
||||
if (editingProduct) {
|
||||
// 编辑现有产品
|
||||
const index = newProducts.findIndex(
|
||||
(p) => p.productId === editingProduct.productId,
|
||||
);
|
||||
if (index !== -1) {
|
||||
newProducts[index] = { ...productForm };
|
||||
}
|
||||
} else {
|
||||
// 添加新产品
|
||||
if (newProducts.find((p) => p.productId === productForm.productId)) {
|
||||
showError(t('产品ID已存在'));
|
||||
return;
|
||||
}
|
||||
newProducts.push({ ...productForm });
|
||||
}
|
||||
|
||||
setProducts(newProducts);
|
||||
closeProductModal();
|
||||
};
|
||||
|
||||
const deleteProduct = (productId) => {
|
||||
const newProducts = products.filter((p) => p.productId !== productId);
|
||||
setProducts(newProducts);
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: t('产品名称'),
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
},
|
||||
{
|
||||
title: t('产品ID'),
|
||||
dataIndex: 'productId',
|
||||
key: 'productId',
|
||||
},
|
||||
{
|
||||
title: t('展示价格'),
|
||||
dataIndex: 'price',
|
||||
key: 'price',
|
||||
render: (price, record) =>
|
||||
`${record.currency === 'EUR' ? '€' : '$'}${price}`,
|
||||
},
|
||||
{
|
||||
title: t('充值额度'),
|
||||
dataIndex: 'quota',
|
||||
key: 'quota',
|
||||
},
|
||||
{
|
||||
title: t('操作'),
|
||||
key: 'action',
|
||||
render: (_, record) => (
|
||||
<div className='flex gap-2'>
|
||||
<Button
|
||||
type='tertiary'
|
||||
size='small'
|
||||
onClick={() => openProductModal(record)}
|
||||
>
|
||||
{t('编辑')}
|
||||
</Button>
|
||||
<Button
|
||||
type='danger'
|
||||
theme='borderless'
|
||||
size='small'
|
||||
icon={<Trash2 size={14} />}
|
||||
onClick={() => deleteProduct(record.productId)}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Spin spinning={loading}>
|
||||
<Form
|
||||
initValues={inputs}
|
||||
onValueChange={handleFormChange}
|
||||
getFormApi={(api) => (formApiRef.current = api)}
|
||||
>
|
||||
<Form.Section text={sectionTitle}>
|
||||
<Banner
|
||||
type='info'
|
||||
icon={<BookOpen size={16} />}
|
||||
description={
|
||||
<>
|
||||
{t('Creem 介绍')}
|
||||
<a href='https://creem.io' target='_blank' rel='noreferrer'>
|
||||
Creem Official Site
|
||||
</a>
|
||||
<br />
|
||||
{t('Creem Setting Tips')}
|
||||
</>
|
||||
}
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
|
||||
<Row gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}>
|
||||
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
|
||||
<Form.Input
|
||||
field='CreemApiKey'
|
||||
label={t('API 密钥')}
|
||||
placeholder={t('Creem API 密钥,敏感信息不显示')}
|
||||
type='password'
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
|
||||
<Form.Input
|
||||
field='CreemWebhookSecret'
|
||||
label={t('Webhook 签名密钥')}
|
||||
placeholder={t(
|
||||
'用于验证回调 new-api 的 webhook 请求的密钥,敏感信息不显示',
|
||||
)}
|
||||
type='password'
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
|
||||
<Form.Switch
|
||||
field='CreemTestMode'
|
||||
label={t('沙盒模式')}
|
||||
extraText={t('启用后将使用 Creem Test Mode')}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<div style={{ marginTop: 24 }}>
|
||||
<div className='flex justify-between items-center mb-4'>
|
||||
<Text strong>{t('产品配置')}</Text>
|
||||
<Button
|
||||
type='primary'
|
||||
icon={<Plus size={16} />}
|
||||
onClick={() => openProductModal()}
|
||||
>
|
||||
{t('添加产品')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={products}
|
||||
pagination={false}
|
||||
empty={
|
||||
<div className='text-center py-8'>
|
||||
<Text type='tertiary'>{t('暂无产品配置')}</Text>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button onClick={submitCreemSetting} style={{ marginTop: 16 }}>
|
||||
{t('更新 Creem 设置')}
|
||||
</Button>
|
||||
</Form.Section>
|
||||
</Form>
|
||||
|
||||
{/* 产品配置模态框 */}
|
||||
<Modal
|
||||
title={editingProduct ? t('编辑产品') : t('添加产品')}
|
||||
visible={showProductModal}
|
||||
onOk={saveProduct}
|
||||
onCancel={closeProductModal}
|
||||
maskClosable={false}
|
||||
size='small'
|
||||
centered
|
||||
>
|
||||
<div className='space-y-4'>
|
||||
<div>
|
||||
<Text strong className='block mb-2'>
|
||||
{t('产品名称')}
|
||||
</Text>
|
||||
<Input
|
||||
value={productForm.name}
|
||||
onChange={(value) =>
|
||||
setProductForm({ ...productForm, name: value })
|
||||
}
|
||||
placeholder={t('例如:基础套餐')}
|
||||
size='large'
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Text strong className='block mb-2'>
|
||||
{t('产品ID')}
|
||||
</Text>
|
||||
<Input
|
||||
value={productForm.productId}
|
||||
onChange={(value) =>
|
||||
setProductForm({ ...productForm, productId: value })
|
||||
}
|
||||
placeholder={t('例如:prod_6I8rBerHpPxyoiU9WK4kot')}
|
||||
size='large'
|
||||
disabled={!!editingProduct}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Text strong className='block mb-2'>
|
||||
{t('货币')}
|
||||
</Text>
|
||||
<Select
|
||||
value={productForm.currency}
|
||||
onChange={(value) =>
|
||||
setProductForm({ ...productForm, currency: value })
|
||||
}
|
||||
size='large'
|
||||
className='w-full'
|
||||
>
|
||||
<Select.Option value='USD'>{t('USD (美元)')}</Select.Option>
|
||||
<Select.Option value='EUR'>{t('EUR (欧元)')}</Select.Option>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Text strong className='block mb-2'>
|
||||
{t('价格')} (
|
||||
{productForm.currency === 'EUR' ? t('欧元') : t('美元')})
|
||||
</Text>
|
||||
<InputNumber
|
||||
value={productForm.price}
|
||||
onChange={(value) =>
|
||||
setProductForm({ ...productForm, price: value })
|
||||
}
|
||||
placeholder={t('例如:4.99')}
|
||||
min={0.01}
|
||||
precision={2}
|
||||
size='large'
|
||||
className='w-full'
|
||||
defaultValue={4.49}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Text strong className='block mb-2'>
|
||||
{t('充值额度')}
|
||||
</Text>
|
||||
<InputNumber
|
||||
value={productForm.quota}
|
||||
onChange={(value) =>
|
||||
setProductForm({ ...productForm, quota: value })
|
||||
}
|
||||
placeholder={t('例如:100000')}
|
||||
min={1}
|
||||
precision={0}
|
||||
size='large'
|
||||
className='w-full'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</Spin>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
/*
|
||||
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 { Banner, Button, Form, Row, Col, Spin } from '@douyinfe/semi-ui';
|
||||
import {
|
||||
API,
|
||||
removeTrailingSlash,
|
||||
showError,
|
||||
showSuccess,
|
||||
} from '../../../helpers';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { BookOpen, TriangleAlert } from 'lucide-react';
|
||||
|
||||
export default function SettingsPaymentGateway(props) {
|
||||
const { t } = useTranslation();
|
||||
const sectionTitle = props.hideSectionTitle ? undefined : t('Stripe 设置');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [inputs, setInputs] = useState({
|
||||
StripeApiSecret: '',
|
||||
StripeWebhookSecret: '',
|
||||
StripePriceId: '',
|
||||
StripeUnitPrice: 8.0,
|
||||
StripeMinTopUp: 1,
|
||||
StripePromotionCodesEnabled: false,
|
||||
});
|
||||
const [originInputs, setOriginInputs] = useState({});
|
||||
const formApiRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (props.options && formApiRef.current) {
|
||||
const currentInputs = {
|
||||
StripeApiSecret: props.options.StripeApiSecret || '',
|
||||
StripeWebhookSecret: props.options.StripeWebhookSecret || '',
|
||||
StripePriceId: props.options.StripePriceId || '',
|
||||
StripeUnitPrice:
|
||||
props.options.StripeUnitPrice !== undefined
|
||||
? parseFloat(props.options.StripeUnitPrice)
|
||||
: 8.0,
|
||||
StripeMinTopUp:
|
||||
props.options.StripeMinTopUp !== undefined
|
||||
? parseFloat(props.options.StripeMinTopUp)
|
||||
: 1,
|
||||
StripePromotionCodesEnabled:
|
||||
props.options.StripePromotionCodesEnabled !== undefined
|
||||
? props.options.StripePromotionCodesEnabled
|
||||
: false,
|
||||
};
|
||||
setInputs(currentInputs);
|
||||
setOriginInputs({ ...currentInputs });
|
||||
formApiRef.current.setValues(currentInputs);
|
||||
}
|
||||
}, [props.options]);
|
||||
|
||||
const handleFormChange = (values) => {
|
||||
setInputs(values);
|
||||
};
|
||||
|
||||
const submitStripeSetting = async () => {
|
||||
if (props.options.ServerAddress === '') {
|
||||
showError(t('请先填写服务器地址'));
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const options = [];
|
||||
|
||||
if (inputs.StripeApiSecret && inputs.StripeApiSecret !== '') {
|
||||
options.push({ key: 'StripeApiSecret', value: inputs.StripeApiSecret });
|
||||
}
|
||||
if (inputs.StripeWebhookSecret && inputs.StripeWebhookSecret !== '') {
|
||||
options.push({
|
||||
key: 'StripeWebhookSecret',
|
||||
value: inputs.StripeWebhookSecret,
|
||||
});
|
||||
}
|
||||
if (inputs.StripePriceId !== '') {
|
||||
options.push({ key: 'StripePriceId', value: inputs.StripePriceId });
|
||||
}
|
||||
if (
|
||||
inputs.StripeUnitPrice !== undefined &&
|
||||
inputs.StripeUnitPrice !== null
|
||||
) {
|
||||
options.push({
|
||||
key: 'StripeUnitPrice',
|
||||
value: inputs.StripeUnitPrice.toString(),
|
||||
});
|
||||
}
|
||||
if (
|
||||
inputs.StripeMinTopUp !== undefined &&
|
||||
inputs.StripeMinTopUp !== null
|
||||
) {
|
||||
options.push({
|
||||
key: 'StripeMinTopUp',
|
||||
value: inputs.StripeMinTopUp.toString(),
|
||||
});
|
||||
}
|
||||
if (
|
||||
originInputs['StripePromotionCodesEnabled'] !==
|
||||
inputs.StripePromotionCodesEnabled &&
|
||||
inputs.StripePromotionCodesEnabled !== undefined
|
||||
) {
|
||||
options.push({
|
||||
key: 'StripePromotionCodesEnabled',
|
||||
value: inputs.StripePromotionCodesEnabled ? 'true' : 'false',
|
||||
});
|
||||
}
|
||||
|
||||
// 发送请求
|
||||
const requestQueue = options.map((opt) =>
|
||||
API.put('/api/option/', {
|
||||
key: opt.key,
|
||||
value: opt.value,
|
||||
}),
|
||||
);
|
||||
|
||||
const results = await Promise.all(requestQueue);
|
||||
|
||||
// 检查所有请求是否成功
|
||||
const errorResults = results.filter((res) => !res.data.success);
|
||||
if (errorResults.length > 0) {
|
||||
errorResults.forEach((res) => {
|
||||
showError(res.data.message);
|
||||
});
|
||||
} else {
|
||||
showSuccess(t('更新成功'));
|
||||
// 更新本地存储的原始值
|
||||
setOriginInputs({ ...inputs });
|
||||
props.refresh?.();
|
||||
}
|
||||
} catch (error) {
|
||||
showError(t('更新失败'));
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Spin spinning={loading}>
|
||||
<Form
|
||||
initValues={inputs}
|
||||
onValueChange={handleFormChange}
|
||||
getFormApi={(api) => (formApiRef.current = api)}
|
||||
>
|
||||
<Form.Section text={sectionTitle}>
|
||||
<Banner
|
||||
type='info'
|
||||
icon={<BookOpen size={16} />}
|
||||
description={
|
||||
<>
|
||||
Stripe 密钥、Webhook 等设置请
|
||||
<a
|
||||
href='https://dashboard.stripe.com/developers'
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
>
|
||||
点击此处
|
||||
</a>
|
||||
进行设置,建议先在
|
||||
<a
|
||||
href='https://dashboard.stripe.com/test/developers'
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
>
|
||||
测试环境
|
||||
</a>
|
||||
完成联调。
|
||||
<br />
|
||||
{t('回调地址')}:
|
||||
{props.options.ServerAddress
|
||||
? removeTrailingSlash(props.options.ServerAddress)
|
||||
: t('网站地址')}
|
||||
/api/stripe/webhook
|
||||
</>
|
||||
}
|
||||
style={{ marginBottom: 12 }}
|
||||
/>
|
||||
<Banner
|
||||
type='warning'
|
||||
icon={<TriangleAlert size={16} />}
|
||||
description='需要包含事件:checkout.session.completed 和 checkout.session.expired'
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
<Row gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}>
|
||||
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
|
||||
<Form.Input
|
||||
field='StripeApiSecret'
|
||||
label={t('API 密钥')}
|
||||
placeholder={t('例如:sk_xxx 或 rk_xxx,留空表示保持当前不变')}
|
||||
extraText={t(
|
||||
'保存后不会回显,请填写当前环境对应的 Stripe API 密钥',
|
||||
)}
|
||||
type='password'
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
|
||||
<Form.Input
|
||||
field='StripeWebhookSecret'
|
||||
label={t('Webhook 签名密钥')}
|
||||
placeholder={t('例如:whsec_xxx,留空表示保持当前不变')}
|
||||
extraText={t('用于校验 Stripe Webhook 签名,保存后不会回显')}
|
||||
type='password'
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
|
||||
<Form.Input
|
||||
field='StripePriceId'
|
||||
label={t('商品价格 ID')}
|
||||
placeholder={t('例如:price_xxx')}
|
||||
extraText={t('在 Stripe 后台创建价格后获得')}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row
|
||||
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
|
||||
style={{ marginTop: 16 }}
|
||||
>
|
||||
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
|
||||
<Form.InputNumber
|
||||
field='StripeUnitPrice'
|
||||
precision={2}
|
||||
label={t('充值价格(x元/美金)')}
|
||||
placeholder={t('例如:7,就是7元/美金')}
|
||||
extraText={t('按 1 美元对应的站内价格填写')}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
|
||||
<Form.InputNumber
|
||||
field='StripeMinTopUp'
|
||||
label={t('最低充值美元数量')}
|
||||
placeholder={t('例如:2,就是最低充值2$')}
|
||||
extraText={t('用户单次最少可充值的美元数量')}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
|
||||
<Form.Switch
|
||||
field='StripePromotionCodesEnabled'
|
||||
size='default'
|
||||
checkedText='|'
|
||||
uncheckedText='〇'
|
||||
label={t('允许在 Stripe 支付中输入促销码')}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Button onClick={submitStripeSetting}>{t('更新 Stripe 设置')}</Button>
|
||||
</Form.Section>
|
||||
</Form>
|
||||
</Spin>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,701 @@
|
||||
/*
|
||||
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 {
|
||||
Banner,
|
||||
Button,
|
||||
Form,
|
||||
Row,
|
||||
Col,
|
||||
Typography,
|
||||
Spin,
|
||||
Table,
|
||||
Modal,
|
||||
Input,
|
||||
Space,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
API,
|
||||
removeTrailingSlash,
|
||||
showError,
|
||||
showSuccess,
|
||||
} from '../../../helpers';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { BookOpen, TriangleAlert } from 'lucide-react';
|
||||
|
||||
const { Text } = Typography;
|
||||
const toBoolean = (value) => value === true || value === 'true';
|
||||
|
||||
export default function SettingsPaymentGatewayWaffo(props) {
|
||||
const { t } = useTranslation();
|
||||
const sectionTitle = props.hideSectionTitle ? undefined : t('Waffo 设置');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [inputs, setInputs] = useState({
|
||||
WaffoEnabled: false,
|
||||
WaffoApiKey: '',
|
||||
WaffoPrivateKey: '',
|
||||
WaffoPublicCert: '',
|
||||
WaffoSandboxPublicCert: '',
|
||||
WaffoSandboxApiKey: '',
|
||||
WaffoSandboxPrivateKey: '',
|
||||
WaffoSandbox: false,
|
||||
WaffoMerchantId: '',
|
||||
WaffoCurrency: 'USD',
|
||||
WaffoUnitPrice: 1.0,
|
||||
WaffoMinTopUp: 1,
|
||||
WaffoNotifyUrl: '',
|
||||
WaffoReturnUrl: '',
|
||||
});
|
||||
const formApiRef = useRef(null);
|
||||
const iconFileInputRef = useRef(null);
|
||||
|
||||
const handleIconFileChange = (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
const MAX_ICON_SIZE = 100 * 1024; // 100 KB
|
||||
if (file.size > MAX_ICON_SIZE) {
|
||||
showError(t('图标文件不能超过 100KB,请压缩后重新上传'));
|
||||
e.target.value = '';
|
||||
return;
|
||||
}
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
setPayMethodForm((prev) => ({ ...prev, icon: event.target.result }));
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
e.target.value = '';
|
||||
};
|
||||
|
||||
// 支付方式列表
|
||||
const [waffoPayMethods, setWaffoPayMethods] = useState([]);
|
||||
// 支付方式弹窗
|
||||
const [payMethodModalVisible, setPayMethodModalVisible] = useState(false);
|
||||
// 当前编辑的索引,-1 表示新增
|
||||
const [editingPayMethodIndex, setEditingPayMethodIndex] = useState(-1);
|
||||
// 弹窗内表单字段的临时状态
|
||||
const [payMethodForm, setPayMethodForm] = useState({
|
||||
name: '',
|
||||
icon: '',
|
||||
payMethodType: '',
|
||||
payMethodName: '',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (props.options && formApiRef.current) {
|
||||
const currentInputs = {
|
||||
WaffoEnabled: toBoolean(props.options.WaffoEnabled),
|
||||
WaffoApiKey: props.options.WaffoApiKey || '',
|
||||
WaffoPrivateKey: props.options.WaffoPrivateKey || '',
|
||||
WaffoPublicCert: props.options.WaffoPublicCert || '',
|
||||
WaffoSandboxPublicCert: props.options.WaffoSandboxPublicCert || '',
|
||||
WaffoSandboxApiKey: props.options.WaffoSandboxApiKey || '',
|
||||
WaffoSandboxPrivateKey: props.options.WaffoSandboxPrivateKey || '',
|
||||
WaffoSandbox: toBoolean(props.options.WaffoSandbox),
|
||||
WaffoMerchantId: props.options.WaffoMerchantId || '',
|
||||
WaffoCurrency: props.options.WaffoCurrency || 'USD',
|
||||
WaffoUnitPrice: parseFloat(props.options.WaffoUnitPrice) || 1.0,
|
||||
WaffoMinTopUp: parseInt(props.options.WaffoMinTopUp) || 1,
|
||||
WaffoNotifyUrl: props.options.WaffoNotifyUrl || '',
|
||||
WaffoReturnUrl: props.options.WaffoReturnUrl || '',
|
||||
};
|
||||
setInputs(currentInputs);
|
||||
formApiRef.current.setValues(currentInputs);
|
||||
|
||||
// 解析支付方式列表
|
||||
try {
|
||||
const rawPayMethods = props.options.WaffoPayMethods;
|
||||
if (rawPayMethods) {
|
||||
const parsed = JSON.parse(rawPayMethods);
|
||||
if (Array.isArray(parsed)) {
|
||||
setWaffoPayMethods(parsed);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
setWaffoPayMethods([]);
|
||||
}
|
||||
}
|
||||
}, [props.options]);
|
||||
|
||||
const handleFormChange = (values) => {
|
||||
setInputs(values);
|
||||
};
|
||||
|
||||
const submitWaffoSetting = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const options = [];
|
||||
|
||||
options.push({
|
||||
key: 'WaffoEnabled',
|
||||
value: inputs.WaffoEnabled ? 'true' : 'false',
|
||||
});
|
||||
|
||||
if (inputs.WaffoApiKey && inputs.WaffoApiKey !== '') {
|
||||
options.push({ key: 'WaffoApiKey', value: inputs.WaffoApiKey });
|
||||
}
|
||||
|
||||
if (inputs.WaffoPrivateKey && inputs.WaffoPrivateKey !== '') {
|
||||
options.push({ key: 'WaffoPrivateKey', value: inputs.WaffoPrivateKey });
|
||||
}
|
||||
|
||||
options.push({
|
||||
key: 'WaffoPublicCert',
|
||||
value: inputs.WaffoPublicCert || '',
|
||||
});
|
||||
options.push({
|
||||
key: 'WaffoSandboxPublicCert',
|
||||
value: inputs.WaffoSandboxPublicCert || '',
|
||||
});
|
||||
|
||||
if (inputs.WaffoSandboxApiKey && inputs.WaffoSandboxApiKey !== '') {
|
||||
options.push({
|
||||
key: 'WaffoSandboxApiKey',
|
||||
value: inputs.WaffoSandboxApiKey,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
inputs.WaffoSandboxPrivateKey &&
|
||||
inputs.WaffoSandboxPrivateKey !== ''
|
||||
) {
|
||||
options.push({
|
||||
key: 'WaffoSandboxPrivateKey',
|
||||
value: inputs.WaffoSandboxPrivateKey,
|
||||
});
|
||||
}
|
||||
|
||||
options.push({
|
||||
key: 'WaffoSandbox',
|
||||
value: inputs.WaffoSandbox ? 'true' : 'false',
|
||||
});
|
||||
|
||||
options.push({
|
||||
key: 'WaffoMerchantId',
|
||||
value: inputs.WaffoMerchantId || '',
|
||||
});
|
||||
options.push({ key: 'WaffoCurrency', value: inputs.WaffoCurrency || '' });
|
||||
|
||||
options.push({
|
||||
key: 'WaffoUnitPrice',
|
||||
value: String(inputs.WaffoUnitPrice || 1.0),
|
||||
});
|
||||
|
||||
options.push({
|
||||
key: 'WaffoMinTopUp',
|
||||
value: String(inputs.WaffoMinTopUp || 1),
|
||||
});
|
||||
|
||||
options.push({
|
||||
key: 'WaffoNotifyUrl',
|
||||
value: inputs.WaffoNotifyUrl || '',
|
||||
});
|
||||
options.push({
|
||||
key: 'WaffoReturnUrl',
|
||||
value: inputs.WaffoReturnUrl || '',
|
||||
});
|
||||
|
||||
// 保存支付方式列表
|
||||
options.push({
|
||||
key: 'WaffoPayMethods',
|
||||
value: JSON.stringify(waffoPayMethods),
|
||||
});
|
||||
|
||||
// 发送请求
|
||||
const requestQueue = options.map((opt) =>
|
||||
API.put('/api/option/', {
|
||||
key: opt.key,
|
||||
value: opt.value,
|
||||
}),
|
||||
);
|
||||
|
||||
const results = await Promise.all(requestQueue);
|
||||
|
||||
// 检查所有请求是否成功
|
||||
const errorResults = results.filter((res) => !res.data.success);
|
||||
if (errorResults.length > 0) {
|
||||
errorResults.forEach((res) => {
|
||||
showError(res.data.message);
|
||||
});
|
||||
} else {
|
||||
showSuccess(t('更新成功'));
|
||||
props.refresh?.();
|
||||
}
|
||||
} catch (error) {
|
||||
showError(t('更新失败'));
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
// 打开新增弹窗
|
||||
const openAddPayMethodModal = () => {
|
||||
setEditingPayMethodIndex(-1);
|
||||
setPayMethodForm({
|
||||
name: '',
|
||||
icon: '',
|
||||
payMethodType: '',
|
||||
payMethodName: '',
|
||||
});
|
||||
setPayMethodModalVisible(true);
|
||||
};
|
||||
|
||||
// 打开编辑弹窗
|
||||
const openEditPayMethodModal = (record, index) => {
|
||||
setEditingPayMethodIndex(index);
|
||||
setPayMethodForm({
|
||||
name: record.name || '',
|
||||
icon: record.icon || '',
|
||||
payMethodType: record.payMethodType || '',
|
||||
payMethodName: record.payMethodName || '',
|
||||
});
|
||||
setPayMethodModalVisible(true);
|
||||
};
|
||||
|
||||
// 确认保存弹窗(新增或编辑)
|
||||
const handlePayMethodModalOk = () => {
|
||||
if (!payMethodForm.name || payMethodForm.name.trim() === '') {
|
||||
showError(t('支付方式名称不能为空'));
|
||||
return;
|
||||
}
|
||||
const newMethod = {
|
||||
name: payMethodForm.name.trim(),
|
||||
icon: payMethodForm.icon.trim(),
|
||||
payMethodType: payMethodForm.payMethodType.trim(),
|
||||
payMethodName: payMethodForm.payMethodName.trim(),
|
||||
};
|
||||
if (editingPayMethodIndex === -1) {
|
||||
// 新增
|
||||
setWaffoPayMethods([...waffoPayMethods, newMethod]);
|
||||
} else {
|
||||
// 编辑
|
||||
const updated = [...waffoPayMethods];
|
||||
updated[editingPayMethodIndex] = newMethod;
|
||||
setWaffoPayMethods(updated);
|
||||
}
|
||||
setPayMethodModalVisible(false);
|
||||
};
|
||||
|
||||
// 删除支付方式
|
||||
const handleDeletePayMethod = (index) => {
|
||||
const updated = waffoPayMethods.filter((_, i) => i !== index);
|
||||
setWaffoPayMethods(updated);
|
||||
};
|
||||
|
||||
// 支付方式表格列定义
|
||||
const payMethodColumns = [
|
||||
{
|
||||
title: t('显示名称'),
|
||||
dataIndex: 'name',
|
||||
},
|
||||
{
|
||||
title: t('图标'),
|
||||
dataIndex: 'icon',
|
||||
render: (text) =>
|
||||
text ? (
|
||||
<img
|
||||
src={text}
|
||||
alt='icon'
|
||||
style={{ width: 24, height: 24, objectFit: 'contain' }}
|
||||
/>
|
||||
) : (
|
||||
<Text type='tertiary'>—</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('支付方式类型'),
|
||||
dataIndex: 'payMethodType',
|
||||
render: (text) => text || <Text type='tertiary'>—</Text>,
|
||||
},
|
||||
{
|
||||
title: t('支付方式名称'),
|
||||
dataIndex: 'payMethodName',
|
||||
render: (text) => text || <Text type='tertiary'>—</Text>,
|
||||
},
|
||||
{
|
||||
title: t('操作'),
|
||||
key: 'action',
|
||||
render: (_, record, index) => (
|
||||
<Space>
|
||||
<Button
|
||||
size='small'
|
||||
onClick={() => openEditPayMethodModal(record, index)}
|
||||
>
|
||||
{t('编辑')}
|
||||
</Button>
|
||||
<Button
|
||||
size='small'
|
||||
type='danger'
|
||||
onClick={() => handleDeletePayMethod(index)}
|
||||
>
|
||||
{t('删除')}
|
||||
</Button>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Spin spinning={loading}>
|
||||
<Form
|
||||
initValues={inputs}
|
||||
onValueChange={handleFormChange}
|
||||
getFormApi={(api) => (formApiRef.current = api)}
|
||||
>
|
||||
<Form.Section text={sectionTitle}>
|
||||
<Banner
|
||||
type='info'
|
||||
icon={<BookOpen size={16} />}
|
||||
description={
|
||||
<>
|
||||
Waffo 密钥、商户和支付方式等设置请
|
||||
<a href='https://waffo.com' target='_blank' rel='noreferrer'>
|
||||
点击此处
|
||||
</a>
|
||||
进行配置,切换沙盒模式时请同步填写对应环境的密钥。
|
||||
<br />
|
||||
{t('回调地址')}:
|
||||
{props.options.ServerAddress
|
||||
? removeTrailingSlash(props.options.ServerAddress)
|
||||
: t('网站地址')}
|
||||
/api/waffo/webhook
|
||||
</>
|
||||
}
|
||||
style={{ marginBottom: 12 }}
|
||||
/>
|
||||
<Banner
|
||||
type='warning'
|
||||
icon={<TriangleAlert size={16} />}
|
||||
description={t('请确认商户和所选环境密钥一致。')}
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
|
||||
<Row gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}>
|
||||
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
|
||||
<Form.Switch
|
||||
field='WaffoEnabled'
|
||||
label={t('启用 Waffo')}
|
||||
size='default'
|
||||
checkedText='|'
|
||||
uncheckedText='〇'
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
|
||||
<Form.Switch
|
||||
field='WaffoSandbox'
|
||||
label={t('沙盒模式')}
|
||||
size='default'
|
||||
checkedText='|'
|
||||
uncheckedText='〇'
|
||||
extraText={t('用于切换当前下单和回调校验所使用的环境')}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
|
||||
<Form.Input
|
||||
field='WaffoMerchantId'
|
||||
label={t('商户 ID')}
|
||||
placeholder={t('例如:MER_xxx')}
|
||||
extraText={t('当前环境共用同一商户 ID')}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row
|
||||
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
|
||||
style={{ marginTop: 16 }}
|
||||
>
|
||||
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
|
||||
<Form.Input
|
||||
field='WaffoApiKey'
|
||||
label={t('API 密钥(生产环境)')}
|
||||
placeholder={t(
|
||||
'填写后覆盖当前生产环境 API 密钥,留空表示保持当前不变',
|
||||
)}
|
||||
extraText={t('保存后不会回显,请填写生产环境对应的 API 密钥')}
|
||||
type='password'
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
|
||||
<Form.TextArea
|
||||
field='WaffoPrivateKey'
|
||||
label={t('API 私钥(生产环境)')}
|
||||
placeholder={t(
|
||||
'填写后覆盖当前生产环境私钥,留空表示保持当前不变',
|
||||
)}
|
||||
extraText={t('保存后不会回显,请填写生产环境对应的 API 私钥')}
|
||||
type='password'
|
||||
autosize={{ minRows: 3, maxRows: 6 }}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
|
||||
<Form.TextArea
|
||||
field='WaffoPublicCert'
|
||||
label={t('Waffo 公钥(生产环境)')}
|
||||
placeholder={t(
|
||||
'填写生产环境 Waffo 公钥,Base64 或 PEM 内容均可',
|
||||
)}
|
||||
extraText={t('用于校验生产环境的 Waffo 回调签名')}
|
||||
type='password'
|
||||
autosize={{ minRows: 3, maxRows: 6 }}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row
|
||||
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
|
||||
style={{ marginTop: 16 }}
|
||||
>
|
||||
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
|
||||
<Form.Input
|
||||
field='WaffoSandboxApiKey'
|
||||
label={t('API 密钥(测试环境)')}
|
||||
placeholder={t(
|
||||
'填写后覆盖当前测试环境 API 密钥,留空表示保持当前不变',
|
||||
)}
|
||||
extraText={t('保存后不会回显,请填写测试环境对应的 API 密钥')}
|
||||
type='password'
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
|
||||
<Form.TextArea
|
||||
field='WaffoSandboxPrivateKey'
|
||||
label={t('API 私钥(测试环境)')}
|
||||
placeholder={t(
|
||||
'填写后覆盖当前测试环境私钥,留空表示保持当前不变',
|
||||
)}
|
||||
extraText={t('保存后不会回显,请填写测试环境对应的 API 私钥')}
|
||||
type='password'
|
||||
autosize={{ minRows: 3, maxRows: 6 }}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
|
||||
<Form.TextArea
|
||||
field='WaffoSandboxPublicCert'
|
||||
label={t('Waffo 公钥(测试环境)')}
|
||||
placeholder={t(
|
||||
'填写测试环境 Waffo 公钥,Base64 或 PEM 内容均可',
|
||||
)}
|
||||
extraText={t('用于校验测试环境的 Waffo 回调签名')}
|
||||
type='password'
|
||||
autosize={{ minRows: 3, maxRows: 6 }}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row
|
||||
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
|
||||
style={{ marginTop: 16 }}
|
||||
>
|
||||
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
|
||||
<Form.Input
|
||||
field='WaffoCurrency'
|
||||
label={t('货币')}
|
||||
placeholder='USD'
|
||||
extraText={t('Waffo 当前使用 USD 结算')}
|
||||
disabled
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
|
||||
<Form.InputNumber
|
||||
field='WaffoUnitPrice'
|
||||
precision={2}
|
||||
label={t('充值价格(x元/美金)')}
|
||||
placeholder={t('例如:7,就是7元/美金')}
|
||||
extraText={t('按 1 美元对应的站内价格填写')}
|
||||
min={0}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
|
||||
<Form.InputNumber
|
||||
field='WaffoMinTopUp'
|
||||
label={t('最低充值美元数量')}
|
||||
placeholder={t('例如:2,就是最低充值2$')}
|
||||
extraText={t('用户单次最少可充值的美元数量')}
|
||||
min={1}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row
|
||||
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
|
||||
style={{ marginTop: 16 }}
|
||||
>
|
||||
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
|
||||
<Form.Input
|
||||
field='WaffoNotifyUrl'
|
||||
label={t('回调地址')}
|
||||
placeholder={t('例如:https://example.com/api/waffo/webhook')}
|
||||
extraText={t('留空则自动使用当前站点的默认回调地址')}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
|
||||
<Form.Input
|
||||
field='WaffoReturnUrl'
|
||||
label={t('支付返回地址')}
|
||||
placeholder={t('例如:https://example.com/console/topup')}
|
||||
extraText={t('留空则自动使用当前站点的默认充值页地址')}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</Form.Section>
|
||||
|
||||
<Form.Section text={t('支付方式设置')}>
|
||||
<Text type='secondary'>
|
||||
{t(
|
||||
'这里配置 Waffo 下展示给用户的 Card、Apple Pay、Google Pay 等子支付方式。',
|
||||
)}
|
||||
</Text>
|
||||
<div style={{ marginTop: 12, marginBottom: 12 }}>
|
||||
<Button onClick={openAddPayMethodModal}>{t('新增支付方式')}</Button>
|
||||
</div>
|
||||
<Table
|
||||
columns={payMethodColumns}
|
||||
dataSource={waffoPayMethods}
|
||||
rowKey={(record, index) => index}
|
||||
pagination={false}
|
||||
size='small'
|
||||
empty={
|
||||
<Text type='tertiary'>{t('暂无支付方式,点击上方按钮新增')}</Text>
|
||||
}
|
||||
/>
|
||||
<Button onClick={submitWaffoSetting} style={{ marginTop: 16 }}>
|
||||
{t('更新 Waffo 设置')}
|
||||
</Button>
|
||||
</Form.Section>
|
||||
</Form>
|
||||
|
||||
{/* 新增/编辑支付方式弹窗 */}
|
||||
<Modal
|
||||
title={
|
||||
editingPayMethodIndex === -1 ? t('新增支付方式') : t('编辑支付方式')
|
||||
}
|
||||
visible={payMethodModalVisible}
|
||||
onOk={handlePayMethodModalOk}
|
||||
onCancel={() => setPayMethodModalVisible(false)}
|
||||
okText={t('确定')}
|
||||
cancelText={t('取消')}
|
||||
>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
<div>
|
||||
<div style={{ marginBottom: 4 }}>
|
||||
<Text strong>{t('显示名称')}</Text>
|
||||
<span
|
||||
style={{ color: 'var(--semi-color-danger)', marginLeft: 4 }}
|
||||
>
|
||||
*
|
||||
</span>
|
||||
</div>
|
||||
<Input
|
||||
value={payMethodForm.name}
|
||||
onChange={(val) =>
|
||||
setPayMethodForm({ ...payMethodForm, name: val })
|
||||
}
|
||||
placeholder={t('例如:Credit Card')}
|
||||
/>
|
||||
<Text type='tertiary' size='small'>
|
||||
{t('用户在充值页面看到的支付方式名称,例如:Credit Card')}
|
||||
</Text>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ marginBottom: 4 }}>
|
||||
<Text strong>{t('图标')}</Text>
|
||||
</div>
|
||||
<Space align='center'>
|
||||
{payMethodForm.icon && (
|
||||
<img
|
||||
src={payMethodForm.icon}
|
||||
alt='preview'
|
||||
style={{
|
||||
width: 32,
|
||||
height: 32,
|
||||
objectFit: 'contain',
|
||||
border: '1px solid var(--semi-color-border)',
|
||||
borderRadius: 4,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<input
|
||||
type='file'
|
||||
accept='image/*'
|
||||
ref={iconFileInputRef}
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleIconFileChange}
|
||||
/>
|
||||
<Button
|
||||
size='small'
|
||||
onClick={() => iconFileInputRef.current?.click()}
|
||||
>
|
||||
{payMethodForm.icon ? t('重新上传') : t('上传图片')}
|
||||
</Button>
|
||||
{payMethodForm.icon && (
|
||||
<Button
|
||||
size='small'
|
||||
type='danger'
|
||||
onClick={() =>
|
||||
setPayMethodForm((prev) => ({ ...prev, icon: '' }))
|
||||
}
|
||||
>
|
||||
{t('清除')}
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
<div>
|
||||
<Text type='tertiary' size='small'>
|
||||
{t('上传 PNG/JPG/SVG 图片,建议尺寸 ≤ 128×128px')}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ marginBottom: 4 }}>
|
||||
<Text strong>{t('支付方式类型')}</Text>
|
||||
</div>
|
||||
<Input
|
||||
value={payMethodForm.payMethodType}
|
||||
onChange={(val) =>
|
||||
setPayMethodForm({ ...payMethodForm, payMethodType: val })
|
||||
}
|
||||
placeholder='CREDITCARD,DEBITCARD'
|
||||
maxLength={64}
|
||||
/>
|
||||
<Text type='tertiary' size='small'>
|
||||
{t(
|
||||
'Waffo API 参数,可空,例如:CREDITCARD,DEBITCARD(最多64位)',
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ marginBottom: 4 }}>
|
||||
<Text strong>{t('支付方式名称')}</Text>
|
||||
</div>
|
||||
<Input
|
||||
value={payMethodForm.payMethodName}
|
||||
onChange={(val) =>
|
||||
setPayMethodForm({ ...payMethodForm, payMethodName: val })
|
||||
}
|
||||
placeholder={t('可空')}
|
||||
maxLength={64}
|
||||
/>
|
||||
<Text type='tertiary' size='small'>
|
||||
{t('Waffo API 参数,可空(最多64位)')}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</Spin>
|
||||
);
|
||||
}
|
||||
+411
@@ -0,0 +1,411 @@
|
||||
/*
|
||||
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 { Banner, Button, Col, Form, Row, Spin } from '@douyinfe/semi-ui';
|
||||
import {
|
||||
API,
|
||||
removeTrailingSlash,
|
||||
showError,
|
||||
showSuccess,
|
||||
} from '../../../helpers';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { BookOpen, TriangleAlert } from 'lucide-react';
|
||||
|
||||
const defaultInputs = {
|
||||
WaffoPancakeEnabled: false,
|
||||
WaffoPancakeSandbox: false,
|
||||
WaffoPancakeMerchantID: '',
|
||||
WaffoPancakePrivateKey: '',
|
||||
WaffoPancakeWebhookPublicKey: '',
|
||||
WaffoPancakeWebhookTestKey: '',
|
||||
WaffoPancakeStoreID: '',
|
||||
WaffoPancakeProductID: '',
|
||||
WaffoPancakeReturnURL: '',
|
||||
WaffoPancakeCurrency: 'USD',
|
||||
WaffoPancakeUnitPrice: 1.0,
|
||||
WaffoPancakeMinTopUp: 1,
|
||||
};
|
||||
|
||||
const toBoolean = (value) => value === true || value === 'true';
|
||||
|
||||
export default function SettingsPaymentGatewayWaffoPancake(props) {
|
||||
const { t } = useTranslation();
|
||||
const sectionTitle = props.hideSectionTitle
|
||||
? undefined
|
||||
: t('Waffo Pancake 设置');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [inputs, setInputs] = useState(defaultInputs);
|
||||
const formApiRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!props.options || !formApiRef.current) return;
|
||||
|
||||
const currentInputs = {
|
||||
WaffoPancakeEnabled: toBoolean(props.options.WaffoPancakeEnabled),
|
||||
WaffoPancakeSandbox: toBoolean(props.options.WaffoPancakeSandbox),
|
||||
WaffoPancakeMerchantID: props.options.WaffoPancakeMerchantID || '',
|
||||
WaffoPancakePrivateKey: props.options.WaffoPancakePrivateKey || '',
|
||||
WaffoPancakeWebhookPublicKey:
|
||||
props.options.WaffoPancakeWebhookPublicKey || '',
|
||||
WaffoPancakeWebhookTestKey:
|
||||
props.options.WaffoPancakeWebhookTestKey || '',
|
||||
WaffoPancakeStoreID: props.options.WaffoPancakeStoreID || '',
|
||||
WaffoPancakeProductID: props.options.WaffoPancakeProductID || '',
|
||||
WaffoPancakeReturnURL: props.options.WaffoPancakeReturnURL || '',
|
||||
WaffoPancakeCurrency: props.options.WaffoPancakeCurrency || 'USD',
|
||||
WaffoPancakeUnitPrice:
|
||||
props.options.WaffoPancakeUnitPrice !== undefined
|
||||
? parseFloat(props.options.WaffoPancakeUnitPrice)
|
||||
: 1.0,
|
||||
WaffoPancakeMinTopUp:
|
||||
props.options.WaffoPancakeMinTopUp !== undefined
|
||||
? parseFloat(props.options.WaffoPancakeMinTopUp)
|
||||
: 1,
|
||||
};
|
||||
|
||||
setInputs(currentInputs);
|
||||
formApiRef.current.setValues(currentInputs);
|
||||
}, [props.options]);
|
||||
|
||||
const handleFormChange = (values) => {
|
||||
setInputs(values);
|
||||
};
|
||||
|
||||
const submitWaffoPancakeSetting = async () => {
|
||||
const values = {
|
||||
...inputs,
|
||||
...(formApiRef.current?.getValues?.() || {}),
|
||||
};
|
||||
values.WaffoPancakeEnabled = toBoolean(values.WaffoPancakeEnabled);
|
||||
values.WaffoPancakeSandbox = toBoolean(values.WaffoPancakeSandbox);
|
||||
const currentWebhookField = values.WaffoPancakeSandbox
|
||||
? 'WaffoPancakeWebhookTestKey'
|
||||
: 'WaffoPancakeWebhookPublicKey';
|
||||
const currentWebhookLabel = values.WaffoPancakeSandbox
|
||||
? t('Webhook 公钥(测试环境)')
|
||||
: t('Webhook 公钥(生产环境)');
|
||||
|
||||
if (values.WaffoPancakeEnabled && !values.WaffoPancakeMerchantID.trim()) {
|
||||
showError(t('请输入商户 ID'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (values.WaffoPancakeEnabled && !values.WaffoPancakeStoreID.trim()) {
|
||||
showError(t('请输入 Store ID'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (values.WaffoPancakeEnabled && !values.WaffoPancakeProductID.trim()) {
|
||||
showError(t('请输入 Product ID'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
values.WaffoPancakeEnabled &&
|
||||
!String(values[currentWebhookField] || '').trim()
|
||||
) {
|
||||
showError(currentWebhookLabel);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
values.WaffoPancakeEnabled &&
|
||||
Number(values.WaffoPancakeUnitPrice) <= 0
|
||||
) {
|
||||
showError(t('充值价格必须大于 0'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (values.WaffoPancakeEnabled && Number(values.WaffoPancakeMinTopUp) < 1) {
|
||||
showError(t('最低充值美元数量必须大于 0'));
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const options = [
|
||||
{
|
||||
key: 'WaffoPancakeEnabled',
|
||||
value: values.WaffoPancakeEnabled ? 'true' : 'false',
|
||||
},
|
||||
{
|
||||
key: 'WaffoPancakeSandbox',
|
||||
value: values.WaffoPancakeSandbox ? 'true' : 'false',
|
||||
},
|
||||
{
|
||||
key: 'WaffoPancakeMerchantID',
|
||||
value: values.WaffoPancakeMerchantID || '',
|
||||
},
|
||||
{
|
||||
key: 'WaffoPancakeStoreID',
|
||||
value: values.WaffoPancakeStoreID || '',
|
||||
},
|
||||
{
|
||||
key: 'WaffoPancakeProductID',
|
||||
value: values.WaffoPancakeProductID || '',
|
||||
},
|
||||
{
|
||||
key: 'WaffoPancakeReturnURL',
|
||||
value: removeTrailingSlash(values.WaffoPancakeReturnURL || ''),
|
||||
},
|
||||
{
|
||||
key: 'WaffoPancakeCurrency',
|
||||
value: values.WaffoPancakeCurrency || 'USD',
|
||||
},
|
||||
{
|
||||
key: 'WaffoPancakeUnitPrice',
|
||||
value: String(values.WaffoPancakeUnitPrice),
|
||||
},
|
||||
{
|
||||
key: 'WaffoPancakeMinTopUp',
|
||||
value: String(values.WaffoPancakeMinTopUp),
|
||||
},
|
||||
];
|
||||
|
||||
if ((values.WaffoPancakePrivateKey || '').trim()) {
|
||||
options.push({
|
||||
key: 'WaffoPancakePrivateKey',
|
||||
value: values.WaffoPancakePrivateKey,
|
||||
});
|
||||
}
|
||||
|
||||
if ((values.WaffoPancakeWebhookPublicKey || '').trim()) {
|
||||
options.push({
|
||||
key: 'WaffoPancakeWebhookPublicKey',
|
||||
value: values.WaffoPancakeWebhookPublicKey,
|
||||
});
|
||||
}
|
||||
|
||||
if ((values.WaffoPancakeWebhookTestKey || '').trim()) {
|
||||
options.push({
|
||||
key: 'WaffoPancakeWebhookTestKey',
|
||||
value: values.WaffoPancakeWebhookTestKey,
|
||||
});
|
||||
}
|
||||
|
||||
const results = await Promise.all(
|
||||
options.map((opt) =>
|
||||
API.put('/api/option/', {
|
||||
key: opt.key,
|
||||
value: opt.value,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const errorResults = results.filter((res) => !res.data.success);
|
||||
if (errorResults.length > 0) {
|
||||
errorResults.forEach((res) => showError(res.data.message));
|
||||
return;
|
||||
}
|
||||
|
||||
showSuccess(t('更新成功'));
|
||||
props.refresh?.();
|
||||
} catch (error) {
|
||||
showError(t('更新失败'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Spin spinning={loading}>
|
||||
<Form
|
||||
initValues={inputs}
|
||||
onValueChange={handleFormChange}
|
||||
getFormApi={(api) => (formApiRef.current = api)}
|
||||
>
|
||||
<Form.Section text={sectionTitle}>
|
||||
<Banner
|
||||
type='info'
|
||||
icon={<BookOpen size={16} />}
|
||||
description={
|
||||
<>
|
||||
Waffo Pancake 的商户、商品和签名密钥请
|
||||
<a
|
||||
href='https://docs.waffo.ai'
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
>
|
||||
点击此处
|
||||
</a>
|
||||
获取,建议先在测试环境完成联调。
|
||||
<br />
|
||||
{t('回调地址')}:
|
||||
{props.options.ServerAddress
|
||||
? removeTrailingSlash(props.options.ServerAddress)
|
||||
: t('网站地址')}
|
||||
/api/waffo-pancake/webhook
|
||||
</>
|
||||
}
|
||||
style={{ marginBottom: 12 }}
|
||||
/>
|
||||
<Banner
|
||||
type='warning'
|
||||
icon={<TriangleAlert size={16} />}
|
||||
description={t(
|
||||
'请确认 Merchant、Store、Product 和所选环境密钥一致。',
|
||||
)}
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
<Row gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}>
|
||||
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
|
||||
<Form.Switch
|
||||
field='WaffoPancakeEnabled'
|
||||
label={t('启用 Waffo Pancake')}
|
||||
checkedText='|'
|
||||
uncheckedText='〇'
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
|
||||
<Form.Switch
|
||||
field='WaffoPancakeSandbox'
|
||||
label={t('沙盒模式')}
|
||||
checkedText='|'
|
||||
uncheckedText='〇'
|
||||
extraText={t('用于切换当前下单和回调校验所使用的环境')}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
|
||||
<Form.Input
|
||||
field='WaffoPancakeCurrency'
|
||||
label={t('货币')}
|
||||
placeholder='USD'
|
||||
extraText={t('默认使用 USD 结算')}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row
|
||||
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
|
||||
style={{ marginTop: 16 }}
|
||||
>
|
||||
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
|
||||
<Form.Input
|
||||
field='WaffoPancakeMerchantID'
|
||||
label={t('商户 ID')}
|
||||
placeholder={t('例如:MER_xxx')}
|
||||
extraText={t('请填写当前环境对应的商户 ID')}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
|
||||
<Form.Input
|
||||
field='WaffoPancakeStoreID'
|
||||
label={t('Store ID')}
|
||||
placeholder={t('例如:STO_xxx')}
|
||||
extraText={t('请填写当前环境对应的 Store ID')}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
|
||||
<Form.Input
|
||||
field='WaffoPancakeProductID'
|
||||
label={t('Product ID')}
|
||||
placeholder={t('例如:PROD_xxx')}
|
||||
extraText={t('请填写当前环境对应的 Product ID')}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row
|
||||
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
|
||||
style={{ marginTop: 16 }}
|
||||
>
|
||||
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
|
||||
<Form.TextArea
|
||||
field='WaffoPancakePrivateKey'
|
||||
label={t('API 私钥')}
|
||||
placeholder={t('填写后覆盖当前私钥,留空表示保持当前不变')}
|
||||
extraText={t('保存后不会回显,请填写当前环境对应的 API 私钥')}
|
||||
type='password'
|
||||
autosize={{ minRows: 4, maxRows: 8 }}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
|
||||
<Form.Input
|
||||
field='WaffoPancakeReturnURL'
|
||||
label={t('支付返回地址')}
|
||||
placeholder={t('例如:https://example.com/console/topup')}
|
||||
extraText={t('留空则自动使用当前站点的默认充值页地址')}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row
|
||||
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
|
||||
style={{ marginTop: 16 }}
|
||||
>
|
||||
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
|
||||
<Form.TextArea
|
||||
field='WaffoPancakeWebhookPublicKey'
|
||||
label={t('Webhook 公钥(生产环境)')}
|
||||
placeholder={t(
|
||||
'填写后覆盖当前生产环境 Webhook 公钥,留空表示保持当前不变',
|
||||
)}
|
||||
extraText={t('用于校验生产环境的 Waffo Pancake Webhook 签名')}
|
||||
type='password'
|
||||
autosize={{ minRows: 4, maxRows: 8 }}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
|
||||
<Form.TextArea
|
||||
field='WaffoPancakeWebhookTestKey'
|
||||
label={t('Webhook 公钥(测试环境)')}
|
||||
placeholder={t(
|
||||
'填写后覆盖当前测试环境 Webhook 公钥,留空表示保持当前不变',
|
||||
)}
|
||||
extraText={t('用于校验测试环境的 Waffo Pancake Webhook 签名')}
|
||||
type='password'
|
||||
autosize={{ minRows: 4, maxRows: 8 }}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row
|
||||
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
|
||||
style={{ marginTop: 16 }}
|
||||
>
|
||||
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
|
||||
<Form.InputNumber
|
||||
field='WaffoPancakeUnitPrice'
|
||||
precision={2}
|
||||
label={t('充值价格(x元/美金)')}
|
||||
placeholder={t('例如:7,就是7元/美金')}
|
||||
extraText={t('按 1 美元对应的站内价格填写')}
|
||||
min={0}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
|
||||
<Form.InputNumber
|
||||
field='WaffoPancakeMinTopUp'
|
||||
label={t('最低充值美元数量')}
|
||||
placeholder={t('例如:2,就是最低充值2$')}
|
||||
extraText={t('用户单次最少可充值的美元数量')}
|
||||
min={1}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Button onClick={submitWaffoPancakeSetting}>
|
||||
{t('更新 Waffo Pancake 设置')}
|
||||
</Button>
|
||||
</Form.Section>
|
||||
</Form>
|
||||
</Spin>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,736 @@
|
||||
/*
|
||||
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 {
|
||||
Banner,
|
||||
Button,
|
||||
Col,
|
||||
Form,
|
||||
InputNumber,
|
||||
Row,
|
||||
Spin,
|
||||
Progress,
|
||||
Descriptions,
|
||||
Tag,
|
||||
Popconfirm,
|
||||
RadioGroup,
|
||||
Radio,
|
||||
Typography,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
compareObjects,
|
||||
API,
|
||||
showError,
|
||||
showSuccess,
|
||||
showWarning,
|
||||
} from '../../../helpers';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
// 格式化字节大小
|
||||
function formatBytes(bytes, decimals = 2) {
|
||||
if (bytes === null || bytes === undefined || isNaN(bytes)) return '0 Bytes';
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
if (bytes < 0) return '-' + formatBytes(-bytes, decimals);
|
||||
const k = 1024;
|
||||
const dm = decimals < 0 ? 0 : decimals;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
if (i < 0 || i >= sizes.length) return bytes + ' Bytes';
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
export default function SettingsPerformance(props) {
|
||||
const { t } = useTranslation();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [statsLoading, setStatsLoading] = useState(false);
|
||||
const [stats, setStats] = useState(null);
|
||||
const [inputs, setInputs] = useState({
|
||||
'performance_setting.disk_cache_enabled': false,
|
||||
'performance_setting.disk_cache_threshold_mb': 10,
|
||||
'performance_setting.disk_cache_max_size_mb': 1024,
|
||||
'performance_setting.disk_cache_path': '',
|
||||
'performance_setting.monitor_enabled': false,
|
||||
'performance_setting.monitor_cpu_threshold': 90,
|
||||
'performance_setting.monitor_memory_threshold': 90,
|
||||
'performance_setting.monitor_disk_threshold': 95,
|
||||
});
|
||||
const refForm = useRef();
|
||||
const [inputsRow, setInputsRow] = useState(inputs);
|
||||
const [logInfo, setLogInfo] = useState(null);
|
||||
const [logCleanupMode, setLogCleanupMode] = useState('by_count');
|
||||
const [logCleanupValue, setLogCleanupValue] = useState(10);
|
||||
const [logCleanupLoading, setLogCleanupLoading] = useState(false);
|
||||
|
||||
function handleFieldChange(fieldName) {
|
||||
return (value) => {
|
||||
setInputs((inputs) => ({ ...inputs, [fieldName]: value }));
|
||||
};
|
||||
}
|
||||
|
||||
function onSubmit() {
|
||||
const updateArray = compareObjects(inputs, inputsRow);
|
||||
if (!updateArray.length) return showWarning(t('你似乎并没有修改什么'));
|
||||
const requestQueue = updateArray.map((item) => {
|
||||
let value = '';
|
||||
if (typeof inputs[item.key] === 'boolean') {
|
||||
value = String(inputs[item.key]);
|
||||
} else {
|
||||
value = String(inputs[item.key]);
|
||||
}
|
||||
return API.put('/api/option/', {
|
||||
key: item.key,
|
||||
value,
|
||||
});
|
||||
});
|
||||
setLoading(true);
|
||||
Promise.all(requestQueue)
|
||||
.then((res) => {
|
||||
if (requestQueue.length === 1) {
|
||||
if (res.includes(undefined)) return;
|
||||
} else if (requestQueue.length > 1) {
|
||||
if (res.includes(undefined))
|
||||
return showError(t('部分保存失败,请重试'));
|
||||
}
|
||||
showSuccess(t('保存成功'));
|
||||
props.refresh();
|
||||
fetchStats();
|
||||
})
|
||||
.catch(() => {
|
||||
showError(t('保存失败,请重试'));
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchStats() {
|
||||
setStatsLoading(true);
|
||||
try {
|
||||
const res = await API.get('/api/performance/stats');
|
||||
if (res.data.success) {
|
||||
setStats(res.data.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch performance stats:', error);
|
||||
} finally {
|
||||
setStatsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function clearDiskCache() {
|
||||
try {
|
||||
const res = await API.delete('/api/performance/disk_cache');
|
||||
if (res.data.success) {
|
||||
showSuccess(t('磁盘缓存已清理'));
|
||||
fetchStats();
|
||||
} else {
|
||||
showError(res.data.message || t('清理失败'));
|
||||
}
|
||||
} catch (error) {
|
||||
showError(t('清理失败'));
|
||||
}
|
||||
}
|
||||
|
||||
async function resetStats() {
|
||||
try {
|
||||
const res = await API.post('/api/performance/reset_stats');
|
||||
if (res.data.success) {
|
||||
showSuccess(t('统计已重置'));
|
||||
fetchStats();
|
||||
}
|
||||
} catch (error) {
|
||||
showError(t('重置失败'));
|
||||
}
|
||||
}
|
||||
|
||||
async function forceGC() {
|
||||
try {
|
||||
const res = await API.post('/api/performance/gc');
|
||||
if (res.data.success) {
|
||||
showSuccess(t('GC 已执行'));
|
||||
fetchStats();
|
||||
}
|
||||
} catch (error) {
|
||||
showError(t('GC 执行失败'));
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchLogInfo() {
|
||||
try {
|
||||
const res = await API.get('/api/performance/logs');
|
||||
if (res.data.success) {
|
||||
setLogInfo(res.data.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch log info:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function cleanupLogFiles() {
|
||||
if (logCleanupValue == null || isNaN(logCleanupValue) || logCleanupValue < 1) {
|
||||
showError(t('请输入有效的数值'));
|
||||
return;
|
||||
}
|
||||
setLogCleanupLoading(true);
|
||||
try {
|
||||
const res = await API.delete(
|
||||
`/api/performance/logs?mode=${logCleanupMode}&value=${logCleanupValue}`,
|
||||
);
|
||||
if (res.data.success) {
|
||||
const { deleted_count, freed_bytes } = res.data.data;
|
||||
showSuccess(
|
||||
t('已清理 {{count}} 个日志文件,释放 {{size}}', {
|
||||
count: deleted_count,
|
||||
size: formatBytes(freed_bytes),
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
showError(res.data.message || t('清理失败'));
|
||||
}
|
||||
fetchLogInfo();
|
||||
} catch (error) {
|
||||
showError(t('清理失败'));
|
||||
} finally {
|
||||
setLogCleanupLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const currentInputs = {};
|
||||
for (let key in props.options) {
|
||||
if (Object.keys(inputs).includes(key)) {
|
||||
if (typeof inputs[key] === 'boolean') {
|
||||
currentInputs[key] =
|
||||
props.options[key] === 'true' || props.options[key] === true;
|
||||
} else if (typeof inputs[key] === 'number') {
|
||||
currentInputs[key] = parseInt(props.options[key]) || inputs[key];
|
||||
} else {
|
||||
currentInputs[key] = props.options[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
setInputs({ ...inputs, ...currentInputs });
|
||||
setInputsRow({ ...inputs, ...currentInputs });
|
||||
if (refForm.current) {
|
||||
refForm.current.setValues({ ...inputs, ...currentInputs });
|
||||
}
|
||||
fetchStats();
|
||||
fetchLogInfo();
|
||||
}, [props.options]);
|
||||
|
||||
const diskCacheUsagePercent =
|
||||
stats?.cache_stats?.disk_cache_max_bytes > 0
|
||||
? (
|
||||
(stats.cache_stats.current_disk_usage_bytes /
|
||||
stats.cache_stats.disk_cache_max_bytes) *
|
||||
100
|
||||
).toFixed(1)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Spin spinning={loading}>
|
||||
<Form
|
||||
values={inputs}
|
||||
getFormApi={(formAPI) => (refForm.current = formAPI)}
|
||||
style={{ marginBottom: 15 }}
|
||||
>
|
||||
<Form.Section text={t('磁盘缓存设置(磁盘换内存)')}>
|
||||
<Banner
|
||||
type='info'
|
||||
description={t(
|
||||
'启用磁盘缓存后,大请求体将临时存储到磁盘而非内存,可显著降低内存占用,适用于处理包含大量图片/文件的请求。建议在 SSD 环境下使用。',
|
||||
)}
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
|
||||
<Form.Switch
|
||||
field={'performance_setting.disk_cache_enabled'}
|
||||
label={t('启用磁盘缓存')}
|
||||
extraText={t('将大请求体临时存储到磁盘')}
|
||||
size='default'
|
||||
checkedText='|'
|
||||
uncheckedText='〇'
|
||||
onChange={handleFieldChange(
|
||||
'performance_setting.disk_cache_enabled',
|
||||
)}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
|
||||
<Form.InputNumber
|
||||
field={'performance_setting.disk_cache_threshold_mb'}
|
||||
label={t('磁盘缓存阈值 (MB)')}
|
||||
extraText={t('请求体超过此大小时使用磁盘缓存')}
|
||||
min={1}
|
||||
max={1024}
|
||||
onChange={handleFieldChange(
|
||||
'performance_setting.disk_cache_threshold_mb',
|
||||
)}
|
||||
disabled={!inputs['performance_setting.disk_cache_enabled']}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
|
||||
<Form.InputNumber
|
||||
field={'performance_setting.disk_cache_max_size_mb'}
|
||||
label={t('磁盘缓存最大总量 (MB)')}
|
||||
extraText={
|
||||
stats?.disk_space_info?.total > 0
|
||||
? t('可用空间: {{free}} / 总空间: {{total}}', {
|
||||
free: formatBytes(stats.disk_space_info.free),
|
||||
total: formatBytes(stats.disk_space_info.total),
|
||||
})
|
||||
: t('磁盘缓存占用的最大空间')
|
||||
}
|
||||
min={100}
|
||||
max={102400}
|
||||
onChange={handleFieldChange(
|
||||
'performance_setting.disk_cache_max_size_mb',
|
||||
)}
|
||||
disabled={!inputs['performance_setting.disk_cache_enabled']}
|
||||
/>
|
||||
</Col>
|
||||
{/* 只在非容器环境显示缓存目录配置 */}
|
||||
{!stats?.config?.is_running_in_container && (
|
||||
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
|
||||
<Form.Input
|
||||
field={'performance_setting.disk_cache_path'}
|
||||
label={t('缓存目录')}
|
||||
extraText={t('留空使用系统临时目录')}
|
||||
placeholder={t('例如 /var/cache/new-api')}
|
||||
onChange={handleFieldChange(
|
||||
'performance_setting.disk_cache_path',
|
||||
)}
|
||||
showClear
|
||||
disabled={!inputs['performance_setting.disk_cache_enabled']}
|
||||
/>
|
||||
</Col>
|
||||
)}
|
||||
</Row>
|
||||
</Form.Section>
|
||||
|
||||
<Form.Section text={t('系统性能监控')}>
|
||||
<Banner
|
||||
type='info'
|
||||
description={t(
|
||||
'启用性能监控后,当系统资源使用率超过设定阈值时,将拒绝新的 Relay 请求 (/v1, /v1beta 等),以保护系统稳定性。',
|
||||
)}
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} sm={12} md={6} lg={6} xl={6}>
|
||||
<Form.Switch
|
||||
field={'performance_setting.monitor_enabled'}
|
||||
label={t('启用性能监控')}
|
||||
extraText={t('超过阈值时拒绝新请求')}
|
||||
size='default'
|
||||
checkedText='|'
|
||||
uncheckedText='〇'
|
||||
onChange={handleFieldChange(
|
||||
'performance_setting.monitor_enabled',
|
||||
)}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={6} lg={6} xl={6}>
|
||||
<Form.InputNumber
|
||||
field={'performance_setting.monitor_cpu_threshold'}
|
||||
label={t('CPU 阈值 (%)')}
|
||||
extraText={t('CPU 使用率超过此值时拒绝请求')}
|
||||
min={0}
|
||||
onChange={handleFieldChange(
|
||||
'performance_setting.monitor_cpu_threshold',
|
||||
)}
|
||||
disabled={!inputs['performance_setting.monitor_enabled']}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={6} lg={6} xl={6}>
|
||||
<Form.InputNumber
|
||||
field={'performance_setting.monitor_memory_threshold'}
|
||||
label={t('内存 阈值 (%)')}
|
||||
extraText={t('内存使用率超过此值时拒绝请求')}
|
||||
min={0}
|
||||
max={100}
|
||||
onChange={handleFieldChange(
|
||||
'performance_setting.monitor_memory_threshold',
|
||||
)}
|
||||
disabled={!inputs['performance_setting.monitor_enabled']}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={6} lg={6} xl={6}>
|
||||
<Form.InputNumber
|
||||
field={'performance_setting.monitor_disk_threshold'}
|
||||
label={t('磁盘 阈值 (%)')}
|
||||
extraText={t('磁盘使用率超过此值时拒绝请求')}
|
||||
min={0}
|
||||
max={100}
|
||||
onChange={handleFieldChange(
|
||||
'performance_setting.monitor_disk_threshold',
|
||||
)}
|
||||
disabled={!inputs['performance_setting.monitor_enabled']}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row>
|
||||
<Button size='default' onClick={onSubmit}>
|
||||
{t('保存性能设置')}
|
||||
</Button>
|
||||
</Row>
|
||||
</Form.Section>
|
||||
</Form>
|
||||
</Spin>
|
||||
|
||||
{/* 服务器日志管理 */}
|
||||
<Form.Section text={t('服务器日志管理')}>
|
||||
<Banner
|
||||
type='info'
|
||||
description={t(
|
||||
'管理服务器运行日志文件。日志文件会随运行时间不断累积,建议定期清理以释放磁盘空间。',
|
||||
)}
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
{logInfo === null ? null : logInfo.enabled ? (
|
||||
<>
|
||||
<Descriptions
|
||||
data={[
|
||||
{ key: t('日志目录'), value: logInfo.log_dir },
|
||||
{
|
||||
key: t('日志文件数'),
|
||||
value: logInfo.file_count,
|
||||
},
|
||||
{
|
||||
key: t('日志总大小'),
|
||||
value: formatBytes(logInfo.total_size),
|
||||
},
|
||||
...(logInfo.oldest_time && logInfo.newest_time
|
||||
? [
|
||||
{
|
||||
key: t('日志时间范围'),
|
||||
value: `${new Date(logInfo.oldest_time).toLocaleDateString()} ~ ${new Date(logInfo.newest_time).toLocaleDateString()}`,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]}
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
<Row gutter={16} style={{ marginBottom: 16 }}>
|
||||
<Col xs={24} sm={12} md={8}>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<Text strong style={{ display: 'block', marginBottom: 8 }}>
|
||||
{t('清理方式')}
|
||||
</Text>
|
||||
<RadioGroup
|
||||
value={logCleanupMode}
|
||||
onChange={(e) => setLogCleanupMode(e.target.value)}
|
||||
>
|
||||
<Radio value='by_count'>{t('保留最近N个文件')}</Radio>
|
||||
<Radio value='by_days'>{t('保留最近N天')}</Radio>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={8}>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<Text strong style={{ display: 'block', marginBottom: 8 }}>
|
||||
{logCleanupMode === 'by_count'
|
||||
? t('保留文件数')
|
||||
: t('保留天数')}
|
||||
</Text>
|
||||
<InputNumber
|
||||
value={logCleanupValue}
|
||||
min={1}
|
||||
max={logCleanupMode === 'by_count' ? 1000 : 3650}
|
||||
onChange={(value) => setLogCleanupValue(value)}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={8}>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<Text
|
||||
strong
|
||||
style={{
|
||||
display: 'block',
|
||||
marginBottom: 8,
|
||||
visibility: 'hidden',
|
||||
}}
|
||||
>
|
||||
|
||||
</Text>
|
||||
<Popconfirm
|
||||
title={t('确认清理日志文件?')}
|
||||
content={
|
||||
logCleanupMode === 'by_count'
|
||||
? t(
|
||||
'将只保留最近 {{value}} 个日志文件,其余将被删除。',
|
||||
{ value: logCleanupValue },
|
||||
)
|
||||
: t('将删除 {{value}} 天前的日志文件。', {
|
||||
value: logCleanupValue,
|
||||
})
|
||||
}
|
||||
onConfirm={cleanupLogFiles}
|
||||
>
|
||||
<Button type='danger' loading={logCleanupLoading}>
|
||||
{t('清理日志文件')}
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
</>
|
||||
) : (
|
||||
<Banner
|
||||
type='warning'
|
||||
description={t('服务器日志功能未启用(未配置日志目录)')}
|
||||
/>
|
||||
)}
|
||||
</Form.Section>
|
||||
|
||||
{/* 性能统计 */}
|
||||
<Spin spinning={statsLoading}>
|
||||
<Form.Section text={t('性能监控')}>
|
||||
<Row gutter={16} style={{ marginBottom: 16 }}>
|
||||
<Col span={24}>
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||
<Button onClick={fetchStats}>{t('刷新统计')}</Button>
|
||||
<Popconfirm
|
||||
title={t('确认清理不活跃的磁盘缓存?')}
|
||||
content={t('这将删除超过 10 分钟未使用的临时缓存文件')}
|
||||
onConfirm={clearDiskCache}
|
||||
>
|
||||
<Button type='warning'>{t('清理不活跃缓存')}</Button>
|
||||
</Popconfirm>
|
||||
<Button onClick={resetStats}>{t('重置统计')}</Button>
|
||||
<Button onClick={forceGC}>{t('执行 GC')}</Button>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{stats && (
|
||||
<>
|
||||
{/* 缓存使用情况 */}
|
||||
<Row
|
||||
gutter={16}
|
||||
style={{
|
||||
marginBottom: 16,
|
||||
display: 'flex',
|
||||
alignItems: 'stretch',
|
||||
}}
|
||||
>
|
||||
<Col xs={24} md={12} style={{ display: 'flex' }}>
|
||||
<div
|
||||
style={{
|
||||
padding: 16,
|
||||
background: 'var(--semi-color-fill-0)',
|
||||
borderRadius: 8,
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
<Text strong style={{ marginBottom: 8, display: 'block' }}>
|
||||
{t('请求体磁盘缓存')}
|
||||
</Text>
|
||||
<Progress
|
||||
percent={parseFloat(diskCacheUsagePercent)}
|
||||
showInfo
|
||||
style={{ marginBottom: 8 }}
|
||||
stroke={
|
||||
parseFloat(diskCacheUsagePercent) > 80
|
||||
? 'var(--semi-color-danger)'
|
||||
: 'var(--semi-color-primary)'
|
||||
}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
<Text type='tertiary'>
|
||||
{formatBytes(
|
||||
stats.cache_stats.current_disk_usage_bytes,
|
||||
)}{' '}
|
||||
/ {formatBytes(stats.cache_stats.disk_cache_max_bytes)}
|
||||
</Text>
|
||||
<Text type='tertiary'>
|
||||
{t('活跃文件')}: {stats.cache_stats.active_disk_files}
|
||||
</Text>
|
||||
</div>
|
||||
<div style={{ marginTop: 'auto' }}>
|
||||
<Tag color='blue'>
|
||||
{t('磁盘命中')}: {stats.cache_stats.disk_cache_hits}
|
||||
</Tag>
|
||||
</div>
|
||||
</div>
|
||||
</Col>
|
||||
<Col xs={24} md={12} style={{ display: 'flex' }}>
|
||||
<div
|
||||
style={{
|
||||
padding: 16,
|
||||
background: 'var(--semi-color-fill-0)',
|
||||
borderRadius: 8,
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
<Text strong style={{ marginBottom: 8, display: 'block' }}>
|
||||
{t('请求体内存缓存')}
|
||||
</Text>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
<Text>
|
||||
{t('当前缓存大小')}:{' '}
|
||||
{formatBytes(
|
||||
stats.cache_stats.current_memory_usage_bytes,
|
||||
)}
|
||||
</Text>
|
||||
<Text>
|
||||
{t('活跃缓存数')}:{' '}
|
||||
{stats.cache_stats.active_memory_buffers}
|
||||
</Text>
|
||||
</div>
|
||||
<div style={{ marginTop: 'auto' }}>
|
||||
<Tag color='green'>
|
||||
{t('内存命中')}: {stats.cache_stats.memory_cache_hits}
|
||||
</Tag>
|
||||
</div>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* 缓存目录磁盘空间 */}
|
||||
{stats.disk_space_info?.total > 0 && (
|
||||
<Row gutter={16} style={{ marginBottom: 16 }}>
|
||||
<Col span={24}>
|
||||
<div
|
||||
style={{
|
||||
padding: 16,
|
||||
background: 'var(--semi-color-fill-0)',
|
||||
borderRadius: 8,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
strong
|
||||
style={{ marginBottom: 8, display: 'block' }}
|
||||
>
|
||||
{t('缓存目录磁盘空间')}
|
||||
</Text>
|
||||
<Progress
|
||||
percent={parseFloat(
|
||||
stats.disk_space_info.used_percent.toFixed(1),
|
||||
)}
|
||||
showInfo
|
||||
style={{ marginBottom: 8 }}
|
||||
stroke={
|
||||
stats.disk_space_info.used_percent > 90
|
||||
? 'var(--semi-color-danger)'
|
||||
: stats.disk_space_info.used_percent > 70
|
||||
? 'var(--semi-color-warning)'
|
||||
: 'var(--semi-color-primary)'
|
||||
}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
flexWrap: 'wrap',
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<Text type='tertiary'>
|
||||
{t('已用')}: {formatBytes(stats.disk_space_info.used)}
|
||||
</Text>
|
||||
<Text type='tertiary'>
|
||||
{t('可用')}: {formatBytes(stats.disk_space_info.free)}
|
||||
</Text>
|
||||
<Text type='tertiary'>
|
||||
{t('总计')}:{' '}
|
||||
{formatBytes(stats.disk_space_info.total)}
|
||||
</Text>
|
||||
</div>
|
||||
{stats.disk_space_info.free <
|
||||
inputs['performance_setting.disk_cache_max_size_mb'] *
|
||||
1024 *
|
||||
1024 && (
|
||||
<Banner
|
||||
type='warning'
|
||||
description={t('磁盘可用空间小于缓存最大总量设置')}
|
||||
style={{ marginTop: 8 }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
)}
|
||||
|
||||
{/* 系统内存统计 */}
|
||||
<Row gutter={16}>
|
||||
<Col span={24}>
|
||||
<Descriptions
|
||||
data={[
|
||||
{
|
||||
key: t('已分配内存'),
|
||||
value: formatBytes(stats.memory_stats.alloc),
|
||||
},
|
||||
{
|
||||
key: t('总分配内存'),
|
||||
value: formatBytes(stats.memory_stats.total_alloc),
|
||||
},
|
||||
{
|
||||
key: t('系统内存'),
|
||||
value: formatBytes(stats.memory_stats.sys),
|
||||
},
|
||||
{ key: t('GC 次数'), value: stats.memory_stats.num_gc },
|
||||
{
|
||||
key: t('Goroutine 数'),
|
||||
value: stats.memory_stats.num_goroutine,
|
||||
},
|
||||
{
|
||||
key: t('缓存目录'),
|
||||
value: stats.disk_cache_info.path,
|
||||
},
|
||||
{
|
||||
key: t('目录文件数'),
|
||||
value: stats.disk_cache_info.file_count,
|
||||
},
|
||||
{
|
||||
key: t('目录总大小'),
|
||||
value: formatBytes(stats.disk_cache_info.total_size),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</>
|
||||
)}
|
||||
</Form.Section>
|
||||
</Spin>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,482 @@
|
||||
/*
|
||||
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 { useState, useEffect, useContext } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Card,
|
||||
Button,
|
||||
Switch,
|
||||
Typography,
|
||||
Row,
|
||||
Col,
|
||||
Avatar,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { API, showSuccess, showError } from '../../../helpers';
|
||||
import { StatusContext } from '../../../context/Status';
|
||||
import { UserContext } from '../../../context/User';
|
||||
import { useUserPermissions } from '../../../hooks/common/useUserPermissions';
|
||||
import { mergeAdminConfig, useSidebar } from '../../../hooks/common/useSidebar';
|
||||
import { Settings } from 'lucide-react';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
export default function SettingsSidebarModulesUser() {
|
||||
const { t } = useTranslation();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [statusState] = useContext(StatusContext);
|
||||
|
||||
// 使用后端权限验证替代前端角色判断
|
||||
const {
|
||||
permissions,
|
||||
loading: permissionsLoading,
|
||||
hasSidebarSettingsPermission,
|
||||
isSidebarSectionAllowed,
|
||||
isSidebarModuleAllowed,
|
||||
} = useUserPermissions();
|
||||
|
||||
// 使用useSidebar钩子获取刷新方法
|
||||
const { refreshUserConfig } = useSidebar();
|
||||
|
||||
// 如果没有边栏设置权限,不显示此组件
|
||||
if (!permissionsLoading && !hasSidebarSettingsPermission()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 权限加载中,显示加载状态
|
||||
if (permissionsLoading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 根据用户权限生成默认配置
|
||||
const generateDefaultConfig = () => {
|
||||
const defaultConfig = {};
|
||||
|
||||
// 聊天区域 - 所有用户都可以访问
|
||||
if (isSidebarSectionAllowed('chat')) {
|
||||
defaultConfig.chat = {
|
||||
enabled: true,
|
||||
playground: isSidebarModuleAllowed('chat', 'playground'),
|
||||
chat: isSidebarModuleAllowed('chat', 'chat'),
|
||||
};
|
||||
}
|
||||
|
||||
// 控制台区域 - 所有用户都可以访问
|
||||
if (isSidebarSectionAllowed('console')) {
|
||||
defaultConfig.console = {
|
||||
enabled: true,
|
||||
detail: isSidebarModuleAllowed('console', 'detail'),
|
||||
token: isSidebarModuleAllowed('console', 'token'),
|
||||
log: isSidebarModuleAllowed('console', 'log'),
|
||||
midjourney: isSidebarModuleAllowed('console', 'midjourney'),
|
||||
task: isSidebarModuleAllowed('console', 'task'),
|
||||
};
|
||||
}
|
||||
|
||||
// 个人中心区域 - 所有用户都可以访问
|
||||
if (isSidebarSectionAllowed('personal')) {
|
||||
defaultConfig.personal = {
|
||||
enabled: true,
|
||||
topup: isSidebarModuleAllowed('personal', 'topup'),
|
||||
personal: isSidebarModuleAllowed('personal', 'personal'),
|
||||
};
|
||||
}
|
||||
|
||||
// 管理员区域 - 只有管理员可以访问
|
||||
if (isSidebarSectionAllowed('admin')) {
|
||||
defaultConfig.admin = {
|
||||
enabled: true,
|
||||
channel: isSidebarModuleAllowed('admin', 'channel'),
|
||||
models: isSidebarModuleAllowed('admin', 'models'),
|
||||
deployment: isSidebarModuleAllowed('admin', 'deployment'),
|
||||
redemption: isSidebarModuleAllowed('admin', 'redemption'),
|
||||
user: isSidebarModuleAllowed('admin', 'user'),
|
||||
setting: isSidebarModuleAllowed('admin', 'setting'),
|
||||
};
|
||||
}
|
||||
|
||||
return defaultConfig;
|
||||
};
|
||||
|
||||
// 用户个人左侧边栏模块设置
|
||||
const [sidebarModulesUser, setSidebarModulesUser] = useState({});
|
||||
|
||||
// 管理员全局配置
|
||||
const [adminConfig, setAdminConfig] = useState(null);
|
||||
|
||||
// 处理区域级别开关变更
|
||||
function handleSectionChange(sectionKey) {
|
||||
return (checked) => {
|
||||
const newModules = {
|
||||
...sidebarModulesUser,
|
||||
[sectionKey]: {
|
||||
...sidebarModulesUser[sectionKey],
|
||||
enabled: checked,
|
||||
},
|
||||
};
|
||||
setSidebarModulesUser(newModules);
|
||||
console.log('用户边栏区域配置变更:', sectionKey, checked, newModules);
|
||||
};
|
||||
}
|
||||
|
||||
// 处理功能级别开关变更
|
||||
function handleModuleChange(sectionKey, moduleKey) {
|
||||
return (checked) => {
|
||||
const newModules = {
|
||||
...sidebarModulesUser,
|
||||
[sectionKey]: {
|
||||
...sidebarModulesUser[sectionKey],
|
||||
[moduleKey]: checked,
|
||||
},
|
||||
};
|
||||
setSidebarModulesUser(newModules);
|
||||
console.log(
|
||||
'用户边栏功能配置变更:',
|
||||
sectionKey,
|
||||
moduleKey,
|
||||
checked,
|
||||
newModules,
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
// 重置为默认配置(基于权限过滤)
|
||||
function resetSidebarModules() {
|
||||
const defaultConfig = generateDefaultConfig();
|
||||
setSidebarModulesUser(defaultConfig);
|
||||
showSuccess(t('已重置为默认配置'));
|
||||
console.log('用户边栏配置重置为默认:', defaultConfig);
|
||||
}
|
||||
|
||||
// 保存配置
|
||||
async function onSubmit() {
|
||||
setLoading(true);
|
||||
try {
|
||||
console.log('保存用户边栏配置:', sidebarModulesUser);
|
||||
const res = await API.put('/api/user/self', {
|
||||
sidebar_modules: JSON.stringify(sidebarModulesUser),
|
||||
});
|
||||
const { success, message } = res.data;
|
||||
if (success) {
|
||||
showSuccess(t('保存成功'));
|
||||
console.log('用户边栏配置保存成功');
|
||||
|
||||
// 刷新useSidebar钩子中的用户配置,实现实时更新
|
||||
await refreshUserConfig();
|
||||
console.log('用户边栏配置已刷新,边栏将立即更新');
|
||||
} else {
|
||||
showError(message);
|
||||
console.error('用户边栏配置保存失败:', message);
|
||||
}
|
||||
} catch (error) {
|
||||
showError(t('保存失败,请重试'));
|
||||
console.error('用户边栏配置保存异常:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
// 统一的配置加载逻辑
|
||||
useEffect(() => {
|
||||
const loadConfigs = async () => {
|
||||
try {
|
||||
// 获取管理员全局配置
|
||||
if (statusState?.status?.SidebarModulesAdmin) {
|
||||
try {
|
||||
const adminConf = JSON.parse(
|
||||
statusState.status.SidebarModulesAdmin,
|
||||
);
|
||||
const mergedAdminConf = mergeAdminConfig(adminConf);
|
||||
setAdminConfig(mergedAdminConf);
|
||||
console.log('加载管理员边栏配置:', mergedAdminConf);
|
||||
} catch (error) {
|
||||
const mergedAdminConf = mergeAdminConfig(null);
|
||||
setAdminConfig(mergedAdminConf);
|
||||
console.log(
|
||||
'加载管理员边栏配置失败,使用默认配置:',
|
||||
mergedAdminConf,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const mergedAdminConf = mergeAdminConfig(null);
|
||||
setAdminConfig(mergedAdminConf);
|
||||
console.log('管理员边栏配置缺失,使用默认配置:', mergedAdminConf);
|
||||
}
|
||||
|
||||
// 获取用户个人配置
|
||||
const userRes = await API.get('/api/user/self');
|
||||
if (userRes.data.success && userRes.data.data.sidebar_modules) {
|
||||
let userConf;
|
||||
// 检查sidebar_modules是字符串还是对象
|
||||
if (typeof userRes.data.data.sidebar_modules === 'string') {
|
||||
userConf = JSON.parse(userRes.data.data.sidebar_modules);
|
||||
} else {
|
||||
userConf = userRes.data.data.sidebar_modules;
|
||||
}
|
||||
console.log('从API加载的用户配置:', userConf);
|
||||
|
||||
// 确保用户配置也经过权限过滤
|
||||
const filteredUserConf = {};
|
||||
Object.keys(userConf).forEach((sectionKey) => {
|
||||
if (isSidebarSectionAllowed(sectionKey)) {
|
||||
filteredUserConf[sectionKey] = { ...userConf[sectionKey] };
|
||||
// 过滤不允许的模块
|
||||
Object.keys(userConf[sectionKey]).forEach((moduleKey) => {
|
||||
if (
|
||||
moduleKey !== 'enabled' &&
|
||||
!isSidebarModuleAllowed(sectionKey, moduleKey)
|
||||
) {
|
||||
delete filteredUserConf[sectionKey][moduleKey];
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
setSidebarModulesUser(filteredUserConf);
|
||||
console.log('权限过滤后的用户配置:', filteredUserConf);
|
||||
} else {
|
||||
// 如果用户没有配置,使用权限过滤后的默认配置
|
||||
const defaultConfig = generateDefaultConfig();
|
||||
setSidebarModulesUser(defaultConfig);
|
||||
console.log('用户无配置,使用默认配置:', defaultConfig);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载边栏配置失败:', error);
|
||||
// 出错时也使用默认配置
|
||||
const defaultConfig = generateDefaultConfig();
|
||||
setSidebarModulesUser(defaultConfig);
|
||||
}
|
||||
};
|
||||
|
||||
// 只有权限加载完成且有边栏设置权限时才加载配置
|
||||
if (!permissionsLoading && hasSidebarSettingsPermission()) {
|
||||
loadConfigs();
|
||||
}
|
||||
}, [
|
||||
statusState,
|
||||
permissionsLoading,
|
||||
hasSidebarSettingsPermission,
|
||||
isSidebarSectionAllowed,
|
||||
isSidebarModuleAllowed,
|
||||
]);
|
||||
|
||||
// 检查功能是否被管理员允许
|
||||
const isAllowedByAdmin = (sectionKey, moduleKey = null) => {
|
||||
if (!adminConfig) return true;
|
||||
|
||||
if (moduleKey) {
|
||||
return (
|
||||
adminConfig[sectionKey]?.enabled && adminConfig[sectionKey]?.[moduleKey]
|
||||
);
|
||||
} else {
|
||||
return adminConfig[sectionKey]?.enabled;
|
||||
}
|
||||
};
|
||||
|
||||
// 区域配置数据(根据后端权限过滤)
|
||||
const sectionConfigs = [
|
||||
{
|
||||
key: 'chat',
|
||||
title: t('聊天区域'),
|
||||
description: t('操练场和聊天功能'),
|
||||
modules: [
|
||||
{
|
||||
key: 'playground',
|
||||
title: t('操练场'),
|
||||
description: t('AI模型测试环境'),
|
||||
},
|
||||
{ key: 'chat', title: t('聊天'), description: t('聊天会话管理') },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'console',
|
||||
title: t('控制台区域'),
|
||||
description: t('数据管理和日志查看'),
|
||||
modules: [
|
||||
{ key: 'detail', title: t('数据看板'), description: t('系统数据统计') },
|
||||
{ key: 'token', title: t('令牌管理'), description: t('API令牌管理') },
|
||||
{ key: 'log', title: t('使用日志'), description: t('API使用记录') },
|
||||
{
|
||||
key: 'midjourney',
|
||||
title: t('绘图日志'),
|
||||
description: t('绘图任务记录'),
|
||||
},
|
||||
{ key: 'task', title: t('任务日志'), description: t('系统任务记录') },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'personal',
|
||||
title: t('个人中心区域'),
|
||||
description: t('用户个人功能'),
|
||||
modules: [
|
||||
{ key: 'topup', title: t('钱包管理'), description: t('余额充值管理') },
|
||||
{
|
||||
key: 'personal',
|
||||
title: t('个人设置'),
|
||||
description: t('个人信息设置'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'admin',
|
||||
title: t('管理员区域'),
|
||||
description: t('系统管理功能'),
|
||||
modules: [
|
||||
{ key: 'channel', title: t('渠道管理'), description: t('API渠道配置') },
|
||||
{ key: 'models', title: t('模型管理'), description: t('AI模型配置') },
|
||||
{
|
||||
key: 'deployment',
|
||||
title: t('模型部署'),
|
||||
description: t('模型部署管理'),
|
||||
},
|
||||
{
|
||||
key: 'redemption',
|
||||
title: t('兑换码管理'),
|
||||
description: t('兑换码生成管理'),
|
||||
},
|
||||
{ key: 'user', title: t('用户管理'), description: t('用户账户管理') },
|
||||
{
|
||||
key: 'setting',
|
||||
title: t('系统设置'),
|
||||
description: t('系统参数配置'),
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
.filter((section) => {
|
||||
// 使用后端权限验证替代前端角色判断
|
||||
return isSidebarSectionAllowed(section.key);
|
||||
})
|
||||
.map((section) => ({
|
||||
...section,
|
||||
modules: section.modules.filter((module) =>
|
||||
isSidebarModuleAllowed(section.key, module.key),
|
||||
),
|
||||
}))
|
||||
.filter(
|
||||
(section) =>
|
||||
// 过滤掉没有可用模块的区域
|
||||
section.modules.length > 0 && isAllowedByAdmin(section.key),
|
||||
);
|
||||
|
||||
return (
|
||||
<Card className='!rounded-2xl shadow-sm border-0'>
|
||||
{/* 卡片头部 */}
|
||||
<div className='flex items-center mb-4'>
|
||||
<Avatar size='small' color='purple' className='mr-3 shadow-md'>
|
||||
<Settings size={16} />
|
||||
</Avatar>
|
||||
<div>
|
||||
<Typography.Text className='text-lg font-medium'>
|
||||
{t('左侧边栏个人设置')}
|
||||
</Typography.Text>
|
||||
<div className='text-xs text-gray-600'>
|
||||
{t('个性化设置左侧边栏的显示内容')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='mb-4'>
|
||||
<Text type='secondary' className='text-sm text-gray-600'>
|
||||
{t('您可以个性化设置侧边栏的要显示功能')}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{sectionConfigs.map((section) => (
|
||||
<div key={section.key} className='mb-6'>
|
||||
{/* 区域标题和总开关 */}
|
||||
<div className='flex justify-between items-center mb-4 p-4 bg-gray-50 rounded-xl border border-gray-200'>
|
||||
<div>
|
||||
<div className='font-semibold text-base text-gray-900 mb-1'>
|
||||
{section.title}
|
||||
</div>
|
||||
<Text className='text-xs text-gray-600'>
|
||||
{section.description}
|
||||
</Text>
|
||||
</div>
|
||||
<Switch
|
||||
checked={sidebarModulesUser[section.key]?.enabled !== false}
|
||||
onChange={handleSectionChange(section.key)}
|
||||
size='default'
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 功能模块网格 */}
|
||||
<Row gutter={[12, 12]}>
|
||||
{section.modules.map((module) => (
|
||||
<Col key={module.key} xs={24} sm={12} md={8} lg={6} xl={6}>
|
||||
<Card
|
||||
className={`!rounded-xl border border-gray-200 hover:border-blue-300 transition-all duration-200 ${
|
||||
sidebarModulesUser[section.key]?.enabled !== false
|
||||
? ''
|
||||
: 'opacity-50'
|
||||
}`}
|
||||
bodyStyle={{ padding: '16px' }}
|
||||
hoverable
|
||||
>
|
||||
<div className='flex justify-between items-center h-full'>
|
||||
<div className='flex-1 text-left'>
|
||||
<div className='font-semibold text-sm text-gray-900 mb-1'>
|
||||
{module.title}
|
||||
</div>
|
||||
<Text className='text-xs text-gray-600 leading-relaxed block'>
|
||||
{module.description}
|
||||
</Text>
|
||||
</div>
|
||||
<div className='ml-4'>
|
||||
<Switch
|
||||
checked={
|
||||
sidebarModulesUser[section.key]?.[module.key] !==
|
||||
false
|
||||
}
|
||||
onChange={handleModuleChange(section.key, module.key)}
|
||||
size='default'
|
||||
disabled={
|
||||
sidebarModulesUser[section.key]?.enabled === false
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 底部按钮 */}
|
||||
<div className='flex justify-end gap-3 mt-6 pt-4 border-t border-gray-200'>
|
||||
<Button
|
||||
type='tertiary'
|
||||
onClick={resetSidebarModules}
|
||||
className='!rounded-lg'
|
||||
>
|
||||
{t('重置为默认')}
|
||||
</Button>
|
||||
<Button
|
||||
type='primary'
|
||||
onClick={onSubmit}
|
||||
loading={loading}
|
||||
className='!rounded-lg'
|
||||
>
|
||||
{t('保存设置')}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
/*
|
||||
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 { Button, Col, Form, Row, Spin } from '@douyinfe/semi-ui';
|
||||
import {
|
||||
compareObjects,
|
||||
API,
|
||||
showError,
|
||||
showSuccess,
|
||||
showWarning,
|
||||
verifyJSON,
|
||||
} from '../../../helpers';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export default function RequestRateLimit(props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [inputs, setInputs] = useState({
|
||||
ModelRequestRateLimitEnabled: false,
|
||||
ModelRequestRateLimitCount: -1,
|
||||
ModelRequestRateLimitSuccessCount: 1000,
|
||||
ModelRequestRateLimitDurationMinutes: 1,
|
||||
ModelRequestRateLimitGroup: '',
|
||||
});
|
||||
const refForm = useRef();
|
||||
const [inputsRow, setInputsRow] = useState(inputs);
|
||||
|
||||
function onSubmit() {
|
||||
const updateArray = compareObjects(inputs, inputsRow);
|
||||
if (!updateArray.length) return showWarning(t('你似乎并没有修改什么'));
|
||||
const requestQueue = updateArray.map((item) => {
|
||||
let value = '';
|
||||
if (typeof inputs[item.key] === 'boolean') {
|
||||
value = String(inputs[item.key]);
|
||||
} else {
|
||||
value = inputs[item.key];
|
||||
}
|
||||
return API.put('/api/option/', {
|
||||
key: item.key,
|
||||
value,
|
||||
});
|
||||
});
|
||||
setLoading(true);
|
||||
Promise.all(requestQueue)
|
||||
.then((res) => {
|
||||
if (requestQueue.length === 1) {
|
||||
if (res.includes(undefined)) return;
|
||||
} else if (requestQueue.length > 1) {
|
||||
if (res.includes(undefined))
|
||||
return showError(t('部分保存失败,请重试'));
|
||||
}
|
||||
|
||||
for (let i = 0; i < res.length; i++) {
|
||||
if (!res[i].data.success) {
|
||||
return showError(res[i].data.message);
|
||||
}
|
||||
}
|
||||
|
||||
showSuccess(t('保存成功'));
|
||||
props.refresh();
|
||||
})
|
||||
.catch(() => {
|
||||
showError(t('保存失败,请重试'));
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const currentInputs = {};
|
||||
for (let key in props.options) {
|
||||
if (Object.keys(inputs).includes(key)) {
|
||||
currentInputs[key] = props.options[key];
|
||||
}
|
||||
}
|
||||
setInputs(currentInputs);
|
||||
setInputsRow(structuredClone(currentInputs));
|
||||
refForm.current.setValues(currentInputs);
|
||||
}, [props.options]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Spin spinning={loading}>
|
||||
<Form
|
||||
values={inputs}
|
||||
getFormApi={(formAPI) => (refForm.current = formAPI)}
|
||||
style={{ marginBottom: 15 }}
|
||||
>
|
||||
<Form.Section text={t('模型请求速率限制')}>
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
|
||||
<Form.Switch
|
||||
field={'ModelRequestRateLimitEnabled'}
|
||||
label={t('启用用户模型请求速率限制(可能会影响高并发性能)')}
|
||||
size='default'
|
||||
checkedText='|'
|
||||
uncheckedText='〇'
|
||||
onChange={(value) => {
|
||||
setInputs({
|
||||
...inputs,
|
||||
ModelRequestRateLimitEnabled: value,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row>
|
||||
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
|
||||
<Form.InputNumber
|
||||
label={t('限制周期')}
|
||||
step={1}
|
||||
min={0}
|
||||
suffix={t('分钟')}
|
||||
extraText={t('频率限制的周期(分钟)')}
|
||||
field={'ModelRequestRateLimitDurationMinutes'}
|
||||
onChange={(value) =>
|
||||
setInputs({
|
||||
...inputs,
|
||||
ModelRequestRateLimitDurationMinutes: String(value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row>
|
||||
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
|
||||
<Form.InputNumber
|
||||
label={t('用户每周期最多请求次数')}
|
||||
step={1}
|
||||
min={0}
|
||||
max={100000000}
|
||||
suffix={t('次')}
|
||||
extraText={t('包括失败请求的次数,0代表不限制')}
|
||||
field={'ModelRequestRateLimitCount'}
|
||||
onChange={(value) =>
|
||||
setInputs({
|
||||
...inputs,
|
||||
ModelRequestRateLimitCount: String(value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
|
||||
<Form.InputNumber
|
||||
label={t('用户每周期最多请求完成次数')}
|
||||
step={1}
|
||||
min={1}
|
||||
max={100000000}
|
||||
suffix={t('次')}
|
||||
extraText={t('只包括请求成功的次数')}
|
||||
field={'ModelRequestRateLimitSuccessCount'}
|
||||
onChange={(value) =>
|
||||
setInputs({
|
||||
...inputs,
|
||||
ModelRequestRateLimitSuccessCount: String(value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row>
|
||||
<Col xs={24} sm={16}>
|
||||
<Form.TextArea
|
||||
label={t('分组速率限制')}
|
||||
placeholder={t(
|
||||
'{\n "default": [200, 100],\n "vip": [0, 1000]\n}',
|
||||
)}
|
||||
field={'ModelRequestRateLimitGroup'}
|
||||
autosize={{ minRows: 5, maxRows: 15 }}
|
||||
trigger='blur'
|
||||
stopValidateWithError
|
||||
rules={[
|
||||
{
|
||||
validator: (rule, value) => verifyJSON(value),
|
||||
message: t('不是合法的 JSON 字符串'),
|
||||
},
|
||||
]}
|
||||
extraText={
|
||||
<div>
|
||||
<p>{t('说明:')}</p>
|
||||
<ul>
|
||||
<li>
|
||||
{t(
|
||||
'使用 JSON 对象格式,格式为:{"组名": [最多请求次数, 最多请求完成次数]}',
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
{t(
|
||||
'示例:{"default": [200, 100], "vip": [0, 1000]}。',
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
{t(
|
||||
'[最多请求次数]必须大于等于0,[最多请求完成次数]必须大于等于1。',
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
{t(
|
||||
'[最多请求次数]和[最多请求完成次数]的最大值为2147483647。',
|
||||
)}
|
||||
</li>
|
||||
<li>{t('分组速率配置优先级高于全局速率限制。')}</li>
|
||||
<li>{t('限制周期统一使用上方配置的“限制周期”值。')}</li>
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
onChange={(value) => {
|
||||
setInputs({ ...inputs, ModelRequestRateLimitGroup: value });
|
||||
}}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row>
|
||||
<Button size='default' onClick={onSubmit}>
|
||||
{t('保存模型速率限制')}
|
||||
</Button>
|
||||
</Row>
|
||||
</Form.Section>
|
||||
</Form>
|
||||
</Spin>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,758 @@
|
||||
/*
|
||||
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, useCallback, useMemo } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Col,
|
||||
Collapsible,
|
||||
Form,
|
||||
Radio,
|
||||
RadioGroup,
|
||||
Row,
|
||||
SideSheet,
|
||||
Spin,
|
||||
Switch,
|
||||
Tabs,
|
||||
Typography,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { IconChevronDown, IconChevronUp } from '@douyinfe/semi-icons';
|
||||
import { IconHelpCircle } from '@douyinfe/semi-icons';
|
||||
import {
|
||||
compareObjects,
|
||||
API,
|
||||
showError,
|
||||
showSuccess,
|
||||
showWarning,
|
||||
verifyJSON,
|
||||
} from '../../../helpers';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import GroupTable from './components/GroupTable';
|
||||
import AutoGroupList from './components/AutoGroupList';
|
||||
import GroupGroupRatioRules from './components/GroupGroupRatioRules';
|
||||
import GroupSpecialUsableRules from './components/GroupSpecialUsableRules';
|
||||
|
||||
const { Text, Title, Paragraph } = Typography;
|
||||
|
||||
const OPTION_KEYS = [
|
||||
'GroupRatio',
|
||||
'UserUsableGroups',
|
||||
'GroupGroupRatio',
|
||||
'group_ratio_setting.group_special_usable_group',
|
||||
'AutoGroups',
|
||||
'DefaultUseAutoGroup',
|
||||
];
|
||||
|
||||
function parseJSONSafe(str, fallback) {
|
||||
if (!str || !str.trim()) return fallback;
|
||||
try {
|
||||
return JSON.parse(str);
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
export default function GroupRatioSettings(props) {
|
||||
const { t } = useTranslation();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [editMode, setEditMode] = useState('visual');
|
||||
const [showGuide, setShowGuide] = useState(false);
|
||||
|
||||
const [inputs, setInputs] = useState({
|
||||
GroupRatio: '',
|
||||
UserUsableGroups: '',
|
||||
GroupGroupRatio: '',
|
||||
'group_ratio_setting.group_special_usable_group': '',
|
||||
AutoGroups: '',
|
||||
DefaultUseAutoGroup: false,
|
||||
});
|
||||
const refForm = useRef();
|
||||
const [inputsRow, setInputsRow] = useState(inputs);
|
||||
const dataVersionRef = useRef(0);
|
||||
|
||||
const groupNames = useMemo(() => {
|
||||
const ratioMap = parseJSONSafe(inputs.GroupRatio, {});
|
||||
return Object.keys(ratioMap);
|
||||
}, [inputs.GroupRatio]);
|
||||
|
||||
async function onSubmit() {
|
||||
if (editMode === 'manual') {
|
||||
try {
|
||||
await refForm.current.validate();
|
||||
} catch {
|
||||
showError(t('请检查输入'));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const updateArray = compareObjects(inputs, inputsRow);
|
||||
if (!updateArray.length) {
|
||||
return showWarning(t('你似乎并没有修改什么'));
|
||||
}
|
||||
|
||||
const requestQueue = updateArray.map((item) => {
|
||||
const value =
|
||||
typeof inputs[item.key] === 'boolean'
|
||||
? String(inputs[item.key])
|
||||
: inputs[item.key];
|
||||
return API.put('/api/option/', { key: item.key, value });
|
||||
});
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await Promise.all(requestQueue);
|
||||
if (res.includes(undefined)) {
|
||||
return showError(
|
||||
requestQueue.length > 1
|
||||
? t('部分保存失败,请重试')
|
||||
: t('保存失败'),
|
||||
);
|
||||
}
|
||||
for (let i = 0; i < res.length; i++) {
|
||||
if (!res[i].data.success) {
|
||||
return showError(res[i].data.message);
|
||||
}
|
||||
}
|
||||
showSuccess(t('保存成功'));
|
||||
props.refresh();
|
||||
} catch (error) {
|
||||
console.error('Unexpected error:', error);
|
||||
showError(t('保存失败,请重试'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const currentInputs = {};
|
||||
for (let key in props.options) {
|
||||
if (OPTION_KEYS.includes(key)) {
|
||||
currentInputs[key] = props.options[key];
|
||||
}
|
||||
}
|
||||
setInputs(currentInputs);
|
||||
setInputsRow(structuredClone(currentInputs));
|
||||
dataVersionRef.current += 1;
|
||||
if (refForm.current) {
|
||||
refForm.current.setValues(currentInputs);
|
||||
}
|
||||
}, [props.options]);
|
||||
|
||||
const handleGroupTableChange = useCallback(
|
||||
({ GroupRatio, UserUsableGroups }) => {
|
||||
setInputs((prev) => ({ ...prev, GroupRatio, UserUsableGroups }));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleAutoGroupsChange = useCallback((value) => {
|
||||
setInputs((prev) => ({ ...prev, AutoGroups: value }));
|
||||
}, []);
|
||||
|
||||
const handleGroupGroupRatioChange = useCallback((value) => {
|
||||
setInputs((prev) => ({ ...prev, GroupGroupRatio: value }));
|
||||
}, []);
|
||||
|
||||
const handleSpecialUsableChange = useCallback((value) => {
|
||||
setInputs((prev) => ({
|
||||
...prev,
|
||||
'group_ratio_setting.group_special_usable_group': value,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const dv = dataVersionRef.current;
|
||||
|
||||
const renderVisualMode = () => (
|
||||
<Form key='form-visual' values={inputs} style={{ marginBottom: 15 }}>
|
||||
<Form.Section text={t('分组管理')}>
|
||||
<Text type='tertiary' size='small' style={{ display: 'block', marginBottom: 12 }}>
|
||||
{t('倍率用于计费乘数,勾选「用户可选」后用户可在创建令牌时选择该分组')}
|
||||
</Text>
|
||||
<GroupTable
|
||||
key={`gt_${dv}`}
|
||||
groupRatio={inputs.GroupRatio}
|
||||
userUsableGroups={inputs.UserUsableGroups}
|
||||
onChange={handleGroupTableChange}
|
||||
/>
|
||||
</Form.Section>
|
||||
|
||||
<Form.Section text={t('自动分组')}>
|
||||
<Text type='tertiary' size='small' style={{ display: 'block', marginBottom: 12 }}>
|
||||
{t('令牌分组设为 auto 时,按以下顺序依次尝试选择可用分组,排在前面的优先级更高')}
|
||||
</Text>
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
|
||||
<Form.Slot label={t('默认使用auto分组')}>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Switch
|
||||
checked={!!inputs.DefaultUseAutoGroup}
|
||||
size='default'
|
||||
checkedText='|'
|
||||
uncheckedText='〇'
|
||||
onChange={(value) =>
|
||||
setInputs((prev) => ({
|
||||
...prev,
|
||||
DefaultUseAutoGroup: value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<Text type='tertiary' size='small' style={{ marginTop: 4 }}>
|
||||
{t('开启后创建令牌默认选择auto分组,初始令牌也将设为auto')}
|
||||
</Text>
|
||||
</Form.Slot>
|
||||
</Col>
|
||||
</Row>
|
||||
<AutoGroupList
|
||||
key={`ag_${dv}`}
|
||||
value={inputs.AutoGroups}
|
||||
groupNames={groupNames}
|
||||
onChange={handleAutoGroupsChange}
|
||||
/>
|
||||
</Form.Section>
|
||||
|
||||
<Form.Section text={t('分组特殊倍率')}>
|
||||
<Text type='tertiary' size='small' style={{ display: 'block', marginBottom: 12 }}>
|
||||
{t('当某个分组的用户使用另一个分组的令牌时,可设置特殊倍率覆盖基础倍率。例如:vip 分组的用户使用 default 分组时倍率为 0.5')}
|
||||
</Text>
|
||||
<GroupGroupRatioRules
|
||||
key={`ggr_${dv}`}
|
||||
value={inputs.GroupGroupRatio}
|
||||
groupNames={groupNames}
|
||||
onChange={handleGroupGroupRatioChange}
|
||||
/>
|
||||
</Form.Section>
|
||||
|
||||
<Form.Section text={t('分组特殊可用分组')}>
|
||||
<Text type='tertiary' size='small' style={{ display: 'block', marginBottom: 12 }}>
|
||||
{t('为特定用户分组配置可用分组的增减规则。「添加」为该分组新增可用分组,「移除」移除默认可用分组,「追加」直接追加分组')}
|
||||
</Text>
|
||||
<GroupSpecialUsableRules
|
||||
key={`gsu_${dv}`}
|
||||
value={inputs['group_ratio_setting.group_special_usable_group']}
|
||||
groupNames={groupNames}
|
||||
onChange={handleSpecialUsableChange}
|
||||
/>
|
||||
</Form.Section>
|
||||
</Form>
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (editMode === 'manual' && refForm.current) {
|
||||
refForm.current.setValues(inputs);
|
||||
}
|
||||
}, [editMode]);
|
||||
|
||||
const renderManualMode = () => (
|
||||
<Form
|
||||
key='form-manual'
|
||||
initValues={inputs}
|
||||
getFormApi={(formAPI) => (refForm.current = formAPI)}
|
||||
style={{ marginBottom: 15 }}
|
||||
>
|
||||
<Form.Section text={t('分组JSON设置')}>
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} sm={16}>
|
||||
<Form.TextArea
|
||||
label={t('分组倍率')}
|
||||
placeholder={t('为一个 JSON 文本,键为分组名称,值为倍率')}
|
||||
extraText={t(
|
||||
'分组倍率设置,可以在此处新增分组或修改现有分组的倍率,格式为 JSON 字符串,例如:{"vip": 0.5, "test": 1},表示 vip 分组的倍率为 0.5,test 分组的倍率为 1',
|
||||
)}
|
||||
field={'GroupRatio'}
|
||||
autosize={{ minRows: 6, maxRows: 12 }}
|
||||
trigger='blur'
|
||||
stopValidateWithError
|
||||
rules={[
|
||||
{
|
||||
validator: (rule, value) => verifyJSON(value),
|
||||
message: t('不是合法的 JSON 字符串'),
|
||||
},
|
||||
]}
|
||||
onChange={(value) =>
|
||||
setInputs((prev) => ({ ...prev, GroupRatio: value }))
|
||||
}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} sm={16}>
|
||||
<Form.TextArea
|
||||
label={t('用户可选分组')}
|
||||
placeholder={t(
|
||||
'为一个 JSON 文本,键为分组名称,值为分组描述',
|
||||
)}
|
||||
extraText={t(
|
||||
'用户新建令牌时可选的分组,格式为 JSON 字符串,例如:{"vip": "VIP 用户", "test": "测试"},表示用户可以选择 vip 分组和 test 分组',
|
||||
)}
|
||||
field={'UserUsableGroups'}
|
||||
autosize={{ minRows: 6, maxRows: 12 }}
|
||||
trigger='blur'
|
||||
stopValidateWithError
|
||||
rules={[
|
||||
{
|
||||
validator: (rule, value) => verifyJSON(value),
|
||||
message: t('不是合法的 JSON 字符串'),
|
||||
},
|
||||
]}
|
||||
onChange={(value) =>
|
||||
setInputs((prev) => ({ ...prev, UserUsableGroups: value }))
|
||||
}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} sm={16}>
|
||||
<Form.TextArea
|
||||
label={t('分组特殊倍率')}
|
||||
placeholder={t('为一个 JSON 文本')}
|
||||
extraText={t(
|
||||
'键为分组名称,值为另一个 JSON 对象,键为分组名称,值为该分组的用户的特殊分组倍率,例如:{"vip": {"default": 0.5, "test": 1}},表示 vip 分组的用户在使用default分组的令牌时倍率为0.5,使用test分组时倍率为1',
|
||||
)}
|
||||
field={'GroupGroupRatio'}
|
||||
autosize={{ minRows: 6, maxRows: 12 }}
|
||||
trigger='blur'
|
||||
stopValidateWithError
|
||||
rules={[
|
||||
{
|
||||
validator: (rule, value) => verifyJSON(value),
|
||||
message: t('不是合法的 JSON 字符串'),
|
||||
},
|
||||
]}
|
||||
onChange={(value) =>
|
||||
setInputs((prev) => ({ ...prev, GroupGroupRatio: value }))
|
||||
}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} sm={16}>
|
||||
<Form.TextArea
|
||||
label={t('分组特殊可用分组')}
|
||||
placeholder={t('为一个 JSON 文本')}
|
||||
extraText={t(
|
||||
'键为用户分组名称,值为操作映射对象。内层键以"+:"开头表示添加指定分组(键值为分组名称,值为描述),以"-:"开头表示移除指定分组(键值为分组名称),不带前缀的键直接添加该分组。例如:{"vip": {"+:premium": "高级分组", "special": "特殊分组", "-:default": "默认分组"}},表示 vip 分组的用户可以使用 premium 和 special 分组,同时移除 default 分组的访问权限',
|
||||
)}
|
||||
field={'group_ratio_setting.group_special_usable_group'}
|
||||
autosize={{ minRows: 6, maxRows: 12 }}
|
||||
trigger='blur'
|
||||
stopValidateWithError
|
||||
rules={[
|
||||
{
|
||||
validator: (rule, value) => verifyJSON(value),
|
||||
message: t('不是合法的 JSON 字符串'),
|
||||
},
|
||||
]}
|
||||
onChange={(value) =>
|
||||
setInputs((prev) => ({
|
||||
...prev,
|
||||
'group_ratio_setting.group_special_usable_group': value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} sm={16}>
|
||||
<Form.TextArea
|
||||
label={t('自动分组auto,从第一个开始选择')}
|
||||
placeholder={t('为一个 JSON 文本')}
|
||||
field={'AutoGroups'}
|
||||
autosize={{ minRows: 6, maxRows: 12 }}
|
||||
trigger='blur'
|
||||
stopValidateWithError
|
||||
rules={[
|
||||
{
|
||||
validator: (rule, value) => {
|
||||
if (!value || value.trim() === '') return true;
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
if (!Array.isArray(parsed)) return false;
|
||||
return parsed.every((item) => typeof item === 'string');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
message: t(
|
||||
'必须是有效的 JSON 字符串数组,例如:["g1","g2"]',
|
||||
),
|
||||
},
|
||||
]}
|
||||
onChange={(value) =>
|
||||
setInputs((prev) => ({ ...prev, AutoGroups: value }))
|
||||
}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={16}>
|
||||
<Col span={16}>
|
||||
<Form.Switch
|
||||
label={t(
|
||||
'创建令牌默认选择auto分组,初始令牌也将设为auto(否则留空,为用户默认分组)',
|
||||
)}
|
||||
field={'DefaultUseAutoGroup'}
|
||||
onChange={(value) =>
|
||||
setInputs((prev) => ({
|
||||
...prev,
|
||||
DefaultUseAutoGroup: value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</Form.Section>
|
||||
</Form>
|
||||
);
|
||||
|
||||
const GuideSection = ({ title, children }) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<Button
|
||||
theme='borderless'
|
||||
size='small'
|
||||
icon={open ? <IconChevronUp /> : <IconChevronDown />}
|
||||
onClick={() => setOpen(!open)}
|
||||
style={{ padding: '4px 0', color: 'var(--semi-color-primary)' }}
|
||||
>
|
||||
{title}
|
||||
</Button>
|
||||
<Collapsible isOpen={open} keepDOM>
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--semi-color-fill-0)',
|
||||
padding: '12px 16px',
|
||||
borderRadius: 8,
|
||||
marginTop: 8,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</Collapsible>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const CodeBlock = ({ children }) => (
|
||||
<pre
|
||||
style={{
|
||||
background: 'var(--semi-color-bg-2)',
|
||||
border: '1px solid var(--semi-color-border)',
|
||||
padding: '10px 14px',
|
||||
borderRadius: 6,
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 13,
|
||||
margin: '8px 0',
|
||||
whiteSpace: 'pre-wrap',
|
||||
lineHeight: 1.6,
|
||||
overflowX: 'auto',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</pre>
|
||||
);
|
||||
|
||||
const renderGuide = () => (
|
||||
<SideSheet
|
||||
title={t('分组设置使用说明')}
|
||||
visible={showGuide}
|
||||
onCancel={() => setShowGuide(false)}
|
||||
width={560}
|
||||
bodyStyle={{ overflow: 'auto', padding: '0 24px 24px' }}
|
||||
>
|
||||
<Tabs type='line' size='small'>
|
||||
<Tabs.TabPane tab={t('概览')} itemKey='overview'>
|
||||
<div style={{ paddingTop: 20 }}>
|
||||
<Title heading={5}>{t('什么是分组?')}</Title>
|
||||
<Paragraph style={{ marginTop: 12, lineHeight: 1.8 }}>
|
||||
{t(
|
||||
'分组是用于控制计费倍率和模型访问权限的核心概念。每个用户属于一个分组,每个令牌也可以指定使用某个分组。',
|
||||
)}
|
||||
</Paragraph>
|
||||
<Paragraph style={{ marginTop: 8, lineHeight: 1.8 }}>
|
||||
{t(
|
||||
'通过分组可以实现不同用户等级的差异化定价,例如 VIP 用户享受更低的 API 调用费用。',
|
||||
)}
|
||||
</Paragraph>
|
||||
|
||||
<GuideSection title={t('核心概念')}>
|
||||
<Paragraph style={{ lineHeight: 1.8 }}>
|
||||
<Text strong>{t('用户分组')}</Text>{' — '}
|
||||
{t('由管理员分配,决定用户身份等级(如 default、vip)。')}
|
||||
</Paragraph>
|
||||
<Paragraph style={{ lineHeight: 1.8, marginTop: 4 }}>
|
||||
<Text strong>{t('令牌分组')}</Text>{' — '}
|
||||
{t('用户创建令牌时选择的分组,决定该令牌的实际计费倍率。一个用户可以创建多个令牌,使用不同分组。')}
|
||||
</Paragraph>
|
||||
<Paragraph style={{ lineHeight: 1.8, marginTop: 4 }}>
|
||||
<Text strong>{t('倍率')}</Text>{' — '}
|
||||
{t('计费乘数,倍率越低费用越低。例如倍率 0.5 表示半价。')}
|
||||
</Paragraph>
|
||||
<Paragraph style={{ lineHeight: 1.8, marginTop: 4 }}>
|
||||
<Text strong>{t('用户可选')}</Text>{' — '}
|
||||
{t('勾选后,该分组会出现在用户创建令牌时的下拉菜单中。未勾选的分组只能由管理员分配,用户自己无法选择。')}
|
||||
</Paragraph>
|
||||
<Paragraph style={{ lineHeight: 1.8, marginTop: 4 }}>
|
||||
<Text strong>{t('自动分组')}</Text>{' — '}
|
||||
{t('令牌分组设为 auto 时,系统按优先级顺序自动选择一个可用分组。')}
|
||||
</Paragraph>
|
||||
</GuideSection>
|
||||
</div>
|
||||
</Tabs.TabPane>
|
||||
|
||||
<Tabs.TabPane tab={t('分组管理')} itemKey='groups'>
|
||||
<div style={{ paddingTop: 20 }}>
|
||||
<Title heading={5}>{t('创建和管理分组')}</Title>
|
||||
<Paragraph style={{ marginTop: 12, lineHeight: 1.8 }}>
|
||||
{t('每个分组代表一个价格档位。管理员创建分组后,可以选择哪些档位对用户开放自选。')}
|
||||
</Paragraph>
|
||||
|
||||
<GuideSection title={t('查看示例')}>
|
||||
<Paragraph size='small' type='tertiary' style={{ marginBottom: 8 }}>
|
||||
{t('场景:站点提供两个价格档位,用户可以按需选择')}
|
||||
</Paragraph>
|
||||
<CodeBlock>
|
||||
{`${t('分组名')} ${t('倍率')} ${t('用户可选')} ${t('说明')}\n──────────────────────────────────────\nstandard 1.0 ${t('是')} ${t('标准价格')}\npremium 0.5 ${t('是')} ${t('高级套餐,半价优惠')}`}
|
||||
</CodeBlock>
|
||||
<Paragraph size='small' style={{ marginTop: 10, lineHeight: 1.8 }}>
|
||||
{t('两个分组都勾选了「用户可选」,所以用户创建令牌时可以看到这两个选项:')}
|
||||
</Paragraph>
|
||||
<CodeBlock>
|
||||
{t('用户创建令牌 → 选择分组下拉框:')}{'\n'}
|
||||
{` ├─ standard (${t('标准价格')})`}{'\n'}
|
||||
{` └─ premium (${t('高级套餐,半价优惠')})`}
|
||||
</CodeBlock>
|
||||
<Paragraph size='small' style={{ marginTop: 10, lineHeight: 1.8 }}>
|
||||
{t('选择 premium 创建的令牌,调用 API 时费用为 standard 的 50%。')}
|
||||
</Paragraph>
|
||||
<Paragraph size='small' style={{ marginTop: 16, lineHeight: 1.8 }}>
|
||||
<Text strong>{t('对比:不勾选「用户可选」的场景')}</Text>
|
||||
</Paragraph>
|
||||
<Paragraph size='small' style={{ marginTop: 4, lineHeight: 1.8 }}>
|
||||
{t('假设再加两个分组 default 和 vip,但不勾选用户可选:')}
|
||||
</Paragraph>
|
||||
<CodeBlock>
|
||||
{`${t('分组名')} ${t('倍率')} ${t('用户可选')} ${t('说明')}\n──────────────────────────────────────\ndefault 1.0 ${t('否')} ${t('管理员分配的基础分组')}\nvip 0.5 ${t('否')} ${t('管理员分配的优惠分组')}\nstandard 1.0 ${t('是')} ${t('标准价格')}\npremium 0.5 ${t('是')} ${t('高级套餐,半价优惠')}`}
|
||||
</CodeBlock>
|
||||
<Paragraph size='small' style={{ marginTop: 8, lineHeight: 1.8 }}>
|
||||
{t('此时用户创建令牌时只能看到 standard 和 premium:')}
|
||||
</Paragraph>
|
||||
<CodeBlock>
|
||||
{t('用户创建令牌 → 选择分组下拉框:')}{'\n'}
|
||||
{` ├─ standard (${t('标准价格')})`}{'\n'}
|
||||
{` └─ premium (${t('高级套餐,半价优惠')})`}{'\n\n'}
|
||||
{` ${t('不会出现')} default ${t('和')} vip`}
|
||||
</CodeBlock>
|
||||
<Paragraph size='small' style={{ marginTop: 8, lineHeight: 1.8 }}>
|
||||
{t('default 和 vip 只能由管理员在「用户管理」中分配给用户。适用于按用户等级定价、内部测试等不希望用户自主选择的场景。')}
|
||||
</Paragraph>
|
||||
<Paragraph size='small' style={{ marginTop: 12, lineHeight: 1.8 }}>
|
||||
<Text strong>{t('用户分组的联动作用')}</Text>
|
||||
</Paragraph>
|
||||
<Paragraph size='small' style={{ lineHeight: 1.8 }}>
|
||||
{t('管理员给用户分配的分组(如 vip)不仅决定用户身份,还会影响后续两个功能:')}
|
||||
</Paragraph>
|
||||
<Paragraph size='small' style={{ lineHeight: 1.8, marginTop: 4 }}>
|
||||
{'1. '}<Text strong>{t('特殊倍率')}</Text>{' — '}
|
||||
{t('可以根据用户分组设置不同的计费倍率。例如 vip 用户使用 standard 令牌时倍率从 1.0 降为 0.8。')}
|
||||
</Paragraph>
|
||||
<Paragraph size='small' style={{ lineHeight: 1.8, marginTop: 2 }}>
|
||||
{'2. '}<Text strong>{t('可用分组')}</Text>{' — '}
|
||||
{t('可以根据用户分组增减令牌可选的分组范围。例如 vip 用户额外开放 premium 分组,或移除某个分组的选择权。')}
|
||||
</Paragraph>
|
||||
<Paragraph size='small' type='tertiary' style={{ lineHeight: 1.8, marginTop: 6 }}>
|
||||
{t('详见「特殊倍率」和「可用分组」标签页。')}
|
||||
</Paragraph>
|
||||
</GuideSection>
|
||||
|
||||
<GuideSection title={t('JSON 格式参考')}>
|
||||
<Paragraph size='small' style={{ marginBottom: 4 }}>
|
||||
<Text strong code>GroupRatio</Text>{' — '}{t('分组名称到倍率的映射')}
|
||||
</Paragraph>
|
||||
<CodeBlock>{`{"default": 1, "vip": 0.5, "standard": 1, "premium": 0.5}`}</CodeBlock>
|
||||
<Paragraph size='small' style={{ marginBottom: 4, marginTop: 8 }}>
|
||||
<Text strong code>UserUsableGroups</Text>{' — '}{t('用户可选分组的名称和描述(只包含勾选了用户可选的分组)')}
|
||||
</Paragraph>
|
||||
<CodeBlock>{`{"standard": "${t('标准价格')}", "premium": "${t('高级套餐,半价优惠')}"}`}</CodeBlock>
|
||||
</GuideSection>
|
||||
</div>
|
||||
</Tabs.TabPane>
|
||||
|
||||
<Tabs.TabPane tab={t('自动分组')} itemKey='auto'>
|
||||
<div style={{ paddingTop: 20 }}>
|
||||
<Title heading={5}>{t('自动分组选择')}</Title>
|
||||
<Paragraph style={{ marginTop: 12, lineHeight: 1.8 }}>
|
||||
{t('当令牌分组设为 auto 时,系统按列表顺序依次选择可用分组。排在前面的优先级更高。')}
|
||||
</Paragraph>
|
||||
|
||||
<GuideSection title={t('查看示例')}>
|
||||
<Paragraph size='small' type='tertiary' style={{ marginBottom: 6 }}>
|
||||
{t('场景:设置自动选择优先级')}
|
||||
</Paragraph>
|
||||
<CodeBlock>
|
||||
{`1. default ${t('最高优先级')}\n2. vip`}
|
||||
</CodeBlock>
|
||||
<Paragraph size='small' style={{ marginTop: 6, lineHeight: 1.6 }}>
|
||||
{t('开启「默认使用 auto 分组」后,新建令牌和初始令牌都会自动设为 auto。')}
|
||||
</Paragraph>
|
||||
</GuideSection>
|
||||
|
||||
<GuideSection title={t('JSON 格式参考')}>
|
||||
<Paragraph size='small' style={{ marginBottom: 4 }}>
|
||||
<Text strong code>AutoGroups</Text>{' — '}{t('有序字符串数组')}
|
||||
</Paragraph>
|
||||
<CodeBlock>{`["default", "vip"]`}</CodeBlock>
|
||||
</GuideSection>
|
||||
</div>
|
||||
</Tabs.TabPane>
|
||||
|
||||
<Tabs.TabPane tab={t('特殊倍率')} itemKey='ratios'>
|
||||
<div style={{ paddingTop: 20 }}>
|
||||
<Title heading={5}>{t('跨分组特殊倍率')}</Title>
|
||||
<Paragraph style={{ marginTop: 12, lineHeight: 1.8 }}>
|
||||
{t('正常情况下,令牌的计费倍率由令牌所选的分组决定。特殊倍率可以根据「用户所在分组」进一步覆盖这个倍率。')}
|
||||
</Paragraph>
|
||||
<Paragraph style={{ marginTop: 8, lineHeight: 1.8 }}>
|
||||
{t('简单来说:同一个令牌分组,不同等级的用户可以享受不同的价格。')}
|
||||
</Paragraph>
|
||||
|
||||
<GuideSection title={t('查看示例')}>
|
||||
<Paragraph size='small' type='tertiary' style={{ marginBottom: 8 }}>
|
||||
{t('场景:站点有 standard(倍率 1.0)和 premium(倍率 0.5)两个分组,希望 vip 用户使用 standard 令牌时也能享受折扣')}
|
||||
</Paragraph>
|
||||
<Paragraph size='small' style={{ marginBottom: 8, lineHeight: 1.8 }}>
|
||||
<Text strong>{t('不配置特殊倍率时:')}</Text>
|
||||
</Paragraph>
|
||||
<CodeBlock>
|
||||
{`${t('普通用户')} + standard ${t('令牌')} → ${t('倍率')} 1.0 (${t('原价')})\nvip ${t('用户')} + standard ${t('令牌')} → ${t('倍率')} 1.0 (${t('原价,和普通用户一样')})`}
|
||||
</CodeBlock>
|
||||
<Paragraph size='small' style={{ marginTop: 10, marginBottom: 8, lineHeight: 1.8 }}>
|
||||
<Text strong>{t('配置特殊倍率后:')}</Text>
|
||||
</Paragraph>
|
||||
<CodeBlock>
|
||||
{`${t('用户分组')} ${t('使用分组')} ${t('倍率')}\n────────────────────────────\nvip standard 0.8\nvip premium 0.3`}
|
||||
</CodeBlock>
|
||||
<Paragraph size='small' style={{ marginTop: 10, lineHeight: 1.8 }}>
|
||||
{t('配置后的效果:')}
|
||||
</Paragraph>
|
||||
<CodeBlock>
|
||||
{`${t('普通用户')} + standard ${t('令牌')} → ${t('倍率')} 1.0 (${t('不变')})\nvip ${t('用户')} + standard ${t('令牌')} → ${t('倍率')} 0.8 (${t('享受 8 折')})\nvip ${t('用户')} + premium ${t('令牌')} → ${t('倍率')} 0.3 (${t('从 0.5 降到 0.3')})`}
|
||||
</CodeBlock>
|
||||
<Paragraph size='small' type='tertiary' style={{ marginTop: 10, lineHeight: 1.8 }}>
|
||||
{t('只有配置了规则的组合才会覆盖,未配置的组合仍使用令牌分组的基础倍率。')}
|
||||
</Paragraph>
|
||||
</GuideSection>
|
||||
|
||||
<GuideSection title={t('JSON 格式参考')}>
|
||||
<Paragraph size='small' style={{ marginBottom: 4 }}>
|
||||
<Text strong code>GroupGroupRatio</Text>{' — '}{t('嵌套映射:用户分组 → 使用分组 → 倍率')}
|
||||
</Paragraph>
|
||||
<CodeBlock>{`{\n "vip": {\n "standard": 0.8,\n "premium": 0.3\n }\n}`}</CodeBlock>
|
||||
</GuideSection>
|
||||
</div>
|
||||
</Tabs.TabPane>
|
||||
|
||||
<Tabs.TabPane tab={t('可用分组')} itemKey='usable'>
|
||||
<div style={{ paddingTop: 20 }}>
|
||||
<Title heading={5}>{t('特殊可用分组规则')}</Title>
|
||||
<Paragraph style={{ marginTop: 12, lineHeight: 1.8 }}>
|
||||
{t('默认情况下,所有用户创建令牌时看到的可选分组列表是一样的(即「用户可选」列勾选的分组)。')}
|
||||
</Paragraph>
|
||||
<Paragraph style={{ marginTop: 8, lineHeight: 1.8 }}>
|
||||
{t('通过此功能,可以根据用户所在分组,为不同等级的用户展示不同的可选列表。')}
|
||||
</Paragraph>
|
||||
|
||||
<GuideSection title={t('查看示例')}>
|
||||
<Paragraph size='small' type='tertiary' style={{ marginBottom: 8 }}>
|
||||
{t('场景:站点有 standard 和 premium 两个用户可选分组。希望 vip 用户额外看到 exclusive 分组,同时不再看到 standard 分组')}
|
||||
</Paragraph>
|
||||
<Paragraph size='small' style={{ marginBottom: 8, lineHeight: 1.8 }}>
|
||||
<Text strong>{t('不配置规则时,所有用户看到的下拉框一样:')}</Text>
|
||||
</Paragraph>
|
||||
<CodeBlock>
|
||||
{`${t('所有用户')} → ${t('创建令牌可选')}:\n ├─ standard\n └─ premium`}
|
||||
</CodeBlock>
|
||||
<Paragraph size='small' style={{ marginTop: 10, marginBottom: 8, lineHeight: 1.8 }}>
|
||||
<Text strong>{t('为 vip 用户配置规则:')}</Text>
|
||||
</Paragraph>
|
||||
<CodeBlock>
|
||||
{`${t('用户分组')} ${t('操作')} ${t('目标分组')} ${t('描述')}\n──────────────────────────────────────────\nvip ${t('添加')} (+:) exclusive ${t('专属分组')}\nvip ${t('移除')} (-:) standard -`}
|
||||
</CodeBlock>
|
||||
<Paragraph size='small' style={{ marginTop: 10, lineHeight: 1.8 }}>
|
||||
{t('配置后的效果:')}
|
||||
</Paragraph>
|
||||
<CodeBlock>
|
||||
{`${t('普通用户')} → ${t('创建令牌可选')}:\n ├─ standard\n └─ premium\n\nvip ${t('用户')} → ${t('创建令牌可选')}:\n ├─ premium (${t('保留')})\n └─ exclusive (${t('新增')})\n\n ${t('standard 已被移除,vip 用户看不到')}`}
|
||||
</CodeBlock>
|
||||
|
||||
<Paragraph size='small' style={{ marginTop: 14, lineHeight: 1.8 }}>
|
||||
<Text strong>{t('三种操作的区别:')}</Text>
|
||||
</Paragraph>
|
||||
<CodeBlock>
|
||||
{`${t('添加')} (+:) → ${t('在默认列表基础上新增一个分组')}\n${t('移除')} (-:) → ${t('从默认列表中去掉一个分组')}\n${t('追加')} → ${t('直接追加(和添加类似,但无前缀)')}`}
|
||||
</CodeBlock>
|
||||
</GuideSection>
|
||||
|
||||
<GuideSection title={t('JSON 格式参考')}>
|
||||
<Paragraph size='small' style={{ marginBottom: 4 }}>
|
||||
<Text strong code>group_special_usable_group</Text>
|
||||
</Paragraph>
|
||||
<CodeBlock>{`{\n "vip": {\n "+:exclusive": "${t('专属分组')}",\n "-:standard": "remove"\n }\n}`}</CodeBlock>
|
||||
<Paragraph size='small' type='tertiary' style={{ marginTop: 6, lineHeight: 1.6 }}>
|
||||
{t('键的前缀 +: 表示添加,-: 表示移除,无前缀表示追加。值为分组描述(移除时填 "remove")。')}
|
||||
</Paragraph>
|
||||
</GuideSection>
|
||||
</div>
|
||||
</Tabs.TabPane>
|
||||
</Tabs>
|
||||
</SideSheet>
|
||||
);
|
||||
|
||||
return (
|
||||
<Spin spinning={loading}>
|
||||
<div style={{ marginBottom: 15 }}>
|
||||
<div className='flex items-center gap-3' style={{ marginTop: 12, marginBottom: 16 }}>
|
||||
<RadioGroup
|
||||
type='button'
|
||||
size='small'
|
||||
value={editMode}
|
||||
onChange={(e) => setEditMode(e.target.value)}
|
||||
>
|
||||
<Radio value='visual'>{t('可视化编辑')}</Radio>
|
||||
<Radio value='manual'>{t('手动编辑')}</Radio>
|
||||
</RadioGroup>
|
||||
<Button
|
||||
icon={<IconHelpCircle />}
|
||||
theme='borderless'
|
||||
type='tertiary'
|
||||
size='small'
|
||||
onClick={() => setShowGuide(true)}
|
||||
>
|
||||
{t('使用说明')}
|
||||
</Button>
|
||||
</div>
|
||||
{editMode === 'visual' ? renderVisualMode() : renderManualMode()}
|
||||
</div>
|
||||
<Button size='default' onClick={onSubmit}>
|
||||
{t('保存分组相关设置')}
|
||||
</Button>
|
||||
{renderGuide()}
|
||||
</Spin>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
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 { Radio, RadioGroup } from '@douyinfe/semi-ui';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import ModelPricingEditor from './components/ModelPricingEditor';
|
||||
import ModelRatioSettings from './ModelRatioSettings';
|
||||
|
||||
export default function ModelPricingCombined({ options, refresh }) {
|
||||
const { t } = useTranslation();
|
||||
const [editMode, setEditMode] = useState('visual');
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ marginTop: 12, marginBottom: 16 }}>
|
||||
<RadioGroup
|
||||
type='button'
|
||||
size='small'
|
||||
value={editMode}
|
||||
onChange={(e) => setEditMode(e.target.value)}
|
||||
>
|
||||
<Radio value='visual'>{t('可视化编辑')}</Radio>
|
||||
<Radio value='manual'>{t('手动编辑')}</Radio>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
{editMode === 'visual' ? (
|
||||
<ModelPricingEditor options={options} refresh={refresh} />
|
||||
) : (
|
||||
<ModelRatioSettings options={options} refresh={refresh} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
/*
|
||||
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 {
|
||||
Button,
|
||||
Col,
|
||||
Form,
|
||||
Popconfirm,
|
||||
Row,
|
||||
Space,
|
||||
Spin,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
compareObjects,
|
||||
API,
|
||||
showError,
|
||||
showSuccess,
|
||||
showWarning,
|
||||
verifyJSON,
|
||||
} from '../../../helpers';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export default function ModelRatioSettings(props) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [inputs, setInputs] = useState({
|
||||
ModelPrice: '',
|
||||
ModelRatio: '',
|
||||
CacheRatio: '',
|
||||
CreateCacheRatio: '',
|
||||
CompletionRatio: '',
|
||||
ImageRatio: '',
|
||||
AudioRatio: '',
|
||||
AudioCompletionRatio: '',
|
||||
ExposeRatioEnabled: false,
|
||||
});
|
||||
const refForm = useRef();
|
||||
const [inputsRow, setInputsRow] = useState(inputs);
|
||||
const { t } = useTranslation();
|
||||
|
||||
async function onSubmit() {
|
||||
try {
|
||||
await refForm.current
|
||||
.validate()
|
||||
.then(() => {
|
||||
const updateArray = compareObjects(inputs, inputsRow);
|
||||
if (!updateArray.length)
|
||||
return showWarning(t('你似乎并没有修改什么'));
|
||||
|
||||
const requestQueue = updateArray.map((item) => {
|
||||
const value =
|
||||
typeof inputs[item.key] === 'boolean'
|
||||
? String(inputs[item.key])
|
||||
: inputs[item.key];
|
||||
return API.put('/api/option/', { key: item.key, value });
|
||||
});
|
||||
|
||||
setLoading(true);
|
||||
Promise.all(requestQueue)
|
||||
.then((res) => {
|
||||
if (res.includes(undefined)) {
|
||||
return showError(
|
||||
requestQueue.length > 1
|
||||
? t('部分保存失败,请重试')
|
||||
: t('保存失败'),
|
||||
);
|
||||
}
|
||||
|
||||
for (let i = 0; i < res.length; i++) {
|
||||
if (!res[i].data.success) {
|
||||
return showError(res[i].data.message);
|
||||
}
|
||||
}
|
||||
|
||||
showSuccess(t('保存成功'));
|
||||
props.refresh();
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Unexpected error:', error);
|
||||
showError(t('保存失败,请重试'));
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
showError(t('请检查输入'));
|
||||
});
|
||||
} catch (error) {
|
||||
showError(t('请检查输入'));
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
async function resetModelRatio() {
|
||||
try {
|
||||
let res = await API.post(`/api/option/rest_model_ratio`);
|
||||
if (res.data.success) {
|
||||
showSuccess(res.data.message);
|
||||
props.refresh();
|
||||
} else {
|
||||
showError(res.data.message);
|
||||
}
|
||||
} catch (error) {
|
||||
showError(error);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const currentInputs = {};
|
||||
for (let key in props.options) {
|
||||
if (Object.keys(inputs).includes(key)) {
|
||||
currentInputs[key] = props.options[key];
|
||||
}
|
||||
}
|
||||
setInputs(currentInputs);
|
||||
setInputsRow(structuredClone(currentInputs));
|
||||
refForm.current.setValues(currentInputs);
|
||||
}, [props.options]);
|
||||
|
||||
return (
|
||||
<Spin spinning={loading}>
|
||||
<Form
|
||||
values={inputs}
|
||||
getFormApi={(formAPI) => (refForm.current = formAPI)}
|
||||
style={{ marginBottom: 15 }}
|
||||
>
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} sm={16}>
|
||||
<Form.TextArea
|
||||
label={t('模型固定价格')}
|
||||
extraText={t('一次调用消耗多少刀,优先级大于模型倍率')}
|
||||
placeholder={t(
|
||||
'为一个 JSON 文本,键为模型名称,值为一次调用消耗多少刀,比如 "gpt-4-gizmo-*": 0.1,一次消耗0.1刀',
|
||||
)}
|
||||
field={'ModelPrice'}
|
||||
autosize={{ minRows: 6, maxRows: 12 }}
|
||||
trigger='blur'
|
||||
stopValidateWithError
|
||||
rules={[
|
||||
{
|
||||
validator: (rule, value) => verifyJSON(value),
|
||||
message: '不是合法的 JSON 字符串',
|
||||
},
|
||||
]}
|
||||
onChange={(value) => setInputs({ ...inputs, ModelPrice: value })}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} sm={16}>
|
||||
<Form.TextArea
|
||||
label={t('模型倍率')}
|
||||
placeholder={t('为一个 JSON 文本,键为模型名称,值为倍率')}
|
||||
field={'ModelRatio'}
|
||||
autosize={{ minRows: 6, maxRows: 12 }}
|
||||
trigger='blur'
|
||||
stopValidateWithError
|
||||
rules={[
|
||||
{
|
||||
validator: (rule, value) => verifyJSON(value),
|
||||
message: '不是合法的 JSON 字符串',
|
||||
},
|
||||
]}
|
||||
onChange={(value) => setInputs({ ...inputs, ModelRatio: value })}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} sm={16}>
|
||||
<Form.TextArea
|
||||
label={t('提示缓存倍率')}
|
||||
placeholder={t('为一个 JSON 文本,键为模型名称,值为倍率')}
|
||||
field={'CacheRatio'}
|
||||
autosize={{ minRows: 6, maxRows: 12 }}
|
||||
trigger='blur'
|
||||
stopValidateWithError
|
||||
rules={[
|
||||
{
|
||||
validator: (rule, value) => verifyJSON(value),
|
||||
message: '不是合法的 JSON 字符串',
|
||||
},
|
||||
]}
|
||||
onChange={(value) => setInputs({ ...inputs, CacheRatio: value })}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} sm={16}>
|
||||
<Form.TextArea
|
||||
label={t('缓存创建倍率')}
|
||||
extraText={t(
|
||||
'默认为 5m 缓存创建倍率;1h 缓存创建倍率按固定乘法自动计算(当前为 1.6x)',
|
||||
)}
|
||||
placeholder={t('为一个 JSON 文本,键为模型名称,值为倍率')}
|
||||
field={'CreateCacheRatio'}
|
||||
autosize={{ minRows: 6, maxRows: 12 }}
|
||||
trigger='blur'
|
||||
stopValidateWithError
|
||||
rules={[
|
||||
{
|
||||
validator: (rule, value) => verifyJSON(value),
|
||||
message: '不是合法的 JSON 字符串',
|
||||
},
|
||||
]}
|
||||
onChange={(value) =>
|
||||
setInputs({ ...inputs, CreateCacheRatio: value })
|
||||
}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} sm={16}>
|
||||
<Form.TextArea
|
||||
label={t('模型补全倍率(仅对自定义模型有效)')}
|
||||
extraText={t('仅对自定义模型有效')}
|
||||
placeholder={t('为一个 JSON 文本,键为模型名称,值为倍率')}
|
||||
field={'CompletionRatio'}
|
||||
autosize={{ minRows: 6, maxRows: 12 }}
|
||||
trigger='blur'
|
||||
stopValidateWithError
|
||||
rules={[
|
||||
{
|
||||
validator: (rule, value) => verifyJSON(value),
|
||||
message: '不是合法的 JSON 字符串',
|
||||
},
|
||||
]}
|
||||
onChange={(value) =>
|
||||
setInputs({ ...inputs, CompletionRatio: value })
|
||||
}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} sm={16}>
|
||||
<Form.TextArea
|
||||
label={t('图片输入倍率(仅部分模型支持该计费)')}
|
||||
extraText={t(
|
||||
'图片输入相关的倍率设置,键为模型名称,值为倍率,仅部分模型支持该计费',
|
||||
)}
|
||||
placeholder={t(
|
||||
'为一个 JSON 文本,键为模型名称,值为倍率,例如:{"gpt-image-1": 2}',
|
||||
)}
|
||||
field={'ImageRatio'}
|
||||
autosize={{ minRows: 6, maxRows: 12 }}
|
||||
trigger='blur'
|
||||
stopValidateWithError
|
||||
rules={[
|
||||
{
|
||||
validator: (rule, value) => verifyJSON(value),
|
||||
message: '不是合法的 JSON 字符串',
|
||||
},
|
||||
]}
|
||||
onChange={(value) => setInputs({ ...inputs, ImageRatio: value })}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} sm={16}>
|
||||
<Form.TextArea
|
||||
label={t('音频倍率(仅部分模型支持该计费)')}
|
||||
extraText={t('音频输入相关的倍率设置,键为模型名称,值为倍率')}
|
||||
placeholder={t(
|
||||
'为一个 JSON 文本,键为模型名称,值为倍率,例如:{"gpt-4o-audio-preview": 16}',
|
||||
)}
|
||||
field={'AudioRatio'}
|
||||
autosize={{ minRows: 6, maxRows: 12 }}
|
||||
trigger='blur'
|
||||
stopValidateWithError
|
||||
rules={[
|
||||
{
|
||||
validator: (rule, value) => verifyJSON(value),
|
||||
message: '不是合法的 JSON 字符串',
|
||||
},
|
||||
]}
|
||||
onChange={(value) => setInputs({ ...inputs, AudioRatio: value })}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} sm={16}>
|
||||
<Form.TextArea
|
||||
label={t('音频补全倍率(仅部分模型支持该计费)')}
|
||||
extraText={t(
|
||||
'音频输出补全相关的倍率设置,键为模型名称,值为倍率',
|
||||
)}
|
||||
placeholder={t(
|
||||
'为一个 JSON 文本,键为模型名称,值为倍率,例如:{"gpt-4o-realtime": 2}',
|
||||
)}
|
||||
field={'AudioCompletionRatio'}
|
||||
autosize={{ minRows: 6, maxRows: 12 }}
|
||||
trigger='blur'
|
||||
stopValidateWithError
|
||||
rules={[
|
||||
{
|
||||
validator: (rule, value) => verifyJSON(value),
|
||||
message: '不是合法的 JSON 字符串',
|
||||
},
|
||||
]}
|
||||
onChange={(value) =>
|
||||
setInputs({ ...inputs, AudioCompletionRatio: value })
|
||||
}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={16}>
|
||||
<Col span={16}>
|
||||
<Form.Switch
|
||||
label={t('暴露倍率接口')}
|
||||
field={'ExposeRatioEnabled'}
|
||||
onChange={(value) =>
|
||||
setInputs({ ...inputs, ExposeRatioEnabled: value })
|
||||
}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</Form>
|
||||
<Space>
|
||||
<Button onClick={onSubmit}>{t('保存模型倍率设置')}</Button>
|
||||
<Popconfirm
|
||||
title={t('确定重置模型倍率吗?')}
|
||||
content={t('此修改将不可逆')}
|
||||
okType={'danger'}
|
||||
position={'top'}
|
||||
onConfirm={resetModelRatio}
|
||||
>
|
||||
<Button type={'danger'}>{t('重置模型倍率')}</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
</Spin>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/*
|
||||
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 { API, showError } from '../../../helpers';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import ModelPricingEditor from './components/ModelPricingEditor';
|
||||
|
||||
export default function ModelRatioNotSetEditor(props) {
|
||||
const { t } = useTranslation();
|
||||
const [enabledModels, setEnabledModels] = useState([]);
|
||||
|
||||
const getAllEnabledModels = async () => {
|
||||
try {
|
||||
const res = await API.get('/api/channel/models_enabled');
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
setEnabledModels(data);
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(t('获取启用模型失败:'), error);
|
||||
showError(t('获取启用模型失败'));
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// 获取所有启用的模型
|
||||
getAllEnabledModels();
|
||||
}, []);
|
||||
return (
|
||||
<ModelPricingEditor
|
||||
options={props.options}
|
||||
refresh={props.refresh}
|
||||
candidateModelNames={enabledModels}
|
||||
filterMode='unset'
|
||||
allowAddModel={false}
|
||||
allowDeleteModel={false}
|
||||
showConflictFilter={false}
|
||||
listDescription={t(
|
||||
'此页面仅显示未设置价格或基础倍率的模型,设置后会自动从列表中移出',
|
||||
)}
|
||||
emptyTitle={t('没有未设置定价的模型')}
|
||||
emptyDescription={t('当前没有未设置定价的模型')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/*
|
||||
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 ModelPricingEditor from './components/ModelPricingEditor';
|
||||
|
||||
export default function ModelSettingsVisualEditor(props) {
|
||||
return <ModelPricingEditor options={props.options} refresh={props.refresh} />;
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
/*
|
||||
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 {
|
||||
Banner,
|
||||
Button,
|
||||
Input,
|
||||
InputNumber,
|
||||
Radio,
|
||||
RadioGroup,
|
||||
Table,
|
||||
TextArea,
|
||||
Typography,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { IconCopy, IconDelete, IconPlus } from '@douyinfe/semi-icons';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { API, copy, showError, showSuccess } from '../../../helpers';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const OPTION_KEY = 'tool_price_setting.prices';
|
||||
|
||||
const DEFAULT_PRICES = {
|
||||
web_search: 10.0,
|
||||
web_search_preview: 10.0,
|
||||
'web_search_preview:gpt-4o*': 25.0,
|
||||
'web_search_preview:gpt-4.1*': 25.0,
|
||||
'web_search_preview:gpt-4o-mini*': 25.0,
|
||||
'web_search_preview:gpt-4.1-mini*': 25.0,
|
||||
file_search: 2.5,
|
||||
google_search: 14.0,
|
||||
};
|
||||
|
||||
function rowsToObject(rows) {
|
||||
const prices = {};
|
||||
for (const row of rows) {
|
||||
const k = row.key.trim();
|
||||
if (!k) continue;
|
||||
prices[k] = Number(row.price) || 0;
|
||||
}
|
||||
return prices;
|
||||
}
|
||||
|
||||
function objectToRows(prices) {
|
||||
return Object.entries(prices).map(([key, price], i) => ({
|
||||
id: i,
|
||||
key,
|
||||
price,
|
||||
}));
|
||||
}
|
||||
|
||||
export default function ToolPriceSettings({ options }) {
|
||||
const { t } = useTranslation();
|
||||
const [rows, setRows] = useState([]);
|
||||
const [mode, setMode] = useState('visual');
|
||||
const [jsonText, setJsonText] = useState('');
|
||||
const [jsonError, setJsonError] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let prices = {};
|
||||
try {
|
||||
const raw = options?.[OPTION_KEY];
|
||||
if (raw) {
|
||||
prices = typeof raw === 'string' ? JSON.parse(raw) : raw;
|
||||
}
|
||||
} catch {
|
||||
prices = {};
|
||||
}
|
||||
|
||||
if (!prices || Object.keys(prices).length === 0) {
|
||||
prices = { ...DEFAULT_PRICES };
|
||||
}
|
||||
|
||||
setRows(objectToRows(prices));
|
||||
setJsonText(JSON.stringify(prices, null, 2));
|
||||
}, [options]);
|
||||
|
||||
const syncToJson = (nextRows) => {
|
||||
setRows(nextRows);
|
||||
setJsonText(JSON.stringify(rowsToObject(nextRows), null, 2));
|
||||
setJsonError('');
|
||||
};
|
||||
|
||||
const syncToVisual = (text) => {
|
||||
setJsonText(text);
|
||||
try {
|
||||
const parsed = JSON.parse(text);
|
||||
if (typeof parsed !== 'object' || Array.isArray(parsed) || parsed === null) {
|
||||
setJsonError(t('JSON 必须是对象'));
|
||||
return;
|
||||
}
|
||||
setRows(objectToRows(parsed));
|
||||
setJsonError('');
|
||||
} catch (e) {
|
||||
setJsonError(e.message);
|
||||
}
|
||||
};
|
||||
|
||||
const updateRow = (id, field, value) => {
|
||||
syncToJson(rows.map((r) => (r.id === id ? { ...r, [field]: value } : r)));
|
||||
};
|
||||
|
||||
const addRow = () => {
|
||||
syncToJson([...rows, { id: Date.now(), key: '', price: 0 }]);
|
||||
};
|
||||
|
||||
const removeRow = (id) => {
|
||||
syncToJson(rows.filter((r) => r.id !== id));
|
||||
};
|
||||
|
||||
const resetToDefault = () => {
|
||||
syncToJson(objectToRows(DEFAULT_PRICES));
|
||||
};
|
||||
|
||||
const currentPrices = useMemo(() => rowsToObject(rows), [rows]);
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await API.put('/api/option/', {
|
||||
key: OPTION_KEY,
|
||||
value: JSON.stringify(currentPrices),
|
||||
});
|
||||
if (res.data.success) {
|
||||
showSuccess(t('保存成功'));
|
||||
} else {
|
||||
showError(res.data.message || t('保存失败'));
|
||||
}
|
||||
} catch (e) {
|
||||
showError(e.message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: t('工具标识'),
|
||||
dataIndex: 'key',
|
||||
render: (text, record) => (
|
||||
<Input
|
||||
value={text}
|
||||
placeholder='web_search_preview:gpt-4o*'
|
||||
onChange={(val) => updateRow(record.id, 'key', val)}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('价格') + ' ($/1K' + t('次') + ')',
|
||||
dataIndex: 'price',
|
||||
width: 160,
|
||||
render: (val, record) => (
|
||||
<InputNumber
|
||||
value={val}
|
||||
min={0}
|
||||
step={0.5}
|
||||
onChange={(v) => updateRow(record.id, 'price', v ?? 0)}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('操作'),
|
||||
width: 60,
|
||||
render: (_, record) => (
|
||||
<Button
|
||||
icon={<IconDelete />}
|
||||
type='danger'
|
||||
theme='borderless'
|
||||
size='small'
|
||||
onClick={() => removeRow(record.id)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 700 }}>
|
||||
<Banner
|
||||
type='info'
|
||||
description={
|
||||
<>
|
||||
<div>{t('配置各工具的调用价格($/1K次调用)。按次计费模型不额外收取工具费用。')}</div>
|
||||
<div style={{ marginTop: 4 }}>
|
||||
<Text strong>{t('格式')}:</Text>
|
||||
<code>web_search_preview</code> {t('为默认价格')},
|
||||
<code>web_search_preview:gpt-4o*</code> {t('为模型前缀覆盖')}
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
|
||||
<RadioGroup
|
||||
type='button'
|
||||
size='small'
|
||||
value={mode}
|
||||
onChange={(e) => setMode(e.target.value)}
|
||||
style={{ marginBottom: 12 }}
|
||||
>
|
||||
<Radio value='visual'>{t('可视化')}</Radio>
|
||||
<Radio value='json'>JSON</Radio>
|
||||
</RadioGroup>
|
||||
|
||||
{mode === 'visual' ? (
|
||||
<>
|
||||
<Table
|
||||
dataSource={rows}
|
||||
columns={columns}
|
||||
pagination={false}
|
||||
size='small'
|
||||
rowKey='id'
|
||||
/>
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
|
||||
<Button icon={<IconPlus />} onClick={addRow}>
|
||||
{t('添加')}
|
||||
</Button>
|
||||
<Button theme='borderless' onClick={resetToDefault}>
|
||||
{t('恢复默认')}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<TextArea
|
||||
value={jsonText}
|
||||
onChange={syncToVisual}
|
||||
autosize={{ minRows: 8, maxRows: 20 }}
|
||||
style={{ fontFamily: 'monospace', fontSize: 13 }}
|
||||
/>
|
||||
{jsonError && (
|
||||
<Text type='danger' size='small' style={{ display: 'block', marginTop: 4 }}>
|
||||
{jsonError}
|
||||
</Text>
|
||||
)}
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
|
||||
<Button
|
||||
icon={<IconCopy />}
|
||||
size='small'
|
||||
theme='borderless'
|
||||
onClick={() => { copy(jsonText, t('JSON')); }}
|
||||
>
|
||||
{t('复制')}
|
||||
</Button>
|
||||
<Button size='small' theme='borderless' onClick={resetToDefault}>
|
||||
{t('恢复默认')}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 16 }}>
|
||||
<Button
|
||||
theme='solid'
|
||||
type='primary'
|
||||
loading={saving}
|
||||
disabled={mode === 'json' && !!jsonError}
|
||||
onClick={handleSave}
|
||||
>
|
||||
{t('保存')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,169 @@
|
||||
import React, { useState, useCallback, useMemo } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Select,
|
||||
Typography,
|
||||
Popconfirm,
|
||||
Tag,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
IconPlus,
|
||||
IconDelete,
|
||||
IconChevronUp,
|
||||
IconChevronDown,
|
||||
} from '@douyinfe/semi-icons';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
let _idCounter = 0;
|
||||
const uid = () => `ag_${++_idCounter}`;
|
||||
|
||||
function parseAutoGroups(str) {
|
||||
if (!str || !str.trim()) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(str);
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
return parsed
|
||||
.filter((item) => typeof item === 'string')
|
||||
.map((name) => ({ _id: uid(), name }));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function serializeAutoGroups(items) {
|
||||
const names = items.map((i) => i.name).filter(Boolean);
|
||||
return names.length === 0 ? '' : JSON.stringify(names);
|
||||
}
|
||||
|
||||
export default function AutoGroupList({ value, groupNames = [], onChange }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [items, setItems] = useState(() => parseAutoGroups(value));
|
||||
|
||||
const emitChange = useCallback(
|
||||
(newItems) => {
|
||||
setItems(newItems);
|
||||
onChange?.(serializeAutoGroups(newItems));
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const groupOptions = useMemo(
|
||||
() => groupNames.map((n) => ({ value: n, label: n })),
|
||||
[groupNames],
|
||||
);
|
||||
|
||||
const addItem = useCallback(() => {
|
||||
emitChange([...items, { _id: uid(), name: '' }]);
|
||||
}, [items, emitChange]);
|
||||
|
||||
const removeItem = useCallback(
|
||||
(id) => {
|
||||
emitChange(items.filter((i) => i._id !== id));
|
||||
},
|
||||
[items, emitChange],
|
||||
);
|
||||
|
||||
const updateItem = useCallback(
|
||||
(id, name) => {
|
||||
emitChange(items.map((i) => (i._id === id ? { ...i, name } : i)));
|
||||
},
|
||||
[items, emitChange],
|
||||
);
|
||||
|
||||
const moveUp = useCallback(
|
||||
(index) => {
|
||||
if (index <= 0) return;
|
||||
const next = [...items];
|
||||
[next[index - 1], next[index]] = [next[index], next[index - 1]];
|
||||
emitChange(next);
|
||||
},
|
||||
[items, emitChange],
|
||||
);
|
||||
|
||||
const moveDown = useCallback(
|
||||
(index) => {
|
||||
if (index >= items.length - 1) return;
|
||||
const next = [...items];
|
||||
[next[index], next[index + 1]] = [next[index + 1], next[index]];
|
||||
emitChange(next);
|
||||
},
|
||||
[items, emitChange],
|
||||
);
|
||||
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div>
|
||||
<Text type='tertiary' className='block text-center py-4'>
|
||||
{t('暂无自动分组,点击下方按钮添加')}
|
||||
</Text>
|
||||
<div className='mt-2 flex justify-center'>
|
||||
<Button icon={<IconPlus />} theme='outline' onClick={addItem}>
|
||||
{t('添加分组')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className='space-y-2'>
|
||||
{items.map((item, index) => (
|
||||
<div
|
||||
key={item._id}
|
||||
className='flex items-center gap-2'
|
||||
>
|
||||
<Tag size='small' color='blue' className='shrink-0'>
|
||||
{index + 1}
|
||||
</Tag>
|
||||
<Select
|
||||
size='small'
|
||||
filter
|
||||
value={item.name || undefined}
|
||||
placeholder={t('选择分组')}
|
||||
optionList={groupOptions}
|
||||
onChange={(v) => updateItem(item._id, v)}
|
||||
style={{ flex: 1 }}
|
||||
allowCreate
|
||||
position='bottomLeft'
|
||||
/>
|
||||
<Button
|
||||
icon={<IconChevronUp />}
|
||||
theme='borderless'
|
||||
size='small'
|
||||
disabled={index === 0}
|
||||
onClick={() => moveUp(index)}
|
||||
/>
|
||||
<Button
|
||||
icon={<IconChevronDown />}
|
||||
theme='borderless'
|
||||
size='small'
|
||||
disabled={index === items.length - 1}
|
||||
onClick={() => moveDown(index)}
|
||||
/>
|
||||
<Popconfirm
|
||||
title={t('确认移除?')}
|
||||
onConfirm={() => removeItem(item._id)}
|
||||
position='left'
|
||||
>
|
||||
<Button
|
||||
icon={<IconDelete />}
|
||||
type='danger'
|
||||
theme='borderless'
|
||||
size='small'
|
||||
/>
|
||||
</Popconfirm>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className='mt-3 flex justify-center'>
|
||||
<Button icon={<IconPlus />} theme='outline' onClick={addItem}>
|
||||
{t('添加分组')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
import React, { useState, useCallback, useMemo } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Collapsible,
|
||||
Input,
|
||||
InputNumber,
|
||||
Select,
|
||||
Tag,
|
||||
Typography,
|
||||
Popconfirm,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
IconPlus,
|
||||
IconDelete,
|
||||
IconChevronDown,
|
||||
IconChevronUp,
|
||||
} from '@douyinfe/semi-icons';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
let _idCounter = 0;
|
||||
const uid = () => `ggr_${++_idCounter}`;
|
||||
|
||||
function parseJSON(str) {
|
||||
if (!str || !str.trim()) return {};
|
||||
try {
|
||||
return JSON.parse(str);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function flattenRules(nested) {
|
||||
const rules = [];
|
||||
for (const [userGroup, inner] of Object.entries(nested)) {
|
||||
if (typeof inner !== 'object' || inner === null) continue;
|
||||
for (const [usingGroup, ratio] of Object.entries(inner)) {
|
||||
rules.push({
|
||||
_id: uid(),
|
||||
userGroup,
|
||||
usingGroup,
|
||||
ratio: typeof ratio === 'number' ? ratio : 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
return rules;
|
||||
}
|
||||
|
||||
function nestRules(rules) {
|
||||
const result = {};
|
||||
rules.forEach(({ userGroup, usingGroup, ratio }) => {
|
||||
if (!userGroup || !usingGroup) return;
|
||||
if (!result[userGroup]) result[userGroup] = {};
|
||||
result[userGroup][usingGroup] = ratio;
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
export function serializeGroupGroupRatio(rules) {
|
||||
const nested = nestRules(rules);
|
||||
return Object.keys(nested).length === 0
|
||||
? ''
|
||||
: JSON.stringify(nested, null, 2);
|
||||
}
|
||||
|
||||
function GroupSection({ groupName, items, groupOptions, onUpdate, onRemove, onAdd, t }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
border: '1px solid var(--semi-color-border)',
|
||||
borderRadius: 8,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className='flex items-center justify-between cursor-pointer'
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
background: 'var(--semi-color-fill-0)',
|
||||
}}
|
||||
onClick={() => setOpen(!open)}
|
||||
>
|
||||
<div className='flex items-center gap-2'>
|
||||
{open ? <IconChevronUp size='small' /> : <IconChevronDown size='small' />}
|
||||
<Text strong>{groupName}</Text>
|
||||
<Tag size='small' color='blue'>{items.length} {t('条规则')}</Tag>
|
||||
</div>
|
||||
<div className='flex items-center gap-1' onClick={(e) => e.stopPropagation()}>
|
||||
<Button
|
||||
icon={<IconPlus />}
|
||||
size='small'
|
||||
theme='borderless'
|
||||
onClick={() => onAdd(groupName)}
|
||||
/>
|
||||
<Popconfirm
|
||||
title={t('确认删除该分组的所有规则?')}
|
||||
onConfirm={() => items.forEach((item) => onRemove(item._id))}
|
||||
position='left'
|
||||
>
|
||||
<Button
|
||||
icon={<IconDelete />}
|
||||
size='small'
|
||||
type='danger'
|
||||
theme='borderless'
|
||||
/>
|
||||
</Popconfirm>
|
||||
</div>
|
||||
</div>
|
||||
<Collapsible isOpen={open} keepDOM>
|
||||
<div style={{ padding: '8px 12px' }}>
|
||||
{items.map((rule) => (
|
||||
<div
|
||||
key={rule._id}
|
||||
className='flex items-center gap-2'
|
||||
style={{ marginBottom: 6 }}
|
||||
>
|
||||
<Select
|
||||
size='small'
|
||||
filter
|
||||
value={rule.usingGroup || undefined}
|
||||
placeholder={t('选择使用分组')}
|
||||
optionList={groupOptions}
|
||||
onChange={(v) => onUpdate(rule._id, 'usingGroup', v)}
|
||||
style={{ flex: 1 }}
|
||||
allowCreate
|
||||
position='bottomLeft'
|
||||
/>
|
||||
<InputNumber
|
||||
size='small'
|
||||
min={0}
|
||||
step={0.1}
|
||||
value={rule.ratio}
|
||||
style={{ width: 100 }}
|
||||
onChange={(v) => onUpdate(rule._id, 'ratio', v ?? 0)}
|
||||
/>
|
||||
<Popconfirm
|
||||
title={t('确认删除该规则?')}
|
||||
onConfirm={() => onRemove(rule._id)}
|
||||
position='left'
|
||||
>
|
||||
<Button
|
||||
icon={<IconDelete />}
|
||||
type='danger'
|
||||
theme='borderless'
|
||||
size='small'
|
||||
/>
|
||||
</Popconfirm>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Collapsible>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function GroupGroupRatioRules({
|
||||
value,
|
||||
groupNames = [],
|
||||
onChange,
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [rules, setRules] = useState(() => flattenRules(parseJSON(value)));
|
||||
const [newGroupName, setNewGroupName] = useState('');
|
||||
|
||||
const emitChange = useCallback(
|
||||
(newRules) => {
|
||||
setRules(newRules);
|
||||
onChange?.(serializeGroupGroupRatio(newRules));
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const updateRule = useCallback(
|
||||
(id, field, val) => {
|
||||
emitChange(rules.map((r) => (r._id === id ? { ...r, [field]: val } : r)));
|
||||
},
|
||||
[rules, emitChange],
|
||||
);
|
||||
|
||||
const removeRule = useCallback(
|
||||
(id) => {
|
||||
emitChange(rules.filter((r) => r._id !== id));
|
||||
},
|
||||
[rules, emitChange],
|
||||
);
|
||||
|
||||
const addRuleToGroup = useCallback(
|
||||
(groupName) => {
|
||||
emitChange([
|
||||
...rules,
|
||||
{ _id: uid(), userGroup: groupName, usingGroup: '', ratio: 1 },
|
||||
]);
|
||||
},
|
||||
[rules, emitChange],
|
||||
);
|
||||
|
||||
const addNewGroup = useCallback(() => {
|
||||
const name = newGroupName.trim();
|
||||
if (!name) return;
|
||||
emitChange([
|
||||
...rules,
|
||||
{ _id: uid(), userGroup: name, usingGroup: '', ratio: 1 },
|
||||
]);
|
||||
setNewGroupName('');
|
||||
}, [rules, emitChange, newGroupName]);
|
||||
|
||||
const groupOptions = useMemo(
|
||||
() => groupNames.map((n) => ({ value: n, label: n })),
|
||||
[groupNames],
|
||||
);
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
const map = {};
|
||||
const order = [];
|
||||
rules.forEach((r) => {
|
||||
if (!r.userGroup) return;
|
||||
if (!map[r.userGroup]) {
|
||||
map[r.userGroup] = [];
|
||||
order.push(r.userGroup);
|
||||
}
|
||||
map[r.userGroup].push(r);
|
||||
});
|
||||
return order.map((name) => ({ name, items: map[name] }));
|
||||
}, [rules]);
|
||||
|
||||
if (grouped.length === 0 && rules.length === 0) {
|
||||
return (
|
||||
<div>
|
||||
<Text type='tertiary' className='block text-center py-4'>
|
||||
{t('暂无规则,点击下方按钮添加')}
|
||||
</Text>
|
||||
<div className='mt-2 flex justify-center gap-2'>
|
||||
<Select
|
||||
size='small'
|
||||
filter
|
||||
allowCreate
|
||||
placeholder={t('选择用户分组')}
|
||||
optionList={groupOptions}
|
||||
value={newGroupName || undefined}
|
||||
onChange={setNewGroupName}
|
||||
style={{ width: 200 }}
|
||||
position='bottomLeft'
|
||||
/>
|
||||
<Button icon={<IconPlus />} theme='outline' onClick={addNewGroup}>
|
||||
{t('添加分组规则')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='space-y-2'>
|
||||
{grouped.map((group) => (
|
||||
<GroupSection
|
||||
key={group.name}
|
||||
groupName={group.name}
|
||||
items={group.items}
|
||||
groupOptions={groupOptions}
|
||||
onUpdate={updateRule}
|
||||
onRemove={removeRule}
|
||||
onAdd={addRuleToGroup}
|
||||
t={t}
|
||||
/>
|
||||
))}
|
||||
<div className='mt-3 flex justify-center gap-2'>
|
||||
<Select
|
||||
size='small'
|
||||
filter
|
||||
allowCreate
|
||||
placeholder={t('选择用户分组')}
|
||||
optionList={groupOptions}
|
||||
value={newGroupName || undefined}
|
||||
onChange={setNewGroupName}
|
||||
style={{ width: 200 }}
|
||||
position='bottomLeft'
|
||||
/>
|
||||
<Button icon={<IconPlus />} theme='outline' onClick={addNewGroup}>
|
||||
{t('添加分组规则')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
/*
|
||||
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, useCallback, useMemo } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Collapsible,
|
||||
Input,
|
||||
Select,
|
||||
Tag,
|
||||
Typography,
|
||||
Popconfirm,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
IconPlus,
|
||||
IconDelete,
|
||||
IconChevronDown,
|
||||
IconChevronUp,
|
||||
} from '@douyinfe/semi-icons';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
let _idCounter = 0;
|
||||
const uid = () => `gsu_${++_idCounter}`;
|
||||
|
||||
const OP_ADD = 'add';
|
||||
const OP_REMOVE = 'remove';
|
||||
const OP_APPEND = 'append';
|
||||
|
||||
function parsePrefix(rawKey) {
|
||||
if (rawKey.startsWith('+:')) return { op: OP_ADD, groupName: rawKey.slice(2) };
|
||||
if (rawKey.startsWith('-:')) return { op: OP_REMOVE, groupName: rawKey.slice(2) };
|
||||
return { op: OP_APPEND, groupName: rawKey };
|
||||
}
|
||||
|
||||
function toRawKey(op, groupName) {
|
||||
if (op === OP_ADD) return `+:${groupName}`;
|
||||
if (op === OP_REMOVE) return `-:${groupName}`;
|
||||
return groupName;
|
||||
}
|
||||
|
||||
function parseJSON(str) {
|
||||
if (!str || !str.trim()) return {};
|
||||
try { return JSON.parse(str); } catch { return {}; }
|
||||
}
|
||||
|
||||
function flattenRules(nested) {
|
||||
const rules = [];
|
||||
for (const [userGroup, inner] of Object.entries(nested)) {
|
||||
if (typeof inner !== 'object' || inner === null) continue;
|
||||
for (const [rawKey, desc] of Object.entries(inner)) {
|
||||
const { op, groupName } = parsePrefix(rawKey);
|
||||
rules.push({
|
||||
_id: uid(),
|
||||
userGroup,
|
||||
op,
|
||||
targetGroup: groupName,
|
||||
description: op === OP_REMOVE ? 'remove' : (typeof desc === 'string' ? desc : ''),
|
||||
});
|
||||
}
|
||||
}
|
||||
return rules;
|
||||
}
|
||||
|
||||
function nestRules(rules) {
|
||||
const result = {};
|
||||
rules.forEach(({ userGroup, op, targetGroup, description }) => {
|
||||
if (!userGroup || !targetGroup) return;
|
||||
if (!result[userGroup]) result[userGroup] = {};
|
||||
result[userGroup][toRawKey(op, targetGroup)] = description;
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
export function serializeGroupSpecialUsable(rules) {
|
||||
const nested = nestRules(rules);
|
||||
return Object.keys(nested).length === 0 ? '' : JSON.stringify(nested, null, 2);
|
||||
}
|
||||
|
||||
const OP_TAG_MAP = {
|
||||
[OP_ADD]: { color: 'green', label: '添加 (+:)' },
|
||||
[OP_REMOVE]: { color: 'red', label: '移除 (-:)' },
|
||||
[OP_APPEND]: { color: 'blue', label: '追加' },
|
||||
};
|
||||
|
||||
function UsableGroupSection({ groupName, items, opOptions, onUpdate, onRemove, onAdd, t }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
border: '1px solid var(--semi-color-border)',
|
||||
borderRadius: 8,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className='flex items-center justify-between cursor-pointer'
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
background: 'var(--semi-color-fill-0)',
|
||||
}}
|
||||
onClick={() => setOpen(!open)}
|
||||
>
|
||||
<div className='flex items-center gap-2'>
|
||||
{open ? <IconChevronUp size='small' /> : <IconChevronDown size='small' />}
|
||||
<Text strong>{groupName}</Text>
|
||||
<Tag size='small' color='blue'>{items.length} {t('条规则')}</Tag>
|
||||
</div>
|
||||
<div className='flex items-center gap-1' onClick={(e) => e.stopPropagation()}>
|
||||
<Button
|
||||
icon={<IconPlus />}
|
||||
size='small'
|
||||
theme='borderless'
|
||||
onClick={() => onAdd(groupName)}
|
||||
/>
|
||||
<Popconfirm
|
||||
title={t('确认删除该分组的所有规则?')}
|
||||
onConfirm={() => items.forEach((item) => onRemove(item._id))}
|
||||
position='left'
|
||||
>
|
||||
<Button
|
||||
icon={<IconDelete />}
|
||||
size='small'
|
||||
type='danger'
|
||||
theme='borderless'
|
||||
/>
|
||||
</Popconfirm>
|
||||
</div>
|
||||
</div>
|
||||
<Collapsible isOpen={open} keepDOM>
|
||||
<div style={{ padding: '8px 12px' }}>
|
||||
{items.map((rule) => (
|
||||
<div
|
||||
key={rule._id}
|
||||
className='flex items-center gap-2'
|
||||
style={{ marginBottom: 6 }}
|
||||
>
|
||||
<Select
|
||||
size='small'
|
||||
value={rule.op}
|
||||
optionList={opOptions}
|
||||
onChange={(v) => onUpdate(rule._id, 'op', v)}
|
||||
style={{ width: 120 }}
|
||||
renderSelectedItem={(optionNode) => {
|
||||
const info = OP_TAG_MAP[optionNode.value] || {};
|
||||
return <Tag size='small' color={info.color}>{optionNode.label}</Tag>;
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
size='small'
|
||||
value={rule.targetGroup}
|
||||
placeholder={t('分组名称')}
|
||||
onChange={(v) => onUpdate(rule._id, 'targetGroup', v)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
{rule.op !== OP_REMOVE ? (
|
||||
<Input
|
||||
size='small'
|
||||
value={rule.description}
|
||||
placeholder={t('分组描述')}
|
||||
onChange={(v) => onUpdate(rule._id, 'description', v)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
) : (
|
||||
<div style={{ flex: 1 }}>
|
||||
<Text type='tertiary' size='small'>-</Text>
|
||||
</div>
|
||||
)}
|
||||
<Popconfirm
|
||||
title={t('确认删除该规则?')}
|
||||
onConfirm={() => onRemove(rule._id)}
|
||||
position='left'
|
||||
>
|
||||
<Button
|
||||
icon={<IconDelete />}
|
||||
type='danger'
|
||||
theme='borderless'
|
||||
size='small'
|
||||
/>
|
||||
</Popconfirm>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Collapsible>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function GroupSpecialUsableRules({
|
||||
value,
|
||||
groupNames = [],
|
||||
onChange,
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [rules, setRules] = useState(() => flattenRules(parseJSON(value)));
|
||||
const [newGroupName, setNewGroupName] = useState('');
|
||||
|
||||
const emitChange = useCallback(
|
||||
(newRules) => {
|
||||
setRules(newRules);
|
||||
onChange?.(serializeGroupSpecialUsable(newRules));
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const updateRule = useCallback(
|
||||
(id, field, val) => {
|
||||
emitChange(
|
||||
rules.map((r) => {
|
||||
if (r._id !== id) return r;
|
||||
const updated = { ...r, [field]: val };
|
||||
if (field === 'op' && val === OP_REMOVE) updated.description = 'remove';
|
||||
else if (field === 'op' && r.op === OP_REMOVE && val !== OP_REMOVE) {
|
||||
if (updated.description === 'remove') updated.description = '';
|
||||
}
|
||||
return updated;
|
||||
}),
|
||||
);
|
||||
},
|
||||
[rules, emitChange],
|
||||
);
|
||||
|
||||
const removeRule = useCallback(
|
||||
(id) => emitChange(rules.filter((r) => r._id !== id)),
|
||||
[rules, emitChange],
|
||||
);
|
||||
|
||||
const addRuleToGroup = useCallback(
|
||||
(groupName) => {
|
||||
emitChange([
|
||||
...rules,
|
||||
{ _id: uid(), userGroup: groupName, op: OP_APPEND, targetGroup: '', description: '' },
|
||||
]);
|
||||
},
|
||||
[rules, emitChange],
|
||||
);
|
||||
|
||||
const addNewGroup = useCallback(() => {
|
||||
const name = newGroupName.trim();
|
||||
if (!name) return;
|
||||
emitChange([
|
||||
...rules,
|
||||
{ _id: uid(), userGroup: name, op: OP_APPEND, targetGroup: '', description: '' },
|
||||
]);
|
||||
setNewGroupName('');
|
||||
}, [rules, emitChange, newGroupName]);
|
||||
|
||||
const groupOptions = useMemo(
|
||||
() => groupNames.map((n) => ({ value: n, label: n })),
|
||||
[groupNames],
|
||||
);
|
||||
|
||||
const opOptions = useMemo(
|
||||
() => [
|
||||
{ value: OP_ADD, label: t('添加 (+:)') },
|
||||
{ value: OP_REMOVE, label: t('移除 (-:)') },
|
||||
{ value: OP_APPEND, label: t('追加') },
|
||||
],
|
||||
[t],
|
||||
);
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
const map = {};
|
||||
const order = [];
|
||||
rules.forEach((r) => {
|
||||
if (!r.userGroup) return;
|
||||
if (!map[r.userGroup]) {
|
||||
map[r.userGroup] = [];
|
||||
order.push(r.userGroup);
|
||||
}
|
||||
map[r.userGroup].push(r);
|
||||
});
|
||||
return order.map((name) => ({ name, items: map[name] }));
|
||||
}, [rules]);
|
||||
|
||||
if (grouped.length === 0 && rules.length === 0) {
|
||||
return (
|
||||
<div>
|
||||
<Text type='tertiary' className='block text-center py-4'>
|
||||
{t('暂无规则,点击下方按钮添加')}
|
||||
</Text>
|
||||
<div className='mt-2 flex justify-center gap-2'>
|
||||
<Select
|
||||
size='small'
|
||||
filter
|
||||
allowCreate
|
||||
placeholder={t('选择用户分组')}
|
||||
optionList={groupOptions}
|
||||
value={newGroupName || undefined}
|
||||
onChange={setNewGroupName}
|
||||
style={{ width: 200 }}
|
||||
position='bottomLeft'
|
||||
/>
|
||||
<Button icon={<IconPlus />} theme='outline' onClick={addNewGroup}>
|
||||
{t('添加分组规则')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='space-y-2'>
|
||||
{grouped.map((group) => (
|
||||
<UsableGroupSection
|
||||
key={group.name}
|
||||
groupName={group.name}
|
||||
items={group.items}
|
||||
opOptions={opOptions}
|
||||
onUpdate={updateRule}
|
||||
onRemove={removeRule}
|
||||
onAdd={addRuleToGroup}
|
||||
t={t}
|
||||
/>
|
||||
))}
|
||||
<div className='mt-3 flex justify-center gap-2'>
|
||||
<Select
|
||||
size='small'
|
||||
filter
|
||||
allowCreate
|
||||
placeholder={t('选择用户分组')}
|
||||
optionList={groupOptions}
|
||||
value={newGroupName || undefined}
|
||||
onChange={setNewGroupName}
|
||||
style={{ width: 200 }}
|
||||
position='bottomLeft'
|
||||
/>
|
||||
<Button icon={<IconPlus />} theme='outline' onClick={addNewGroup}>
|
||||
{t('添加分组规则')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
import React, { useState, useCallback, useMemo, useRef } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
InputNumber,
|
||||
Checkbox,
|
||||
Typography,
|
||||
Popconfirm,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { IconPlus, IconDelete } from '@douyinfe/semi-icons';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import CardTable from '../../../../components/common/ui/CardTable';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
let _idCounter = 0;
|
||||
const uid = () => `gr_${++_idCounter}`;
|
||||
|
||||
function parseJSON(str, fallback) {
|
||||
if (!str || !str.trim()) return fallback;
|
||||
try {
|
||||
return JSON.parse(str);
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function buildRows(groupRatioStr, userUsableGroupsStr) {
|
||||
const ratioMap = parseJSON(groupRatioStr, {});
|
||||
const usableMap = parseJSON(userUsableGroupsStr, {});
|
||||
|
||||
const allNames = new Set([
|
||||
...Object.keys(ratioMap),
|
||||
...Object.keys(usableMap),
|
||||
]);
|
||||
|
||||
return Array.from(allNames).map((name) => ({
|
||||
_id: uid(),
|
||||
name,
|
||||
ratio: ratioMap[name] ?? 1,
|
||||
selectable: name in usableMap,
|
||||
description: usableMap[name] ?? '',
|
||||
}));
|
||||
}
|
||||
|
||||
export function serializeGroupTable(rows) {
|
||||
const groupRatio = {};
|
||||
const userUsableGroups = {};
|
||||
|
||||
rows.forEach((row) => {
|
||||
if (!row.name) return;
|
||||
groupRatio[row.name] = row.ratio;
|
||||
if (row.selectable) {
|
||||
userUsableGroups[row.name] = row.description;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
GroupRatio: JSON.stringify(groupRatio, null, 2),
|
||||
UserUsableGroups: JSON.stringify(userUsableGroups, null, 2),
|
||||
};
|
||||
}
|
||||
|
||||
export default function GroupTable({ groupRatio, userUsableGroups, onChange }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [rows, setRows] = useState(() =>
|
||||
buildRows(groupRatio, userUsableGroups),
|
||||
);
|
||||
|
||||
// Use functional setRows to keep updateRow/addRow/removeRow referentially
|
||||
// stable, preventing columns useMemo from rebuilding on every keystroke
|
||||
// which causes the Input cursor to jump to end (cursor reset bug).
|
||||
const onChangeRef = useRef(onChange);
|
||||
onChangeRef.current = onChange;
|
||||
|
||||
const emitAndSet = useCallback((updater) => {
|
||||
setRows((prev) => {
|
||||
const next = typeof updater === 'function' ? updater(prev) : updater;
|
||||
onChangeRef.current?.(serializeGroupTable(next));
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const updateRow = useCallback(
|
||||
(id, field, value) => {
|
||||
emitAndSet((prev) =>
|
||||
prev.map((r) => (r._id === id ? { ...r, [field]: value } : r)),
|
||||
);
|
||||
},
|
||||
[emitAndSet],
|
||||
);
|
||||
|
||||
const addRow = useCallback(() => {
|
||||
emitAndSet((prev) => {
|
||||
const existingNames = new Set(prev.map((r) => r.name));
|
||||
let counter = 1;
|
||||
let newName = `group_${counter}`;
|
||||
while (existingNames.has(newName)) {
|
||||
counter++;
|
||||
newName = `group_${counter}`;
|
||||
}
|
||||
return [
|
||||
...prev,
|
||||
{
|
||||
_id: uid(),
|
||||
name: newName,
|
||||
ratio: 1,
|
||||
selectable: true,
|
||||
description: '',
|
||||
},
|
||||
];
|
||||
});
|
||||
}, [emitAndSet]);
|
||||
|
||||
const removeRow = useCallback(
|
||||
(id) => {
|
||||
emitAndSet((prev) => prev.filter((r) => r._id !== id));
|
||||
},
|
||||
[emitAndSet],
|
||||
);
|
||||
|
||||
const groupNames = useMemo(() => rows.map((r) => r.name), [rows]);
|
||||
|
||||
const duplicateNames = useMemo(() => {
|
||||
const counts = {};
|
||||
groupNames.forEach((n) => {
|
||||
counts[n] = (counts[n] || 0) + 1;
|
||||
});
|
||||
return new Set(Object.keys(counts).filter((k) => counts[k] > 1));
|
||||
}, [groupNames]);
|
||||
|
||||
// Use ref so column render functions always read the latest duplicate set
|
||||
// without adding duplicateNames to columns deps (which would break cursor).
|
||||
const duplicateNamesRef = useRef(duplicateNames);
|
||||
duplicateNamesRef.current = duplicateNames;
|
||||
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{
|
||||
title: t('分组名称'),
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
width: 180,
|
||||
render: (_, record) => (
|
||||
<Input
|
||||
size='small'
|
||||
value={record.name}
|
||||
status={
|
||||
duplicateNamesRef.current.has(record.name) ? 'warning' : undefined
|
||||
}
|
||||
onChange={(v) => updateRow(record._id, 'name', v)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('倍率'),
|
||||
dataIndex: 'ratio',
|
||||
key: 'ratio',
|
||||
width: 120,
|
||||
render: (_, record) => (
|
||||
<InputNumber
|
||||
size='small'
|
||||
min={0}
|
||||
step={0.1}
|
||||
value={record.ratio}
|
||||
style={{ width: '100%' }}
|
||||
onChange={(v) => updateRow(record._id, 'ratio', v ?? 0)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('用户可选'),
|
||||
dataIndex: 'selectable',
|
||||
key: 'selectable',
|
||||
width: 90,
|
||||
align: 'center',
|
||||
render: (_, record) => (
|
||||
<Checkbox
|
||||
checked={record.selectable}
|
||||
onChange={(e) =>
|
||||
updateRow(record._id, 'selectable', e.target.checked)
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('描述'),
|
||||
dataIndex: 'description',
|
||||
key: 'description',
|
||||
render: (_, record) =>
|
||||
record.selectable ? (
|
||||
<Input
|
||||
size='small'
|
||||
value={record.description}
|
||||
placeholder={t('分组描述')}
|
||||
onChange={(v) => updateRow(record._id, 'description', v)}
|
||||
/>
|
||||
) : (
|
||||
<Text type='tertiary' size='small'>
|
||||
-
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
key: 'actions',
|
||||
width: 50,
|
||||
render: (_, record) => (
|
||||
<Popconfirm
|
||||
title={t('确认删除该分组?')}
|
||||
onConfirm={() => removeRow(record._id)}
|
||||
position='left'
|
||||
>
|
||||
<Button
|
||||
icon={<IconDelete />}
|
||||
type='danger'
|
||||
theme='borderless'
|
||||
size='small'
|
||||
/>
|
||||
</Popconfirm>
|
||||
),
|
||||
},
|
||||
],
|
||||
[t, updateRow, removeRow],
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<CardTable
|
||||
columns={columns}
|
||||
dataSource={rows}
|
||||
rowKey='_id'
|
||||
hidePagination
|
||||
size='small'
|
||||
empty={<Text type='tertiary'>{t('暂无分组,点击下方按钮添加')}</Text>}
|
||||
/>
|
||||
<div className='mt-3 flex justify-center'>
|
||||
<Button icon={<IconPlus />} theme='outline' onClick={addRow}>
|
||||
{t('添加分组')}
|
||||
</Button>
|
||||
</div>
|
||||
{duplicateNames.size > 0 && (
|
||||
<Text type='warning' size='small' className='mt-2 block'>
|
||||
{t('存在重复的分组名称:')}
|
||||
{Array.from(duplicateNames).join(', ')}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,781 @@
|
||||
/*
|
||||
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, useMemo, useState } from 'react';
|
||||
import {
|
||||
Banner,
|
||||
Button,
|
||||
Card,
|
||||
Checkbox,
|
||||
Empty,
|
||||
Input,
|
||||
Modal,
|
||||
Radio,
|
||||
RadioGroup,
|
||||
Space,
|
||||
Switch,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
IconDelete,
|
||||
IconPlus,
|
||||
IconSave,
|
||||
IconSearch,
|
||||
} from '@douyinfe/semi-icons';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
PAGE_SIZE,
|
||||
PRICE_SUFFIX,
|
||||
buildSummaryText,
|
||||
hasValue,
|
||||
useModelPricingEditorState,
|
||||
} from '../hooks/useModelPricingEditorState';
|
||||
import { useIsMobile } from '../../../../hooks/common/useIsMobile';
|
||||
import TieredPricingEditor from './TieredPricingEditor';
|
||||
|
||||
const { Text } = Typography;
|
||||
const EMPTY_CANDIDATE_MODEL_NAMES = [];
|
||||
|
||||
const PriceInput = ({
|
||||
label,
|
||||
value,
|
||||
placeholder,
|
||||
onChange,
|
||||
suffix = PRICE_SUFFIX,
|
||||
disabled = false,
|
||||
extraText = '',
|
||||
headerAction = null,
|
||||
hidden = false,
|
||||
}) => (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div className='mb-1 font-medium text-gray-700 flex items-center justify-between gap-3'>
|
||||
<span>{label}</span>
|
||||
{headerAction}
|
||||
</div>
|
||||
{!hidden ? (
|
||||
<Input
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
onChange={onChange}
|
||||
suffix={suffix}
|
||||
disabled={disabled}
|
||||
/>
|
||||
) : null}
|
||||
{extraText ? (
|
||||
<div className='mt-1 text-xs text-gray-500'>{extraText}</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
export default function ModelPricingEditor({
|
||||
options,
|
||||
refresh,
|
||||
candidateModelNames = EMPTY_CANDIDATE_MODEL_NAMES,
|
||||
filterMode = 'all',
|
||||
allowAddModel = true,
|
||||
allowDeleteModel = true,
|
||||
showConflictFilter = true,
|
||||
listDescription = '',
|
||||
emptyTitle = '',
|
||||
emptyDescription = '',
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const isMobile = useIsMobile();
|
||||
const [addVisible, setAddVisible] = useState(false);
|
||||
const [batchVisible, setBatchVisible] = useState(false);
|
||||
const [newModelName, setNewModelName] = useState('');
|
||||
|
||||
const {
|
||||
selectedModel,
|
||||
selectedModelName,
|
||||
selectedModelNames,
|
||||
setSelectedModelName,
|
||||
setSelectedModelNames,
|
||||
searchText,
|
||||
setSearchText,
|
||||
currentPage,
|
||||
setCurrentPage,
|
||||
loading,
|
||||
conflictOnly,
|
||||
setConflictOnly,
|
||||
filteredModels,
|
||||
pagedData,
|
||||
selectedWarnings,
|
||||
previewRows,
|
||||
isOptionalFieldEnabled,
|
||||
handleOptionalFieldToggle,
|
||||
handleNumericFieldChange,
|
||||
handleBillingModeChange,
|
||||
handleBillingExprChange,
|
||||
handleRequestRuleExprChange,
|
||||
handleSubmit,
|
||||
addModel,
|
||||
deleteModel,
|
||||
applySelectedModelPricing,
|
||||
} = useModelPricingEditorState({
|
||||
options,
|
||||
refresh,
|
||||
t,
|
||||
candidateModelNames,
|
||||
filterMode,
|
||||
});
|
||||
|
||||
const getExprModeLabel = useCallback((model) => {
|
||||
if (model?.billingMode !== 'tiered_expr') {
|
||||
return '';
|
||||
}
|
||||
return (model.billingExpr || '').includes('tier(')
|
||||
? t('阶梯计费')
|
||||
: t('表达式计费');
|
||||
}, [t]);
|
||||
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{
|
||||
title: t('模型名称'),
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
render: (text, record) => (
|
||||
<Space>
|
||||
<Button
|
||||
theme='borderless'
|
||||
type='tertiary'
|
||||
onClick={() => setSelectedModelName(record.name)}
|
||||
style={{
|
||||
padding: 0,
|
||||
color:
|
||||
record.name === selectedModelName
|
||||
? 'var(--semi-color-primary)'
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</Button>
|
||||
{selectedModelNames.includes(record.name) ? (
|
||||
<Tag color='green' shape='circle'>
|
||||
{t('已勾选')}
|
||||
</Tag>
|
||||
) : null}
|
||||
{record.hasConflict ? (
|
||||
<Tag color='red' shape='circle'>
|
||||
{t('矛盾')}
|
||||
</Tag>
|
||||
) : null}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('计费方式'),
|
||||
dataIndex: 'billingMode',
|
||||
key: 'billingMode',
|
||||
render: (_, record) => (
|
||||
<Tag
|
||||
color={
|
||||
record.billingMode === 'per-request'
|
||||
? 'teal'
|
||||
: record.billingMode === 'tiered_expr'
|
||||
? 'amber'
|
||||
: 'violet'
|
||||
}
|
||||
>
|
||||
{record.billingMode === 'per-request'
|
||||
? t('按次计费')
|
||||
: record.billingMode === 'tiered_expr'
|
||||
? getExprModeLabel(record)
|
||||
: t('按量计费')}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('价格摘要'),
|
||||
dataIndex: 'summary',
|
||||
key: 'summary',
|
||||
render: (_, record) => buildSummaryText(record, t),
|
||||
},
|
||||
{
|
||||
title: t('操作'),
|
||||
key: 'action',
|
||||
render: (_, record) => (
|
||||
<Space>
|
||||
{allowDeleteModel ? (
|
||||
<Button
|
||||
size='small'
|
||||
type='danger'
|
||||
icon={<IconDelete />}
|
||||
onClick={() => deleteModel(record.name)}
|
||||
/>
|
||||
) : null}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
],
|
||||
[
|
||||
allowDeleteModel,
|
||||
deleteModel,
|
||||
getExprModeLabel,
|
||||
selectedModelName,
|
||||
selectedModelNames,
|
||||
setSelectedModelName,
|
||||
t,
|
||||
],
|
||||
);
|
||||
|
||||
const handleAddModel = () => {
|
||||
if (addModel(newModelName)) {
|
||||
setNewModelName('');
|
||||
setAddVisible(false);
|
||||
}
|
||||
};
|
||||
|
||||
const rowSelection = {
|
||||
selectedRowKeys: selectedModelNames,
|
||||
onChange: (selectedRowKeys) => setSelectedModelNames(selectedRowKeys),
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Space vertical align='start' style={{ width: '100%' }}>
|
||||
<Space wrap className='mt-2'>
|
||||
{allowAddModel ? (
|
||||
<Button
|
||||
icon={<IconPlus />}
|
||||
onClick={() => setAddVisible(true)}
|
||||
style={isMobile ? { width: '100%' } : undefined}
|
||||
>
|
||||
{t('添加模型')}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
type='primary'
|
||||
icon={<IconSave />}
|
||||
loading={loading}
|
||||
onClick={handleSubmit}
|
||||
style={isMobile ? { width: '100%' } : undefined}
|
||||
>
|
||||
{t('应用更改')}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!selectedModel || selectedModelNames.length === 0}
|
||||
onClick={() => setBatchVisible(true)}
|
||||
style={isMobile ? { width: '100%' } : undefined}
|
||||
>
|
||||
{t('批量应用当前模型价格')}
|
||||
{selectedModelNames.length > 0 ? ` (${selectedModelNames.length})` : ''}
|
||||
</Button>
|
||||
<Input
|
||||
prefix={<IconSearch />}
|
||||
placeholder={t('搜索模型名称')}
|
||||
value={searchText}
|
||||
onChange={(value) => setSearchText(value)}
|
||||
style={{ width: isMobile ? '100%' : 220 }}
|
||||
showClear
|
||||
/>
|
||||
{showConflictFilter ? (
|
||||
<Checkbox
|
||||
checked={conflictOnly}
|
||||
onChange={(event) => setConflictOnly(event.target.checked)}
|
||||
>
|
||||
{t('仅显示矛盾倍率')}
|
||||
</Checkbox>
|
||||
) : null}
|
||||
</Space>
|
||||
|
||||
{listDescription ? (
|
||||
<div className='text-sm text-gray-500'>{listDescription}</div>
|
||||
) : null}
|
||||
{selectedModelNames.length > 0 ? (
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px 12px',
|
||||
borderRadius: 8,
|
||||
background: 'var(--semi-color-primary-light-default)',
|
||||
border: '1px solid var(--semi-color-primary)',
|
||||
color: 'var(--semi-color-primary)',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{t('已勾选 {{count}} 个模型', { count: selectedModelNames.length })}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
display: 'grid',
|
||||
gap: 16,
|
||||
gridTemplateColumns: isMobile
|
||||
? 'minmax(0, 1fr)'
|
||||
: 'minmax(300px, 0.8fr) minmax(480px, 1.2fr)',
|
||||
}}
|
||||
>
|
||||
<Card
|
||||
bodyStyle={{ padding: 0 }}
|
||||
style={isMobile ? { order: 2 } : undefined}
|
||||
>
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={pagedData}
|
||||
rowKey='name'
|
||||
rowSelection={rowSelection}
|
||||
pagination={{
|
||||
currentPage,
|
||||
pageSize: PAGE_SIZE,
|
||||
total: filteredModels.length,
|
||||
onPageChange: (page) => setCurrentPage(page),
|
||||
showTotal: true,
|
||||
showSizeChanger: false,
|
||||
}}
|
||||
empty={
|
||||
<div style={{ textAlign: 'center', padding: '20px' }}>
|
||||
{emptyTitle || t('暂无模型')}
|
||||
</div>
|
||||
}
|
||||
onRow={(record) => ({
|
||||
style: {
|
||||
background: selectedModelNames.includes(record.name)
|
||||
? 'var(--semi-color-success-light-default)'
|
||||
: record.name === selectedModelName
|
||||
? 'var(--semi-color-primary-light-default)'
|
||||
: undefined,
|
||||
boxShadow: selectedModelNames.includes(record.name)
|
||||
? 'inset 4px 0 0 var(--semi-color-success)'
|
||||
: record.name === selectedModelName
|
||||
? 'inset 4px 0 0 var(--semi-color-primary)'
|
||||
: undefined,
|
||||
transition: 'background 0.2s ease, box-shadow 0.2s ease',
|
||||
},
|
||||
onClick: () => setSelectedModelName(record.name),
|
||||
})}
|
||||
scroll={isMobile ? { x: 720 } : undefined}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
style={isMobile ? { order: 1 } : undefined}
|
||||
title={selectedModel ? selectedModel.name : t('模型计费编辑器')}
|
||||
headerExtraContent={
|
||||
selectedModel ? (
|
||||
<Tag
|
||||
color={
|
||||
selectedModel.billingMode === 'per-request'
|
||||
? 'teal'
|
||||
: selectedModel.billingMode === 'tiered_expr'
|
||||
? 'amber'
|
||||
: 'blue'
|
||||
}
|
||||
>
|
||||
{selectedModel.billingMode === 'per-request'
|
||||
? t('按次计费')
|
||||
: selectedModel.billingMode === 'tiered_expr'
|
||||
? getExprModeLabel(selectedModel)
|
||||
: t('按量计费')}
|
||||
</Tag>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
{!selectedModel ? (
|
||||
<Empty
|
||||
title={emptyTitle || t('暂无模型')}
|
||||
description={
|
||||
emptyDescription || t('请先新增模型或从左侧列表选择一个模型')
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div>
|
||||
<div className='mb-4'>
|
||||
<div className='mb-2 font-medium text-gray-700'>
|
||||
{t('计费方式')}
|
||||
</div>
|
||||
<RadioGroup
|
||||
type='button'
|
||||
value={selectedModel.billingMode}
|
||||
onChange={(event) => handleBillingModeChange(event.target.value)}
|
||||
>
|
||||
<Radio value='per-token'>{t('按量计费')}</Radio>
|
||||
<Radio value='per-request'>{t('按次计费')}</Radio>
|
||||
<Radio value='tiered_expr'>{t('表达式/阶梯计费')}</Radio>
|
||||
</RadioGroup>
|
||||
<div className='mt-2 text-xs text-gray-500'>
|
||||
{t(
|
||||
'普通按量/按次直接填价格就行;如果价格要跟请求参数或请求头联动,请切到表达式/阶梯计费。',
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedWarnings.length > 0 ? (
|
||||
<Card
|
||||
bodyStyle={{ padding: 12 }}
|
||||
style={{
|
||||
marginBottom: 16,
|
||||
background: 'var(--semi-color-warning-light-default)',
|
||||
}}
|
||||
>
|
||||
<div className='font-medium mb-2'>{t('当前提示')}</div>
|
||||
{selectedWarnings.map((warning) => (
|
||||
<div key={warning} className='text-sm text-gray-700 mb-1'>
|
||||
{warning}
|
||||
</div>
|
||||
))}
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{selectedModel.billingMode === 'per-request' ? (
|
||||
<PriceInput
|
||||
label={t('固定价格')}
|
||||
value={selectedModel.fixedPrice}
|
||||
placeholder={t('输入每次调用价格')}
|
||||
suffix={t('$/次')}
|
||||
onChange={(value) => handleNumericFieldChange('fixedPrice', value)}
|
||||
extraText={t('适合 MJ / 任务类等按次收费模型。')}
|
||||
/>
|
||||
) : selectedModel.billingMode === 'tiered_expr' ? (
|
||||
<TieredPricingEditor
|
||||
model={selectedModel}
|
||||
onExprChange={handleBillingExprChange}
|
||||
requestRuleExpr={selectedModel.requestRuleExpr}
|
||||
onRequestRuleExprChange={handleRequestRuleExprChange}
|
||||
t={t}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<Card
|
||||
bodyStyle={{ padding: 16 }}
|
||||
style={{
|
||||
marginBottom: 16,
|
||||
background: 'var(--semi-color-fill-0)',
|
||||
}}
|
||||
>
|
||||
<div className='font-medium mb-3'>{t('基础价格')}</div>
|
||||
<PriceInput
|
||||
label={t('输入价格')}
|
||||
value={selectedModel.inputPrice}
|
||||
placeholder={t('输入 $/1M tokens')}
|
||||
onChange={(value) => handleNumericFieldChange('inputPrice', value)}
|
||||
/>
|
||||
{selectedModel.completionRatioLocked ? (
|
||||
<Banner
|
||||
type='warning'
|
||||
bordered
|
||||
fullMode={false}
|
||||
closeIcon={null}
|
||||
style={{ marginBottom: 12 }}
|
||||
title={t('补全价格已锁定')}
|
||||
description={t(
|
||||
'该模型补全倍率由后端固定为 {{ratio}}。补全价格不能在这里修改。',
|
||||
{
|
||||
ratio: selectedModel.lockedCompletionRatio || '-',
|
||||
},
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
<PriceInput
|
||||
label={t('补全价格')}
|
||||
value={selectedModel.completionPrice}
|
||||
placeholder={t('输入 $/1M tokens')}
|
||||
onChange={(value) =>
|
||||
handleNumericFieldChange('completionPrice', value)
|
||||
}
|
||||
headerAction={
|
||||
<Switch
|
||||
size='small'
|
||||
checked={isOptionalFieldEnabled(
|
||||
selectedModel,
|
||||
'completionPrice',
|
||||
)}
|
||||
disabled={selectedModel.completionRatioLocked}
|
||||
onChange={(checked) =>
|
||||
handleOptionalFieldToggle('completionPrice', checked)
|
||||
}
|
||||
/>
|
||||
}
|
||||
hidden={
|
||||
!isOptionalFieldEnabled(selectedModel, 'completionPrice')
|
||||
}
|
||||
disabled={
|
||||
!hasValue(selectedModel.inputPrice) ||
|
||||
selectedModel.completionRatioLocked
|
||||
}
|
||||
extraText={
|
||||
selectedModel.completionRatioLocked
|
||||
? t(
|
||||
'后端固定倍率:{{ratio}}。该字段仅展示换算后的价格。',
|
||||
{
|
||||
ratio: selectedModel.lockedCompletionRatio || '-',
|
||||
},
|
||||
)
|
||||
: !isOptionalFieldEnabled(
|
||||
selectedModel,
|
||||
'completionPrice',
|
||||
)
|
||||
? t('当前未启用,需要时再打开即可。')
|
||||
: ''
|
||||
}
|
||||
/>
|
||||
<PriceInput
|
||||
label={t('缓存读取价格')}
|
||||
value={selectedModel.cachePrice}
|
||||
placeholder={t('输入 $/1M tokens')}
|
||||
onChange={(value) => handleNumericFieldChange('cachePrice', value)}
|
||||
headerAction={
|
||||
<Switch
|
||||
size='small'
|
||||
checked={isOptionalFieldEnabled(selectedModel, 'cachePrice')}
|
||||
onChange={(checked) =>
|
||||
handleOptionalFieldToggle('cachePrice', checked)
|
||||
}
|
||||
/>
|
||||
}
|
||||
hidden={!isOptionalFieldEnabled(selectedModel, 'cachePrice')}
|
||||
disabled={!hasValue(selectedModel.inputPrice)}
|
||||
extraText={
|
||||
!isOptionalFieldEnabled(selectedModel, 'cachePrice')
|
||||
? t('当前未启用,需要时再打开即可。')
|
||||
: ''
|
||||
}
|
||||
/>
|
||||
<PriceInput
|
||||
label={t('缓存创建价格')}
|
||||
value={selectedModel.createCachePrice}
|
||||
placeholder={t('输入 $/1M tokens')}
|
||||
onChange={(value) =>
|
||||
handleNumericFieldChange('createCachePrice', value)
|
||||
}
|
||||
headerAction={
|
||||
<Switch
|
||||
size='small'
|
||||
checked={isOptionalFieldEnabled(
|
||||
selectedModel,
|
||||
'createCachePrice',
|
||||
)}
|
||||
onChange={(checked) =>
|
||||
handleOptionalFieldToggle('createCachePrice', checked)
|
||||
}
|
||||
/>
|
||||
}
|
||||
hidden={
|
||||
!isOptionalFieldEnabled(selectedModel, 'createCachePrice')
|
||||
}
|
||||
disabled={!hasValue(selectedModel.inputPrice)}
|
||||
extraText={
|
||||
!isOptionalFieldEnabled(
|
||||
selectedModel,
|
||||
'createCachePrice',
|
||||
)
|
||||
? t('当前未启用,需要时再打开即可。')
|
||||
: ''
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
bodyStyle={{ padding: 16 }}
|
||||
style={{
|
||||
marginBottom: 16,
|
||||
background: 'var(--semi-color-fill-0)',
|
||||
}}
|
||||
>
|
||||
<div className='mb-3'>
|
||||
<div className='font-medium'>{t('扩展价格')}</div>
|
||||
<div className='text-xs text-gray-500 mt-1'>
|
||||
{t('这些价格都是可选项,不填也可以。')}
|
||||
</div>
|
||||
</div>
|
||||
<PriceInput
|
||||
label={t('图片输入价格')}
|
||||
value={selectedModel.imagePrice}
|
||||
placeholder={t('输入 $/1M tokens')}
|
||||
onChange={(value) => handleNumericFieldChange('imagePrice', value)}
|
||||
headerAction={
|
||||
<Switch
|
||||
size='small'
|
||||
checked={isOptionalFieldEnabled(selectedModel, 'imagePrice')}
|
||||
onChange={(checked) =>
|
||||
handleOptionalFieldToggle('imagePrice', checked)
|
||||
}
|
||||
/>
|
||||
}
|
||||
hidden={!isOptionalFieldEnabled(selectedModel, 'imagePrice')}
|
||||
disabled={!hasValue(selectedModel.inputPrice)}
|
||||
extraText={
|
||||
!isOptionalFieldEnabled(selectedModel, 'imagePrice')
|
||||
? t('当前未启用,需要时再打开即可。')
|
||||
: ''
|
||||
}
|
||||
/>
|
||||
<PriceInput
|
||||
label={t('音频输入价格')}
|
||||
value={selectedModel.audioInputPrice}
|
||||
placeholder={t('输入 $/1M tokens')}
|
||||
onChange={(value) =>
|
||||
handleNumericFieldChange('audioInputPrice', value)
|
||||
}
|
||||
headerAction={
|
||||
<Switch
|
||||
size='small'
|
||||
checked={isOptionalFieldEnabled(
|
||||
selectedModel,
|
||||
'audioInputPrice',
|
||||
)}
|
||||
onChange={(checked) =>
|
||||
handleOptionalFieldToggle('audioInputPrice', checked)
|
||||
}
|
||||
/>
|
||||
}
|
||||
hidden={!isOptionalFieldEnabled(selectedModel, 'audioInputPrice')}
|
||||
disabled={!hasValue(selectedModel.inputPrice)}
|
||||
extraText={
|
||||
!isOptionalFieldEnabled(
|
||||
selectedModel,
|
||||
'audioInputPrice',
|
||||
)
|
||||
? t('当前未启用,需要时再打开即可。')
|
||||
: ''
|
||||
}
|
||||
/>
|
||||
<PriceInput
|
||||
label={t('音频补全价格')}
|
||||
value={selectedModel.audioOutputPrice}
|
||||
placeholder={t('输入 $/1M tokens')}
|
||||
onChange={(value) =>
|
||||
handleNumericFieldChange('audioOutputPrice', value)
|
||||
}
|
||||
headerAction={
|
||||
<Switch
|
||||
size='small'
|
||||
checked={isOptionalFieldEnabled(
|
||||
selectedModel,
|
||||
'audioOutputPrice',
|
||||
)}
|
||||
disabled={!isOptionalFieldEnabled(
|
||||
selectedModel,
|
||||
'audioInputPrice',
|
||||
)}
|
||||
onChange={(checked) =>
|
||||
handleOptionalFieldToggle('audioOutputPrice', checked)
|
||||
}
|
||||
/>
|
||||
}
|
||||
hidden={
|
||||
!isOptionalFieldEnabled(selectedModel, 'audioOutputPrice')
|
||||
}
|
||||
disabled={!hasValue(selectedModel.audioInputPrice)}
|
||||
extraText={
|
||||
!isOptionalFieldEnabled(
|
||||
selectedModel,
|
||||
'audioInputPrice',
|
||||
)
|
||||
? t('请先开启并填写音频输入价格。')
|
||||
: !isOptionalFieldEnabled(
|
||||
selectedModel,
|
||||
'audioOutputPrice',
|
||||
)
|
||||
? t('当前未启用,需要时再打开即可。')
|
||||
: ''
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Card
|
||||
bodyStyle={{ padding: 16 }}
|
||||
style={{ background: 'var(--semi-color-fill-0)' }}
|
||||
>
|
||||
<div className='font-medium mb-3'>{t('保存预览')}</div>
|
||||
<div className='text-xs text-gray-500 mb-3'>
|
||||
{t(
|
||||
'下面展示这个模型保存后会写入哪些后端字段,便于和原始 JSON 编辑框保持一致。',
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'minmax(140px, 180px) 1fr',
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
{previewRows.map((row) => (
|
||||
<React.Fragment key={row.key}>
|
||||
<Text strong>{row.label}</Text>
|
||||
<Text>{row.value}</Text>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
</Space>
|
||||
|
||||
{allowAddModel ? (
|
||||
<Modal
|
||||
title={t('添加模型')}
|
||||
visible={addVisible}
|
||||
onCancel={() => {
|
||||
setAddVisible(false);
|
||||
setNewModelName('');
|
||||
}}
|
||||
onOk={handleAddModel}
|
||||
>
|
||||
<Input
|
||||
value={newModelName}
|
||||
placeholder={t('输入模型名称,例如 gpt-4.1')}
|
||||
onChange={(value) => setNewModelName(value)}
|
||||
/>
|
||||
</Modal>
|
||||
) : null}
|
||||
|
||||
<Modal
|
||||
title={t('批量应用当前模型价格')}
|
||||
visible={batchVisible}
|
||||
onCancel={() => setBatchVisible(false)}
|
||||
onOk={() => {
|
||||
if (applySelectedModelPricing()) {
|
||||
setBatchVisible(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className='text-sm text-gray-600'>
|
||||
{selectedModel
|
||||
? t(
|
||||
'将把当前编辑中的模型 {{name}} 的价格配置,批量应用到已勾选的 {{count}} 个模型。',
|
||||
{
|
||||
name: selectedModel.name,
|
||||
count: selectedModelNames.length,
|
||||
},
|
||||
)
|
||||
: t('请先选择一个作为模板的模型')}
|
||||
</div>
|
||||
{selectedModel ? (
|
||||
<div className='text-xs text-gray-500 mt-3'>
|
||||
{t(
|
||||
'适合同系列模型一起定价,例如把 gpt-5.1 的价格批量同步到 gpt-5.1-high、gpt-5.1-low 等模型。',
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,443 @@
|
||||
export const SOURCE_PARAM = 'param';
|
||||
export const SOURCE_HEADER = 'header';
|
||||
export const SOURCE_TIME = 'time';
|
||||
|
||||
export const MATCH_EQ = 'eq';
|
||||
export const MATCH_CONTAINS = 'contains';
|
||||
export const MATCH_GT = 'gt';
|
||||
export const MATCH_GTE = 'gte';
|
||||
export const MATCH_LT = 'lt';
|
||||
export const MATCH_LTE = 'lte';
|
||||
export const MATCH_EXISTS = 'exists';
|
||||
export const MATCH_RANGE = 'range';
|
||||
|
||||
export const TIME_FUNCS = ['hour', 'minute', 'weekday', 'month', 'day'];
|
||||
|
||||
export const COMMON_TIMEZONES = [
|
||||
{ value: 'Asia/Shanghai', label: 'UTC+8 北京 (Asia/Shanghai)' },
|
||||
{ value: 'UTC', label: 'UTC' },
|
||||
{ value: 'America/New_York', label: 'UTC-5 纽约 (America/New_York)' },
|
||||
{ value: 'America/Los_Angeles', label: 'UTC-8 洛杉矶 (America/Los_Angeles)' },
|
||||
{ value: 'America/Chicago', label: 'UTC-6 芝加哥 (America/Chicago)' },
|
||||
{ value: 'Europe/London', label: 'UTC+0 伦敦 (Europe/London)' },
|
||||
{ value: 'Europe/Berlin', label: 'UTC+1 柏林 (Europe/Berlin)' },
|
||||
{ value: 'Asia/Tokyo', label: 'UTC+9 东京 (Asia/Tokyo)' },
|
||||
{ value: 'Asia/Singapore', label: 'UTC+8 新加坡 (Asia/Singapore)' },
|
||||
{ value: 'Asia/Seoul', label: 'UTC+9 首尔 (Asia/Seoul)' },
|
||||
{ value: 'Australia/Sydney', label: 'UTC+10 悉尼 (Australia/Sydney)' },
|
||||
];
|
||||
|
||||
export const NUMERIC_LITERAL_REGEX =
|
||||
/^-?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?$/;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Condition creators (no multiplier — multiplier lives on the group)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function createEmptyCondition() {
|
||||
return { source: SOURCE_PARAM, path: '', mode: MATCH_EQ, value: '' };
|
||||
}
|
||||
|
||||
export function createEmptyTimeCondition() {
|
||||
return {
|
||||
source: SOURCE_TIME,
|
||||
timeFunc: 'hour',
|
||||
timezone: 'Asia/Shanghai',
|
||||
mode: MATCH_GTE,
|
||||
value: '',
|
||||
rangeStart: '',
|
||||
rangeEnd: '',
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Group creators
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function createEmptyRuleGroup() {
|
||||
return { conditions: [createEmptyCondition()], multiplier: '' };
|
||||
}
|
||||
|
||||
export function createEmptyTimeRuleGroup() {
|
||||
return { conditions: [createEmptyTimeCondition()], multiplier: '' };
|
||||
}
|
||||
|
||||
// Kept for backward compat with old preset format
|
||||
export function createEmptyRequestRule() {
|
||||
return { source: SOURCE_PARAM, path: '', mode: MATCH_EQ, value: '', multiplier: '' };
|
||||
}
|
||||
|
||||
export function createEmptyTimeRule() {
|
||||
return {
|
||||
source: SOURCE_TIME, timeFunc: 'hour', timezone: 'Asia/Shanghai',
|
||||
mode: MATCH_GTE, value: '', rangeStart: '', rangeEnd: '', multiplier: '',
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Match options
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function getRequestRuleMatchOptions(source, t) {
|
||||
if (source === SOURCE_TIME) {
|
||||
return [
|
||||
{ value: MATCH_EQ, label: t('等于') },
|
||||
{ value: MATCH_GTE, label: t('大于等于') },
|
||||
{ value: MATCH_LT, label: t('小于') },
|
||||
{ value: MATCH_RANGE, label: t('跨夜范围') },
|
||||
];
|
||||
}
|
||||
const base = [
|
||||
{ value: MATCH_EQ, label: t('等于') },
|
||||
{ value: MATCH_CONTAINS, label: t('包含') },
|
||||
{ value: MATCH_EXISTS, label: t('存在') },
|
||||
];
|
||||
if (source === SOURCE_HEADER) {
|
||||
return base;
|
||||
}
|
||||
return [
|
||||
...base,
|
||||
{ value: MATCH_GT, label: t('大于') },
|
||||
{ value: MATCH_GTE, label: t('大于等于') },
|
||||
{ value: MATCH_LT, label: t('小于') },
|
||||
{ value: MATCH_LTE, label: t('小于等于') },
|
||||
];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Normalize a single condition
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function normalizeCondition(cond) {
|
||||
const source = cond?.source === SOURCE_TIME
|
||||
? SOURCE_TIME
|
||||
: cond?.source === SOURCE_HEADER
|
||||
? SOURCE_HEADER
|
||||
: SOURCE_PARAM;
|
||||
|
||||
if (source === SOURCE_TIME) {
|
||||
const timeFunc = TIME_FUNCS.includes(cond?.timeFunc) ? cond.timeFunc : 'hour';
|
||||
const options = getRequestRuleMatchOptions(SOURCE_TIME, (v) => v);
|
||||
const mode = options.some((item) => item.value === cond?.mode) ? cond.mode : MATCH_GTE;
|
||||
return {
|
||||
source: SOURCE_TIME,
|
||||
timeFunc,
|
||||
timezone: cond?.timezone || 'Asia/Shanghai',
|
||||
mode,
|
||||
value: cond?.value == null ? '' : String(cond.value),
|
||||
rangeStart: cond?.rangeStart == null ? '' : String(cond.rangeStart),
|
||||
rangeEnd: cond?.rangeEnd == null ? '' : String(cond.rangeEnd),
|
||||
};
|
||||
}
|
||||
|
||||
const options = getRequestRuleMatchOptions(source, (v) => v);
|
||||
const mode = options.some((item) => item.value === cond?.mode) ? cond.mode : MATCH_EQ;
|
||||
return {
|
||||
source,
|
||||
path: cond?.path || '',
|
||||
mode,
|
||||
value: cond?.value == null ? '' : String(cond.value),
|
||||
};
|
||||
}
|
||||
|
||||
// Legacy compat wrapper
|
||||
export function normalizeRequestRule(rule) {
|
||||
const base = normalizeCondition(rule);
|
||||
return { ...base, multiplier: rule?.multiplier == null ? '' : String(rule.multiplier) };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function splitTopLevelMultiply(expr) {
|
||||
const parts = [];
|
||||
let start = 0;
|
||||
let depth = 0;
|
||||
for (let index = 0; index < expr.length; index += 1) {
|
||||
const char = expr[index];
|
||||
if (char === '(') depth += 1;
|
||||
if (char === ')') depth -= 1;
|
||||
if (depth === 0 && expr.slice(index, index + 3) === ' * ') {
|
||||
parts.push(expr.slice(start, index).trim());
|
||||
start = index + 3;
|
||||
index += 2;
|
||||
}
|
||||
}
|
||||
parts.push(expr.slice(start).trim());
|
||||
return parts.filter(Boolean);
|
||||
}
|
||||
|
||||
function splitTopLevelAnd(expr) {
|
||||
const parts = [];
|
||||
let start = 0;
|
||||
let depth = 0;
|
||||
for (let i = 0; i < expr.length; i += 1) {
|
||||
const c = expr[i];
|
||||
if (c === '(') depth += 1;
|
||||
if (c === ')') depth -= 1;
|
||||
if (depth === 0 && expr.slice(i, i + 4) === ' && ') {
|
||||
parts.push(expr.slice(start, i).trim());
|
||||
start = i + 4;
|
||||
i += 3;
|
||||
}
|
||||
}
|
||||
parts.push(expr.slice(start).trim());
|
||||
return parts.filter(Boolean);
|
||||
}
|
||||
|
||||
function parseExprLiteral(raw) {
|
||||
const text = raw.trim();
|
||||
if (text === 'true' || text === 'false') return text;
|
||||
if (NUMERIC_LITERAL_REGEX.test(text)) return text;
|
||||
try { return JSON.parse(text); } catch { return null; }
|
||||
}
|
||||
|
||||
function buildExprLiteral(mode, value) {
|
||||
const text = String(value || '').trim();
|
||||
if (mode === MATCH_CONTAINS) return JSON.stringify(text);
|
||||
if (text === 'true' || text === 'false') return text;
|
||||
if (NUMERIC_LITERAL_REGEX.test(text)) return text;
|
||||
return JSON.stringify(text);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Build a single condition expression string (no ? mult : 1 wrapper)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function buildTimeConditionExpr(cond) {
|
||||
const normalized = normalizeCondition(cond);
|
||||
const { timeFunc, timezone, mode } = normalized;
|
||||
const tz = JSON.stringify(timezone);
|
||||
const fn = `${timeFunc}(${tz})`;
|
||||
|
||||
if (mode === MATCH_RANGE) {
|
||||
const s = normalized.rangeStart.trim();
|
||||
const e = normalized.rangeEnd.trim();
|
||||
if (!NUMERIC_LITERAL_REGEX.test(s) || !NUMERIC_LITERAL_REGEX.test(e)) return '';
|
||||
return `${fn} >= ${s} || ${fn} < ${e}`;
|
||||
}
|
||||
const v = normalized.value.trim();
|
||||
if (!NUMERIC_LITERAL_REGEX.test(v)) return '';
|
||||
const opMap = { [MATCH_EQ]: '==', [MATCH_GTE]: '>=', [MATCH_LT]: '<' };
|
||||
return `${fn} ${opMap[mode] || '=='} ${v}`;
|
||||
}
|
||||
|
||||
function buildRequestConditionExpr(cond) {
|
||||
if (cond?.source === SOURCE_TIME) return buildTimeConditionExpr(cond);
|
||||
const normalized = normalizeCondition(cond);
|
||||
const path = normalized.path.trim();
|
||||
if (!path) return '';
|
||||
|
||||
const sourceExpr = normalized.source === SOURCE_HEADER
|
||||
? `header(${JSON.stringify(path)})`
|
||||
: `param(${JSON.stringify(path)})`;
|
||||
|
||||
switch (normalized.mode) {
|
||||
case MATCH_EXISTS:
|
||||
return normalized.source === SOURCE_HEADER
|
||||
? `${sourceExpr} != ""`
|
||||
: `${sourceExpr} != nil`;
|
||||
case MATCH_CONTAINS:
|
||||
return normalized.source === SOURCE_HEADER
|
||||
? `has(${sourceExpr}, ${buildExprLiteral(normalized.mode, normalized.value)})`
|
||||
: `${sourceExpr} != nil && has(${sourceExpr}, ${buildExprLiteral(normalized.mode, normalized.value)})`;
|
||||
case MATCH_GT: case MATCH_GTE: case MATCH_LT: case MATCH_LTE: {
|
||||
const opMap = { [MATCH_GT]: '>', [MATCH_GTE]: '>=', [MATCH_LT]: '<', [MATCH_LTE]: '<=' };
|
||||
if (!NUMERIC_LITERAL_REGEX.test(String(normalized.value).trim())) return '';
|
||||
return `${sourceExpr} != nil && ${sourceExpr} ${opMap[normalized.mode]} ${String(normalized.value).trim()}`;
|
||||
}
|
||||
case MATCH_EQ:
|
||||
default:
|
||||
return `${sourceExpr} == ${buildExprLiteral(normalized.mode, normalized.value)}`;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Build a group factor: (cond1 && cond2 ? mult : 1)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function buildRuleGroupFactor(group) {
|
||||
const multiplier = (group.multiplier || '').trim();
|
||||
if (!NUMERIC_LITERAL_REGEX.test(multiplier)) return '';
|
||||
const condExprs = (group.conditions || [])
|
||||
.map(buildRequestConditionExpr)
|
||||
.filter(Boolean);
|
||||
if (condExprs.length === 0) return '';
|
||||
|
||||
const combined = condExprs.length === 1
|
||||
? condExprs[0]
|
||||
: condExprs.map((e) => (e.includes(' || ') ? `(${e})` : e)).join(' && ');
|
||||
return `(${combined} ? ${multiplier} : 1)`;
|
||||
}
|
||||
|
||||
export function buildRequestRuleExpr(groups) {
|
||||
return (groups || []).map(buildRuleGroupFactor).filter(Boolean).join(' * ');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Parse a single condition from an expression fragment
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function tryParseTimeCondition(expr) {
|
||||
// Range: hour("tz") >= s || hour("tz") < e
|
||||
let m = expr.match(
|
||||
/^(hour|minute|weekday|month|day)\("([^"]+)"\) >= ([\d.eE+-]+) \|\| \1\("\2"\) < ([\d.eE+-]+)$/,
|
||||
);
|
||||
if (m) {
|
||||
return {
|
||||
source: SOURCE_TIME, timeFunc: m[1], timezone: m[2],
|
||||
mode: MATCH_RANGE, value: '', rangeStart: m[3], rangeEnd: m[4],
|
||||
};
|
||||
}
|
||||
// Wrapped range: (hour("tz") >= s || hour("tz") < e)
|
||||
m = expr.match(
|
||||
/^\((hour|minute|weekday|month|day)\("([^"]+)"\) >= ([\d.eE+-]+) \|\| \1\("\2"\) < ([\d.eE+-]+)\)$/,
|
||||
);
|
||||
if (m) {
|
||||
return {
|
||||
source: SOURCE_TIME, timeFunc: m[1], timezone: m[2],
|
||||
mode: MATCH_RANGE, value: '', rangeStart: m[3], rangeEnd: m[4],
|
||||
};
|
||||
}
|
||||
// Simple: hour("tz") op value
|
||||
m = expr.match(
|
||||
/^(hour|minute|weekday|month|day)\("([^"]+)"\) (==|>=|<) ([\d.eE+-]+)$/,
|
||||
);
|
||||
if (m) {
|
||||
const opMap = { '==': MATCH_EQ, '>=': MATCH_GTE, '<': MATCH_LT };
|
||||
return {
|
||||
source: SOURCE_TIME, timeFunc: m[1], timezone: m[2],
|
||||
mode: opMap[m[3]] || MATCH_EQ, value: m[4], rangeStart: '', rangeEnd: '',
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function tryParseRequestCondition(expr) {
|
||||
const tc = tryParseTimeCondition(expr);
|
||||
if (tc) return tc;
|
||||
|
||||
let m = expr.match(/^header\("([^"]+)"\) != ""$/);
|
||||
if (m) return { source: SOURCE_HEADER, path: m[1], mode: MATCH_EXISTS, value: '' };
|
||||
|
||||
m = expr.match(/^param\("([^"]+)"\) != nil$/);
|
||||
if (m) return { source: SOURCE_PARAM, path: m[1], mode: MATCH_EXISTS, value: '' };
|
||||
|
||||
m = expr.match(/^has\(header\("([^"]+)"\), ((?:"(?:[^"\\]|\\.)*"))\)$/);
|
||||
if (m) return { source: SOURCE_HEADER, path: m[1], mode: MATCH_CONTAINS, value: JSON.parse(m[2]) };
|
||||
|
||||
m = expr.match(/^param\("([^"]+)"\) != nil && has\(param\("([^"]+)"\), ((?:"(?:[^"\\]|\\.)*"))\)$/);
|
||||
if (m && m[1] === m[2]) return { source: SOURCE_PARAM, path: m[1], mode: MATCH_CONTAINS, value: JSON.parse(m[3]) };
|
||||
|
||||
m = expr.match(/^param\("([^"]+)"\) != nil && param\("([^"]+)"\) (>|>=|<|<=) ([\d.eE+-]+)$/);
|
||||
if (m && m[1] === m[2]) {
|
||||
const opMap = { '>': MATCH_GT, '>=': MATCH_GTE, '<': MATCH_LT, '<=': MATCH_LTE };
|
||||
return { source: SOURCE_PARAM, path: m[1], mode: opMap[m[3]], value: m[4] };
|
||||
}
|
||||
|
||||
m = expr.match(/^(param|header)\("([^"]+)"\) == (.+)$/);
|
||||
if (m) {
|
||||
const parsedValue = parseExprLiteral(m[3]);
|
||||
if (parsedValue === null) return null;
|
||||
return { source: m[1], path: m[2], mode: MATCH_EQ, value: String(parsedValue) };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Parse a group factor: (cond1 && cond2 ? mult : 1)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function tryParseRuleGroupFactor(part) {
|
||||
// Must be wrapped in ( ... ? mult : 1)
|
||||
const m = part.match(/^\((.+) \? ([\d.eE+-]+) : 1\)$/s);
|
||||
if (!m) return null;
|
||||
|
||||
const conditionStr = m[1];
|
||||
const multiplier = m[2];
|
||||
|
||||
const andParts = splitTopLevelAnd(conditionStr);
|
||||
const conditions = [];
|
||||
for (const ap of andParts) {
|
||||
const cond = tryParseRequestCondition(ap.trim());
|
||||
if (!cond) return null;
|
||||
conditions.push(normalizeCondition(cond));
|
||||
}
|
||||
if (conditions.length === 0) return null;
|
||||
return { conditions, multiplier };
|
||||
}
|
||||
|
||||
export function tryParseRequestRuleExpr(expr) {
|
||||
const trimmed = (expr || '').trim();
|
||||
if (!trimmed) return [];
|
||||
|
||||
const parts = splitTopLevelMultiply(trimmed);
|
||||
const groups = [];
|
||||
for (const part of parts) {
|
||||
const group = tryParseRuleGroupFactor(part);
|
||||
if (!group) return null;
|
||||
groups.push(group);
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Combine / split billing expr and request rules
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function hasFullOuterParens(expr) {
|
||||
if (!expr.startsWith('(') || !expr.endsWith(')')) return false;
|
||||
let depth = 0;
|
||||
for (let i = 0; i < expr.length; i += 1) {
|
||||
if (expr[i] === '(') depth += 1;
|
||||
if (expr[i] === ')') depth -= 1;
|
||||
if (depth === 0 && i < expr.length - 1) return false;
|
||||
}
|
||||
return depth === 0;
|
||||
}
|
||||
|
||||
export function unwrapOuterParens(expr) {
|
||||
let current = (expr || '').trim();
|
||||
while (hasFullOuterParens(current)) {
|
||||
current = current.slice(1, -1).trim();
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
export function combineBillingExpr(baseExpr, requestRuleExpr) {
|
||||
const base = (baseExpr || '').trim();
|
||||
const rules = (requestRuleExpr || '').trim();
|
||||
if (!base) return '';
|
||||
if (!rules) return base;
|
||||
return `(${base}) * ${rules}`;
|
||||
}
|
||||
|
||||
export function splitBillingExprAndRequestRules(expr) {
|
||||
const trimmed = (expr || '').trim();
|
||||
if (!trimmed) return { billingExpr: '', requestRuleExpr: '' };
|
||||
|
||||
const parts = splitTopLevelMultiply(trimmed);
|
||||
if (parts.length <= 1) return { billingExpr: trimmed, requestRuleExpr: '' };
|
||||
|
||||
const ruleParts = [];
|
||||
const baseParts = [];
|
||||
|
||||
parts.forEach((part) => {
|
||||
if (tryParseRequestRuleExpr(part) !== null && tryParseRequestRuleExpr(part).length > 0) {
|
||||
ruleParts.push(part);
|
||||
} else {
|
||||
baseParts.push(part);
|
||||
}
|
||||
});
|
||||
|
||||
if (ruleParts.length === 0 || baseParts.length !== 1) {
|
||||
return { billingExpr: trimmed, requestRuleExpr: '' };
|
||||
}
|
||||
|
||||
return {
|
||||
billingExpr: unwrapOuterParens(baseParts[0]),
|
||||
requestRuleExpr: ruleParts.join(' * '),
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+217
@@ -0,0 +1,217 @@
|
||||
/*
|
||||
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 { Layout, TabPane, Tabs } from '@douyinfe/semi-ui';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Settings,
|
||||
Calculator,
|
||||
Gauge,
|
||||
Shapes,
|
||||
Cog,
|
||||
MoreHorizontal,
|
||||
LayoutDashboard,
|
||||
MessageSquare,
|
||||
Palette,
|
||||
CreditCard,
|
||||
Server,
|
||||
Activity,
|
||||
} from 'lucide-react';
|
||||
|
||||
import SystemSetting from '../../components/settings/SystemSetting';
|
||||
import { isRoot } from '../../helpers';
|
||||
import OtherSetting from '../../components/settings/OtherSetting';
|
||||
import OperationSetting from '../../components/settings/OperationSetting';
|
||||
import RateLimitSetting from '../../components/settings/RateLimitSetting';
|
||||
import ModelSetting from '../../components/settings/ModelSetting';
|
||||
import DashboardSetting from '../../components/settings/DashboardSetting';
|
||||
import RatioSetting from '../../components/settings/RatioSetting';
|
||||
import ChatsSetting from '../../components/settings/ChatsSetting';
|
||||
import DrawingSetting from '../../components/settings/DrawingSetting';
|
||||
import PaymentSetting from '../../components/settings/PaymentSetting';
|
||||
import ModelDeploymentSetting from '../../components/settings/ModelDeploymentSetting';
|
||||
import PerformanceSetting from '../../components/settings/PerformanceSetting';
|
||||
|
||||
const Setting = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [tabActiveKey, setTabActiveKey] = useState('1');
|
||||
let panes = [];
|
||||
|
||||
if (isRoot()) {
|
||||
panes.push({
|
||||
tab: (
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: '5px' }}>
|
||||
<Settings size={18} />
|
||||
{t('运营设置')}
|
||||
</span>
|
||||
),
|
||||
content: <OperationSetting />,
|
||||
itemKey: 'operation',
|
||||
});
|
||||
panes.push({
|
||||
tab: (
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: '5px' }}>
|
||||
<LayoutDashboard size={18} />
|
||||
{t('仪表盘设置')}
|
||||
</span>
|
||||
),
|
||||
content: <DashboardSetting />,
|
||||
itemKey: 'dashboard',
|
||||
});
|
||||
panes.push({
|
||||
tab: (
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: '5px' }}>
|
||||
<MessageSquare size={18} />
|
||||
{t('聊天设置')}
|
||||
</span>
|
||||
),
|
||||
content: <ChatsSetting />,
|
||||
itemKey: 'chats',
|
||||
});
|
||||
panes.push({
|
||||
tab: (
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: '5px' }}>
|
||||
<Palette size={18} />
|
||||
{t('绘图设置')}
|
||||
</span>
|
||||
),
|
||||
content: <DrawingSetting />,
|
||||
itemKey: 'drawing',
|
||||
});
|
||||
panes.push({
|
||||
tab: (
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: '5px' }}>
|
||||
<CreditCard size={18} />
|
||||
{t('支付设置')}
|
||||
</span>
|
||||
),
|
||||
content: <PaymentSetting />,
|
||||
itemKey: 'payment',
|
||||
});
|
||||
panes.push({
|
||||
tab: (
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: '5px' }}>
|
||||
<Calculator size={18} />
|
||||
{t('分组与模型定价设置')}
|
||||
</span>
|
||||
),
|
||||
content: <RatioSetting />,
|
||||
itemKey: 'ratio',
|
||||
});
|
||||
panes.push({
|
||||
tab: (
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: '5px' }}>
|
||||
<Gauge size={18} />
|
||||
{t('速率限制设置')}
|
||||
</span>
|
||||
),
|
||||
content: <RateLimitSetting />,
|
||||
itemKey: 'ratelimit',
|
||||
});
|
||||
panes.push({
|
||||
tab: (
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: '5px' }}>
|
||||
<Shapes size={18} />
|
||||
{t('模型相关设置')}
|
||||
</span>
|
||||
),
|
||||
content: <ModelSetting />,
|
||||
itemKey: 'models',
|
||||
});
|
||||
panes.push({
|
||||
tab: (
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: '5px' }}>
|
||||
<Server size={18} />
|
||||
{t('模型部署设置')}
|
||||
</span>
|
||||
),
|
||||
content: <ModelDeploymentSetting />,
|
||||
itemKey: 'model-deployment',
|
||||
});
|
||||
panes.push({
|
||||
tab: (
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: '5px' }}>
|
||||
<Activity size={18} />
|
||||
{t('性能设置')}
|
||||
</span>
|
||||
),
|
||||
content: <PerformanceSetting />,
|
||||
itemKey: 'performance',
|
||||
});
|
||||
panes.push({
|
||||
tab: (
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: '5px' }}>
|
||||
<Cog size={18} />
|
||||
{t('系统设置')}
|
||||
</span>
|
||||
),
|
||||
content: <SystemSetting />,
|
||||
itemKey: 'system',
|
||||
});
|
||||
panes.push({
|
||||
tab: (
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: '5px' }}>
|
||||
<MoreHorizontal size={18} />
|
||||
{t('其他设置')}
|
||||
</span>
|
||||
),
|
||||
content: <OtherSetting />,
|
||||
itemKey: 'other',
|
||||
});
|
||||
}
|
||||
const onChangeTab = (key) => {
|
||||
setTabActiveKey(key);
|
||||
navigate(`?tab=${key}`);
|
||||
};
|
||||
useEffect(() => {
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
const tab = searchParams.get('tab');
|
||||
if (tab) {
|
||||
setTabActiveKey(tab);
|
||||
} else {
|
||||
onChangeTab('operation');
|
||||
}
|
||||
}, [location.search]);
|
||||
return (
|
||||
<div className='mt-[60px] px-2'>
|
||||
<Layout>
|
||||
<Layout.Content>
|
||||
<Tabs
|
||||
type='card'
|
||||
collapsible
|
||||
activeKey={tabActiveKey}
|
||||
onChange={(key) => onChangeTab(key)}
|
||||
>
|
||||
{panes.map((pane) => (
|
||||
<TabPane itemKey={pane.itemKey} tab={pane.tab} key={pane.itemKey}>
|
||||
{tabActiveKey === pane.itemKey && pane.content}
|
||||
</TabPane>
|
||||
))}
|
||||
</Tabs>
|
||||
</Layout.Content>
|
||||
</Layout>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Setting;
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
/*
|
||||
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 { SetupWizard } from '../../components/setup';
|
||||
|
||||
/**
|
||||
* Setup页面组件
|
||||
* 使用新的组件化结构进行系统初始化
|
||||
*/
|
||||
const Setup = () => {
|
||||
return <SetupWizard />;
|
||||
};
|
||||
|
||||
export default Setup;
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
/*
|
||||
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 SubscriptionsPage from '../../components/table/subscriptions';
|
||||
|
||||
const Subscription = () => {
|
||||
return (
|
||||
<div className='mt-[60px] px-2'>
|
||||
<SubscriptionsPage />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Subscription;
|
||||
Vendored
+29
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
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 TaskLogsTable from '../../components/table/task-logs';
|
||||
|
||||
const Task = () => (
|
||||
<div className='mt-[60px] px-2'>
|
||||
<TaskLogsTable />
|
||||
</div>
|
||||
);
|
||||
|
||||
export default Task;
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
/*
|
||||
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 TokensTable from '../../components/table/tokens';
|
||||
|
||||
const Token = () => {
|
||||
return (
|
||||
<div className='mt-[60px] px-2'>
|
||||
<TokensTable />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Token;
|
||||
Vendored
+22
@@ -0,0 +1,22 @@
|
||||
/*
|
||||
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 TopUp from '../../components/topup';
|
||||
|
||||
export default TopUp;
|
||||
Vendored
+31
@@ -0,0 +1,31 @@
|
||||
/*
|
||||
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 UsersTable from '../../components/table/users';
|
||||
|
||||
const User = () => {
|
||||
return (
|
||||
<div className='mt-[60px] px-2'>
|
||||
<UsersTable />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default User;
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
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 { useTranslation } from 'react-i18next';
|
||||
import DocumentRenderer from '../../components/common/DocumentRenderer';
|
||||
|
||||
const UserAgreement = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<DocumentRenderer
|
||||
apiEndpoint='/api/user-agreement'
|
||||
title={t('用户协议')}
|
||||
cacheKey='user_agreement'
|
||||
emptyMessage={t('加载用户协议内容失败...')}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserAgreement;
|
||||
Reference in New Issue
Block a user