🚀 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:
+49
@@ -0,0 +1,49 @@
|
||||
import { api } from '@/lib/api'
|
||||
import type {
|
||||
DeleteLogsResponse,
|
||||
FetchUpstreamRatiosRequest,
|
||||
SystemOptionsResponse,
|
||||
UpdateOptionRequest,
|
||||
UpdateOptionResponse,
|
||||
UpstreamChannelsResponse,
|
||||
UpstreamRatiosResponse,
|
||||
} from './types'
|
||||
|
||||
export async function getSystemOptions() {
|
||||
const res = await api.get<SystemOptionsResponse>('/api/option/')
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function updateSystemOption(request: UpdateOptionRequest) {
|
||||
const res = await api.put<UpdateOptionResponse>('/api/option/', request)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function deleteLogsBefore(targetTimestamp: number) {
|
||||
const res = await api.delete<DeleteLogsResponse>('/api/log/', {
|
||||
params: { target_timestamp: targetTimestamp },
|
||||
})
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function resetModelRatios() {
|
||||
const res = await api.post<UpdateOptionResponse>(
|
||||
'/api/option/rest_model_ratio'
|
||||
)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function getUpstreamChannels() {
|
||||
const res = await api.get<UpstreamChannelsResponse>(
|
||||
'/api/ratio_sync/channels'
|
||||
)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function fetchUpstreamRatios(request: FetchUpstreamRatiosRequest) {
|
||||
const res = await api.post<UpstreamRatiosResponse>(
|
||||
'/api/ratio_sync/fetch',
|
||||
request
|
||||
)
|
||||
return res.data
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
import { useMemo } from 'react'
|
||||
import * as z from 'zod'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { SettingsSection } from '../components/settings-section'
|
||||
import { useResetForm } from '../hooks/use-reset-form'
|
||||
import { useUpdateOption } from '../hooks/use-update-option'
|
||||
|
||||
const basicAuthSchema = z.object({
|
||||
PasswordLoginEnabled: z.boolean(),
|
||||
PasswordRegisterEnabled: z.boolean(),
|
||||
EmailVerificationEnabled: z.boolean(),
|
||||
RegisterEnabled: z.boolean(),
|
||||
EmailDomainRestrictionEnabled: z.boolean(),
|
||||
EmailAliasRestrictionEnabled: z.boolean(),
|
||||
EmailDomainWhitelist: z.string(),
|
||||
})
|
||||
|
||||
type BasicAuthFormValues = z.infer<typeof basicAuthSchema>
|
||||
|
||||
type BasicAuthSectionProps = {
|
||||
defaultValues: BasicAuthFormValues
|
||||
}
|
||||
|
||||
export function BasicAuthSection({ defaultValues }: BasicAuthSectionProps) {
|
||||
const { t } = useTranslation()
|
||||
const updateOption = useUpdateOption()
|
||||
|
||||
const formDefaults = useMemo<BasicAuthFormValues>(
|
||||
() => ({
|
||||
...defaultValues,
|
||||
EmailDomainWhitelist: defaultValues.EmailDomainWhitelist.split(',')
|
||||
.map((domain) => domain.trim())
|
||||
.filter(Boolean)
|
||||
.join('\n'),
|
||||
}),
|
||||
[defaultValues]
|
||||
)
|
||||
|
||||
const form = useForm<BasicAuthFormValues>({
|
||||
resolver: zodResolver(basicAuthSchema),
|
||||
defaultValues: formDefaults,
|
||||
})
|
||||
|
||||
useResetForm(form, formDefaults)
|
||||
|
||||
const onSubmit = async (data: BasicAuthFormValues) => {
|
||||
const updates: Array<{ key: string; value: string | boolean }> = []
|
||||
|
||||
Object.entries(data).forEach(([key, value]) => {
|
||||
if (key === 'EmailDomainWhitelist') {
|
||||
if (typeof value !== 'string') return
|
||||
const domains = value
|
||||
.split('\n')
|
||||
.map((domain) => domain.trim())
|
||||
.filter(Boolean)
|
||||
.join(',')
|
||||
if (domains !== defaultValues.EmailDomainWhitelist) {
|
||||
updates.push({ key, value: domains })
|
||||
}
|
||||
} else if (value !== defaultValues[key as keyof typeof defaultValues]) {
|
||||
updates.push({ key, value })
|
||||
}
|
||||
})
|
||||
|
||||
for (const update of updates) {
|
||||
await updateOption.mutateAsync(update)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
title={t('Basic Authentication')}
|
||||
description={t('Configure password-based login and registration')}
|
||||
>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className='space-y-6'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='PasswordLoginEnabled'
|
||||
render={({ field }) => (
|
||||
<FormItem className='flex flex-row items-center justify-between rounded-lg border p-4'>
|
||||
<div className='space-y-0.5'>
|
||||
<FormLabel className='text-base'>
|
||||
{t('Password Login')}
|
||||
</FormLabel>
|
||||
<FormDescription>
|
||||
{t('Allow users to log in with password')}
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='RegisterEnabled'
|
||||
render={({ field }) => (
|
||||
<FormItem className='flex flex-row items-center justify-between rounded-lg border p-4'>
|
||||
<div className='space-y-0.5'>
|
||||
<FormLabel className='text-base'>
|
||||
{t('Registration Enabled')}
|
||||
</FormLabel>
|
||||
<FormDescription>
|
||||
{t('Allow new users to register')}
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='PasswordRegisterEnabled'
|
||||
render={({ field }) => (
|
||||
<FormItem className='flex flex-row items-center justify-between rounded-lg border p-4'>
|
||||
<div className='space-y-0.5'>
|
||||
<FormLabel className='text-base'>
|
||||
{t('Password Registration')}
|
||||
</FormLabel>
|
||||
<FormDescription>
|
||||
{t('Allow registration with password')}
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='EmailVerificationEnabled'
|
||||
render={({ field }) => (
|
||||
<FormItem className='flex flex-row items-center justify-between rounded-lg border p-4'>
|
||||
<div className='space-y-0.5'>
|
||||
<FormLabel className='text-base'>
|
||||
{t('Email Verification')}
|
||||
</FormLabel>
|
||||
<FormDescription>
|
||||
{t('Require email verification for new accounts')}
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='EmailDomainRestrictionEnabled'
|
||||
render={({ field }) => (
|
||||
<FormItem className='flex flex-row items-center justify-between rounded-lg border p-4'>
|
||||
<div className='space-y-0.5'>
|
||||
<FormLabel className='text-base'>
|
||||
{t('Email Domain Restriction')}
|
||||
</FormLabel>
|
||||
<FormDescription>
|
||||
{t('Only allow specific email domains')}
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='EmailAliasRestrictionEnabled'
|
||||
render={({ field }) => (
|
||||
<FormItem className='flex flex-row items-center justify-between rounded-lg border p-4'>
|
||||
<div className='space-y-0.5'>
|
||||
<FormLabel className='text-base'>
|
||||
{t('Email Alias Restriction')}
|
||||
</FormLabel>
|
||||
<FormDescription>
|
||||
{t('Block email aliases (e.g., user+alias@domain.com)')}
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='EmailDomainWhitelist'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Email Domain Whitelist')}</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder={t('example.com company.com')}
|
||||
rows={4}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
'One domain per line (only used when domain restriction is enabled)'
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button type='submit' disabled={updateOption.isPending}>
|
||||
{updateOption.isPending ? t('Saving...') : t('Save Changes')}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
</SettingsSection>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { useEffect } from 'react'
|
||||
import * as z from 'zod'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { SettingsSection } from '../components/settings-section'
|
||||
import { useUpdateOption } from '../hooks/use-update-option'
|
||||
|
||||
const botProtectionSchema = z.object({
|
||||
TurnstileCheckEnabled: z.boolean(),
|
||||
TurnstileSiteKey: z.string().optional(),
|
||||
TurnstileSecretKey: z.string().optional(),
|
||||
})
|
||||
|
||||
type BotProtectionFormValues = z.infer<typeof botProtectionSchema>
|
||||
|
||||
type BotProtectionSectionProps = {
|
||||
defaultValues: BotProtectionFormValues
|
||||
}
|
||||
|
||||
export function BotProtectionSection({
|
||||
defaultValues,
|
||||
}: BotProtectionSectionProps) {
|
||||
const { t } = useTranslation()
|
||||
const updateOption = useUpdateOption()
|
||||
|
||||
const form = useForm<BotProtectionFormValues>({
|
||||
resolver: zodResolver(botProtectionSchema),
|
||||
defaultValues,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
form.reset(defaultValues)
|
||||
}, [defaultValues, form])
|
||||
|
||||
const onSubmit = async (data: BotProtectionFormValues) => {
|
||||
const updates = Object.entries(data).filter(
|
||||
([key, value]) =>
|
||||
value !== defaultValues[key as keyof BotProtectionFormValues]
|
||||
)
|
||||
|
||||
for (const [key, value] of updates) {
|
||||
await updateOption.mutateAsync({ key, value: value ?? '' })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
title={t('Bot Protection')}
|
||||
description={t(
|
||||
'Protect login and registration with Cloudflare Turnstile'
|
||||
)}
|
||||
>
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className='space-y-6'
|
||||
autoComplete='off'
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='TurnstileCheckEnabled'
|
||||
render={({ field }) => (
|
||||
<FormItem className='flex flex-row items-center justify-between rounded-lg border p-4'>
|
||||
<div className='space-y-0.5'>
|
||||
<FormLabel className='text-base'>
|
||||
{t('Enable Turnstile')}
|
||||
</FormLabel>
|
||||
<FormDescription>
|
||||
{t(
|
||||
'Protect login and registration with Cloudflare Turnstile'
|
||||
)}
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='TurnstileSiteKey'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Site Key')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t('Your Turnstile site key')}
|
||||
autoComplete='off'
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='TurnstileSecretKey'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Secret Key')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type='password'
|
||||
placeholder={t('Your Turnstile secret key')}
|
||||
autoComplete='new-password'
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button type='submit' disabled={updateOption.isPending}>
|
||||
{updateOption.isPending ? t('Saving...') : t('Save Changes')}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
</SettingsSection>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { api } from '@/lib/api'
|
||||
import type { CustomOAuthProvider, DiscoveryResponse } from './types'
|
||||
|
||||
// ============================================================================
|
||||
// Response Types
|
||||
// ============================================================================
|
||||
|
||||
interface ApiResponse<T = unknown> {
|
||||
success: boolean
|
||||
message?: string
|
||||
data?: T
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Custom OAuth Provider APIs
|
||||
// ============================================================================
|
||||
|
||||
export async function getCustomOAuthProviders(): Promise<
|
||||
ApiResponse<CustomOAuthProvider[]>
|
||||
> {
|
||||
const res = await api.get('/api/custom-oauth-provider/')
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function getCustomOAuthProvider(
|
||||
id: number
|
||||
): Promise<ApiResponse<CustomOAuthProvider>> {
|
||||
const res = await api.get(`/api/custom-oauth-provider/${id}`)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function createCustomOAuthProvider(
|
||||
data: Omit<CustomOAuthProvider, 'id'>
|
||||
): Promise<ApiResponse<CustomOAuthProvider>> {
|
||||
const res = await api.post('/api/custom-oauth-provider/', data)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function updateCustomOAuthProvider(
|
||||
id: number,
|
||||
data: Partial<CustomOAuthProvider>
|
||||
): Promise<ApiResponse<CustomOAuthProvider>> {
|
||||
const res = await api.put(`/api/custom-oauth-provider/${id}`, data)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function deleteCustomOAuthProvider(
|
||||
id: number
|
||||
): Promise<ApiResponse> {
|
||||
const res = await api.delete(`/api/custom-oauth-provider/${id}`)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function discoverOIDCEndpoints(
|
||||
wellKnownUrl: string
|
||||
): Promise<DiscoveryResponse> {
|
||||
const res = await api.post('/api/custom-oauth-provider/discovery', {
|
||||
well_known_url: wellKnownUrl,
|
||||
})
|
||||
return res.data
|
||||
}
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
import type { UseFormReturn } from 'react-hook-form'
|
||||
import { Search } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useDiscoverEndpoints } from '../hooks/use-custom-oauth-mutations'
|
||||
import type { CustomOAuthFormValues } from '../types'
|
||||
|
||||
type DiscoveryButtonProps = {
|
||||
form: UseFormReturn<CustomOAuthFormValues>
|
||||
}
|
||||
|
||||
export function DiscoveryButton(props: DiscoveryButtonProps) {
|
||||
const { t } = useTranslation()
|
||||
const discover = useDiscoverEndpoints()
|
||||
|
||||
const handleDiscover = async () => {
|
||||
const wellKnown = props.form.getValues('well_known')
|
||||
if (!wellKnown) {
|
||||
toast.error(t('Please enter a Well-Known URL first'))
|
||||
return
|
||||
}
|
||||
|
||||
if (!wellKnown.startsWith('http://') && !wellKnown.startsWith('https://')) {
|
||||
toast.error(t('Well-Known URL must start with http:// or https://'))
|
||||
return
|
||||
}
|
||||
|
||||
const res = await discover.mutateAsync(wellKnown)
|
||||
if (res.success && res.data?.discovery) {
|
||||
const disc = res.data.discovery
|
||||
if (disc.authorization_endpoint) {
|
||||
props.form.setValue(
|
||||
'authorization_endpoint',
|
||||
disc.authorization_endpoint,
|
||||
{ shouldDirty: true }
|
||||
)
|
||||
}
|
||||
if (disc.token_endpoint) {
|
||||
props.form.setValue('token_endpoint', disc.token_endpoint, {
|
||||
shouldDirty: true,
|
||||
})
|
||||
}
|
||||
if (disc.userinfo_endpoint) {
|
||||
props.form.setValue('user_info_endpoint', disc.userinfo_endpoint, {
|
||||
shouldDirty: true,
|
||||
})
|
||||
}
|
||||
if (disc.scopes_supported && disc.scopes_supported.length > 0) {
|
||||
const currentScopes = props.form.getValues('scopes')
|
||||
if (!currentScopes) {
|
||||
const defaultScopes = disc.scopes_supported
|
||||
.filter((s) => ['openid', 'profile', 'email'].includes(s))
|
||||
.join(' ')
|
||||
if (defaultScopes) {
|
||||
props.form.setValue('scopes', defaultScopes, {
|
||||
shouldDirty: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={handleDiscover}
|
||||
disabled={discover.isPending}
|
||||
>
|
||||
<Search className='mr-1.5 h-3.5 w-3.5' />
|
||||
{discover.isPending ? t('Discovering...') : t('Auto-discover')}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
import { useState } from 'react'
|
||||
import type { UseFormReturn } from 'react-hook-form'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { OAUTH_PRESETS, type CustomOAuthFormValues } from '../types'
|
||||
|
||||
type PresetSelectorProps = {
|
||||
form: UseFormReturn<CustomOAuthFormValues>
|
||||
}
|
||||
|
||||
export function PresetSelector(props: PresetSelectorProps) {
|
||||
const { t } = useTranslation()
|
||||
const [selectedPreset, setSelectedPreset] = useState<string>('')
|
||||
const [baseUrl, setBaseUrl] = useState<string>('')
|
||||
|
||||
const handlePresetChange = (presetKey: string) => {
|
||||
setSelectedPreset(presetKey)
|
||||
const preset = OAUTH_PRESETS.find((p) => p.key === presetKey)
|
||||
if (!preset) return
|
||||
|
||||
// Auto-fill name, slug, icon, and field mappings immediately
|
||||
props.form.setValue('name', preset.name, { shouldDirty: true })
|
||||
props.form.setValue('slug', presetKey.toLowerCase().replace(/\s+/g, '-'), {
|
||||
shouldDirty: true,
|
||||
})
|
||||
props.form.setValue('icon', preset.icon, { shouldDirty: true })
|
||||
props.form.setValue('scopes', preset.scopes, { shouldDirty: true })
|
||||
props.form.setValue('user_id_field', preset.user_id_field, {
|
||||
shouldDirty: true,
|
||||
})
|
||||
props.form.setValue('username_field', preset.username_field, {
|
||||
shouldDirty: true,
|
||||
})
|
||||
props.form.setValue('display_name_field', preset.display_name_field, {
|
||||
shouldDirty: true,
|
||||
})
|
||||
props.form.setValue('email_field', preset.email_field, {
|
||||
shouldDirty: true,
|
||||
})
|
||||
|
||||
// Apply base URL if already entered
|
||||
if (baseUrl) {
|
||||
applyEndpoints(preset, baseUrl)
|
||||
}
|
||||
}
|
||||
|
||||
const handleBaseUrlChange = (url: string) => {
|
||||
setBaseUrl(url)
|
||||
if (!selectedPreset) return
|
||||
|
||||
const preset = OAUTH_PRESETS.find((p) => p.key === selectedPreset)
|
||||
if (!preset) return
|
||||
|
||||
applyEndpoints(preset, url)
|
||||
}
|
||||
|
||||
const applyEndpoints = (
|
||||
preset: (typeof OAUTH_PRESETS)[number],
|
||||
url: string
|
||||
) => {
|
||||
const cleanUrl = url.replace(/\/+$/, '')
|
||||
props.form.setValue(
|
||||
'authorization_endpoint',
|
||||
cleanUrl + preset.authorization_endpoint,
|
||||
{ shouldDirty: true }
|
||||
)
|
||||
props.form.setValue('token_endpoint', cleanUrl + preset.token_endpoint, {
|
||||
shouldDirty: true,
|
||||
})
|
||||
props.form.setValue(
|
||||
'user_info_endpoint',
|
||||
cleanUrl + preset.user_info_endpoint,
|
||||
{ shouldDirty: true }
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='space-y-3 rounded-lg border border-dashed p-4'>
|
||||
<p className='text-sm font-medium'>{t('Quick Setup from Preset')}</p>
|
||||
<div className='grid grid-cols-1 gap-3 sm:grid-cols-2'>
|
||||
<div className='space-y-1.5'>
|
||||
<Label>{t('Preset Template')}</Label>
|
||||
<Select value={selectedPreset} onValueChange={handlePresetChange}>
|
||||
<SelectTrigger className='w-full'>
|
||||
<SelectValue placeholder={t('Select a preset...')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{OAUTH_PRESETS.map((preset) => (
|
||||
<SelectItem key={preset.key} value={preset.key}>
|
||||
{preset.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className='space-y-1.5'>
|
||||
<Label>{t('Base URL')}</Label>
|
||||
<Input
|
||||
placeholder={t('https://your-server.example.com')}
|
||||
value={baseUrl}
|
||||
onChange={(e) => handleBaseUrlChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Vendored
+583
@@ -0,0 +1,583 @@
|
||||
import { useEffect } from 'react'
|
||||
import { type Resolver, useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
useCreateProvider,
|
||||
useUpdateProvider,
|
||||
} from '../hooks/use-custom-oauth-mutations'
|
||||
import {
|
||||
customOAuthFormSchema,
|
||||
AUTH_STYLE_OPTIONS,
|
||||
type CustomOAuthProvider,
|
||||
type CustomOAuthFormValues,
|
||||
} from '../types'
|
||||
import { DiscoveryButton } from './discovery-button'
|
||||
import { PresetSelector } from './preset-selector'
|
||||
|
||||
type ProviderFormDialogProps = {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
provider?: CustomOAuthProvider | null
|
||||
}
|
||||
|
||||
export function ProviderFormDialog(props: ProviderFormDialogProps) {
|
||||
const { t } = useTranslation()
|
||||
const isEditing = !!props.provider
|
||||
const createProvider = useCreateProvider()
|
||||
const updateProvider = useUpdateProvider()
|
||||
|
||||
const form = useForm<CustomOAuthFormValues>({
|
||||
resolver: zodResolver(
|
||||
customOAuthFormSchema
|
||||
) as unknown as Resolver<CustomOAuthFormValues>,
|
||||
defaultValues: {
|
||||
name: '',
|
||||
slug: '',
|
||||
icon: '',
|
||||
enabled: true,
|
||||
client_id: '',
|
||||
client_secret: '',
|
||||
authorization_endpoint: '',
|
||||
token_endpoint: '',
|
||||
user_info_endpoint: '',
|
||||
scopes: '',
|
||||
user_id_field: '',
|
||||
username_field: '',
|
||||
display_name_field: '',
|
||||
email_field: '',
|
||||
well_known: '',
|
||||
auth_style: 0,
|
||||
access_policy: '',
|
||||
access_denied_message: '',
|
||||
},
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (props.open && props.provider) {
|
||||
form.reset({
|
||||
name: props.provider.name,
|
||||
slug: props.provider.slug,
|
||||
icon: props.provider.icon || '',
|
||||
enabled: props.provider.enabled,
|
||||
client_id: props.provider.client_id,
|
||||
client_secret: props.provider.client_secret || '',
|
||||
authorization_endpoint: props.provider.authorization_endpoint,
|
||||
token_endpoint: props.provider.token_endpoint,
|
||||
user_info_endpoint: props.provider.user_info_endpoint,
|
||||
scopes: props.provider.scopes || '',
|
||||
user_id_field: props.provider.user_id_field,
|
||||
username_field: props.provider.username_field || '',
|
||||
display_name_field: props.provider.display_name_field || '',
|
||||
email_field: props.provider.email_field || '',
|
||||
well_known: props.provider.well_known || '',
|
||||
auth_style: props.provider.auth_style ?? 0,
|
||||
access_policy: props.provider.access_policy || '',
|
||||
access_denied_message: props.provider.access_denied_message || '',
|
||||
})
|
||||
} else if (props.open && !props.provider) {
|
||||
form.reset({
|
||||
name: '',
|
||||
slug: '',
|
||||
icon: '',
|
||||
enabled: true,
|
||||
client_id: '',
|
||||
client_secret: '',
|
||||
authorization_endpoint: '',
|
||||
token_endpoint: '',
|
||||
user_info_endpoint: '',
|
||||
scopes: '',
|
||||
user_id_field: '',
|
||||
username_field: '',
|
||||
display_name_field: '',
|
||||
email_field: '',
|
||||
well_known: '',
|
||||
auth_style: 0,
|
||||
access_policy: '',
|
||||
access_denied_message: '',
|
||||
})
|
||||
}
|
||||
}, [props.open, props.provider, form])
|
||||
|
||||
const onSubmit = async (values: CustomOAuthFormValues) => {
|
||||
if (isEditing && props.provider) {
|
||||
const res = await updateProvider.mutateAsync({
|
||||
id: props.provider.id,
|
||||
data: values,
|
||||
})
|
||||
if (res.success) {
|
||||
props.onOpenChange(false)
|
||||
}
|
||||
} else {
|
||||
const res = await createProvider.mutateAsync(
|
||||
values as Omit<CustomOAuthProvider, 'id'>
|
||||
)
|
||||
if (res.success) {
|
||||
props.onOpenChange(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const isPending = createProvider.isPending || updateProvider.isPending
|
||||
|
||||
return (
|
||||
<Dialog open={props.open} onOpenChange={props.onOpenChange}>
|
||||
<DialogContent className='max-h-[85vh] overflow-y-auto sm:max-w-2xl'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{isEditing ? t('Edit OAuth Provider') : t('Add OAuth Provider')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isEditing
|
||||
? t('Update the configuration for this custom OAuth provider.')
|
||||
: t(
|
||||
'Configure a new custom OAuth provider for user authentication.'
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className='space-y-6'>
|
||||
{/* Preset Selector (only for creating) */}
|
||||
{!isEditing && <PresetSelector form={form} />}
|
||||
|
||||
{/* Basic Info */}
|
||||
<div className='space-y-4'>
|
||||
<h4 className='text-sm font-medium'>{t('Basic Info')}</h4>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='enabled'
|
||||
render={({ field }) => (
|
||||
<FormItem className='flex flex-row items-center justify-between rounded-lg border p-4'>
|
||||
<div className='space-y-0.5'>
|
||||
<FormLabel className='text-base'>
|
||||
{t('Enabled')}
|
||||
</FormLabel>
|
||||
<FormDescription>
|
||||
{t('Allow users to sign in with this provider')}
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className='grid grid-cols-1 gap-4 sm:grid-cols-2'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='name'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Provider Name')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder={t('e.g. My GitLab')} {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='slug'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Slug')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder={t('e.g. my-gitlab')} {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('Used in URLs and API routes')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='icon'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Icon')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t('Icon identifier (e.g. github, gitlab)')}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('Optional icon identifier for the login button')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Credentials */}
|
||||
<div className='space-y-4'>
|
||||
<h4 className='text-sm font-medium'>{t('Credentials')}</h4>
|
||||
<div className='grid grid-cols-1 gap-4 sm:grid-cols-2'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='client_id'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Client ID')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t('OAuth Client ID')}
|
||||
autoComplete='off'
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='client_secret'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Client Secret')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type='password'
|
||||
placeholder={t('OAuth Client Secret')}
|
||||
autoComplete='new-password'
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='auth_style'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Auth Style')}</FormLabel>
|
||||
<Select
|
||||
value={String(field.value)}
|
||||
onValueChange={(val) => field.onChange(Number(val))}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger className='w-full'>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{AUTH_STYLE_OPTIONS.map((option) => (
|
||||
<SelectItem
|
||||
key={option.value}
|
||||
value={String(option.value)}
|
||||
>
|
||||
{t(option.labelKey)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
{t(
|
||||
'How client credentials are sent to the token endpoint'
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Endpoints */}
|
||||
<div className='space-y-4'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<h4 className='text-sm font-medium'>{t('Endpoints')}</h4>
|
||||
<DiscoveryButton form={form} />
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='well_known'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Well-Known URL')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t(
|
||||
'https://provider.com/.well-known/openid-configuration'
|
||||
)}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
'OIDC discovery URL. Click "Auto-discover" to fetch endpoints automatically.'
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='authorization_endpoint'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Authorization Endpoint')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder='https://provider.com/oauth/authorize'
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='token_endpoint'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Token Endpoint')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder='https://provider.com/oauth/token'
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='user_info_endpoint'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('User Info Endpoint')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder='https://provider.com/api/user'
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='scopes'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Scopes')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t('e.g. openid profile email')}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('Space-separated OAuth scopes')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Field Mapping */}
|
||||
<div className='space-y-4'>
|
||||
<h4 className='text-sm font-medium'>{t('Field Mapping')}</h4>
|
||||
<FormDescription>
|
||||
{t(
|
||||
'Map fields from the user info response to local user attributes. Supports nested paths (e.g. ocs.data.id).'
|
||||
)}
|
||||
</FormDescription>
|
||||
|
||||
<div className='grid grid-cols-1 gap-4 sm:grid-cols-2'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='user_id_field'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('User ID Field')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder='id' {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='username_field'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Username Field')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder='login' {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='display_name_field'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Display Name Field')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder='name' {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='email_field'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Email Field')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder='email' {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Advanced */}
|
||||
<div className='space-y-4'>
|
||||
<h4 className='text-sm font-medium'>{t('Advanced')}</h4>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='access_policy'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Access Policy (JSON)')}</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder={t(
|
||||
'Optional JSON policy to restrict access based on user info fields'
|
||||
)}
|
||||
className='min-h-[80px] font-mono text-xs'
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
'JSON-based access control rules. Leave empty to allow all users.'
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='access_denied_message'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Access Denied Message')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t(
|
||||
'Custom message shown when access is denied'
|
||||
)}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
onClick={() => props.onOpenChange(false)}
|
||||
disabled={isPending}
|
||||
>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
<Button type='submit' disabled={isPending}>
|
||||
{isPending
|
||||
? t('Saving...')
|
||||
: isEditing
|
||||
? t('Update Provider')
|
||||
: t('Create Provider')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
import { useState } from 'react'
|
||||
import { Pencil, Trash2, Plus } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { useDeleteProvider } from '../hooks/use-custom-oauth-mutations'
|
||||
import type { CustomOAuthProvider } from '../types'
|
||||
|
||||
type ProviderTableProps = {
|
||||
providers: CustomOAuthProvider[]
|
||||
onEdit: (provider: CustomOAuthProvider) => void
|
||||
onCreate: () => void
|
||||
}
|
||||
|
||||
export function ProviderTable(props: ProviderTableProps) {
|
||||
const { t } = useTranslation()
|
||||
const deleteProvider = useDeleteProvider()
|
||||
const [deleteTarget, setDeleteTarget] = useState<CustomOAuthProvider | null>(
|
||||
null
|
||||
)
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteTarget) return
|
||||
await deleteProvider.mutateAsync(deleteTarget.id)
|
||||
setDeleteTarget(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='flex items-center justify-between'>
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
{t('Manage custom OAuth providers for user authentication')}
|
||||
</p>
|
||||
<Button size='sm' onClick={props.onCreate}>
|
||||
<Plus className='mr-1.5 h-4 w-4' />
|
||||
{t('Add Provider')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{props.providers.length === 0 ? (
|
||||
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center text-sm'>
|
||||
{t('No custom OAuth providers configured yet.')}
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t('Icon')}</TableHead>
|
||||
<TableHead>{t('Name')}</TableHead>
|
||||
<TableHead>{t('Slug')}</TableHead>
|
||||
<TableHead>{t('Status')}</TableHead>
|
||||
<TableHead>{t('Client ID')}</TableHead>
|
||||
<TableHead className='text-right'>{t('Actions')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{props.providers.map((provider) => (
|
||||
<TableRow key={provider.id}>
|
||||
<TableCell>
|
||||
{provider.icon ? (
|
||||
<span className='text-lg'>{provider.icon}</span>
|
||||
) : (
|
||||
<span className='text-muted-foreground text-xs'>--</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className='font-medium'>{provider.name}</TableCell>
|
||||
<TableCell>
|
||||
<StatusBadge
|
||||
label={provider.slug}
|
||||
variant='neutral'
|
||||
copyable={false}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<StatusBadge
|
||||
label={provider.enabled ? t('Enabled') : t('Disabled')}
|
||||
variant={provider.enabled ? 'success' : 'neutral'}
|
||||
copyable={false}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className='text-muted-foreground max-w-[120px] truncate font-mono text-xs'>
|
||||
{provider.client_id}
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<div className='flex justify-end gap-1'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={() => props.onEdit(provider)}
|
||||
>
|
||||
<Pencil className='h-4 w-4' />
|
||||
</Button>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={() => setDeleteTarget(provider)}
|
||||
>
|
||||
<Trash2 className='text-destructive h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!deleteTarget}
|
||||
onOpenChange={(open) => !open && setDeleteTarget(null)}
|
||||
title={t('Delete Provider')}
|
||||
desc={t(
|
||||
'Are you sure you want to delete "{{name}}"? Users who authenticated with this provider will no longer be able to log in.',
|
||||
{ name: deleteTarget?.name || '' }
|
||||
)}
|
||||
confirmText={t('Delete')}
|
||||
destructive
|
||||
handleConfirm={handleDelete}
|
||||
isLoading={deleteProvider.isPending}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { SettingsSection } from '../../components/settings-section'
|
||||
import { ProviderFormDialog } from './components/provider-form-dialog'
|
||||
import { ProviderTable } from './components/provider-table'
|
||||
import { useCustomOAuthProviders } from './hooks/use-custom-oauth-providers'
|
||||
import type { CustomOAuthProvider } from './types'
|
||||
|
||||
export function CustomOAuthSection() {
|
||||
const { t } = useTranslation()
|
||||
const { data: providers = [], isLoading } = useCustomOAuthProviders()
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [editingProvider, setEditingProvider] =
|
||||
useState<CustomOAuthProvider | null>(null)
|
||||
|
||||
const handleCreate = () => {
|
||||
setEditingProvider(null)
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
const handleEdit = (provider: CustomOAuthProvider) => {
|
||||
setEditingProvider(provider)
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
const handleDialogChange = (open: boolean) => {
|
||||
setDialogOpen(open)
|
||||
if (!open) {
|
||||
setEditingProvider(null)
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<SettingsSection
|
||||
title={t('Custom OAuth Providers')}
|
||||
description={t(
|
||||
'Configure custom OAuth providers for user authentication'
|
||||
)}
|
||||
>
|
||||
<div className='text-muted-foreground py-8 text-center text-sm'>
|
||||
{t('Loading...')}
|
||||
</div>
|
||||
</SettingsSection>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
title={t('Custom OAuth Providers')}
|
||||
description={t(
|
||||
'Configure custom OAuth providers for user authentication'
|
||||
)}
|
||||
>
|
||||
<ProviderTable
|
||||
providers={providers}
|
||||
onEdit={handleEdit}
|
||||
onCreate={handleCreate}
|
||||
/>
|
||||
|
||||
<ProviderFormDialog
|
||||
open={dialogOpen}
|
||||
onOpenChange={handleDialogChange}
|
||||
provider={editingProvider}
|
||||
/>
|
||||
</SettingsSection>
|
||||
)
|
||||
}
|
||||
Vendored
+94
@@ -0,0 +1,94 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import i18next from 'i18next'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
createCustomOAuthProvider,
|
||||
updateCustomOAuthProvider,
|
||||
deleteCustomOAuthProvider,
|
||||
discoverOIDCEndpoints,
|
||||
} from '../api'
|
||||
import type { CustomOAuthProvider, DiscoveryResponse } from '../types'
|
||||
|
||||
function useInvalidateOnSuccess() {
|
||||
const queryClient = useQueryClient()
|
||||
return {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['custom-oauth-providers'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['status'] })
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function useCreateProvider() {
|
||||
const invalidate = useInvalidateOnSuccess()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (data: Omit<CustomOAuthProvider, 'id'>) =>
|
||||
createCustomOAuthProvider(data),
|
||||
onSuccess: (res) => {
|
||||
if (res.success) {
|
||||
toast.success(i18next.t('Provider created successfully'))
|
||||
invalidate.onSuccess()
|
||||
}
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || i18next.t('Failed to create provider'))
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useUpdateProvider() {
|
||||
const invalidate = useInvalidateOnSuccess()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
id,
|
||||
data,
|
||||
}: {
|
||||
id: number
|
||||
data: Partial<CustomOAuthProvider>
|
||||
}) => updateCustomOAuthProvider(id, data),
|
||||
onSuccess: (res) => {
|
||||
if (res.success) {
|
||||
toast.success(i18next.t('Provider updated successfully'))
|
||||
invalidate.onSuccess()
|
||||
}
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || i18next.t('Failed to update provider'))
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useDeleteProvider() {
|
||||
const invalidate = useInvalidateOnSuccess()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (id: number) => deleteCustomOAuthProvider(id),
|
||||
onSuccess: (res) => {
|
||||
if (res.success) {
|
||||
toast.success(i18next.t('Provider deleted successfully'))
|
||||
invalidate.onSuccess()
|
||||
}
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || i18next.t('Failed to delete provider'))
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useDiscoverEndpoints() {
|
||||
return useMutation({
|
||||
mutationFn: (wellKnownUrl: string) => discoverOIDCEndpoints(wellKnownUrl),
|
||||
onSuccess: (res: DiscoveryResponse) => {
|
||||
if (res.success) {
|
||||
toast.success(i18next.t('OIDC endpoints discovered successfully'))
|
||||
}
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(
|
||||
error.message || i18next.t('Failed to discover OIDC endpoints')
|
||||
)
|
||||
},
|
||||
})
|
||||
}
|
||||
Vendored
+12
@@ -0,0 +1,12 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { getCustomOAuthProviders } from '../api'
|
||||
|
||||
export function useCustomOAuthProviders() {
|
||||
return useQuery({
|
||||
queryKey: ['custom-oauth-providers'],
|
||||
queryFn: async () => {
|
||||
const res = await getCustomOAuthProviders()
|
||||
return res.data ?? []
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
import * as z from 'zod'
|
||||
|
||||
// ============================================================================
|
||||
// Custom OAuth Provider Types
|
||||
// ============================================================================
|
||||
|
||||
export interface CustomOAuthProvider {
|
||||
id: number
|
||||
name: string
|
||||
slug: string
|
||||
icon: string
|
||||
enabled: boolean
|
||||
client_id: string
|
||||
client_secret: string
|
||||
authorization_endpoint: string
|
||||
token_endpoint: string
|
||||
user_info_endpoint: string
|
||||
scopes: string
|
||||
user_id_field: string
|
||||
username_field: string
|
||||
display_name_field: string
|
||||
email_field: string
|
||||
well_known: string
|
||||
auth_style: number // 0=auto, 1=params, 2=header
|
||||
access_policy: string
|
||||
access_denied_message: string
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Form Schema
|
||||
// ============================================================================
|
||||
|
||||
export const customOAuthFormSchema = z.object({
|
||||
name: z.string().min(1, 'Name is required'),
|
||||
slug: z
|
||||
.string()
|
||||
.min(1, 'Slug is required')
|
||||
.regex(
|
||||
/^[a-z0-9-]+$/,
|
||||
'Slug must only contain lowercase letters, numbers, and hyphens'
|
||||
),
|
||||
icon: z.string().optional().default(''),
|
||||
enabled: z.boolean().default(true),
|
||||
client_id: z.string().min(1, 'Client ID is required'),
|
||||
client_secret: z.string().optional().default(''),
|
||||
authorization_endpoint: z
|
||||
.string()
|
||||
.min(1, 'Authorization endpoint is required'),
|
||||
token_endpoint: z.string().min(1, 'Token endpoint is required'),
|
||||
user_info_endpoint: z.string().min(1, 'User info endpoint is required'),
|
||||
scopes: z.string().optional().default(''),
|
||||
user_id_field: z.string().min(1, 'User ID field is required'),
|
||||
username_field: z.string().optional().default(''),
|
||||
display_name_field: z.string().optional().default(''),
|
||||
email_field: z.string().optional().default(''),
|
||||
well_known: z.string().optional().default(''),
|
||||
auth_style: z.number().int().min(0).max(2).default(0),
|
||||
access_policy: z.string().optional().default(''),
|
||||
access_denied_message: z.string().optional().default(''),
|
||||
})
|
||||
|
||||
export type CustomOAuthFormValues = z.infer<typeof customOAuthFormSchema>
|
||||
|
||||
// ============================================================================
|
||||
// OIDC Discovery
|
||||
// ============================================================================
|
||||
|
||||
export interface DiscoveryResponse {
|
||||
success: boolean
|
||||
message?: string
|
||||
data?: {
|
||||
well_known_url?: string
|
||||
discovery?: {
|
||||
authorization_endpoint?: string
|
||||
token_endpoint?: string
|
||||
userinfo_endpoint?: string
|
||||
scopes_supported?: string[]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Preset Templates
|
||||
// ============================================================================
|
||||
|
||||
export interface OAuthPreset {
|
||||
key: string
|
||||
name: string
|
||||
icon: string
|
||||
authorization_endpoint: string
|
||||
token_endpoint: string
|
||||
user_info_endpoint: string
|
||||
scopes: string
|
||||
user_id_field: string
|
||||
username_field: string
|
||||
display_name_field: string
|
||||
email_field: string
|
||||
needsBaseUrl: boolean
|
||||
}
|
||||
|
||||
export const OAUTH_PRESETS: OAuthPreset[] = [
|
||||
{
|
||||
key: 'github-enterprise',
|
||||
name: 'GitHub Enterprise',
|
||||
icon: 'github',
|
||||
authorization_endpoint: '/login/oauth/authorize',
|
||||
token_endpoint: '/login/oauth/access_token',
|
||||
user_info_endpoint: '/api/v3/user',
|
||||
scopes: 'user:email',
|
||||
user_id_field: 'id',
|
||||
username_field: 'login',
|
||||
display_name_field: 'name',
|
||||
email_field: 'email',
|
||||
needsBaseUrl: true,
|
||||
},
|
||||
{
|
||||
key: 'gitlab',
|
||||
name: 'GitLab',
|
||||
icon: 'gitlab',
|
||||
authorization_endpoint: '/oauth/authorize',
|
||||
token_endpoint: '/oauth/token',
|
||||
user_info_endpoint: '/api/v4/user',
|
||||
scopes: 'openid profile email',
|
||||
user_id_field: 'id',
|
||||
username_field: 'username',
|
||||
display_name_field: 'name',
|
||||
email_field: 'email',
|
||||
needsBaseUrl: true,
|
||||
},
|
||||
{
|
||||
key: 'gitea',
|
||||
name: 'Gitea',
|
||||
icon: 'gitea',
|
||||
authorization_endpoint: '/login/oauth/authorize',
|
||||
token_endpoint: '/login/oauth/access_token',
|
||||
user_info_endpoint: '/api/v1/user',
|
||||
scopes: 'openid profile email',
|
||||
user_id_field: 'id',
|
||||
username_field: 'login',
|
||||
display_name_field: 'full_name',
|
||||
email_field: 'email',
|
||||
needsBaseUrl: true,
|
||||
},
|
||||
{
|
||||
key: 'nextcloud',
|
||||
name: 'Nextcloud',
|
||||
icon: 'nextcloud',
|
||||
authorization_endpoint: '/apps/oauth2/authorize',
|
||||
token_endpoint: '/apps/oauth2/api/v1/token',
|
||||
user_info_endpoint: '/ocs/v2.php/cloud/user?format=json',
|
||||
scopes: 'openid profile email',
|
||||
user_id_field: 'ocs.data.id',
|
||||
username_field: 'ocs.data.id',
|
||||
display_name_field: 'ocs.data.displayname',
|
||||
email_field: 'ocs.data.email',
|
||||
needsBaseUrl: true,
|
||||
},
|
||||
{
|
||||
key: 'keycloak',
|
||||
name: 'Keycloak',
|
||||
icon: 'keycloak',
|
||||
authorization_endpoint: '/realms/{realm}/protocol/openid-connect/auth',
|
||||
token_endpoint: '/realms/{realm}/protocol/openid-connect/token',
|
||||
user_info_endpoint: '/realms/{realm}/protocol/openid-connect/userinfo',
|
||||
scopes: 'openid profile email',
|
||||
user_id_field: 'sub',
|
||||
username_field: 'preferred_username',
|
||||
display_name_field: 'name',
|
||||
email_field: 'email',
|
||||
needsBaseUrl: true,
|
||||
},
|
||||
{
|
||||
key: 'authentik',
|
||||
name: 'Authentik',
|
||||
icon: 'authentik',
|
||||
authorization_endpoint: '/application/o/authorize/',
|
||||
token_endpoint: '/application/o/token/',
|
||||
user_info_endpoint: '/application/o/userinfo/',
|
||||
scopes: 'openid profile email',
|
||||
user_id_field: 'sub',
|
||||
username_field: 'preferred_username',
|
||||
display_name_field: 'name',
|
||||
email_field: 'email',
|
||||
needsBaseUrl: true,
|
||||
},
|
||||
{
|
||||
key: 'ory',
|
||||
name: 'ORY Hydra',
|
||||
icon: 'openid',
|
||||
authorization_endpoint: '/oauth2/auth',
|
||||
token_endpoint: '/oauth2/token',
|
||||
user_info_endpoint: '/userinfo',
|
||||
scopes: 'openid profile email',
|
||||
user_id_field: 'sub',
|
||||
username_field: 'preferred_username',
|
||||
display_name_field: 'name',
|
||||
email_field: 'email',
|
||||
needsBaseUrl: true,
|
||||
},
|
||||
]
|
||||
|
||||
export const AUTH_STYLE_OPTIONS = [
|
||||
{ value: 0, labelKey: 'Auto Detect' },
|
||||
{ value: 1, labelKey: 'Params (in body)' },
|
||||
{ value: 2, labelKey: 'Header (Basic Auth)' },
|
||||
] as const
|
||||
@@ -0,0 +1,61 @@
|
||||
import { SettingsPage } from '../components/settings-page'
|
||||
import type { AuthSettings } from '../types'
|
||||
import {
|
||||
AUTH_DEFAULT_SECTION,
|
||||
getAuthSectionContent,
|
||||
} from './section-registry.tsx'
|
||||
|
||||
const defaultAuthSettings: AuthSettings = {
|
||||
PasswordLoginEnabled: true,
|
||||
PasswordRegisterEnabled: true,
|
||||
EmailVerificationEnabled: false,
|
||||
RegisterEnabled: true,
|
||||
EmailDomainRestrictionEnabled: false,
|
||||
EmailAliasRestrictionEnabled: false,
|
||||
EmailDomainWhitelist: '',
|
||||
GitHubOAuthEnabled: false,
|
||||
GitHubClientId: '',
|
||||
GitHubClientSecret: '',
|
||||
'discord.enabled': false,
|
||||
'discord.client_id': '',
|
||||
'discord.client_secret': '',
|
||||
'oidc.enabled': false,
|
||||
'oidc.client_id': '',
|
||||
'oidc.client_secret': '',
|
||||
'oidc.well_known': '',
|
||||
'oidc.authorization_endpoint': '',
|
||||
'oidc.token_endpoint': '',
|
||||
'oidc.user_info_endpoint': '',
|
||||
TelegramOAuthEnabled: false,
|
||||
TelegramBotToken: '',
|
||||
TelegramBotName: '',
|
||||
LinuxDOOAuthEnabled: false,
|
||||
LinuxDOClientId: '',
|
||||
LinuxDOClientSecret: '',
|
||||
LinuxDOMinimumTrustLevel: '0',
|
||||
WeChatAuthEnabled: false,
|
||||
WeChatServerAddress: '',
|
||||
WeChatServerToken: '',
|
||||
WeChatAccountQRCodeImageURL: '',
|
||||
TurnstileCheckEnabled: false,
|
||||
TurnstileSiteKey: '',
|
||||
TurnstileSecretKey: '',
|
||||
'passkey.enabled': false,
|
||||
'passkey.rp_display_name': '',
|
||||
'passkey.rp_id': '',
|
||||
'passkey.origins': '',
|
||||
'passkey.allow_insecure_origin': false,
|
||||
'passkey.user_verification': 'preferred',
|
||||
'passkey.attachment_preference': '',
|
||||
}
|
||||
|
||||
export function AuthSettings() {
|
||||
return (
|
||||
<SettingsPage
|
||||
routePath='/_authenticated/system-settings/auth/$section'
|
||||
defaultSettings={defaultAuthSettings}
|
||||
defaultSection={AUTH_DEFAULT_SECTION}
|
||||
getSectionContent={getAuthSectionContent}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,763 @@
|
||||
import { useState } from 'react'
|
||||
import * as z from 'zod'
|
||||
import axios from 'axios'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { RotateCcw } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { FormDirtyIndicator } from '../components/form-dirty-indicator'
|
||||
import { FormNavigationGuard } from '../components/form-navigation-guard'
|
||||
import { SettingsSection } from '../components/settings-section'
|
||||
import { useUpdateOption } from '../hooks/use-update-option'
|
||||
|
||||
const oauthSchema = z.object({
|
||||
GitHubOAuthEnabled: z.boolean(),
|
||||
GitHubClientId: z.string().optional(),
|
||||
GitHubClientSecret: z.string().optional(),
|
||||
'discord.enabled': z.boolean(),
|
||||
'discord.client_id': z.string().optional(),
|
||||
'discord.client_secret': z.string().optional(),
|
||||
'oidc.enabled': z.boolean(),
|
||||
'oidc.client_id': z.string().optional(),
|
||||
'oidc.client_secret': z.string().optional(),
|
||||
'oidc.well_known': z.string().optional(),
|
||||
'oidc.authorization_endpoint': z.string().optional(),
|
||||
'oidc.token_endpoint': z.string().optional(),
|
||||
'oidc.user_info_endpoint': z.string().optional(),
|
||||
TelegramOAuthEnabled: z.boolean(),
|
||||
TelegramBotToken: z.string().optional(),
|
||||
TelegramBotName: z.string().optional(),
|
||||
LinuxDOOAuthEnabled: z.boolean(),
|
||||
LinuxDOClientId: z.string().optional(),
|
||||
LinuxDOClientSecret: z.string().optional(),
|
||||
LinuxDOMinimumTrustLevel: z.string().optional(),
|
||||
WeChatAuthEnabled: z.boolean(),
|
||||
WeChatServerAddress: z.string().optional(),
|
||||
WeChatServerToken: z.string().optional(),
|
||||
WeChatAccountQRCodeImageURL: z.string().optional(),
|
||||
})
|
||||
|
||||
type OAuthFormValues = z.infer<typeof oauthSchema>
|
||||
|
||||
type OAuthSectionProps = {
|
||||
defaultValues: OAuthFormValues
|
||||
}
|
||||
|
||||
export function OAuthSection({ defaultValues }: OAuthSectionProps) {
|
||||
const { t } = useTranslation()
|
||||
const updateOption = useUpdateOption()
|
||||
const [activeTab, setActiveTab] = useState('github')
|
||||
|
||||
// Normalize empty strings for optional fields (only at mount)
|
||||
const normalizedDefaults: OAuthFormValues = {
|
||||
...defaultValues,
|
||||
GitHubClientId: defaultValues.GitHubClientId ?? '',
|
||||
GitHubClientSecret: defaultValues.GitHubClientSecret ?? '',
|
||||
'discord.client_id': defaultValues['discord.client_id'] ?? '',
|
||||
'discord.client_secret': defaultValues['discord.client_secret'] ?? '',
|
||||
'oidc.client_id': defaultValues['oidc.client_id'] ?? '',
|
||||
'oidc.client_secret': defaultValues['oidc.client_secret'] ?? '',
|
||||
'oidc.well_known': defaultValues['oidc.well_known'] ?? '',
|
||||
'oidc.authorization_endpoint':
|
||||
defaultValues['oidc.authorization_endpoint'] ?? '',
|
||||
'oidc.token_endpoint': defaultValues['oidc.token_endpoint'] ?? '',
|
||||
'oidc.user_info_endpoint': defaultValues['oidc.user_info_endpoint'] ?? '',
|
||||
TelegramBotToken: defaultValues.TelegramBotToken ?? '',
|
||||
TelegramBotName: defaultValues.TelegramBotName ?? '',
|
||||
LinuxDOClientId: defaultValues.LinuxDOClientId ?? '',
|
||||
LinuxDOClientSecret: defaultValues.LinuxDOClientSecret ?? '',
|
||||
LinuxDOMinimumTrustLevel: defaultValues.LinuxDOMinimumTrustLevel ?? '',
|
||||
WeChatServerAddress: defaultValues.WeChatServerAddress ?? '',
|
||||
WeChatServerToken: defaultValues.WeChatServerToken ?? '',
|
||||
WeChatAccountQRCodeImageURL:
|
||||
defaultValues.WeChatAccountQRCodeImageURL ?? '',
|
||||
}
|
||||
|
||||
const form = useForm<OAuthFormValues>({
|
||||
resolver: zodResolver(oauthSchema),
|
||||
defaultValues: normalizedDefaults,
|
||||
})
|
||||
|
||||
const onSubmit = async () => {
|
||||
// Get raw form values directly
|
||||
// React Hook Form treats "oidc.xxx" as nested paths, so we need to flatten
|
||||
const rawData = form.getValues() as Record<string, unknown>
|
||||
|
||||
// Flatten nested oidc object back to dot notation keys
|
||||
const flattenedData: Record<string, unknown> = {}
|
||||
|
||||
Object.entries(rawData).forEach(([key, value]) => {
|
||||
if (
|
||||
(key === 'oidc' || key === 'discord') &&
|
||||
typeof value === 'object' &&
|
||||
value !== null
|
||||
) {
|
||||
// React Hook Form auto-nested these fields, flatten them back
|
||||
Object.entries(value as Record<string, unknown>).forEach(
|
||||
([nestedKey, nestedValue]) => {
|
||||
flattenedData[`${key}.${nestedKey}`] = nestedValue
|
||||
}
|
||||
)
|
||||
} else {
|
||||
flattenedData[key] = value
|
||||
}
|
||||
})
|
||||
|
||||
const finalData = flattenedData as OAuthFormValues
|
||||
|
||||
if (finalData['oidc.well_known'] && finalData['oidc.well_known'] !== '') {
|
||||
if (
|
||||
!finalData['oidc.well_known'].startsWith('http://') &&
|
||||
!finalData['oidc.well_known'].startsWith('https://')
|
||||
) {
|
||||
toast.error(t('Well-Known URL must start with http:// or https://'))
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await axios.create().get(finalData['oidc.well_known'])
|
||||
const authEndpoint = res.data['authorization_endpoint'] || ''
|
||||
const tokenEndpoint = res.data['token_endpoint'] || ''
|
||||
const userInfoEndpoint = res.data['userinfo_endpoint'] || ''
|
||||
|
||||
finalData['oidc.authorization_endpoint'] = authEndpoint
|
||||
finalData['oidc.token_endpoint'] = tokenEndpoint
|
||||
finalData['oidc.user_info_endpoint'] = userInfoEndpoint
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
form.setValue('oidc.authorization_endpoint' as any, authEndpoint)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
form.setValue('oidc.token_endpoint' as any, tokenEndpoint)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
form.setValue('oidc.user_info_endpoint' as any, userInfoEndpoint)
|
||||
|
||||
toast.success(t('OIDC configuration fetched successfully'))
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(err)
|
||||
toast.error(
|
||||
t(
|
||||
'Failed to fetch OIDC configuration. Please check the URL and network status'
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Find changed fields by comparing to initial values
|
||||
const updates = Object.entries(finalData).filter(
|
||||
([key, value]) =>
|
||||
value !== normalizedDefaults[key as keyof OAuthFormValues]
|
||||
)
|
||||
|
||||
if (updates.length === 0) {
|
||||
toast.info(t('No changes to save'))
|
||||
return
|
||||
}
|
||||
|
||||
// Save all changed fields
|
||||
for (const [key, value] of updates) {
|
||||
await updateOption.mutateAsync({ key, value: value ?? '' })
|
||||
}
|
||||
|
||||
// Reset form dirty state after successful save
|
||||
form.reset(finalData)
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
// React Hook Form auto-nests 'oidc.xxx' fields into { oidc: { xxx: value } }
|
||||
// So we need to pass the same structure when resetting
|
||||
const currentValues = form.getValues() as Record<string, unknown>
|
||||
|
||||
// Create reset values matching RHF's internal structure
|
||||
const resetValues = { ...currentValues }
|
||||
|
||||
// Update nested oidc fields
|
||||
if (resetValues.oidc && typeof resetValues.oidc === 'object') {
|
||||
Object.keys(resetValues.oidc as Record<string, unknown>).forEach(
|
||||
(key) => {
|
||||
const flatKey = `oidc.${key}` as keyof typeof normalizedDefaults
|
||||
if (flatKey in normalizedDefaults) {
|
||||
;(resetValues.oidc as Record<string, unknown>)[key] =
|
||||
normalizedDefaults[flatKey]
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Update nested discord fields
|
||||
if (resetValues.discord && typeof resetValues.discord === 'object') {
|
||||
Object.keys(resetValues.discord as Record<string, unknown>).forEach(
|
||||
(key) => {
|
||||
const flatKey = `discord.${key}` as keyof typeof normalizedDefaults
|
||||
if (flatKey in normalizedDefaults) {
|
||||
;(resetValues.discord as Record<string, unknown>)[key] =
|
||||
normalizedDefaults[flatKey]
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Update top-level fields
|
||||
Object.keys(resetValues).forEach((key) => {
|
||||
if (key !== 'oidc' && key in normalizedDefaults) {
|
||||
resetValues[key] =
|
||||
normalizedDefaults[key as keyof typeof normalizedDefaults]
|
||||
}
|
||||
})
|
||||
|
||||
form.reset(resetValues, {
|
||||
keepDirty: false,
|
||||
keepDirtyValues: false,
|
||||
keepErrors: false,
|
||||
})
|
||||
toast.success(t('Form reset to saved values'))
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<FormNavigationGuard when={form.formState.isDirty} />
|
||||
|
||||
<SettingsSection
|
||||
title={t('OAuth Integrations')}
|
||||
description={t('Configure third-party authentication providers')}
|
||||
>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className='space-y-6'>
|
||||
<FormDirtyIndicator isDirty={form.formState.isDirty} />
|
||||
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<TabsList className='grid w-full grid-cols-6'>
|
||||
<TabsTrigger value='github'>{t('GitHub')}</TabsTrigger>
|
||||
<TabsTrigger value='discord'>{t('Discord')}</TabsTrigger>
|
||||
<TabsTrigger value='oidc'>{t('OIDC')}</TabsTrigger>
|
||||
<TabsTrigger value='telegram'>{t('Telegram')}</TabsTrigger>
|
||||
<TabsTrigger value='linuxdo'>{t('LinuxDO')}</TabsTrigger>
|
||||
<TabsTrigger value='wechat'>{t('WeChat')}</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value='github' className='space-y-4'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='GitHubOAuthEnabled'
|
||||
render={({ field }) => (
|
||||
<FormItem className='flex flex-row items-center justify-between rounded-lg border p-4'>
|
||||
<div className='space-y-0.5'>
|
||||
<FormLabel className='text-base'>
|
||||
{t('Enable GitHub OAuth')}
|
||||
</FormLabel>
|
||||
<FormDescription>
|
||||
{t('Allow users to sign in with GitHub')}
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='GitHubClientId'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Client ID')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t('Your GitHub OAuth Client ID')}
|
||||
autoComplete='off'
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='GitHubClientSecret'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Client Secret')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type='password'
|
||||
placeholder={t('Your GitHub OAuth Client Secret')}
|
||||
autoComplete='new-password'
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value='discord' className='space-y-4'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='discord.enabled'
|
||||
render={({ field }) => (
|
||||
<FormItem className='flex flex-row items-center justify-between rounded-lg border p-4'>
|
||||
<div className='space-y-0.5'>
|
||||
<FormLabel className='text-base'>
|
||||
{t('Enable Discord OAuth')}
|
||||
</FormLabel>
|
||||
<FormDescription>
|
||||
{t('Allow users to sign in with Discord')}
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
name={'discord.client_id' as any}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Client ID')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t('Your Discord OAuth Client ID')}
|
||||
autoComplete='off'
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='discord.client_secret'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Client Secret')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type='password'
|
||||
placeholder={t('Your Discord OAuth Client Secret')}
|
||||
autoComplete='new-password'
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value='oidc' className='space-y-4'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='oidc.enabled'
|
||||
render={({ field }) => (
|
||||
<FormItem className='flex flex-row items-center justify-between rounded-lg border p-4'>
|
||||
<div className='space-y-0.5'>
|
||||
<FormLabel className='text-base'>
|
||||
{t('Enable OIDC')}
|
||||
</FormLabel>
|
||||
<FormDescription>
|
||||
{t('Allow users to sign in with OpenID Connect')}
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
name={'oidc.client_id' as any}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Client ID')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t('OIDC Client ID')}
|
||||
autoComplete='off'
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='oidc.client_secret'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Client Secret')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type='password'
|
||||
placeholder={t('OIDC Client Secret')}
|
||||
autoComplete='new-password'
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='oidc.well_known'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Well-Known URL')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t(
|
||||
'https://provider.com/.well-known/openid-configuration'
|
||||
)}
|
||||
autoComplete='off'
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('Auto-discovers endpoints from the provider')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='oidc.authorization_endpoint'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t('Authorization Endpoint (Optional)')}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t('Override auto-discovered endpoint')}
|
||||
autoComplete='off'
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='oidc.token_endpoint'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Token Endpoint (Optional)')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t('Override auto-discovered endpoint')}
|
||||
autoComplete='off'
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='oidc.user_info_endpoint'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t('User Info Endpoint (Optional)')}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t('Override auto-discovered endpoint')}
|
||||
autoComplete='off'
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value='telegram' className='space-y-4'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='TelegramOAuthEnabled'
|
||||
render={({ field }) => (
|
||||
<FormItem className='flex flex-row items-center justify-between rounded-lg border p-4'>
|
||||
<div className='space-y-0.5'>
|
||||
<FormLabel className='text-base'>
|
||||
{t('Enable Telegram OAuth')}
|
||||
</FormLabel>
|
||||
<FormDescription>
|
||||
{t('Allow users to sign in with Telegram')}
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='TelegramBotToken'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Bot Token')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type='password'
|
||||
placeholder={t('Your Telegram Bot Token')}
|
||||
autoComplete='new-password'
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='TelegramBotName'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Bot Name')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t('Your Bot Name')}
|
||||
autoComplete='off'
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value='linuxdo' className='space-y-4'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='LinuxDOOAuthEnabled'
|
||||
render={({ field }) => (
|
||||
<FormItem className='flex flex-row items-center justify-between rounded-lg border p-4'>
|
||||
<div className='space-y-0.5'>
|
||||
<FormLabel className='text-base'>
|
||||
{t('Enable LinuxDO OAuth')}
|
||||
</FormLabel>
|
||||
<FormDescription>
|
||||
{t('Allow users to sign in with LinuxDO')}
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='LinuxDOClientId'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Client ID')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t('LinuxDO Client ID')}
|
||||
autoComplete='off'
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='LinuxDOClientSecret'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Client Secret')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type='password'
|
||||
placeholder={t('LinuxDO Client Secret')}
|
||||
autoComplete='new-password'
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='LinuxDOMinimumTrustLevel'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Minimum Trust Level')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder='0' autoComplete='off' {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('Minimum LinuxDO trust level required')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value='wechat' className='space-y-4'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='WeChatAuthEnabled'
|
||||
render={({ field }) => (
|
||||
<FormItem className='flex flex-row items-center justify-between rounded-lg border p-4'>
|
||||
<div className='space-y-0.5'>
|
||||
<FormLabel className='text-base'>
|
||||
{t('Enable WeChat Auth')}
|
||||
</FormLabel>
|
||||
<FormDescription>
|
||||
{t('Allow users to sign in with WeChat')}
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='WeChatServerAddress'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Server Address')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t('https://wechat-server.example.com')}
|
||||
autoComplete='off'
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='WeChatServerToken'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Server Token')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type='password'
|
||||
placeholder={t('Server Token')}
|
||||
autoComplete='new-password'
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='WeChatAccountQRCodeImageURL'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('QR Code Image URL')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t('https://example.com/qr-code.png')}
|
||||
autoComplete='off'
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<div className='flex gap-2'>
|
||||
<Button type='submit' disabled={updateOption.isPending}>
|
||||
{updateOption.isPending ? t('Saving...') : t('Save Changes')}
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
onClick={handleReset}
|
||||
disabled={!form.formState.isDirty || updateOption.isPending}
|
||||
>
|
||||
<RotateCcw className='mr-2 h-4 w-4' />
|
||||
{t('Reset')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</SettingsSection>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
import { useMemo } from 'react'
|
||||
import * as z from 'zod'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { SettingsSection } from '../components/settings-section'
|
||||
import { useResetForm } from '../hooks/use-reset-form'
|
||||
import { useUpdateOption } from '../hooks/use-update-option'
|
||||
|
||||
const passkeySchema = z.object({
|
||||
'passkey.enabled': z.boolean(),
|
||||
'passkey.rp_display_name': z.string(),
|
||||
'passkey.rp_id': z.string(),
|
||||
'passkey.origins': z.string(),
|
||||
'passkey.allow_insecure_origin': z.boolean(),
|
||||
'passkey.user_verification': z.enum(['required', 'preferred', 'discouraged']),
|
||||
'passkey.attachment_preference': z.enum([
|
||||
'none',
|
||||
'platform',
|
||||
'cross-platform',
|
||||
]),
|
||||
})
|
||||
|
||||
type PasskeyFormValues = z.infer<typeof passkeySchema>
|
||||
|
||||
interface PasskeySectionProps {
|
||||
defaultValues: PasskeyFormValues
|
||||
}
|
||||
|
||||
export function PasskeySection({ defaultValues }: PasskeySectionProps) {
|
||||
const { t } = useTranslation()
|
||||
const updateOption = useUpdateOption()
|
||||
|
||||
const formDefaults = useMemo<PasskeyFormValues>(
|
||||
() => ({
|
||||
...defaultValues,
|
||||
'passkey.origins': (defaultValues['passkey.origins'] as string)
|
||||
.split(',')
|
||||
.map((origin: string) => origin.trim())
|
||||
.filter(Boolean)
|
||||
.join('\n'),
|
||||
'passkey.attachment_preference':
|
||||
(defaultValues['passkey.attachment_preference'] as string) === ''
|
||||
? 'none'
|
||||
: (defaultValues['passkey.attachment_preference'] as
|
||||
| 'platform'
|
||||
| 'cross-platform'),
|
||||
}),
|
||||
[defaultValues]
|
||||
)
|
||||
|
||||
const form = useForm<PasskeyFormValues>({
|
||||
resolver: zodResolver(passkeySchema),
|
||||
defaultValues: formDefaults,
|
||||
})
|
||||
|
||||
useResetForm(form, formDefaults)
|
||||
|
||||
const onSubmit = async () => {
|
||||
const rawData = form.getValues() as Record<string, unknown>
|
||||
const flattenedEntries: Array<
|
||||
[keyof PasskeyFormValues, PasskeyFormValues[keyof PasskeyFormValues]]
|
||||
> = []
|
||||
|
||||
Object.entries(rawData).forEach(([key, value]) => {
|
||||
if (key === 'passkey' && value && typeof value === 'object') {
|
||||
Object.entries(value as Record<string, unknown>).forEach(
|
||||
([nestedKey, nestedValue]) => {
|
||||
flattenedEntries.push([
|
||||
`passkey.${nestedKey}` as keyof PasskeyFormValues,
|
||||
nestedValue as PasskeyFormValues[keyof PasskeyFormValues],
|
||||
])
|
||||
}
|
||||
)
|
||||
} else {
|
||||
flattenedEntries.push([
|
||||
key as keyof PasskeyFormValues,
|
||||
value as PasskeyFormValues[keyof PasskeyFormValues],
|
||||
])
|
||||
}
|
||||
})
|
||||
|
||||
const data = Object.fromEntries(flattenedEntries) as PasskeyFormValues
|
||||
const updates: Array<{ key: string; value: string | boolean }> = []
|
||||
|
||||
Object.entries(data).forEach(([key, value]) => {
|
||||
if (key === 'passkey.origins') {
|
||||
const processed = (value as string)
|
||||
.split('\n')
|
||||
.map((origin: string) => origin.trim())
|
||||
.filter(Boolean)
|
||||
.join(',')
|
||||
const currentDefault = defaultValues['passkey.origins'] as string
|
||||
if (processed !== currentDefault) {
|
||||
updates.push({ key, value: processed })
|
||||
}
|
||||
} else if (key === 'passkey.attachment_preference') {
|
||||
const attachmentPreference =
|
||||
value as PasskeyFormValues['passkey.attachment_preference']
|
||||
const incoming =
|
||||
attachmentPreference === 'none' ? '' : attachmentPreference
|
||||
const currentDefault =
|
||||
defaultValues['passkey.attachment_preference'] === 'none'
|
||||
? ''
|
||||
: defaultValues['passkey.attachment_preference']
|
||||
if (incoming !== currentDefault) {
|
||||
updates.push({ key, value: incoming })
|
||||
}
|
||||
} else if (value !== defaultValues[key as keyof PasskeyFormValues]) {
|
||||
updates.push({ key, value })
|
||||
}
|
||||
})
|
||||
|
||||
for (const update of updates) {
|
||||
await updateOption.mutateAsync(update)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
title={t('Passkey Authentication')}
|
||||
description={t('Configure Passkey (WebAuthn) login settings')}
|
||||
>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className='space-y-6'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='passkey.enabled'
|
||||
render={({ field }) => (
|
||||
<FormItem className='flex flex-row items-center justify-between rounded-lg border p-4'>
|
||||
<div className='space-y-0.5'>
|
||||
<FormLabel className='text-base'>
|
||||
{t('Enable Passkey')}
|
||||
</FormLabel>
|
||||
<FormDescription>
|
||||
{t(
|
||||
'Allow users to register and sign in with Passkey (WebAuthn)'
|
||||
)}
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='passkey.rp_display_name'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Relying Party Display Name')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t('e.g. New API Console')}
|
||||
{...field}
|
||||
value={field.value ?? ''}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
'Human-readable name shown to users during Passkey prompts.'
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='passkey.rp_id'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Relying Party ID')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t('e.g. example.com')}
|
||||
{...field}
|
||||
value={field.value ?? ''}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
'The effective domain for Passkey registration. Must match the current domain or be its parent domain.'
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='passkey.user_verification'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('User Verification')}</FormLabel>
|
||||
<FormControl>
|
||||
<Select value={field.value} onValueChange={field.onChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t('Select requirement')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='required'>{t('Required')}</SelectItem>
|
||||
<SelectItem value='preferred'>
|
||||
{t('Recommended')}
|
||||
</SelectItem>
|
||||
<SelectItem value='discouraged'>
|
||||
{t('Discouraged')}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
'Controls whether user verification (biometrics/PIN) is required during Passkey flows.'
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='passkey.attachment_preference'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Device Type Preference')}</FormLabel>
|
||||
<FormControl>
|
||||
<Select value={field.value} onValueChange={field.onChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t('No preference')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='none'>{t('Unlimited')}</SelectItem>
|
||||
<SelectItem value='platform'>
|
||||
{t('Built-in Device')}
|
||||
</SelectItem>
|
||||
<SelectItem value='cross-platform'>
|
||||
{t('External Device')}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
'Built-in: phone fingerprint/face, or Windows Hello; External: USB security key'
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='passkey.allow_insecure_origin'
|
||||
render={({ field }) => (
|
||||
<FormItem className='flex flex-row items-center justify-between rounded-lg border p-4'>
|
||||
<div className='space-y-0.5'>
|
||||
<FormLabel className='text-base'>
|
||||
{t('Allow Insecure Origins')}
|
||||
</FormLabel>
|
||||
<FormDescription>
|
||||
{t(
|
||||
'Permit Passkey registration on non-HTTPS origins (only recommended for development)'
|
||||
)}
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='passkey.origins'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Allowed Origins')}</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
rows={4}
|
||||
placeholder={t('https://example.com')}
|
||||
{...field}
|
||||
value={field.value ?? ''}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
'List of origins (one per line) allowed for Passkey registration and authentication.'
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button type='submit'>{t('Save Changes')}</Button>
|
||||
</form>
|
||||
</Form>
|
||||
</SettingsSection>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import type { AuthSettings } from '../types'
|
||||
import { createSectionRegistry } from '../utils/section-registry'
|
||||
import { BasicAuthSection } from './basic-auth-section'
|
||||
import { BotProtectionSection } from './bot-protection-section'
|
||||
import { CustomOAuthSection } from './custom-oauth/custom-oauth-section'
|
||||
import { OAuthSection } from './oauth-section'
|
||||
import { PasskeySection } from './passkey-section'
|
||||
|
||||
const AUTH_SECTIONS = [
|
||||
{
|
||||
id: 'basic-auth',
|
||||
titleKey: 'Basic Authentication',
|
||||
descriptionKey: 'Configure password-based login and registration',
|
||||
build: (settings: AuthSettings) => (
|
||||
<BasicAuthSection
|
||||
defaultValues={{
|
||||
PasswordLoginEnabled: settings.PasswordLoginEnabled,
|
||||
PasswordRegisterEnabled: settings.PasswordRegisterEnabled,
|
||||
EmailVerificationEnabled: settings.EmailVerificationEnabled,
|
||||
RegisterEnabled: settings.RegisterEnabled,
|
||||
EmailDomainRestrictionEnabled: settings.EmailDomainRestrictionEnabled,
|
||||
EmailAliasRestrictionEnabled: settings.EmailAliasRestrictionEnabled,
|
||||
EmailDomainWhitelist: settings.EmailDomainWhitelist,
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'oauth',
|
||||
titleKey: 'OAuth Integrations',
|
||||
descriptionKey: 'Configure third-party authentication providers',
|
||||
build: (settings: AuthSettings) => (
|
||||
<OAuthSection
|
||||
defaultValues={{
|
||||
GitHubOAuthEnabled: settings.GitHubOAuthEnabled,
|
||||
GitHubClientId: settings.GitHubClientId,
|
||||
GitHubClientSecret: settings.GitHubClientSecret,
|
||||
'discord.enabled': settings['discord.enabled'],
|
||||
'discord.client_id': settings['discord.client_id'],
|
||||
'discord.client_secret': settings['discord.client_secret'],
|
||||
'oidc.enabled': settings['oidc.enabled'],
|
||||
'oidc.client_id': settings['oidc.client_id'],
|
||||
'oidc.client_secret': settings['oidc.client_secret'],
|
||||
'oidc.well_known': settings['oidc.well_known'],
|
||||
'oidc.authorization_endpoint':
|
||||
settings['oidc.authorization_endpoint'],
|
||||
'oidc.token_endpoint': settings['oidc.token_endpoint'],
|
||||
'oidc.user_info_endpoint': settings['oidc.user_info_endpoint'],
|
||||
TelegramOAuthEnabled: settings.TelegramOAuthEnabled,
|
||||
TelegramBotToken: settings.TelegramBotToken,
|
||||
TelegramBotName: settings.TelegramBotName,
|
||||
LinuxDOOAuthEnabled: settings.LinuxDOOAuthEnabled,
|
||||
LinuxDOClientId: settings.LinuxDOClientId,
|
||||
LinuxDOClientSecret: settings.LinuxDOClientSecret,
|
||||
LinuxDOMinimumTrustLevel: settings.LinuxDOMinimumTrustLevel,
|
||||
WeChatAuthEnabled: settings.WeChatAuthEnabled,
|
||||
WeChatServerAddress: settings.WeChatServerAddress,
|
||||
WeChatServerToken: settings.WeChatServerToken,
|
||||
WeChatAccountQRCodeImageURL: settings.WeChatAccountQRCodeImageURL,
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'passkey',
|
||||
titleKey: 'Passkey Authentication',
|
||||
descriptionKey: 'Configure Passkey (WebAuthn) login settings',
|
||||
build: (settings: AuthSettings) => (
|
||||
<PasskeySection
|
||||
defaultValues={{
|
||||
'passkey.enabled': settings['passkey.enabled'],
|
||||
'passkey.rp_display_name': settings['passkey.rp_display_name'],
|
||||
'passkey.rp_id': settings['passkey.rp_id'],
|
||||
'passkey.origins': settings['passkey.origins'],
|
||||
'passkey.allow_insecure_origin':
|
||||
settings['passkey.allow_insecure_origin'],
|
||||
'passkey.user_verification': settings['passkey.user_verification'] as
|
||||
| 'required'
|
||||
| 'preferred'
|
||||
| 'discouraged',
|
||||
'passkey.attachment_preference': (settings[
|
||||
'passkey.attachment_preference'
|
||||
] === ''
|
||||
? 'none'
|
||||
: settings['passkey.attachment_preference']) as
|
||||
| 'none'
|
||||
| 'platform'
|
||||
| 'cross-platform',
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'bot-protection',
|
||||
titleKey: 'Bot Protection',
|
||||
descriptionKey: 'Protect login and registration with Cloudflare Turnstile',
|
||||
build: (settings: AuthSettings) => (
|
||||
<BotProtectionSection
|
||||
defaultValues={{
|
||||
TurnstileCheckEnabled: settings.TurnstileCheckEnabled,
|
||||
TurnstileSiteKey: settings.TurnstileSiteKey,
|
||||
TurnstileSecretKey: settings.TurnstileSecretKey,
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'custom-oauth',
|
||||
titleKey: 'Custom OAuth',
|
||||
descriptionKey: 'Configure custom OAuth providers for user authentication',
|
||||
build: () => <CustomOAuthSection />,
|
||||
},
|
||||
] as const
|
||||
|
||||
export type AuthSectionId = (typeof AUTH_SECTIONS)[number]['id']
|
||||
|
||||
const authRegistry = createSectionRegistry<AuthSectionId, AuthSettings>({
|
||||
sections: AUTH_SECTIONS,
|
||||
defaultSection: 'basic-auth',
|
||||
basePath: '/system-settings/auth',
|
||||
urlStyle: 'path',
|
||||
})
|
||||
|
||||
export const AUTH_SECTION_IDS = authRegistry.sectionIds
|
||||
export const AUTH_DEFAULT_SECTION = authRegistry.defaultSection
|
||||
export const getAuthSectionNavItems = authRegistry.getSectionNavItems
|
||||
export const getAuthSectionContent = authRegistry.getSectionContent
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Info } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
|
||||
type FormDirtyIndicatorProps = {
|
||||
isDirty: boolean
|
||||
message?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Visual indicator that the form has unsaved changes
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <FormDirtyIndicator isDirty={form.formState.isDirty} />
|
||||
* ```
|
||||
*/
|
||||
export function FormDirtyIndicator({
|
||||
isDirty,
|
||||
message,
|
||||
}: FormDirtyIndicatorProps) {
|
||||
const { t } = useTranslation()
|
||||
if (!isDirty) return null
|
||||
|
||||
return (
|
||||
<Alert
|
||||
variant='default'
|
||||
className='border-orange-500/50 bg-orange-50 dark:bg-orange-950/20'
|
||||
>
|
||||
<Info className='h-4 w-4 text-orange-600 dark:text-orange-500' />
|
||||
<AlertDescription className='text-orange-800 dark:text-orange-400'>
|
||||
{message ?? t('You have unsaved changes')}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useBlocker } from '@tanstack/react-router'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
|
||||
type FormNavigationGuardProps = {
|
||||
when: boolean
|
||||
title?: string
|
||||
message?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Form navigation guard with custom dialog
|
||||
*
|
||||
* Prevents navigation when form has unsaved changes.
|
||||
* Uses project's native ConfirmDialog instead of browser's window.confirm()
|
||||
*
|
||||
* @param when - Whether to block navigation (typically form.formState.isDirty)
|
||||
* @param title - Dialog title
|
||||
* @param message - Dialog message
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <FormNavigationGuard when={form.formState.isDirty} />
|
||||
* ```
|
||||
*/
|
||||
export function FormNavigationGuard({
|
||||
when,
|
||||
title,
|
||||
message,
|
||||
}: FormNavigationGuardProps) {
|
||||
const { t } = useTranslation()
|
||||
const resolvedTitle = title ?? t('Unsaved changes')
|
||||
const resolvedMessage =
|
||||
message ?? t('You have unsaved changes. Are you sure you want to leave?')
|
||||
const blocker = useBlocker({ condition: when })
|
||||
const [showDialog, setShowDialog] = useState(false)
|
||||
|
||||
// Listen to blocker status changes
|
||||
useEffect(() => {
|
||||
if (blocker.status === 'blocked') {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setShowDialog(true)
|
||||
}
|
||||
}, [blocker.status])
|
||||
|
||||
const handleConfirm = () => {
|
||||
setShowDialog(false)
|
||||
blocker.proceed?.()
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
setShowDialog(false)
|
||||
blocker.reset?.()
|
||||
}
|
||||
|
||||
// Handle browser navigation (refresh, close tab)
|
||||
useEffect(() => {
|
||||
if (!when) return
|
||||
|
||||
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
|
||||
e.preventDefault()
|
||||
e.returnValue = ''
|
||||
return ''
|
||||
}
|
||||
|
||||
window.addEventListener('beforeunload', handleBeforeUnload)
|
||||
return () => window.removeEventListener('beforeunload', handleBeforeUnload)
|
||||
}, [when])
|
||||
|
||||
return (
|
||||
<ConfirmDialog
|
||||
open={showDialog}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) handleCancel()
|
||||
}}
|
||||
title={resolvedTitle}
|
||||
desc={resolvedMessage}
|
||||
confirmText={t('Leave')}
|
||||
cancelBtnText={t('Stay')}
|
||||
destructive
|
||||
handleConfirm={handleConfirm}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import {
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
AccordionContent,
|
||||
} from '@/components/ui/accordion'
|
||||
|
||||
type SettingsAccordionProps = {
|
||||
value: string
|
||||
title: string
|
||||
description?: string
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function SettingsAccordion({
|
||||
value,
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
className,
|
||||
}: SettingsAccordionProps) {
|
||||
return (
|
||||
<AccordionItem value={value} className={className}>
|
||||
<AccordionTrigger className='hover:no-underline'>
|
||||
<div className='flex flex-col gap-1 text-left'>
|
||||
<div className='text-base font-semibold'>{title}</div>
|
||||
{description && (
|
||||
<div className='text-muted-foreground text-sm'>{description}</div>
|
||||
)}
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className='pt-4'>{children}</AccordionContent>
|
||||
</AccordionItem>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { memo } from 'react'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
} from '@/components/ui/card'
|
||||
|
||||
type SettingsCardProps = {
|
||||
title: string
|
||||
description?: string
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
export const SettingsCard = memo(function SettingsCard({
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
className,
|
||||
}: SettingsCardProps) {
|
||||
return (
|
||||
<Card className={className}>
|
||||
<CardHeader>
|
||||
<CardTitle>{title}</CardTitle>
|
||||
{description && <CardDescription>{description}</CardDescription>}
|
||||
</CardHeader>
|
||||
<CardContent>{children}</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useParams } from '@tanstack/react-router'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useSystemOptions, getOptionValue } from '../hooks/use-system-options'
|
||||
|
||||
type SettingsPageProps<
|
||||
TSettings extends Record<string, string | number | boolean | unknown[]>,
|
||||
TSectionId extends string,
|
||||
> = {
|
||||
routePath: string
|
||||
defaultSettings: TSettings
|
||||
defaultSection: TSectionId
|
||||
getSectionContent: (
|
||||
sectionId: TSectionId,
|
||||
settings: TSettings,
|
||||
...extraArgs: unknown[]
|
||||
) => React.ReactNode
|
||||
extraArgs?: unknown[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic settings page component
|
||||
* Handles loading state, data fetching, and section rendering
|
||||
*/
|
||||
export function SettingsPage<
|
||||
TSettings extends Record<string, string | number | boolean | unknown[]>,
|
||||
TSectionId extends string,
|
||||
>({
|
||||
routePath,
|
||||
defaultSettings,
|
||||
defaultSection,
|
||||
getSectionContent,
|
||||
extraArgs = [],
|
||||
}: SettingsPageProps<TSettings, TSectionId>) {
|
||||
const { t } = useTranslation()
|
||||
const { data, isLoading } = useSystemOptions()
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const params = useParams({ from: routePath as any })
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className='flex items-center justify-center py-12'>
|
||||
<div className='text-muted-foreground'>{t('Loading settings...')}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const settings = getOptionValue(data?.data, defaultSettings) as TSettings
|
||||
const activeSection = (params?.section ?? defaultSection) as TSectionId
|
||||
const sectionContent = getSectionContent(
|
||||
activeSection,
|
||||
settings,
|
||||
...extraArgs
|
||||
)
|
||||
|
||||
return (
|
||||
<div className='flex h-full w-full flex-1 flex-col'>
|
||||
<div className='faded-bottom h-full w-full overflow-y-auto scroll-smooth pe-4 pb-12'>
|
||||
<div className='space-y-4'>{sectionContent}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
type SettingsSectionProps = {
|
||||
title: string
|
||||
titleProps?: React.HTMLAttributes<HTMLHeadingElement>
|
||||
description?: string
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function SettingsSection({
|
||||
title,
|
||||
titleProps,
|
||||
description,
|
||||
children,
|
||||
className,
|
||||
}: SettingsSectionProps) {
|
||||
const baseClassName = 'space-y-4'
|
||||
const sectionClassName = className
|
||||
? `${baseClassName} ${className}`
|
||||
: baseClassName
|
||||
|
||||
return (
|
||||
<section className={sectionClassName}>
|
||||
<div className='space-y-1'>
|
||||
<h3
|
||||
{...titleProps}
|
||||
className={
|
||||
titleProps?.className
|
||||
? `text-base font-semibold ${titleProps.className}`
|
||||
: 'text-base font-semibold'
|
||||
}
|
||||
>
|
||||
{title}
|
||||
</h3>
|
||||
{description && (
|
||||
<p className='text-muted-foreground text-sm'>{description}</p>
|
||||
)}
|
||||
</div>
|
||||
{children}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,599 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import * as z from 'zod'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { Plus, Edit, Trash2, Save } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import dayjs from '@/lib/dayjs'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { DateTimePicker } from '@/components/datetime-picker'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { SettingsSection } from '../components/settings-section'
|
||||
import { useUpdateOption } from '../hooks/use-update-option'
|
||||
|
||||
type Announcement = {
|
||||
id: number
|
||||
content: string
|
||||
publishDate: string
|
||||
type: 'default' | 'ongoing' | 'success' | 'warning' | 'error'
|
||||
extra?: string
|
||||
}
|
||||
|
||||
type AnnouncementsSectionProps = {
|
||||
enabled: boolean
|
||||
data: string
|
||||
}
|
||||
|
||||
const announcementSchema = z.object({
|
||||
content: z
|
||||
.string()
|
||||
.min(1, 'Content is required')
|
||||
.max(500, 'Content must be less than 500 characters'),
|
||||
publishDate: z.string().min(1, 'Publish date is required'),
|
||||
type: z.enum(['default', 'ongoing', 'success', 'warning', 'error']),
|
||||
extra: z
|
||||
.string()
|
||||
.max(100, 'Extra must be less than 100 characters')
|
||||
.optional(),
|
||||
})
|
||||
|
||||
type AnnouncementFormValues = z.infer<typeof announcementSchema>
|
||||
|
||||
const typeOptions = [
|
||||
{
|
||||
value: 'default',
|
||||
label: 'Default',
|
||||
color: 'bg-gray-500',
|
||||
badgeVariant: 'neutral' as const,
|
||||
},
|
||||
{
|
||||
value: 'ongoing',
|
||||
label: 'Ongoing',
|
||||
color: 'bg-blue-500',
|
||||
badgeVariant: 'info' as const,
|
||||
},
|
||||
{
|
||||
value: 'success',
|
||||
label: 'Success',
|
||||
color: 'bg-green-500',
|
||||
badgeVariant: 'success' as const,
|
||||
},
|
||||
{
|
||||
value: 'warning',
|
||||
label: 'Warning',
|
||||
color: 'bg-orange-500',
|
||||
badgeVariant: 'warning' as const,
|
||||
},
|
||||
{
|
||||
value: 'error',
|
||||
label: 'Error',
|
||||
color: 'bg-red-500',
|
||||
badgeVariant: 'danger' as const,
|
||||
},
|
||||
]
|
||||
|
||||
export function AnnouncementsSection({
|
||||
enabled,
|
||||
data,
|
||||
}: AnnouncementsSectionProps) {
|
||||
const { t } = useTranslation()
|
||||
const updateOption = useUpdateOption()
|
||||
const [announcements, setAnnouncements] = useState<Announcement[]>([])
|
||||
const [isEnabled, setIsEnabled] = useState(enabled)
|
||||
const [hasChanges, setHasChanges] = useState(false)
|
||||
const [selectedIds, setSelectedIds] = useState<number[]>([])
|
||||
const [showDialog, setShowDialog] = useState(false)
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
||||
const [editingAnnouncement, setEditingAnnouncement] =
|
||||
useState<Announcement | null>(null)
|
||||
const [deleteTarget, setDeleteTarget] = useState<'single' | 'batch'>('single')
|
||||
|
||||
const form = useForm<AnnouncementFormValues>({
|
||||
resolver: zodResolver(announcementSchema),
|
||||
defaultValues: {
|
||||
content: '',
|
||||
publishDate: new Date().toISOString(),
|
||||
type: 'default',
|
||||
extra: '',
|
||||
},
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const parsed = JSON.parse(data || '[]')
|
||||
if (Array.isArray(parsed)) {
|
||||
setAnnouncements(
|
||||
parsed.map((item, idx) => ({
|
||||
...item,
|
||||
id: item.id || idx + 1,
|
||||
}))
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
setAnnouncements([])
|
||||
}
|
||||
}, [data])
|
||||
|
||||
useEffect(() => {
|
||||
setIsEnabled(enabled)
|
||||
}, [enabled])
|
||||
|
||||
const handleToggleEnabled = async (checked: boolean) => {
|
||||
try {
|
||||
await updateOption.mutateAsync({
|
||||
key: 'console_setting.announcements_enabled',
|
||||
value: checked,
|
||||
})
|
||||
setIsEnabled(checked)
|
||||
toast.success(t('Setting saved'))
|
||||
} catch {
|
||||
toast.error(t('Failed to update setting'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleAdd = () => {
|
||||
setEditingAnnouncement(null)
|
||||
form.reset({
|
||||
content: '',
|
||||
publishDate: new Date().toISOString(),
|
||||
type: 'default',
|
||||
extra: '',
|
||||
})
|
||||
setShowDialog(true)
|
||||
}
|
||||
|
||||
const handleEdit = (announcement: Announcement) => {
|
||||
setEditingAnnouncement(announcement)
|
||||
form.reset({
|
||||
content: announcement.content,
|
||||
publishDate: announcement.publishDate,
|
||||
type: announcement.type,
|
||||
extra: announcement.extra || '',
|
||||
})
|
||||
setShowDialog(true)
|
||||
}
|
||||
|
||||
const handleDelete = (announcement: Announcement) => {
|
||||
setEditingAnnouncement(announcement)
|
||||
setDeleteTarget('single')
|
||||
setShowDeleteDialog(true)
|
||||
}
|
||||
|
||||
const handleBatchDelete = () => {
|
||||
if (selectedIds.length === 0) {
|
||||
toast.error(t('Please select items to delete'))
|
||||
return
|
||||
}
|
||||
setDeleteTarget('batch')
|
||||
setShowDeleteDialog(true)
|
||||
}
|
||||
|
||||
const confirmDelete = () => {
|
||||
if (deleteTarget === 'single' && editingAnnouncement) {
|
||||
setAnnouncements((prev) =>
|
||||
prev.filter((item) => item.id !== editingAnnouncement.id)
|
||||
)
|
||||
setHasChanges(true)
|
||||
toast.success(t('Announcement deleted. Click "Save Settings" to apply.'))
|
||||
} else if (deleteTarget === 'batch') {
|
||||
setAnnouncements((prev) =>
|
||||
prev.filter((item) => !selectedIds.includes(item.id))
|
||||
)
|
||||
setSelectedIds([])
|
||||
setHasChanges(true)
|
||||
toast.success(
|
||||
t('{{count}} announcements deleted. Click "Save Settings" to apply.', {
|
||||
count: selectedIds.length,
|
||||
})
|
||||
)
|
||||
}
|
||||
setShowDeleteDialog(false)
|
||||
setEditingAnnouncement(null)
|
||||
}
|
||||
|
||||
const handleSubmitForm = (values: AnnouncementFormValues) => {
|
||||
if (editingAnnouncement) {
|
||||
setAnnouncements((prev) =>
|
||||
prev.map((item) =>
|
||||
item.id === editingAnnouncement.id ? { ...item, ...values } : item
|
||||
)
|
||||
)
|
||||
toast.success(t('Announcement updated. Click "Save Settings" to apply.'))
|
||||
} else {
|
||||
const newId = Math.max(...announcements.map((item) => item.id), 0) + 1
|
||||
setAnnouncements((prev) => [...prev, { id: newId, ...values }])
|
||||
toast.success(t('Announcement added. Click "Save Settings" to apply.'))
|
||||
}
|
||||
setHasChanges(true)
|
||||
setShowDialog(false)
|
||||
}
|
||||
|
||||
const handleSaveAll = async () => {
|
||||
try {
|
||||
await updateOption.mutateAsync({
|
||||
key: 'console_setting.announcements',
|
||||
value: JSON.stringify(announcements),
|
||||
})
|
||||
setHasChanges(false)
|
||||
toast.success(t('Announcements saved successfully'))
|
||||
} catch {
|
||||
toast.error(t('Failed to save announcements'))
|
||||
}
|
||||
}
|
||||
|
||||
const toggleSelectAll = (checked: boolean) => {
|
||||
setSelectedIds(checked ? announcements.map((item) => item.id) : [])
|
||||
}
|
||||
|
||||
const toggleSelectOne = (id: number, checked: boolean) => {
|
||||
setSelectedIds((prev) =>
|
||||
checked ? [...prev, id] : prev.filter((item) => item !== id)
|
||||
)
|
||||
}
|
||||
|
||||
const sortedAnnouncements = useMemo(() => {
|
||||
return [...announcements].sort((a, b) => {
|
||||
return (
|
||||
new Date(b.publishDate).getTime() - new Date(a.publishDate).getTime()
|
||||
)
|
||||
})
|
||||
}, [announcements])
|
||||
|
||||
const getRelativeTime = (date: string) => {
|
||||
const now = new Date()
|
||||
const past = new Date(date)
|
||||
const diffMs = now.getTime() - past.getTime()
|
||||
const diffMins = Math.floor(diffMs / 60000)
|
||||
const diffHours = Math.floor(diffMins / 60)
|
||||
const diffDays = Math.floor(diffHours / 24)
|
||||
|
||||
if (diffMins < 60) return `${diffMins}m ago`
|
||||
if (diffHours < 24) return `${diffHours}h ago`
|
||||
return `${diffDays}d ago`
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
title={t('Announcements')}
|
||||
description={t('Broadcast short system notices on the dashboard')}
|
||||
>
|
||||
<div className='space-y-4'>
|
||||
<div className='flex flex-wrap items-center justify-between gap-2'>
|
||||
<div className='flex flex-wrap items-center gap-2'>
|
||||
<Button onClick={handleAdd} size='sm'>
|
||||
<Plus className='mr-2 h-4 w-4' />
|
||||
{t('Add Announcement')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleBatchDelete}
|
||||
size='sm'
|
||||
variant='destructive'
|
||||
disabled={selectedIds.length === 0}
|
||||
>
|
||||
<Trash2 className='mr-2 h-4 w-4' />
|
||||
{t('Delete (')}
|
||||
{selectedIds.length})
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSaveAll}
|
||||
size='sm'
|
||||
variant='secondary'
|
||||
disabled={!hasChanges || updateOption.isPending}
|
||||
>
|
||||
<Save className='mr-2 h-4 w-4' />
|
||||
{updateOption.isPending ? t('Saving...') : t('Save Settings')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className='flex items-center gap-2'>
|
||||
<span className='text-muted-foreground text-sm'>
|
||||
{t('Enabled')}
|
||||
</span>
|
||||
<Switch checked={isEnabled} onCheckedChange={handleToggleEnabled} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='rounded-md border'>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className='w-12'>
|
||||
<Checkbox
|
||||
checked={
|
||||
selectedIds.length === announcements.length &&
|
||||
announcements.length > 0
|
||||
}
|
||||
onCheckedChange={toggleSelectAll}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead>{t('Content')}</TableHead>
|
||||
<TableHead>{t('Publish Date')}</TableHead>
|
||||
<TableHead>{t('Type')}</TableHead>
|
||||
<TableHead>{t('Extra')}</TableHead>
|
||||
<TableHead className='w-32'>{t('Actions')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{sortedAnnouncements.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className='h-24 text-center'>
|
||||
{t(
|
||||
'No announcements yet. Click "Add Announcement" to create one.'
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
sortedAnnouncements.map((announcement) => (
|
||||
<TableRow key={announcement.id}>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
checked={selectedIds.includes(announcement.id)}
|
||||
onCheckedChange={(checked) =>
|
||||
toggleSelectOne(announcement.id, checked as boolean)
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className='max-w-xs truncate'
|
||||
title={announcement.content}
|
||||
>
|
||||
{announcement.content}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className='flex flex-col gap-1'>
|
||||
<span className='text-sm font-medium'>
|
||||
{getRelativeTime(announcement.publishDate)}
|
||||
</span>
|
||||
<span className='text-muted-foreground text-xs'>
|
||||
{dayjs(announcement.publishDate).format(
|
||||
'YYYY-MM-DD HH:mm:ss'
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<StatusBadge
|
||||
label={
|
||||
typeOptions.find(
|
||||
(opt) => opt.value === announcement.type
|
||||
)?.label
|
||||
}
|
||||
variant={
|
||||
typeOptions.find(
|
||||
(opt) => opt.value === announcement.type
|
||||
)?.badgeVariant ?? 'neutral'
|
||||
}
|
||||
copyable={false}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className='text-muted-foreground max-w-xs truncate'
|
||||
title={announcement.extra}
|
||||
>
|
||||
{announcement.extra || '-'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className='flex gap-2'>
|
||||
<Button
|
||||
onClick={() => handleEdit(announcement)}
|
||||
size='sm'
|
||||
variant='ghost'
|
||||
>
|
||||
<Edit className='h-4 w-4' />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleDelete(announcement)}
|
||||
size='sm'
|
||||
variant='ghost'
|
||||
>
|
||||
<Trash2 className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog open={showDialog} onOpenChange={setShowDialog}>
|
||||
<DialogContent className='max-w-2xl'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{editingAnnouncement
|
||||
? t('Edit Announcement')
|
||||
: t('Add Announcement')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('Create or update system announcements for the dashboard')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(handleSubmitForm)}
|
||||
className='space-y-4'
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='content'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Content')}</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder={t(
|
||||
'Enter announcement content (supports Markdown/HTML)'
|
||||
)}
|
||||
rows={4}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('Maximum 500 characters. Supports Markdown and HTML.')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='publishDate'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Publish Date')}</FormLabel>
|
||||
<FormControl>
|
||||
<DateTimePicker
|
||||
value={field.value ? new Date(field.value) : undefined}
|
||||
onChange={(date) =>
|
||||
field.onChange(date ? date.toISOString() : '')
|
||||
}
|
||||
placeholder={t('Select publish date')}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
'Date and time when this announcement should be displayed'
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='type'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Type')}</FormLabel>
|
||||
<Select onValueChange={field.onChange} value={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={t('Select announcement type')}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{typeOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
<div className='flex items-center gap-2'>
|
||||
<div
|
||||
className={`h-3 w-3 rounded-full ${option.color}`}
|
||||
/>
|
||||
{option.label}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='extra'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Extra Notes (Optional)')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t('Additional information')}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
'Optional supplementary information (max 100 characters)'
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
onClick={() => setShowDialog(false)}
|
||||
>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
<Button type='submit'>
|
||||
{editingAnnouncement ? t('Update') : t('Add')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t('Are you sure?')}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{deleteTarget === 'single'
|
||||
? 'This announcement will be removed from the list.'
|
||||
: `${selectedIds.length} announcements will be removed from the list.`}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t('Cancel')}</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={confirmDelete}>
|
||||
{t('Delete')}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</SettingsSection>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,528 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import * as z from 'zod'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { Plus, Edit, Trash2, Save } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { SettingsSection } from '../components/settings-section'
|
||||
import { useUpdateOption } from '../hooks/use-update-option'
|
||||
|
||||
type ApiInfo = {
|
||||
id: number
|
||||
url: string
|
||||
route: string
|
||||
description: string
|
||||
color: string
|
||||
}
|
||||
|
||||
type ApiInfoSectionProps = {
|
||||
enabled: boolean
|
||||
data: string
|
||||
}
|
||||
|
||||
const createApiInfoSchema = (t: (key: string) => string) =>
|
||||
z.object({
|
||||
url: z.string().url(t('Must be a valid URL')),
|
||||
route: z.string().min(1, t('Route is required')),
|
||||
description: z.string().min(1, t('Description is required')),
|
||||
color: z.string().min(1, t('Color is required')),
|
||||
})
|
||||
|
||||
type ApiInfoFormValues = z.infer<ReturnType<typeof createApiInfoSchema>>
|
||||
|
||||
const colorOptions = [
|
||||
{ value: 'blue', label: 'Blue', bgClass: 'bg-blue-500' },
|
||||
{ value: 'green', label: 'Green', bgClass: 'bg-green-500' },
|
||||
{ value: 'cyan', label: 'Cyan', bgClass: 'bg-cyan-500' },
|
||||
{ value: 'purple', label: 'Purple', bgClass: 'bg-purple-500' },
|
||||
{ value: 'pink', label: 'Pink', bgClass: 'bg-pink-500' },
|
||||
{ value: 'red', label: 'Red', bgClass: 'bg-red-500' },
|
||||
{ value: 'orange', label: 'Orange', bgClass: 'bg-orange-500' },
|
||||
{ value: 'amber', label: 'Amber', bgClass: 'bg-amber-500' },
|
||||
{ value: 'yellow', label: 'Yellow', bgClass: 'bg-yellow-500' },
|
||||
{ value: 'lime', label: 'Lime', bgClass: 'bg-lime-500' },
|
||||
{ value: 'teal', label: 'Teal', bgClass: 'bg-teal-500' },
|
||||
{ value: 'indigo', label: 'Indigo', bgClass: 'bg-indigo-500' },
|
||||
{ value: 'violet', label: 'Violet', bgClass: 'bg-violet-500' },
|
||||
{ value: 'slate', label: 'Slate', bgClass: 'bg-slate-500' },
|
||||
]
|
||||
|
||||
export function ApiInfoSection({ enabled, data }: ApiInfoSectionProps) {
|
||||
const { t } = useTranslation()
|
||||
const updateOption = useUpdateOption()
|
||||
const apiInfoSchema = createApiInfoSchema(t)
|
||||
const [apiInfoList, setApiInfoList] = useState<ApiInfo[]>([])
|
||||
const [isEnabled, setIsEnabled] = useState(enabled)
|
||||
const [hasChanges, setHasChanges] = useState(false)
|
||||
const [selectedIds, setSelectedIds] = useState<number[]>([])
|
||||
const [showDialog, setShowDialog] = useState(false)
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
||||
const [editingApiInfo, setEditingApiInfo] = useState<ApiInfo | null>(null)
|
||||
const [deleteTarget, setDeleteTarget] = useState<'single' | 'batch'>('single')
|
||||
|
||||
const form = useForm<ApiInfoFormValues>({
|
||||
resolver: zodResolver(apiInfoSchema),
|
||||
defaultValues: {
|
||||
url: '',
|
||||
route: '',
|
||||
description: '',
|
||||
color: 'blue',
|
||||
},
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const parsed = JSON.parse(data || '[]')
|
||||
if (Array.isArray(parsed)) {
|
||||
setApiInfoList(
|
||||
parsed.map((item, idx) => ({
|
||||
...item,
|
||||
id: item.id || idx + 1,
|
||||
}))
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
setApiInfoList([])
|
||||
}
|
||||
}, [data])
|
||||
|
||||
useEffect(() => {
|
||||
setIsEnabled(enabled)
|
||||
}, [enabled])
|
||||
|
||||
const handleToggleEnabled = async (checked: boolean) => {
|
||||
try {
|
||||
await updateOption.mutateAsync({
|
||||
key: 'console_setting.api_info_enabled',
|
||||
value: checked,
|
||||
})
|
||||
setIsEnabled(checked)
|
||||
toast.success(t('Setting saved'))
|
||||
} catch {
|
||||
toast.error(t('Failed to update setting'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleAdd = () => {
|
||||
setEditingApiInfo(null)
|
||||
form.reset({
|
||||
url: '',
|
||||
route: '',
|
||||
description: '',
|
||||
color: 'blue',
|
||||
})
|
||||
setShowDialog(true)
|
||||
}
|
||||
|
||||
const handleEdit = (apiInfo: ApiInfo) => {
|
||||
setEditingApiInfo(apiInfo)
|
||||
form.reset({
|
||||
url: apiInfo.url,
|
||||
route: apiInfo.route,
|
||||
description: apiInfo.description,
|
||||
color: apiInfo.color,
|
||||
})
|
||||
setShowDialog(true)
|
||||
}
|
||||
|
||||
const handleDelete = (apiInfo: ApiInfo) => {
|
||||
setEditingApiInfo(apiInfo)
|
||||
setDeleteTarget('single')
|
||||
setShowDeleteDialog(true)
|
||||
}
|
||||
|
||||
const handleBatchDelete = () => {
|
||||
if (selectedIds.length === 0) {
|
||||
toast.error(t('Please select items to delete'))
|
||||
return
|
||||
}
|
||||
setDeleteTarget('batch')
|
||||
setShowDeleteDialog(true)
|
||||
}
|
||||
|
||||
const confirmDelete = () => {
|
||||
if (deleteTarget === 'single' && editingApiInfo) {
|
||||
setApiInfoList((prev) =>
|
||||
prev.filter((item) => item.id !== editingApiInfo.id)
|
||||
)
|
||||
setHasChanges(true)
|
||||
toast.success(t('API info deleted. Click "Save Settings" to apply.'))
|
||||
} else if (deleteTarget === 'batch') {
|
||||
setApiInfoList((prev) =>
|
||||
prev.filter((item) => !selectedIds.includes(item.id))
|
||||
)
|
||||
setSelectedIds([])
|
||||
setHasChanges(true)
|
||||
toast.success(
|
||||
t('{{count}} API entries deleted. Click "Save Settings" to apply.', {
|
||||
count: selectedIds.length,
|
||||
})
|
||||
)
|
||||
}
|
||||
setShowDeleteDialog(false)
|
||||
setEditingApiInfo(null)
|
||||
}
|
||||
|
||||
const handleSubmitForm = (values: ApiInfoFormValues) => {
|
||||
if (editingApiInfo) {
|
||||
setApiInfoList((prev) =>
|
||||
prev.map((item) =>
|
||||
item.id === editingApiInfo.id ? { ...item, ...values } : item
|
||||
)
|
||||
)
|
||||
toast.success(t('API info updated. Click "Save Settings" to apply.'))
|
||||
} else {
|
||||
const newId = Math.max(...apiInfoList.map((item) => item.id), 0) + 1
|
||||
setApiInfoList((prev) => [...prev, { id: newId, ...values }])
|
||||
toast.success(t('API info added. Click "Save Settings" to apply.'))
|
||||
}
|
||||
setHasChanges(true)
|
||||
setShowDialog(false)
|
||||
}
|
||||
|
||||
const handleSaveAll = async () => {
|
||||
try {
|
||||
await updateOption.mutateAsync({
|
||||
key: 'console_setting.api_info',
|
||||
value: JSON.stringify(apiInfoList),
|
||||
})
|
||||
setHasChanges(false)
|
||||
toast.success(t('API info saved successfully'))
|
||||
} catch {
|
||||
toast.error(t('Failed to save API info'))
|
||||
}
|
||||
}
|
||||
|
||||
const toggleSelectAll = (checked: boolean) => {
|
||||
setSelectedIds(checked ? apiInfoList.map((item) => item.id) : [])
|
||||
}
|
||||
|
||||
const toggleSelectOne = (id: number, checked: boolean) => {
|
||||
setSelectedIds((prev) =>
|
||||
checked ? [...prev, id] : prev.filter((item) => item !== id)
|
||||
)
|
||||
}
|
||||
|
||||
const getColorClass = (color: string) => {
|
||||
return (
|
||||
colorOptions.find((opt) => opt.value === color)?.bgClass || 'bg-blue-500'
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
title={t('API Addresses')}
|
||||
description={t('Curate quick links to your different Domains')}
|
||||
>
|
||||
<div className='space-y-4'>
|
||||
<div className='flex flex-wrap items-center justify-between gap-2'>
|
||||
<div className='flex flex-wrap items-center gap-2'>
|
||||
<Button onClick={handleAdd} size='sm'>
|
||||
<Plus className='mr-2 h-4 w-4' />
|
||||
{t('Add API')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleBatchDelete}
|
||||
size='sm'
|
||||
variant='destructive'
|
||||
disabled={selectedIds.length === 0}
|
||||
>
|
||||
<Trash2 className='mr-2 h-4 w-4' />
|
||||
{t('Delete (')}
|
||||
{selectedIds.length})
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSaveAll}
|
||||
size='sm'
|
||||
variant='secondary'
|
||||
disabled={!hasChanges || updateOption.isPending}
|
||||
>
|
||||
<Save className='mr-2 h-4 w-4' />
|
||||
{updateOption.isPending ? t('Saving...') : t('Save Settings')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className='flex items-center gap-2'>
|
||||
<span className='text-muted-foreground text-sm'>
|
||||
{t('Enabled')}
|
||||
</span>
|
||||
<Switch checked={isEnabled} onCheckedChange={handleToggleEnabled} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='rounded-md border'>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className='w-12'>
|
||||
<Checkbox
|
||||
checked={
|
||||
selectedIds.length === apiInfoList.length &&
|
||||
apiInfoList.length > 0
|
||||
}
|
||||
onCheckedChange={toggleSelectAll}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead>{t('URL')}</TableHead>
|
||||
<TableHead>{t('Route')}</TableHead>
|
||||
<TableHead>{t('Description')}</TableHead>
|
||||
<TableHead>{t('Color')}</TableHead>
|
||||
<TableHead className='w-32'>{t('Actions')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{apiInfoList.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className='h-24 text-center'>
|
||||
{t('No API Domains yet. Click "Add API" to create one.')}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
apiInfoList.map((apiInfo) => (
|
||||
<TableRow key={apiInfo.id}>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
checked={selectedIds.includes(apiInfo.id)}
|
||||
onCheckedChange={(checked) =>
|
||||
toggleSelectOne(apiInfo.id, checked as boolean)
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className='max-w-xs truncate font-mono text-sm'
|
||||
title={apiInfo.url}
|
||||
>
|
||||
<StatusBadge
|
||||
label={apiInfo.url}
|
||||
variant='neutral'
|
||||
copyable={false}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<StatusBadge
|
||||
label={apiInfo.route}
|
||||
variant='neutral'
|
||||
copyable={false}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className='max-w-xs truncate'
|
||||
title={apiInfo.description}
|
||||
>
|
||||
{apiInfo.description}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className='flex items-center gap-2'>
|
||||
<div
|
||||
className={`h-4 w-4 rounded-full ${getColorClass(apiInfo.color)}`}
|
||||
/>
|
||||
<span className='text-sm capitalize'>
|
||||
{apiInfo.color}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className='flex gap-2'>
|
||||
<Button
|
||||
onClick={() => handleEdit(apiInfo)}
|
||||
size='sm'
|
||||
variant='ghost'
|
||||
>
|
||||
<Edit className='h-4 w-4' />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleDelete(apiInfo)}
|
||||
size='sm'
|
||||
variant='ghost'
|
||||
>
|
||||
<Trash2 className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog open={showDialog} onOpenChange={setShowDialog}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{editingApiInfo ? t('Edit API Shortcut') : t('Add API Shortcut')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('Configure API documentation links for the dashboard')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(handleSubmitForm)}
|
||||
className='space-y-4'
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='url'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('API URL')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t('https://api.example.com')}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='route'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Route Description')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder={t('e.g., CN2 GIA')} {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='description'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Description')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t(
|
||||
'e.g., Recommended for China Mainland Users'
|
||||
)}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='color'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Badge Color')}</FormLabel>
|
||||
<Select onValueChange={field.onChange} value={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t('Select a color')} />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{colorOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
<div className='flex items-center gap-2'>
|
||||
<div
|
||||
className={`h-4 w-4 rounded-full ${option.bgClass}`}
|
||||
/>
|
||||
{option.label}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
{t('Visual indicator color for the API card')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
onClick={() => setShowDialog(false)}
|
||||
>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
<Button type='submit'>
|
||||
{editingApiInfo ? t('Update') : t('Add')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t('Are you sure?')}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{deleteTarget === 'single'
|
||||
? 'This API shortcut will be removed from the list.'
|
||||
: `${selectedIds.length} API shortcuts will be removed from the list.`}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t('Cancel')}</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={confirmDelete}>
|
||||
{t('Delete')}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</SettingsSection>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import { useEffect } from 'react'
|
||||
import * as z from 'zod'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import { Input } from '@/components/ui/input'
|
||||
|
||||
const createChatDialogSchema = (t: (key: string) => string) =>
|
||||
z.object({
|
||||
name: z.string().min(1, t('Chat client name is required')),
|
||||
url: z.string().min(1, t('URL is required')),
|
||||
})
|
||||
|
||||
type ChatDialogFormValues = z.infer<ReturnType<typeof createChatDialogSchema>>
|
||||
|
||||
export type ChatEntryData = {
|
||||
name: string
|
||||
url: string
|
||||
}
|
||||
|
||||
type ChatDialogProps = {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onSave: (data: ChatEntryData) => void
|
||||
editData?: ChatEntryData | null
|
||||
}
|
||||
|
||||
export function ChatDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onSave,
|
||||
editData,
|
||||
}: ChatDialogProps) {
|
||||
const { t } = useTranslation()
|
||||
const isEditMode = !!editData
|
||||
const chatDialogSchema = createChatDialogSchema(t)
|
||||
|
||||
const form = useForm<ChatDialogFormValues>({
|
||||
resolver: zodResolver(chatDialogSchema),
|
||||
defaultValues: {
|
||||
name: '',
|
||||
url: '',
|
||||
},
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (editData) {
|
||||
form.reset(editData)
|
||||
} else {
|
||||
form.reset({
|
||||
name: '',
|
||||
url: '',
|
||||
})
|
||||
}
|
||||
}, [editData, form, open])
|
||||
|
||||
const handleSubmit = (values: ChatDialogFormValues) => {
|
||||
onSave(values)
|
||||
form.reset()
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className='sm:max-w-[500px]'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{isEditMode ? t('Edit chat preset') : t('Add chat preset')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('Configure a predefined chat link for end users.')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(handleSubmit)}
|
||||
className='space-y-4'
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='name'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Chat Client Name')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t('Please enter chat client name')}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('Display name for this chat client.')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='url'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('URL')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder={t('Please enter the URL')} {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('The URL for this chat client.')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
<Button type='submit'>
|
||||
{isEditMode ? t('Update') : t('Add')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import * as z from 'zod'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { SettingsSection } from '../components/settings-section'
|
||||
import { useUpdateOption } from '../hooks/use-update-option'
|
||||
import { ChatSettingsVisualEditor } from './chat-settings-visual-editor'
|
||||
import { formatJsonForEditor, normalizeJsonString } from './utils'
|
||||
|
||||
const createChatSchema = (t: (key: string) => string) =>
|
||||
z.object({
|
||||
Chats: z.string().superRefine((value, ctx) => {
|
||||
try {
|
||||
const parsed = JSON.parse(value || '[]')
|
||||
if (!Array.isArray(parsed)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: t('Expected a JSON array.'),
|
||||
})
|
||||
return
|
||||
}
|
||||
for (const item of parsed) {
|
||||
if (
|
||||
item === null ||
|
||||
typeof item !== 'object' ||
|
||||
Array.isArray(item)
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: t(
|
||||
'Each item must be an object with a single key-value pair.'
|
||||
),
|
||||
})
|
||||
return
|
||||
}
|
||||
const entries = Object.entries(item)
|
||||
if (entries.length !== 1) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: t('Each item must have exactly one key-value pair.'),
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: t('Invalid JSON string.'),
|
||||
})
|
||||
}
|
||||
}),
|
||||
})
|
||||
|
||||
type ChatSettingsFormValues = z.infer<ReturnType<typeof createChatSchema>>
|
||||
|
||||
type ChatSettingsSectionProps = {
|
||||
defaultValue: string
|
||||
}
|
||||
|
||||
export function ChatSettingsSection({
|
||||
defaultValue,
|
||||
}: ChatSettingsSectionProps) {
|
||||
const { t } = useTranslation()
|
||||
const updateOption = useUpdateOption()
|
||||
const [editMode, setEditMode] = useState<'visual' | 'json'>('visual')
|
||||
|
||||
const chatSchema = createChatSchema(t)
|
||||
const formatted = formatJsonForEditor(defaultValue, '[]')
|
||||
const form = useForm<ChatSettingsFormValues>({
|
||||
resolver: zodResolver(chatSchema),
|
||||
mode: 'onChange', // Enable real-time validation
|
||||
defaultValues: {
|
||||
Chats: formatted,
|
||||
},
|
||||
})
|
||||
|
||||
const initialNormalizedRef = useRef(normalizeJsonString(defaultValue, '[]'))
|
||||
|
||||
useEffect(() => {
|
||||
form.reset({ Chats: formatJsonForEditor(defaultValue, '[]') })
|
||||
initialNormalizedRef.current = normalizeJsonString(defaultValue, '[]')
|
||||
}, [defaultValue, form])
|
||||
|
||||
const onSubmit = async (values: ChatSettingsFormValues) => {
|
||||
const normalized = normalizeJsonString(values.Chats, '[]')
|
||||
if (normalized === initialNormalizedRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
await updateOption.mutateAsync({
|
||||
key: 'Chats',
|
||||
value: normalized,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
title={t('Chat Presets')}
|
||||
description={t('Configure predefined chat links surfaced to end users.')}
|
||||
>
|
||||
<Form {...form}>
|
||||
{/* eslint-disable-next-line react-hooks/refs */}
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className='space-y-6'>
|
||||
<Tabs
|
||||
value={editMode}
|
||||
onValueChange={(value) => setEditMode(value as 'visual' | 'json')}
|
||||
>
|
||||
<TabsList className='grid w-full grid-cols-2'>
|
||||
<TabsTrigger value='visual'>{t('Visual')}</TabsTrigger>
|
||||
<TabsTrigger value='json'>{t('JSON')}</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value='visual' className='mt-6'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='Chats'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<ChatSettingsVisualEditor
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value='json' className='mt-6'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='Chats'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Chat configuration JSON')}</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
rows={12}
|
||||
placeholder={t(
|
||||
'[{"ChatGPT":"https://chat.openai.com"},{"Lobe Chat":"https://chat-preview.lobehub.com/?settings={...}"}]'
|
||||
)}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
'Array of chat client presets. Each item is an object with one key-value pair: client name and its URL.'
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<Button type='submit' disabled={updateOption.isPending}>
|
||||
{updateOption.isPending ? t('Saving...') : t('Save chat settings')}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
</SettingsSection>
|
||||
)
|
||||
}
|
||||
+192
@@ -0,0 +1,192 @@
|
||||
import { useState, useMemo } from 'react'
|
||||
import { Pencil, Plus, Search, Trash2 } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { safeJsonParseWithValidation } from '../utils/json-parser'
|
||||
import { isArray } from '../utils/json-validators'
|
||||
import { ChatDialog, type ChatEntryData } from './chat-dialog'
|
||||
|
||||
type ChatSettingsVisualEditorProps = {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
}
|
||||
|
||||
type ChatEntry = ChatEntryData
|
||||
|
||||
export function ChatSettingsVisualEditor({
|
||||
value,
|
||||
onChange,
|
||||
}: ChatSettingsVisualEditorProps) {
|
||||
const { t } = useTranslation()
|
||||
const [searchText, setSearchText] = useState('')
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [editData, setEditData] = useState<ChatEntry | null>(null)
|
||||
|
||||
const chats = useMemo(() => {
|
||||
const parsed = safeJsonParseWithValidation<unknown[]>(value, {
|
||||
fallback: [],
|
||||
validator: isArray,
|
||||
validatorMessage: 'Chats must be a JSON array',
|
||||
context: 'chats',
|
||||
})
|
||||
|
||||
return parsed
|
||||
.map((item) => {
|
||||
if (typeof item === 'object' && item !== null && !Array.isArray(item)) {
|
||||
const entries = Object.entries(item)
|
||||
if (entries.length === 1) {
|
||||
const [name, url] = entries[0]
|
||||
return { name, url: String(url) }
|
||||
}
|
||||
}
|
||||
return null
|
||||
})
|
||||
.filter((item): item is ChatEntry => item !== null)
|
||||
}, [value])
|
||||
|
||||
const filteredChats = useMemo(() => {
|
||||
if (!searchText) return chats
|
||||
const lowerSearch = searchText.toLowerCase()
|
||||
return chats.filter(
|
||||
(chat) =>
|
||||
chat.name.toLowerCase().includes(lowerSearch) ||
|
||||
chat.url.toLowerCase().includes(lowerSearch)
|
||||
)
|
||||
}, [chats, searchText])
|
||||
|
||||
const handleSave = (data: ChatEntryData) => {
|
||||
const chatsArray = safeJsonParseWithValidation<unknown[]>(value, {
|
||||
fallback: [],
|
||||
validator: isArray,
|
||||
silent: true,
|
||||
})
|
||||
|
||||
let updatedArray = [...chatsArray]
|
||||
|
||||
if (editData) {
|
||||
updatedArray = updatedArray.filter((item) => {
|
||||
if (typeof item === 'object' && item !== null && !Array.isArray(item)) {
|
||||
return !Object.keys(item).includes(editData.name)
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
updatedArray.push({ [data.name]: data.url })
|
||||
|
||||
onChange(JSON.stringify(updatedArray, null, 2))
|
||||
}
|
||||
|
||||
const handleDelete = (name: string) => {
|
||||
const chatsArray = safeJsonParseWithValidation<unknown[]>(value, {
|
||||
fallback: [],
|
||||
validator: isArray,
|
||||
silent: true,
|
||||
})
|
||||
|
||||
const updatedArray = chatsArray.filter((item) => {
|
||||
if (typeof item === 'object' && item !== null && !Array.isArray(item)) {
|
||||
return !Object.keys(item).includes(name)
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
onChange(JSON.stringify(updatedArray, null, 2))
|
||||
}
|
||||
|
||||
const handleEdit = (chat: ChatEntry) => {
|
||||
setEditData(chat)
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
const handleAdd = () => {
|
||||
setEditData(null)
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='space-y-4'>
|
||||
<div className='flex items-center gap-4'>
|
||||
<div className='relative flex-1'>
|
||||
<Search className='text-muted-foreground absolute top-2.5 left-2.5 h-4 w-4' />
|
||||
<Input
|
||||
placeholder={t('Search chat presets...')}
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
className='pl-9'
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={handleAdd}>
|
||||
<Plus className='mr-2 h-4 w-4' />
|
||||
{t('Add chat preset')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{filteredChats.length === 0 ? (
|
||||
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center'>
|
||||
{searchText
|
||||
? t('No chat presets match your search')
|
||||
: t(
|
||||
'No chat presets configured. Click "Add chat preset" to get started.'
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className='rounded-md border'>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t('Chat Client Name')}</TableHead>
|
||||
<TableHead>{t('URL')}</TableHead>
|
||||
<TableHead className='text-right'>{t('Actions')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredChats.map((chat) => (
|
||||
<TableRow key={chat.name}>
|
||||
<TableCell className='font-medium'>{chat.name}</TableCell>
|
||||
<TableCell className='max-w-md truncate font-mono text-sm'>
|
||||
{chat.url}
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<div className='flex justify-end gap-2'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={() => handleEdit(chat)}
|
||||
>
|
||||
<Pencil className='h-4 w-4' />
|
||||
</Button>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={() => handleDelete(chat.name)}
|
||||
>
|
||||
<Trash2 className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ChatDialog
|
||||
open={dialogOpen}
|
||||
onOpenChange={setDialogOpen}
|
||||
onSave={handleSave}
|
||||
editData={editData}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import { useEffect } from 'react'
|
||||
import * as z from 'zod'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { SettingsSection } from '../components/settings-section'
|
||||
import { useUpdateOption } from '../hooks/use-update-option'
|
||||
|
||||
const dataDashboardSchema = z.object({
|
||||
DataExportEnabled: z.boolean(),
|
||||
DataExportInterval: z.number().int().min(1).max(1440),
|
||||
DataExportDefaultTime: z.enum(['hour', 'day', 'week']),
|
||||
})
|
||||
|
||||
type DataDashboardFormValues = z.infer<typeof dataDashboardSchema>
|
||||
|
||||
type DashboardSectionProps = {
|
||||
defaultValues: DataDashboardFormValues
|
||||
}
|
||||
|
||||
const granularityOptions = [
|
||||
{ label: 'Hour', value: 'hour' },
|
||||
{ label: 'Day', value: 'day' },
|
||||
{ label: 'Week', value: 'week' },
|
||||
]
|
||||
|
||||
export function DashboardSection({ defaultValues }: DashboardSectionProps) {
|
||||
const { t } = useTranslation()
|
||||
const updateOption = useUpdateOption()
|
||||
|
||||
const form = useForm<DataDashboardFormValues>({
|
||||
resolver: zodResolver(dataDashboardSchema),
|
||||
defaultValues,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
form.reset(defaultValues)
|
||||
}, [defaultValues, form])
|
||||
|
||||
const onSubmit = async (values: DataDashboardFormValues) => {
|
||||
const updates = Object.entries(values).filter(
|
||||
([key, value]) =>
|
||||
value !== defaultValues[key as keyof DataDashboardFormValues]
|
||||
)
|
||||
|
||||
for (const [key, value] of updates) {
|
||||
await updateOption.mutateAsync({ key, value })
|
||||
}
|
||||
}
|
||||
|
||||
const isEnabled = form.watch('DataExportEnabled')
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
title={t('Data Dashboard')}
|
||||
description={t('Configure experimental data export for the dashboard')}
|
||||
>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className='space-y-6'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='DataExportEnabled'
|
||||
render={({ field }) => (
|
||||
<FormItem className='flex flex-row items-center justify-between rounded-lg border p-4'>
|
||||
<div className='space-y-0.5'>
|
||||
<FormLabel className='text-base'>
|
||||
{t('Enable Data Dashboard')}
|
||||
</FormLabel>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className='grid gap-6 sm:grid-cols-2'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='DataExportInterval'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Refresh interval (minutes)')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type='number'
|
||||
min={1}
|
||||
max={1440}
|
||||
step={1}
|
||||
disabled={!isEnabled}
|
||||
value={field.value}
|
||||
onChange={(e) => field.onChange(e.target.valueAsNumber)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('Keep this above 1 minute to avoid heavy database load')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='DataExportDefaultTime'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Default time granularity')}</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
value={field.value}
|
||||
disabled={!isEnabled}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t('Select granularity')} />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{granularityOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
{t(
|
||||
'UI granularity only — data is still aggregated hourly'
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button type='submit' disabled={updateOption.isPending}>
|
||||
{updateOption.isPending ? t('Saving...') : t('Save Changes')}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
</SettingsSection>
|
||||
)
|
||||
}
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
import { useEffect } from 'react'
|
||||
import * as z from 'zod'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { SettingsSection } from '../components/settings-section'
|
||||
import { useUpdateOption } from '../hooks/use-update-option'
|
||||
|
||||
const drawingSchema = z.object({
|
||||
DrawingEnabled: z.boolean(),
|
||||
MjNotifyEnabled: z.boolean(),
|
||||
MjAccountFilterEnabled: z.boolean(),
|
||||
MjForwardUrlEnabled: z.boolean(),
|
||||
MjModeClearEnabled: z.boolean(),
|
||||
MjActionCheckSuccessEnabled: z.boolean(),
|
||||
})
|
||||
|
||||
type DrawingFormValues = z.infer<typeof drawingSchema>
|
||||
|
||||
type DrawingSettingsSectionProps = {
|
||||
defaultValues: DrawingFormValues
|
||||
}
|
||||
|
||||
export function DrawingSettingsSection({
|
||||
defaultValues,
|
||||
}: DrawingSettingsSectionProps) {
|
||||
const { t } = useTranslation()
|
||||
const updateOption = useUpdateOption()
|
||||
const form = useForm<DrawingFormValues>({
|
||||
resolver: zodResolver(drawingSchema),
|
||||
defaultValues,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
form.reset(defaultValues)
|
||||
}, [defaultValues, form])
|
||||
|
||||
const onSubmit = async (values: DrawingFormValues) => {
|
||||
const updates = Object.entries(values).filter(
|
||||
([key, value]) => value !== defaultValues[key as keyof DrawingFormValues]
|
||||
)
|
||||
|
||||
for (const [key, value] of updates) {
|
||||
await updateOption.mutateAsync({ key, value })
|
||||
}
|
||||
}
|
||||
|
||||
const switches: Array<{
|
||||
name: keyof DrawingFormValues
|
||||
label: string
|
||||
description: string
|
||||
}> = [
|
||||
{
|
||||
name: 'DrawingEnabled',
|
||||
label: t('Enable drawing features'),
|
||||
description: t(
|
||||
'Required to expose Midjourney-style image generation to end users.'
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'MjNotifyEnabled',
|
||||
label: t('Allow upstream callbacks'),
|
||||
description: t(
|
||||
'When enabled, Midjourney callbacks are accepted (reveals server IP).'
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'MjAccountFilterEnabled',
|
||||
label: t('Allow accountFilter parameter'),
|
||||
description: t(
|
||||
'Keep enabled if you need to proxy requests for different upstream accounts.'
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'MjForwardUrlEnabled',
|
||||
label: t('Rewrite callback URLs to the local server'),
|
||||
description: t(
|
||||
'Automatically replaces upstream callback URLs with the server address.'
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'MjModeClearEnabled',
|
||||
label: t('Clear mode flags in prompts'),
|
||||
description: t(
|
||||
'Removes Midjourney flags such as --fast, --relax, and --turbo from user prompts.'
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'MjActionCheckSuccessEnabled',
|
||||
label: t('Require job success before follow-up actions'),
|
||||
description: t(
|
||||
'Users must wait for a successful drawing before upscales or variations.'
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
title={t('Drawing')}
|
||||
description={t('Fine-tune Midjourney integration and guardrails.')}
|
||||
>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className='space-y-6'>
|
||||
<div className='space-y-4'>
|
||||
{switches.map((item) => (
|
||||
<FormField
|
||||
key={item.name}
|
||||
control={form.control}
|
||||
name={item.name}
|
||||
render={({ field }) => (
|
||||
<FormItem className='flex flex-row items-start justify-between rounded-lg border p-4'>
|
||||
<div className='space-y-0.5 pe-4'>
|
||||
<FormLabel className='text-base'>{item.label}</FormLabel>
|
||||
<FormDescription>{item.description}</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Button type='submit' disabled={updateOption.isPending}>
|
||||
{updateOption.isPending
|
||||
? t('Saving...')
|
||||
: t('Save drawing settings')}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
</SettingsSection>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,433 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import * as z from 'zod'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { Plus, Edit, Trash2, Save } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { SettingsSection } from '../components/settings-section'
|
||||
import { useUpdateOption } from '../hooks/use-update-option'
|
||||
|
||||
type FAQ = {
|
||||
id: number
|
||||
question: string
|
||||
answer: string
|
||||
}
|
||||
|
||||
type FAQSectionProps = {
|
||||
enabled: boolean
|
||||
data: string
|
||||
}
|
||||
|
||||
const faqSchema = z.object({
|
||||
question: z
|
||||
.string()
|
||||
.min(1, 'Question is required')
|
||||
.max(200, 'Question must be less than 200 characters'),
|
||||
answer: z
|
||||
.string()
|
||||
.min(1, 'Answer is required')
|
||||
.max(1000, 'Answer must be less than 1000 characters'),
|
||||
})
|
||||
|
||||
type FAQFormValues = z.infer<typeof faqSchema>
|
||||
|
||||
export function FAQSection({ enabled, data }: FAQSectionProps) {
|
||||
const { t } = useTranslation()
|
||||
const updateOption = useUpdateOption()
|
||||
const [faqList, setFaqList] = useState<FAQ[]>([])
|
||||
const [isEnabled, setIsEnabled] = useState(enabled)
|
||||
const [hasChanges, setHasChanges] = useState(false)
|
||||
const [selectedIds, setSelectedIds] = useState<number[]>([])
|
||||
const [showDialog, setShowDialog] = useState(false)
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
||||
const [editingFaq, setEditingFaq] = useState<FAQ | null>(null)
|
||||
const [deleteTarget, setDeleteTarget] = useState<'single' | 'batch'>('single')
|
||||
|
||||
const form = useForm<FAQFormValues>({
|
||||
resolver: zodResolver(faqSchema),
|
||||
defaultValues: {
|
||||
question: '',
|
||||
answer: '',
|
||||
},
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const parsed = JSON.parse(data || '[]')
|
||||
if (Array.isArray(parsed)) {
|
||||
setFaqList(
|
||||
parsed.map((item, idx) => ({
|
||||
...item,
|
||||
id: item.id || idx + 1,
|
||||
}))
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
setFaqList([])
|
||||
}
|
||||
}, [data])
|
||||
|
||||
useEffect(() => {
|
||||
setIsEnabled(enabled)
|
||||
}, [enabled])
|
||||
|
||||
const handleToggleEnabled = async (checked: boolean) => {
|
||||
try {
|
||||
await updateOption.mutateAsync({
|
||||
key: 'console_setting.faq_enabled',
|
||||
value: checked,
|
||||
})
|
||||
setIsEnabled(checked)
|
||||
toast.success(t('Setting saved'))
|
||||
} catch {
|
||||
toast.error(t('Failed to update setting'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleAdd = () => {
|
||||
setEditingFaq(null)
|
||||
form.reset({
|
||||
question: '',
|
||||
answer: '',
|
||||
})
|
||||
setShowDialog(true)
|
||||
}
|
||||
|
||||
const handleEdit = (faq: FAQ) => {
|
||||
setEditingFaq(faq)
|
||||
form.reset({
|
||||
question: faq.question,
|
||||
answer: faq.answer,
|
||||
})
|
||||
setShowDialog(true)
|
||||
}
|
||||
|
||||
const handleDelete = (faq: FAQ) => {
|
||||
setEditingFaq(faq)
|
||||
setDeleteTarget('single')
|
||||
setShowDeleteDialog(true)
|
||||
}
|
||||
|
||||
const handleBatchDelete = () => {
|
||||
if (selectedIds.length === 0) {
|
||||
toast.error(t('Please select items to delete'))
|
||||
return
|
||||
}
|
||||
setDeleteTarget('batch')
|
||||
setShowDeleteDialog(true)
|
||||
}
|
||||
|
||||
const confirmDelete = () => {
|
||||
if (deleteTarget === 'single' && editingFaq) {
|
||||
setFaqList((prev) => prev.filter((item) => item.id !== editingFaq.id))
|
||||
setHasChanges(true)
|
||||
toast.success(t('FAQ deleted. Click "Save Settings" to apply.'))
|
||||
} else if (deleteTarget === 'batch') {
|
||||
setFaqList((prev) =>
|
||||
prev.filter((item) => !selectedIds.includes(item.id))
|
||||
)
|
||||
setSelectedIds([])
|
||||
setHasChanges(true)
|
||||
toast.success(
|
||||
t('{{count}} FAQs deleted. Click "Save Settings" to apply.', {
|
||||
count: selectedIds.length,
|
||||
})
|
||||
)
|
||||
}
|
||||
setShowDeleteDialog(false)
|
||||
setEditingFaq(null)
|
||||
}
|
||||
|
||||
const handleSubmitForm = (values: FAQFormValues) => {
|
||||
if (editingFaq) {
|
||||
setFaqList((prev) =>
|
||||
prev.map((item) =>
|
||||
item.id === editingFaq.id ? { ...item, ...values } : item
|
||||
)
|
||||
)
|
||||
toast.success(t('FAQ updated. Click "Save Settings" to apply.'))
|
||||
} else {
|
||||
const newId = Math.max(...faqList.map((item) => item.id), 0) + 1
|
||||
setFaqList((prev) => [...prev, { id: newId, ...values }])
|
||||
toast.success(t('FAQ added. Click "Save Settings" to apply.'))
|
||||
}
|
||||
setHasChanges(true)
|
||||
setShowDialog(false)
|
||||
}
|
||||
|
||||
const handleSaveAll = async () => {
|
||||
try {
|
||||
await updateOption.mutateAsync({
|
||||
key: 'console_setting.faq',
|
||||
value: JSON.stringify(faqList),
|
||||
})
|
||||
setHasChanges(false)
|
||||
toast.success(t('FAQ saved successfully'))
|
||||
} catch {
|
||||
toast.error(t('Failed to save FAQ'))
|
||||
}
|
||||
}
|
||||
|
||||
const toggleSelectAll = (checked: boolean) => {
|
||||
setSelectedIds(checked ? faqList.map((item) => item.id) : [])
|
||||
}
|
||||
|
||||
const toggleSelectOne = (id: number, checked: boolean) => {
|
||||
setSelectedIds((prev) =>
|
||||
checked ? [...prev, id] : prev.filter((item) => item !== id)
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
title={t('FAQ')}
|
||||
description={t(
|
||||
'Maintain a list of common questions for the dashboard help panel'
|
||||
)}
|
||||
>
|
||||
<div className='space-y-4'>
|
||||
<div className='flex flex-wrap items-center justify-between gap-2'>
|
||||
<div className='flex flex-wrap items-center gap-2'>
|
||||
<Button onClick={handleAdd} size='sm'>
|
||||
<Plus className='mr-2 h-4 w-4' />
|
||||
{t('Add FAQ')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleBatchDelete}
|
||||
size='sm'
|
||||
variant='destructive'
|
||||
disabled={selectedIds.length === 0}
|
||||
>
|
||||
<Trash2 className='mr-2 h-4 w-4' />
|
||||
{t('Delete (')}
|
||||
{selectedIds.length})
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSaveAll}
|
||||
size='sm'
|
||||
variant='secondary'
|
||||
disabled={!hasChanges || updateOption.isPending}
|
||||
>
|
||||
<Save className='mr-2 h-4 w-4' />
|
||||
{updateOption.isPending ? t('Saving...') : t('Save Settings')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className='flex items-center gap-2'>
|
||||
<span className='text-muted-foreground text-sm'>
|
||||
{t('Enabled')}
|
||||
</span>
|
||||
<Switch checked={isEnabled} onCheckedChange={handleToggleEnabled} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='rounded-md border'>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className='w-12'>
|
||||
<Checkbox
|
||||
checked={
|
||||
selectedIds.length === faqList.length &&
|
||||
faqList.length > 0
|
||||
}
|
||||
onCheckedChange={toggleSelectAll}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead>{t('Question')}</TableHead>
|
||||
<TableHead>{t('Answer')}</TableHead>
|
||||
<TableHead className='w-32'>{t('Actions')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{faqList.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className='h-24 text-center'>
|
||||
{t('No FAQ entries yet. Click "Add FAQ" to create one.')}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
faqList.map((faq) => (
|
||||
<TableRow key={faq.id}>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
checked={selectedIds.includes(faq.id)}
|
||||
onCheckedChange={(checked) =>
|
||||
toggleSelectOne(faq.id, checked as boolean)
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className='max-w-xs truncate font-medium'
|
||||
title={faq.question}
|
||||
>
|
||||
{faq.question}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className='text-muted-foreground max-w-md truncate'
|
||||
title={faq.answer}
|
||||
>
|
||||
{faq.answer}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className='flex gap-2'>
|
||||
<Button
|
||||
onClick={() => handleEdit(faq)}
|
||||
size='sm'
|
||||
variant='ghost'
|
||||
>
|
||||
<Edit className='h-4 w-4' />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleDelete(faq)}
|
||||
size='sm'
|
||||
variant='ghost'
|
||||
>
|
||||
<Trash2 className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog open={showDialog} onOpenChange={setShowDialog}>
|
||||
<DialogContent className='max-w-2xl'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{editingFaq ? t('Edit FAQ') : t('Add FAQ')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('Create or update frequently asked questions for users')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(handleSubmitForm)}
|
||||
className='space-y-4'
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='question'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Question')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t('How to reset my quota?')}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('Maximum 200 characters')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='answer'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Answer')}</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder={t(
|
||||
'Visit Settings → General and adjust quota options...'
|
||||
)}
|
||||
rows={8}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
'Maximum 1000 characters. Supports Markdown and HTML.'
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
onClick={() => setShowDialog(false)}
|
||||
>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
<Button type='submit'>
|
||||
{editingFaq ? t('Update') : t('Add')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t('Are you sure?')}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{deleteTarget === 'single'
|
||||
? 'This FAQ entry will be removed from the list.'
|
||||
: `${selectedIds.length} FAQ entries will be removed from the list.`}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t('Cancel')}</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={confirmDelete}>
|
||||
{t('Delete')}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</SettingsSection>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useParams } from '@tanstack/react-router'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { getOptionValue, useSystemOptions } from '../hooks/use-system-options'
|
||||
import type { ContentSettings } from '../types'
|
||||
import {
|
||||
CONTENT_DEFAULT_SECTION,
|
||||
getContentSectionContent,
|
||||
} from './section-registry.tsx'
|
||||
|
||||
const defaultContentSettings: ContentSettings = {
|
||||
'console_setting.api_info': '[]',
|
||||
'console_setting.announcements': '[]',
|
||||
'console_setting.faq': '[]',
|
||||
'console_setting.uptime_kuma_groups': '[]',
|
||||
'console_setting.api_info_enabled': true,
|
||||
'console_setting.announcements_enabled': true,
|
||||
'console_setting.faq_enabled': true,
|
||||
'console_setting.uptime_kuma_enabled': false,
|
||||
DataExportEnabled: false,
|
||||
DataExportDefaultTime: 'hour',
|
||||
DataExportInterval: 5,
|
||||
Chats: '[]',
|
||||
DrawingEnabled: false,
|
||||
MjNotifyEnabled: false,
|
||||
MjAccountFilterEnabled: false,
|
||||
MjForwardUrlEnabled: false,
|
||||
MjModeClearEnabled: false,
|
||||
MjActionCheckSuccessEnabled: false,
|
||||
}
|
||||
|
||||
export function ContentSettings() {
|
||||
const { t } = useTranslation()
|
||||
const { data, isLoading } = useSystemOptions()
|
||||
const params = useParams({
|
||||
from: '/_authenticated/system-settings/content/$section',
|
||||
})
|
||||
|
||||
const settings = useMemo(() => {
|
||||
const resolved = getOptionValue(data?.data, defaultContentSettings)
|
||||
|
||||
const optionMap = new Map(
|
||||
(data?.data ?? []).map((item) => [item.key, item.value])
|
||||
)
|
||||
|
||||
if (!optionMap.has('console_setting.announcements')) {
|
||||
const legacy = optionMap.get('Announcements')
|
||||
if (legacy !== undefined) {
|
||||
resolved['console_setting.announcements'] = legacy
|
||||
}
|
||||
}
|
||||
|
||||
if (!optionMap.has('console_setting.api_info')) {
|
||||
const legacy = optionMap.get('ApiInfo')
|
||||
if (legacy !== undefined) {
|
||||
resolved['console_setting.api_info'] = legacy
|
||||
}
|
||||
}
|
||||
|
||||
if (!optionMap.has('console_setting.faq')) {
|
||||
const legacy = optionMap.get('FAQ')
|
||||
if (legacy !== undefined) {
|
||||
resolved['console_setting.faq'] = legacy
|
||||
}
|
||||
}
|
||||
|
||||
if (!optionMap.has('console_setting.uptime_kuma_groups')) {
|
||||
const legacyUrl = optionMap.get('UptimeKumaUrl')
|
||||
const legacySlug = optionMap.get('UptimeKumaSlug')
|
||||
if (legacyUrl && legacySlug) {
|
||||
resolved['console_setting.uptime_kuma_groups'] = JSON.stringify([
|
||||
{
|
||||
id: 1,
|
||||
categoryName: 'Legacy',
|
||||
url: legacyUrl,
|
||||
slug: legacySlug,
|
||||
},
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
return resolved
|
||||
}, [data?.data])
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className='flex items-center justify-center py-12'>
|
||||
<div className='text-muted-foreground'>
|
||||
{t('Loading content settings...')}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const activeSection = (params?.section ?? CONTENT_DEFAULT_SECTION) as
|
||||
| 'dashboard'
|
||||
| 'announcements'
|
||||
| 'api-info'
|
||||
| 'faq'
|
||||
| 'uptime-kuma'
|
||||
| 'chat'
|
||||
| 'drawing'
|
||||
const sectionContent = getContentSectionContent(activeSection, settings)
|
||||
|
||||
return (
|
||||
<div className='flex h-full w-full flex-1 flex-col'>
|
||||
<div className='faded-bottom h-full w-full overflow-y-auto scroll-smooth pe-4 pb-12'>
|
||||
<div className='space-y-4'>{sectionContent}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import { useEffect, useMemo, useRef } from 'react'
|
||||
import * as z from 'zod'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { SettingsAccordion } from '../components/settings-accordion'
|
||||
import { useUpdateOption } from '../hooks/use-update-option'
|
||||
import { formatJsonForEditor, normalizeJsonString } from './utils'
|
||||
|
||||
type JsonToggleSectionProps = {
|
||||
value: string
|
||||
title: string
|
||||
description?: string
|
||||
toggleDescription?: string
|
||||
optionKey: string
|
||||
enabledKey: string
|
||||
defaultEnabled: boolean
|
||||
defaultValue: string
|
||||
fallbackValue?: string
|
||||
textareaLabel: string
|
||||
textareaDescription?: string
|
||||
placeholder?: string
|
||||
example?: string
|
||||
submitLabel?: string
|
||||
validate?: (parsed: unknown) => { valid: boolean; message?: string }
|
||||
}
|
||||
|
||||
type JsonToggleFormValues = {
|
||||
enabled: boolean
|
||||
json: string
|
||||
}
|
||||
|
||||
export function JsonToggleSection({
|
||||
value,
|
||||
title,
|
||||
description,
|
||||
toggleDescription,
|
||||
optionKey,
|
||||
enabledKey,
|
||||
defaultEnabled,
|
||||
defaultValue,
|
||||
fallbackValue = '[]',
|
||||
textareaLabel,
|
||||
textareaDescription,
|
||||
placeholder,
|
||||
example,
|
||||
submitLabel = 'Save Changes',
|
||||
validate,
|
||||
}: JsonToggleSectionProps) {
|
||||
const { t } = useTranslation()
|
||||
const updateOption = useUpdateOption()
|
||||
|
||||
const formattedDefault = useMemo(
|
||||
() => formatJsonForEditor(defaultValue, fallbackValue),
|
||||
[defaultValue, fallbackValue]
|
||||
)
|
||||
|
||||
const form = useForm<JsonToggleFormValues>({
|
||||
mode: 'onChange', // Enable real-time validation
|
||||
resolver: zodResolver(
|
||||
z.object({
|
||||
enabled: z.boolean(),
|
||||
json: z.string().superRefine((value, ctx) => {
|
||||
try {
|
||||
const normalized = normalizeJsonString(value, fallbackValue)
|
||||
const parsed = JSON.parse(normalized)
|
||||
if (validate) {
|
||||
const result = validate(parsed)
|
||||
if (!result.valid) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message:
|
||||
result.message ||
|
||||
'JSON structure is invalid for this setting',
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message:
|
||||
(error instanceof Error ? error.message : null) ||
|
||||
'Invalid JSON data',
|
||||
})
|
||||
}
|
||||
}),
|
||||
})
|
||||
),
|
||||
defaultValues: {
|
||||
enabled: defaultEnabled,
|
||||
json: formattedDefault,
|
||||
},
|
||||
})
|
||||
|
||||
const initialNormalizedRef = useRef(
|
||||
normalizeJsonString(defaultValue, fallbackValue)
|
||||
)
|
||||
const initialEnabledRef = useRef(defaultEnabled)
|
||||
|
||||
useEffect(() => {
|
||||
initialNormalizedRef.current = normalizeJsonString(
|
||||
defaultValue,
|
||||
fallbackValue
|
||||
)
|
||||
initialEnabledRef.current = defaultEnabled
|
||||
form.reset({
|
||||
enabled: defaultEnabled,
|
||||
json: formatJsonForEditor(defaultValue, fallbackValue),
|
||||
})
|
||||
}, [defaultEnabled, defaultValue, fallbackValue, form])
|
||||
|
||||
const onSubmit = async (values: JsonToggleFormValues) => {
|
||||
const updates: Array<{ key: string; value: string | boolean }> = []
|
||||
|
||||
if (values.enabled !== initialEnabledRef.current) {
|
||||
updates.push({ key: enabledKey, value: values.enabled })
|
||||
}
|
||||
|
||||
const normalized = normalizeJsonString(values.json, fallbackValue)
|
||||
if (normalized !== initialNormalizedRef.current) {
|
||||
updates.push({ key: optionKey, value: normalized })
|
||||
}
|
||||
|
||||
for (const update of updates) {
|
||||
await updateOption.mutateAsync(update)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsAccordion value={value} title={title} description={description}>
|
||||
<Form {...form}>
|
||||
{/* eslint-disable-next-line react-hooks/refs */}
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className='space-y-6'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='enabled'
|
||||
render={({ field }) => (
|
||||
<FormItem className='flex flex-row items-center justify-between rounded-lg border p-4'>
|
||||
<div className='space-y-0.5'>
|
||||
<FormLabel className='text-base'>
|
||||
{t('Module availability')}
|
||||
</FormLabel>
|
||||
{toggleDescription && (
|
||||
<FormDescription>{t(toggleDescription)}</FormDescription>
|
||||
)}
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='json'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{textareaLabel}</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea rows={12} placeholder={placeholder} {...field} />
|
||||
</FormControl>
|
||||
{textareaDescription && (
|
||||
<FormDescription>{t(textareaDescription)}</FormDescription>
|
||||
)}
|
||||
{example && (
|
||||
<div className='text-muted-foreground text-xs'>{example}</div>
|
||||
)}
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button type='submit' disabled={updateOption.isPending}>
|
||||
{updateOption.isPending ? t('Saving...') : t(submitLabel)}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
</SettingsAccordion>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import type { ContentSettings } from '../types'
|
||||
import { createSectionRegistry } from '../utils/section-registry'
|
||||
import { AnnouncementsSection } from './announcements-section'
|
||||
import { ApiInfoSection } from './api-info-section'
|
||||
import { ChatSettingsSection } from './chat-settings-section'
|
||||
import { DashboardSection } from './dashboard-section'
|
||||
import { DrawingSettingsSection } from './drawing-settings-section'
|
||||
import { FAQSection } from './faq-section'
|
||||
import { UptimeKumaSection } from './uptime-kuma-section'
|
||||
|
||||
/**
|
||||
* Validate and coerce DataExportDefaultTime to a safe value
|
||||
*/
|
||||
function validateDataExportDefaultTime(value: string): 'week' | 'hour' | 'day' {
|
||||
if (value === 'week' || value === 'hour' || value === 'day') {
|
||||
return value
|
||||
}
|
||||
// Default to 'hour' if value is unexpected
|
||||
return 'hour'
|
||||
}
|
||||
|
||||
const CONTENT_SECTIONS = [
|
||||
{
|
||||
id: 'dashboard',
|
||||
titleKey: 'Data Dashboard',
|
||||
descriptionKey: 'Configure data export settings for dashboard',
|
||||
build: (settings: ContentSettings) => (
|
||||
<DashboardSection
|
||||
defaultValues={{
|
||||
DataExportEnabled: settings.DataExportEnabled,
|
||||
DataExportInterval: settings.DataExportInterval,
|
||||
DataExportDefaultTime: validateDataExportDefaultTime(
|
||||
settings.DataExportDefaultTime
|
||||
),
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'announcements',
|
||||
titleKey: 'Announcements',
|
||||
descriptionKey: 'Configure system announcements',
|
||||
build: (settings: ContentSettings) => (
|
||||
<AnnouncementsSection
|
||||
enabled={settings['console_setting.announcements_enabled']}
|
||||
data={settings['console_setting.announcements']}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'api-info',
|
||||
titleKey: 'API Addresses',
|
||||
descriptionKey: 'Configure API information display',
|
||||
build: (settings: ContentSettings) => (
|
||||
<ApiInfoSection
|
||||
enabled={settings['console_setting.api_info_enabled']}
|
||||
data={settings['console_setting.api_info']}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'faq',
|
||||
titleKey: 'FAQ',
|
||||
descriptionKey: 'Configure frequently asked questions',
|
||||
build: (settings: ContentSettings) => (
|
||||
<FAQSection
|
||||
enabled={settings['console_setting.faq_enabled']}
|
||||
data={settings['console_setting.faq']}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'uptime-kuma',
|
||||
titleKey: 'Uptime Kuma',
|
||||
descriptionKey: 'Configure Uptime Kuma monitoring integration',
|
||||
build: (settings: ContentSettings) => (
|
||||
<UptimeKumaSection
|
||||
enabled={settings['console_setting.uptime_kuma_enabled']}
|
||||
data={settings['console_setting.uptime_kuma_groups']}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'chat',
|
||||
titleKey: 'Chat Presets',
|
||||
descriptionKey: 'Configure chat-related settings',
|
||||
build: (settings: ContentSettings) => (
|
||||
<ChatSettingsSection defaultValue={settings.Chats} />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'drawing',
|
||||
titleKey: 'Drawing',
|
||||
descriptionKey: 'Configure drawing and Midjourney settings',
|
||||
build: (settings: ContentSettings) => (
|
||||
<DrawingSettingsSection
|
||||
defaultValues={{
|
||||
DrawingEnabled: settings.DrawingEnabled,
|
||||
MjNotifyEnabled: settings.MjNotifyEnabled,
|
||||
MjAccountFilterEnabled: settings.MjAccountFilterEnabled,
|
||||
MjForwardUrlEnabled: settings.MjForwardUrlEnabled,
|
||||
MjModeClearEnabled: settings.MjModeClearEnabled,
|
||||
MjActionCheckSuccessEnabled: settings.MjActionCheckSuccessEnabled,
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
] as const
|
||||
|
||||
export type ContentSectionId = (typeof CONTENT_SECTIONS)[number]['id']
|
||||
|
||||
const contentRegistry = createSectionRegistry<
|
||||
ContentSectionId,
|
||||
ContentSettings
|
||||
>({
|
||||
sections: CONTENT_SECTIONS,
|
||||
defaultSection: 'dashboard',
|
||||
basePath: '/system-settings/content',
|
||||
urlStyle: 'path',
|
||||
})
|
||||
|
||||
export const CONTENT_SECTION_IDS = contentRegistry.sectionIds
|
||||
export const CONTENT_DEFAULT_SECTION = contentRegistry.defaultSection
|
||||
export const getContentSectionNavItems = contentRegistry.getSectionNavItems
|
||||
export const getContentSectionContent = contentRegistry.getSectionContent
|
||||
@@ -0,0 +1,461 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import * as z from 'zod'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { Plus, Edit, Trash2, Save } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { SettingsSection } from '../components/settings-section'
|
||||
import { useUpdateOption } from '../hooks/use-update-option'
|
||||
|
||||
type UptimeKumaGroup = {
|
||||
id: number
|
||||
categoryName: string
|
||||
url: string
|
||||
slug: string
|
||||
}
|
||||
|
||||
type UptimeKumaSectionProps = {
|
||||
enabled: boolean
|
||||
data: string
|
||||
}
|
||||
|
||||
const createUptimeKumaSchema = (t: (key: string) => string) =>
|
||||
z.object({
|
||||
categoryName: z
|
||||
.string()
|
||||
.min(1, { error: t('Category name is required') })
|
||||
.max(50, { error: t('Category name must be less than 50 characters') }),
|
||||
url: z.string().url({ error: t('Must be a valid URL') }),
|
||||
slug: z
|
||||
.string()
|
||||
.min(1, { error: t('Slug is required') })
|
||||
.max(100, { error: t('Slug must be less than 100 characters') })
|
||||
.regex(/^[a-zA-Z0-9_-]+$/, {
|
||||
error: t(
|
||||
'Slug can only contain letters, numbers, hyphens, and underscores'
|
||||
),
|
||||
}),
|
||||
})
|
||||
|
||||
type UptimeKumaFormValues = z.infer<ReturnType<typeof createUptimeKumaSchema>>
|
||||
|
||||
export function UptimeKumaSection({ enabled, data }: UptimeKumaSectionProps) {
|
||||
const { t } = useTranslation()
|
||||
const updateOption = useUpdateOption()
|
||||
const uptimeKumaSchema = createUptimeKumaSchema(t)
|
||||
const [groups, setGroups] = useState<UptimeKumaGroup[]>([])
|
||||
const [isEnabled, setIsEnabled] = useState(enabled)
|
||||
const [hasChanges, setHasChanges] = useState(false)
|
||||
const [selectedIds, setSelectedIds] = useState<number[]>([])
|
||||
const [showDialog, setShowDialog] = useState(false)
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
||||
const [editingGroup, setEditingGroup] = useState<UptimeKumaGroup | null>(null)
|
||||
const [deleteTarget, setDeleteTarget] = useState<'single' | 'batch'>('single')
|
||||
|
||||
const form = useForm<UptimeKumaFormValues>({
|
||||
resolver: zodResolver(uptimeKumaSchema),
|
||||
defaultValues: {
|
||||
categoryName: '',
|
||||
url: '',
|
||||
slug: '',
|
||||
},
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const parsed = JSON.parse(data || '[]')
|
||||
if (Array.isArray(parsed)) {
|
||||
setGroups(
|
||||
parsed.map((item, idx) => ({
|
||||
...item,
|
||||
id: item.id || idx + 1,
|
||||
}))
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
setGroups([])
|
||||
}
|
||||
}, [data])
|
||||
|
||||
useEffect(() => {
|
||||
setIsEnabled(enabled)
|
||||
}, [enabled])
|
||||
|
||||
const handleToggleEnabled = async (checked: boolean) => {
|
||||
try {
|
||||
await updateOption.mutateAsync({
|
||||
key: 'console_setting.uptime_kuma_enabled',
|
||||
value: checked,
|
||||
})
|
||||
setIsEnabled(checked)
|
||||
toast.success(t('Setting saved'))
|
||||
} catch {
|
||||
toast.error(t('Failed to update setting'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleAdd = () => {
|
||||
setEditingGroup(null)
|
||||
form.reset({
|
||||
categoryName: '',
|
||||
url: '',
|
||||
slug: '',
|
||||
})
|
||||
setShowDialog(true)
|
||||
}
|
||||
|
||||
const handleEdit = (group: UptimeKumaGroup) => {
|
||||
setEditingGroup(group)
|
||||
form.reset({
|
||||
categoryName: group.categoryName,
|
||||
url: group.url,
|
||||
slug: group.slug,
|
||||
})
|
||||
setShowDialog(true)
|
||||
}
|
||||
|
||||
const handleDelete = (group: UptimeKumaGroup) => {
|
||||
setEditingGroup(group)
|
||||
setDeleteTarget('single')
|
||||
setShowDeleteDialog(true)
|
||||
}
|
||||
|
||||
const handleBatchDelete = () => {
|
||||
if (selectedIds.length === 0) {
|
||||
toast.error(t('Please select items to delete'))
|
||||
return
|
||||
}
|
||||
setDeleteTarget('batch')
|
||||
setShowDeleteDialog(true)
|
||||
}
|
||||
|
||||
const confirmDelete = () => {
|
||||
if (deleteTarget === 'single' && editingGroup) {
|
||||
setGroups((prev) => prev.filter((item) => item.id !== editingGroup.id))
|
||||
setHasChanges(true)
|
||||
toast.success(t('Group deleted. Click "Save Settings" to apply.'))
|
||||
} else if (deleteTarget === 'batch') {
|
||||
setGroups((prev) => prev.filter((item) => !selectedIds.includes(item.id)))
|
||||
setSelectedIds([])
|
||||
setHasChanges(true)
|
||||
toast.success(
|
||||
t('{{count}} groups deleted. Click "Save Settings" to apply.', {
|
||||
count: selectedIds.length,
|
||||
})
|
||||
)
|
||||
}
|
||||
setShowDeleteDialog(false)
|
||||
setEditingGroup(null)
|
||||
}
|
||||
|
||||
const handleSubmitForm = (values: UptimeKumaFormValues) => {
|
||||
if (editingGroup) {
|
||||
setGroups((prev) =>
|
||||
prev.map((item) =>
|
||||
item.id === editingGroup.id ? { ...item, ...values } : item
|
||||
)
|
||||
)
|
||||
toast.success(t('Group updated. Click "Save Settings" to apply.'))
|
||||
} else {
|
||||
const newId = Math.max(...groups.map((item) => item.id), 0) + 1
|
||||
setGroups((prev) => [...prev, { id: newId, ...values }])
|
||||
toast.success(t('Group added. Click "Save Settings" to apply.'))
|
||||
}
|
||||
setHasChanges(true)
|
||||
setShowDialog(false)
|
||||
}
|
||||
|
||||
const handleSaveAll = async () => {
|
||||
try {
|
||||
await updateOption.mutateAsync({
|
||||
key: 'console_setting.uptime_kuma_groups',
|
||||
value: JSON.stringify(groups),
|
||||
})
|
||||
setHasChanges(false)
|
||||
toast.success(t('Uptime Kuma groups saved successfully'))
|
||||
} catch {
|
||||
toast.error(t('Failed to save Uptime Kuma groups'))
|
||||
}
|
||||
}
|
||||
|
||||
const toggleSelectAll = (checked: boolean) => {
|
||||
setSelectedIds(checked ? groups.map((item) => item.id) : [])
|
||||
}
|
||||
|
||||
const toggleSelectOne = (id: number, checked: boolean) => {
|
||||
setSelectedIds((prev) =>
|
||||
checked ? [...prev, id] : prev.filter((item) => item !== id)
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
title={t('Uptime Kuma')}
|
||||
description={t(
|
||||
'Expose grouped Uptime Kuma status pages directly on the dashboard'
|
||||
)}
|
||||
>
|
||||
<div className='space-y-4'>
|
||||
<div className='flex flex-wrap items-center justify-between gap-2'>
|
||||
<div className='flex flex-wrap items-center gap-2'>
|
||||
<Button onClick={handleAdd} size='sm'>
|
||||
<Plus className='mr-2 h-4 w-4' />
|
||||
{t('Add Group')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleBatchDelete}
|
||||
size='sm'
|
||||
variant='destructive'
|
||||
disabled={selectedIds.length === 0}
|
||||
>
|
||||
<Trash2 className='mr-2 h-4 w-4' />
|
||||
{t('Delete (')}
|
||||
{selectedIds.length})
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSaveAll}
|
||||
size='sm'
|
||||
variant='secondary'
|
||||
disabled={!hasChanges || updateOption.isPending}
|
||||
>
|
||||
<Save className='mr-2 h-4 w-4' />
|
||||
{updateOption.isPending ? t('Saving...') : t('Save Settings')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className='flex items-center gap-2'>
|
||||
<span className='text-muted-foreground text-sm'>
|
||||
{t('Enabled')}
|
||||
</span>
|
||||
<Switch checked={isEnabled} onCheckedChange={handleToggleEnabled} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='rounded-md border'>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className='w-12'>
|
||||
<Checkbox
|
||||
checked={
|
||||
selectedIds.length === groups.length && groups.length > 0
|
||||
}
|
||||
onCheckedChange={toggleSelectAll}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead>{t('Category Name')}</TableHead>
|
||||
<TableHead>{t('Uptime Kuma URL')}</TableHead>
|
||||
<TableHead>{t('Status Page Slug')}</TableHead>
|
||||
<TableHead className='w-32'>{t('Actions')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{groups.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className='h-24 text-center'>
|
||||
{t(
|
||||
'No Uptime Kuma groups yet. Click "Add Group" to create one.'
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
groups.map((group) => (
|
||||
<TableRow key={group.id}>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
checked={selectedIds.includes(group.id)}
|
||||
onCheckedChange={(checked) =>
|
||||
toggleSelectOne(group.id, checked as boolean)
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className='font-medium'>
|
||||
{group.categoryName}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className='text-primary max-w-xs truncate font-mono text-sm'
|
||||
title={group.url}
|
||||
>
|
||||
{group.url}
|
||||
</TableCell>
|
||||
<TableCell className='text-muted-foreground font-mono text-sm'>
|
||||
{group.slug}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className='flex gap-2'>
|
||||
<Button
|
||||
onClick={() => handleEdit(group)}
|
||||
size='sm'
|
||||
variant='ghost'
|
||||
>
|
||||
<Edit className='h-4 w-4' />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleDelete(group)}
|
||||
size='sm'
|
||||
variant='ghost'
|
||||
>
|
||||
<Trash2 className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog open={showDialog} onOpenChange={setShowDialog}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{editingGroup
|
||||
? t('Edit Uptime Kuma Group')
|
||||
: t('Add Uptime Kuma Group')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('Configure monitoring status page groups for the dashboard')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(handleSubmitForm)}
|
||||
className='space-y-4'
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='categoryName'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Category Name')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t('e.g., Core APIs, OpenAI, Claude')}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
'Display name for this monitoring group (max 50 characters)'
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='url'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Uptime Kuma URL')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t('https://status.example.com')}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('Base URL of your Uptime Kuma instance')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='slug'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Status Page Slug')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder={t('my-status')} {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('The slug is appended to the URL:')} {'{url}'}
|
||||
{t('/status/')}
|
||||
{'{slug}'}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
onClick={() => setShowDialog(false)}
|
||||
>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
<Button type='submit'>
|
||||
{editingGroup ? t('Update') : t('Add')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t('Are you sure?')}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{deleteTarget === 'single'
|
||||
? 'This Uptime Kuma group will be removed from the list.'
|
||||
: `${selectedIds.length} Uptime Kuma groups will be removed from the list.`}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t('Cancel')}</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={confirmDelete}>
|
||||
{t('Delete')}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</SettingsSection>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
export function formatJsonForEditor(value: string, fallback = '[]') {
|
||||
const target = value && value.trim() ? value : fallback
|
||||
try {
|
||||
const parsed = JSON.parse(target)
|
||||
return JSON.stringify(parsed, null, 2)
|
||||
} catch {
|
||||
return target
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeJsonString(value: string, fallback = '[]') {
|
||||
const target = value && value.trim() ? value : fallback
|
||||
try {
|
||||
const parsed = JSON.parse(target)
|
||||
return JSON.stringify(parsed)
|
||||
} catch {
|
||||
return target.trim()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { api } from '@/lib/api'
|
||||
import type { CacheStats } from './types'
|
||||
|
||||
export async function getCacheStats(): Promise<{
|
||||
success: boolean
|
||||
message?: string
|
||||
data?: CacheStats
|
||||
}> {
|
||||
const res = await api.get('/api/option/channel_affinity_cache', {
|
||||
disableDuplicate: true,
|
||||
} as Record<string, unknown>)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function clearAllCache(): Promise<{
|
||||
success: boolean
|
||||
message?: string
|
||||
}> {
|
||||
const res = await api.delete('/api/option/channel_affinity_cache', {
|
||||
params: { all: true },
|
||||
})
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function clearRuleCache(
|
||||
ruleName: string
|
||||
): Promise<{ success: boolean; message?: string }> {
|
||||
const res = await api.delete('/api/option/channel_affinity_cache', {
|
||||
params: { rule_name: ruleName },
|
||||
})
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function getAffinityUsageCache(params: {
|
||||
rule_name: string
|
||||
using_group: string
|
||||
key_hint: string
|
||||
key_fp: string
|
||||
}): Promise<{ success: boolean; message?: string; data?: unknown }> {
|
||||
const res = await api.get('/api/log/channel_affinity_usage_cache', {
|
||||
params,
|
||||
disableDuplicate: true,
|
||||
} as Record<string, unknown>)
|
||||
return res.data
|
||||
}
|
||||
+154
@@ -0,0 +1,154 @@
|
||||
import { useEffect, useMemo, useState, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { formatTimestampToDate } from '@/lib/format'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { getAffinityUsageCache } from './api'
|
||||
|
||||
function formatRate(hit: number, total: number): string {
|
||||
if (!total || total <= 0) return '-'
|
||||
const r = (hit / total) * 100
|
||||
if (!Number.isFinite(r)) return '-'
|
||||
return `${r.toFixed(2)}%`
|
||||
}
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
target: {
|
||||
rule_name: string
|
||||
using_group: string
|
||||
key_hint: string
|
||||
key_fp: string
|
||||
} | null
|
||||
}
|
||||
|
||||
export function CacheStatsDialog(props: Props) {
|
||||
const { t } = useTranslation()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [stats, setStats] = useState<Record<string, unknown> | null>(null)
|
||||
const seqRef = useRef(0)
|
||||
|
||||
useEffect(() => {
|
||||
if (!props.open || !props.target?.rule_name || !props.target?.key_fp) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setStats(null)
|
||||
return
|
||||
}
|
||||
|
||||
const seq = ++seqRef.current
|
||||
|
||||
setLoading(true)
|
||||
|
||||
setStats(null)
|
||||
|
||||
getAffinityUsageCache(props.target)
|
||||
.then((res) => {
|
||||
if (seq !== seqRef.current) return
|
||||
if (res.success) setStats((res.data as Record<string, unknown>) || {})
|
||||
else toast.error(res.message || t('Request failed'))
|
||||
})
|
||||
.catch(() => {
|
||||
if (seq !== seqRef.current) return
|
||||
toast.error(t('Request failed'))
|
||||
})
|
||||
.finally(() => {
|
||||
if (seq !== seqRef.current) return
|
||||
setLoading(false)
|
||||
})
|
||||
}, [props.open, props.target, t])
|
||||
|
||||
const rows = useMemo(() => {
|
||||
if (!stats) return []
|
||||
const s = stats
|
||||
const data: { key: string; value: string | number }[] = []
|
||||
const hit = Number(s.hit || 0)
|
||||
const total = Number(s.total || 0)
|
||||
|
||||
if (s.rule_name || props.target?.rule_name)
|
||||
data.push({
|
||||
key: t('Rule'),
|
||||
value: (s.rule_name || props.target?.rule_name || '') as string,
|
||||
})
|
||||
if (s.using_group || props.target?.using_group)
|
||||
data.push({
|
||||
key: t('Group'),
|
||||
value: (s.using_group || props.target?.using_group || '') as string,
|
||||
})
|
||||
if (props.target?.key_hint)
|
||||
data.push({ key: t('Key Summary'), value: props.target.key_hint })
|
||||
if (s.key_fp || props.target?.key_fp)
|
||||
data.push({
|
||||
key: t('Key Fingerprint'),
|
||||
value: (s.key_fp || props.target?.key_fp || '') as string,
|
||||
})
|
||||
if (Number(s.window_seconds || 0) > 0)
|
||||
data.push({ key: t('TTL (seconds)'), value: s.window_seconds as number })
|
||||
if (total > 0)
|
||||
data.push({
|
||||
key: t('Hit Rate'),
|
||||
value: `${hit}/${total} (${formatRate(hit, total)})`,
|
||||
})
|
||||
if (Number(s.last_seen_at || 0) > 0)
|
||||
data.push({
|
||||
key: t('Last Seen'),
|
||||
value: formatTimestampToDate(s.last_seen_at as number | undefined),
|
||||
})
|
||||
|
||||
const promptTokens = Number(s.prompt_tokens || 0)
|
||||
const cachedTokens = Number(s.cached_tokens || 0)
|
||||
const completionTokens = Number(s.completion_tokens || 0)
|
||||
const totalTokens = Number(s.total_tokens || 0)
|
||||
|
||||
if (promptTokens > 0)
|
||||
data.push({ key: 'Prompt tokens', value: promptTokens })
|
||||
if (cachedTokens > 0)
|
||||
data.push({ key: 'Cached tokens', value: cachedTokens })
|
||||
if (completionTokens > 0)
|
||||
data.push({ key: 'Completion tokens', value: completionTokens })
|
||||
if (totalTokens > 0) data.push({ key: 'Total tokens', value: totalTokens })
|
||||
|
||||
return data
|
||||
}, [stats, props.target, t])
|
||||
|
||||
return (
|
||||
<Dialog open={props.open} onOpenChange={props.onOpenChange}>
|
||||
<DialogContent className='sm:max-w-lg'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('Channel Affinity: Upstream Cache Hit')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{t(
|
||||
'Hit criteria: If cached tokens exist in usage, it counts as a hit.'
|
||||
)}
|
||||
</p>
|
||||
{loading ? (
|
||||
<div className='text-muted-foreground py-8 text-center text-sm'>
|
||||
{t('Loading...')}
|
||||
</div>
|
||||
) : rows.length > 0 ? (
|
||||
<div className='space-y-2'>
|
||||
{rows.map((row) => (
|
||||
<div
|
||||
key={row.key}
|
||||
className='flex justify-between border-b pb-1 text-sm'
|
||||
>
|
||||
<span className='text-muted-foreground'>{row.key}</span>
|
||||
<span className='font-medium'>{row.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className='text-muted-foreground py-8 text-center text-sm'>
|
||||
{t('No data available')}
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
import type { AffinityRule } from './types'
|
||||
|
||||
const CODEX_CLI_HEADER_PASSTHROUGH_HEADERS = [
|
||||
'Originator',
|
||||
'Session_id',
|
||||
'User-Agent',
|
||||
'X-Codex-Beta-Features',
|
||||
'X-Codex-Turn-Metadata',
|
||||
]
|
||||
|
||||
const CLAUDE_CLI_HEADER_PASSTHROUGH_HEADERS = [
|
||||
'X-Stainless-Arch',
|
||||
'X-Stainless-Lang',
|
||||
'X-Stainless-Os',
|
||||
'X-Stainless-Package-Version',
|
||||
'X-Stainless-Retry-Count',
|
||||
'X-Stainless-Runtime',
|
||||
'X-Stainless-Runtime-Version',
|
||||
'X-Stainless-Timeout',
|
||||
'User-Agent',
|
||||
'X-App',
|
||||
'Anthropic-Beta',
|
||||
'Anthropic-Dangerous-Direct-Browser-Access',
|
||||
'Anthropic-Version',
|
||||
]
|
||||
|
||||
function buildPassHeadersTemplate(headers: string[]) {
|
||||
return {
|
||||
operations: [
|
||||
{
|
||||
mode: 'pass_headers',
|
||||
value: [...headers],
|
||||
keep_origin: true,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
export type RuleTemplate = Omit<AffinityRule, 'id'>
|
||||
|
||||
export const RULE_TEMPLATES: Record<string, RuleTemplate> = {
|
||||
codexCli: {
|
||||
name: 'codex cli trace',
|
||||
model_regex: ['^gpt-.*$'],
|
||||
path_regex: ['/v1/responses'],
|
||||
key_sources: [{ type: 'gjson', path: 'prompt_cache_key' }],
|
||||
param_override_template: buildPassHeadersTemplate(
|
||||
CODEX_CLI_HEADER_PASSTHROUGH_HEADERS
|
||||
),
|
||||
value_regex: '',
|
||||
ttl_seconds: 0,
|
||||
skip_retry_on_failure: true,
|
||||
include_using_group: true,
|
||||
include_model_name: false,
|
||||
include_rule_name: true,
|
||||
},
|
||||
claudeCli: {
|
||||
name: 'claude cli trace',
|
||||
model_regex: ['^claude-.*$'],
|
||||
path_regex: ['/v1/messages'],
|
||||
key_sources: [{ type: 'gjson', path: 'metadata.user_id' }],
|
||||
param_override_template: buildPassHeadersTemplate(
|
||||
CLAUDE_CLI_HEADER_PASSTHROUGH_HEADERS
|
||||
),
|
||||
value_regex: '',
|
||||
ttl_seconds: 0,
|
||||
skip_retry_on_failure: true,
|
||||
include_using_group: true,
|
||||
include_model_name: false,
|
||||
include_rule_name: true,
|
||||
},
|
||||
}
|
||||
|
||||
export function makeUniqueName(
|
||||
existingNames: Set<string>,
|
||||
baseName: string
|
||||
): string {
|
||||
const base = (baseName || '').trim() || 'rule'
|
||||
if (!existingNames.has(base)) return base
|
||||
for (let i = 2; i < 1000; i++) {
|
||||
const n = `${base}-${i}`
|
||||
if (!existingNames.has(n)) return n
|
||||
}
|
||||
return `${base}-${Date.now()}`
|
||||
}
|
||||
|
||||
export function cloneTemplate<T>(template: T): T {
|
||||
return JSON.parse(JSON.stringify(template))
|
||||
}
|
||||
@@ -0,0 +1,696 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Edit, FileText, Plus, RefreshCw, Trash2, X } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { SettingsSection } from '../../components/settings-section'
|
||||
import { useUpdateOption } from '../../hooks/use-update-option'
|
||||
import { getCacheStats, clearAllCache, clearRuleCache } from './api'
|
||||
import { RULE_TEMPLATES, cloneTemplate, makeUniqueName } from './constants'
|
||||
import { RuleEditorDialog } from './rule-editor-dialog'
|
||||
import type { AffinityRule, CacheStats, ChannelAffinitySettings } from './types'
|
||||
|
||||
function parseRules(jsonStr: string): AffinityRule[] {
|
||||
try {
|
||||
const arr = JSON.parse(jsonStr || '[]')
|
||||
if (!Array.isArray(arr)) return []
|
||||
return arr.map(
|
||||
(r: Record<string, unknown>, i: number) =>
|
||||
({ id: i, ...r }) as AffinityRule
|
||||
)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function serializeRules(rules: AffinityRule[]): string {
|
||||
return JSON.stringify(rules.map(({ id: _, ...rest }) => rest))
|
||||
}
|
||||
|
||||
interface Props {
|
||||
defaultValues: ChannelAffinitySettings
|
||||
}
|
||||
|
||||
export function ChannelAffinitySection(props: Props) {
|
||||
const { t } = useTranslation()
|
||||
const updateOption = useUpdateOption()
|
||||
|
||||
const [enabled, setEnabled] = useState(
|
||||
props.defaultValues['channel_affinity_setting.enabled']
|
||||
)
|
||||
const [switchOnSuccess, setSwitchOnSuccess] = useState(
|
||||
props.defaultValues['channel_affinity_setting.switch_on_success']
|
||||
)
|
||||
const [maxEntries, setMaxEntries] = useState(
|
||||
props.defaultValues['channel_affinity_setting.max_entries']
|
||||
)
|
||||
const [defaultTtl, setDefaultTtl] = useState(
|
||||
props.defaultValues['channel_affinity_setting.default_ttl_seconds']
|
||||
)
|
||||
const [rules, setRules] = useState<AffinityRule[]>(() =>
|
||||
parseRules(props.defaultValues['channel_affinity_setting.rules'])
|
||||
)
|
||||
|
||||
const [editMode, setEditMode] = useState<'visual' | 'json'>('visual')
|
||||
const [jsonText, setJsonText] = useState(() =>
|
||||
JSON.stringify(
|
||||
parseRules(props.defaultValues['channel_affinity_setting.rules']).map(
|
||||
({ id: _, ...r }) => r
|
||||
),
|
||||
null,
|
||||
2
|
||||
)
|
||||
)
|
||||
const [cacheStats, setCacheStats] = useState<CacheStats | null>(null)
|
||||
const [cacheLoading, setCacheLoading] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const [ruleEditorOpen, setRuleEditorOpen] = useState(false)
|
||||
const [editingRule, setEditingRule] = useState<AffinityRule | null>(null)
|
||||
const [ruleTemplateKey, setRuleTemplateKey] = useState<string | null>(null)
|
||||
const [clearAllDialogOpen, setClearAllDialogOpen] = useState(false)
|
||||
const [clearRuleName, setClearRuleName] = useState<string | null>(null)
|
||||
const [fillTemplateDialogOpen, setFillTemplateDialogOpen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setEnabled(props.defaultValues['channel_affinity_setting.enabled'])
|
||||
setSwitchOnSuccess(
|
||||
props.defaultValues['channel_affinity_setting.switch_on_success']
|
||||
)
|
||||
setMaxEntries(props.defaultValues['channel_affinity_setting.max_entries'])
|
||||
setDefaultTtl(
|
||||
props.defaultValues['channel_affinity_setting.default_ttl_seconds']
|
||||
)
|
||||
const parsed = parseRules(
|
||||
props.defaultValues['channel_affinity_setting.rules']
|
||||
)
|
||||
setRules(parsed)
|
||||
setJsonText(
|
||||
JSON.stringify(
|
||||
parsed.map(({ id: _, ...r }) => r),
|
||||
null,
|
||||
2
|
||||
)
|
||||
)
|
||||
}, [props.defaultValues])
|
||||
|
||||
const refreshCache = useCallback(async () => {
|
||||
setCacheLoading(true)
|
||||
try {
|
||||
const res = await getCacheStats()
|
||||
if (res.success) setCacheStats(res.data || null)
|
||||
} catch {
|
||||
toast.error(t('Failed to refresh cache stats'))
|
||||
} finally {
|
||||
setCacheLoading(false)
|
||||
}
|
||||
}, [t])
|
||||
|
||||
useEffect(() => {
|
||||
refreshCache()
|
||||
}, [refreshCache])
|
||||
|
||||
const appendCliTemplates = () => {
|
||||
const existingNames = new Set(
|
||||
rules.map((r) => (r.name || '').trim()).filter((x) => x.length > 0)
|
||||
)
|
||||
|
||||
const templates = Object.values(RULE_TEMPLATES).map((tpl) => {
|
||||
const base = cloneTemplate(tpl)
|
||||
const name = makeUniqueName(existingNames, tpl.name)
|
||||
existingNames.add(name)
|
||||
return { ...base, name }
|
||||
})
|
||||
|
||||
setRules((prev) =>
|
||||
[...prev, ...templates].map((r, idx) => ({ ...r, id: idx }))
|
||||
)
|
||||
toast.success(t('Templates appended'))
|
||||
setFillTemplateDialogOpen(false)
|
||||
}
|
||||
|
||||
const handleFillTemplates = () => {
|
||||
if (rules.length === 0) {
|
||||
appendCliTemplates()
|
||||
} else {
|
||||
setFillTemplateDialogOpen(true)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
let rulesJson: string
|
||||
if (editMode === 'json') {
|
||||
try {
|
||||
const parsed = JSON.parse(jsonText)
|
||||
if (!Array.isArray(parsed)) {
|
||||
toast.error(t('Rules JSON must be an array'))
|
||||
return
|
||||
}
|
||||
rulesJson = JSON.stringify(parsed)
|
||||
} catch {
|
||||
toast.error(t('Invalid rules JSON format'))
|
||||
return
|
||||
}
|
||||
} else {
|
||||
rulesJson = serializeRules(rules)
|
||||
}
|
||||
|
||||
setSaving(true)
|
||||
try {
|
||||
const updates: { key: string; value: string }[] = []
|
||||
|
||||
if (enabled !== props.defaultValues['channel_affinity_setting.enabled'])
|
||||
updates.push({
|
||||
key: 'channel_affinity_setting.enabled',
|
||||
value: String(enabled),
|
||||
})
|
||||
if (
|
||||
switchOnSuccess !==
|
||||
props.defaultValues['channel_affinity_setting.switch_on_success']
|
||||
)
|
||||
updates.push({
|
||||
key: 'channel_affinity_setting.switch_on_success',
|
||||
value: String(switchOnSuccess),
|
||||
})
|
||||
if (
|
||||
maxEntries !==
|
||||
props.defaultValues['channel_affinity_setting.max_entries']
|
||||
)
|
||||
updates.push({
|
||||
key: 'channel_affinity_setting.max_entries',
|
||||
value: String(maxEntries),
|
||||
})
|
||||
if (
|
||||
defaultTtl !==
|
||||
props.defaultValues['channel_affinity_setting.default_ttl_seconds']
|
||||
)
|
||||
updates.push({
|
||||
key: 'channel_affinity_setting.default_ttl_seconds',
|
||||
value: String(defaultTtl),
|
||||
})
|
||||
|
||||
const origRules = props.defaultValues['channel_affinity_setting.rules']
|
||||
const origSerialized = (() => {
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(origRules || '[]'))
|
||||
} catch {
|
||||
return '[]'
|
||||
}
|
||||
})()
|
||||
if (rulesJson !== origSerialized) {
|
||||
updates.push({
|
||||
key: 'channel_affinity_setting.rules',
|
||||
value: rulesJson,
|
||||
})
|
||||
}
|
||||
|
||||
if (updates.length === 0) {
|
||||
toast.info(t('No changes'))
|
||||
return
|
||||
}
|
||||
|
||||
for (const u of updates) {
|
||||
await updateOption.mutateAsync(u)
|
||||
}
|
||||
toast.success(t('Saved successfully'))
|
||||
} catch {
|
||||
toast.error(t('Failed to save'))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRuleSave = (rule: AffinityRule) => {
|
||||
setRules((prev) => {
|
||||
const existIdx = prev.findIndex(
|
||||
(r) => r.id === rule.id || (rule.name && r.name === editingRule?.name)
|
||||
)
|
||||
if (existIdx >= 0) {
|
||||
const next = [...prev]
|
||||
next[existIdx] = { ...rule, id: existIdx }
|
||||
return next
|
||||
}
|
||||
return [...prev, { ...rule, id: prev.length }]
|
||||
})
|
||||
setEditingRule(null)
|
||||
}
|
||||
|
||||
const handleDeleteRule = (idx: number) => {
|
||||
setRules((prev) =>
|
||||
prev.filter((_, i) => i !== idx).map((r, i) => ({ ...r, id: i }))
|
||||
)
|
||||
toast.success(t('Deleted successfully'))
|
||||
}
|
||||
|
||||
const handleClearAll = async () => {
|
||||
const res = await clearAllCache()
|
||||
if (res.success) {
|
||||
toast.success(t('Cleared'))
|
||||
refreshCache()
|
||||
}
|
||||
setClearAllDialogOpen(false)
|
||||
}
|
||||
|
||||
const handleClearRule = async () => {
|
||||
if (!clearRuleName) return
|
||||
const res = await clearRuleCache(clearRuleName)
|
||||
if (res.success) {
|
||||
toast.success(t('Cleared'))
|
||||
refreshCache()
|
||||
}
|
||||
setClearRuleName(null)
|
||||
}
|
||||
|
||||
const switchToJsonMode = () => {
|
||||
setJsonText(
|
||||
JSON.stringify(
|
||||
rules.map(({ id: _, ...r }) => r),
|
||||
null,
|
||||
2
|
||||
)
|
||||
)
|
||||
setEditMode('json')
|
||||
}
|
||||
|
||||
const switchToVisualMode = () => {
|
||||
try {
|
||||
const parsed = JSON.parse(jsonText)
|
||||
if (!Array.isArray(parsed)) {
|
||||
toast.error(t('Rules JSON must be an array'))
|
||||
return
|
||||
}
|
||||
setRules(
|
||||
parsed.map(
|
||||
(r: Record<string, unknown>, i: number) =>
|
||||
({ id: i, ...r }) as AffinityRule
|
||||
)
|
||||
)
|
||||
setEditMode('visual')
|
||||
} catch {
|
||||
toast.error(t('Invalid rules JSON format'))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsSection
|
||||
title={t('Channel Affinity')}
|
||||
description={t(
|
||||
'Prioritize reusing the last successful channel based on keys extracted from request context (sticky routing)'
|
||||
)}
|
||||
>
|
||||
<Alert>
|
||||
<AlertDescription className='text-xs'>
|
||||
{t(
|
||||
'Channel affinity reuses the last successful channel based on keys extracted from the request context or JSON body.'
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
{/* Basic Settings */}
|
||||
<div className='grid grid-cols-1 gap-4 md:grid-cols-3'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Switch checked={enabled} onCheckedChange={setEnabled} />
|
||||
<Label>{t('Enable')}</Label>
|
||||
</div>
|
||||
<div className='grid gap-1.5'>
|
||||
<Label>{t('Max Entries')}</Label>
|
||||
<Input
|
||||
type='number'
|
||||
min={0}
|
||||
value={maxEntries}
|
||||
onChange={(e) => setMaxEntries(Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
<div className='grid gap-1.5'>
|
||||
<Label>{t('Default TTL (seconds)')}</Label>
|
||||
<Input
|
||||
type='number'
|
||||
min={0}
|
||||
value={defaultTtl}
|
||||
onChange={(e) => setDefaultTtl(Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex items-center gap-2'>
|
||||
<Switch
|
||||
checked={switchOnSuccess}
|
||||
onCheckedChange={setSwitchOnSuccess}
|
||||
/>
|
||||
<Label>{t('Switch affinity on success')}</Label>
|
||||
<span className='text-muted-foreground text-xs'>
|
||||
{t(
|
||||
'If the affinity channel fails and retry succeeds on another channel, update affinity to the successful channel.'
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className='flex flex-wrap items-center gap-2'>
|
||||
<Button
|
||||
variant={editMode === 'visual' ? 'default' : 'outline'}
|
||||
size='sm'
|
||||
onClick={editMode === 'json' ? switchToVisualMode : undefined}
|
||||
>
|
||||
{t('Visual')}
|
||||
</Button>
|
||||
<Button
|
||||
variant={editMode === 'json' ? 'default' : 'outline'}
|
||||
size='sm'
|
||||
onClick={editMode === 'visual' ? switchToJsonMode : undefined}
|
||||
>
|
||||
JSON
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant='outline' size='sm'>
|
||||
<Plus className='mr-1 h-3 w-3' />
|
||||
{t('Add Rule')}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setEditingRule(null)
|
||||
setRuleTemplateKey(null)
|
||||
setRuleEditorOpen(true)
|
||||
}}
|
||||
>
|
||||
{t('Blank Rule')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setEditingRule(null)
|
||||
setRuleTemplateKey('codexCli')
|
||||
setRuleEditorOpen(true)
|
||||
}}
|
||||
>
|
||||
Codex CLI
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setEditingRule(null)
|
||||
setRuleTemplateKey('claudeCli')
|
||||
setRuleEditorOpen(true)
|
||||
}}
|
||||
>
|
||||
Claude CLI
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Button variant='outline' size='sm' onClick={handleFillTemplates}>
|
||||
<FileText className='mr-1 h-3 w-3' />
|
||||
{t('Fill Templates')}
|
||||
</Button>
|
||||
<Button size='sm' onClick={handleSave} disabled={saving}>
|
||||
{saving ? t('Saving...') : t('Save')}
|
||||
</Button>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={refreshCache}
|
||||
disabled={cacheLoading}
|
||||
>
|
||||
<RefreshCw
|
||||
className={`mr-1 h-3 w-3 ${cacheLoading ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
{t('Refresh Cache')}
|
||||
</Button>
|
||||
<Button
|
||||
variant='destructive'
|
||||
size='sm'
|
||||
onClick={() => setClearAllDialogOpen(true)}
|
||||
>
|
||||
{t('Clear All Cache')}
|
||||
</Button>
|
||||
{cacheStats && (
|
||||
<span className='text-muted-foreground text-xs'>
|
||||
{t('Cache Entries')}: {cacheStats.total} /{' '}
|
||||
{cacheStats.cache_capacity}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Rules Table or JSON Editor */}
|
||||
{editMode === 'visual' ? (
|
||||
<div className='overflow-x-auto rounded-md border'>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t('Name')}</TableHead>
|
||||
<TableHead>{t('Model Regex')}</TableHead>
|
||||
<TableHead>{t('Key Sources')}</TableHead>
|
||||
<TableHead>{t('TTL')}</TableHead>
|
||||
<TableHead>{t('Retry')}</TableHead>
|
||||
<TableHead>{t('Scope')}</TableHead>
|
||||
<TableHead>{t('Cache')}</TableHead>
|
||||
<TableHead className='text-right'>{t('Actions')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rules.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={8}
|
||||
className='text-muted-foreground py-8 text-center'
|
||||
>
|
||||
{t('No rules yet')}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
rules.map((rule, idx) => (
|
||||
<TableRow key={idx}>
|
||||
<TableCell className='font-medium'>
|
||||
{rule.name || '-'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className='text-muted-foreground flex items-center gap-1.5 text-xs font-medium'>
|
||||
{(rule.model_regex || []).length > 0 && (
|
||||
<span
|
||||
className='size-1.5 shrink-0 rounded-full bg-slate-400'
|
||||
aria-hidden='true'
|
||||
/>
|
||||
)}
|
||||
{(rule.model_regex || [])
|
||||
.slice(0, 2)
|
||||
.map((r, i, arr) => (
|
||||
<span
|
||||
key={i}
|
||||
className='flex items-center gap-1.5'
|
||||
>
|
||||
{r}
|
||||
{i < arr.length - 1 && (
|
||||
<span className='text-muted-foreground/30'>
|
||||
·
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
{(rule.model_regex || []).length > 2 && (
|
||||
<span className='text-muted-foreground/50'>
|
||||
+{(rule.model_regex || []).length - 2}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className='text-muted-foreground flex items-center gap-1.5 text-xs font-medium'>
|
||||
{(rule.key_sources || []).length > 0 && (
|
||||
<span
|
||||
className='size-1.5 shrink-0 rounded-full bg-slate-400'
|
||||
aria-hidden='true'
|
||||
/>
|
||||
)}
|
||||
{(rule.key_sources || [])
|
||||
.slice(0, 2)
|
||||
.map((src, i, arr) => (
|
||||
<span
|
||||
key={i}
|
||||
className='flex items-center gap-1.5'
|
||||
>
|
||||
{src.type}:
|
||||
{src.type === 'gjson' ? src.path : src.key}
|
||||
{i < arr.length - 1 && (
|
||||
<span className='text-muted-foreground/30'>
|
||||
·
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
{(rule.key_sources || []).length > 2 && (
|
||||
<span className='text-muted-foreground/50'>
|
||||
+{(rule.key_sources || []).length - 2}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>{rule.ttl_seconds || '-'}</TableCell>
|
||||
<TableCell>
|
||||
<StatusBadge
|
||||
label={
|
||||
rule.skip_retry_on_failure
|
||||
? t('No Retry')
|
||||
: t('Retry')
|
||||
}
|
||||
variant={
|
||||
rule.skip_retry_on_failure ? 'danger' : 'neutral'
|
||||
}
|
||||
copyable={false}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{(() => {
|
||||
const scopeItems = [
|
||||
rule.include_using_group && t('Group'),
|
||||
rule.include_model_name && t('Model'),
|
||||
rule.include_rule_name && t('Rule'),
|
||||
].filter(Boolean) as string[]
|
||||
if (scopeItems.length === 0) return '-'
|
||||
return (
|
||||
<div className='text-muted-foreground flex items-center gap-1.5 text-xs font-medium'>
|
||||
<span
|
||||
className='size-1.5 shrink-0 rounded-full bg-slate-400'
|
||||
aria-hidden='true'
|
||||
/>
|
||||
{scopeItems.map((item, idx, arr) => (
|
||||
<span
|
||||
key={idx}
|
||||
className='flex items-center gap-1.5'
|
||||
>
|
||||
{item}
|
||||
{idx < arr.length - 1 && (
|
||||
<span className='text-muted-foreground/30'>
|
||||
·
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{rule.include_rule_name && cacheStats?.by_rule_name
|
||||
? cacheStats.by_rule_name[rule.name] || 0
|
||||
: 'N/A'}
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<div className='flex justify-end gap-1'>
|
||||
{rule.include_rule_name && (
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='h-7 w-7'
|
||||
onClick={() => setClearRuleName(rule.name)}
|
||||
title={t('Clear cache for this rule')}
|
||||
>
|
||||
<X className='h-3 w-3' />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='h-7 w-7'
|
||||
onClick={() => {
|
||||
setEditingRule(rule)
|
||||
setRuleTemplateKey(null)
|
||||
setRuleEditorOpen(true)
|
||||
}}
|
||||
>
|
||||
<Edit className='h-3 w-3' />
|
||||
</Button>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='h-7 w-7'
|
||||
onClick={() => handleDeleteRule(idx)}
|
||||
>
|
||||
<Trash2 className='h-3 w-3' />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
) : (
|
||||
<div className='grid gap-1.5'>
|
||||
<Label>{t('Rules JSON')}</Label>
|
||||
<Textarea
|
||||
className='min-h-[300px] font-mono text-xs'
|
||||
value={jsonText}
|
||||
onChange={(e) => setJsonText(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</SettingsSection>
|
||||
|
||||
<RuleEditorDialog
|
||||
open={ruleEditorOpen}
|
||||
onOpenChange={setRuleEditorOpen}
|
||||
rule={editingRule}
|
||||
onSave={handleRuleSave}
|
||||
templateKey={ruleTemplateKey}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={clearAllDialogOpen}
|
||||
onOpenChange={setClearAllDialogOpen}
|
||||
title={t('Confirm clearing all channel affinity cache')}
|
||||
desc={t(
|
||||
'This will delete all channel affinity cache entries still in memory.'
|
||||
)}
|
||||
handleConfirm={handleClearAll}
|
||||
destructive
|
||||
/>
|
||||
|
||||
{clearRuleName !== null && (
|
||||
<ConfirmDialog
|
||||
open
|
||||
onOpenChange={(v) => !v && setClearRuleName(null)}
|
||||
title={t('Confirm clearing cache for this rule')}
|
||||
desc={`${t('Rule')}: ${clearRuleName}`}
|
||||
handleConfirm={handleClearRule}
|
||||
destructive
|
||||
/>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
open={fillTemplateDialogOpen}
|
||||
onOpenChange={setFillTemplateDialogOpen}
|
||||
title={t('Fill Codex CLI / Claude CLI Templates')}
|
||||
desc={t(
|
||||
'This will append 2 template rules (Codex CLI and Claude CLI) to the existing rule list.'
|
||||
)}
|
||||
handleConfirm={appendCliTemplates}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
+429
@@ -0,0 +1,429 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { Plus, Trash2 } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { RULE_TEMPLATES } from './constants'
|
||||
import type { AffinityRule, KeySource } from './types'
|
||||
|
||||
const KEY_SOURCE_TYPES = ['context_int', 'context_string', 'gjson'] as const
|
||||
|
||||
const CONTEXT_KEY_PRESETS = [
|
||||
'id',
|
||||
'token_id',
|
||||
'token_key',
|
||||
'token_group',
|
||||
'group',
|
||||
'username',
|
||||
'user_group',
|
||||
'user_email',
|
||||
'specific_channel_id',
|
||||
]
|
||||
|
||||
interface RuleFormValues {
|
||||
name: string
|
||||
model_regex_text: string
|
||||
path_regex_text: string
|
||||
user_agent_include_text: string
|
||||
value_regex: string
|
||||
ttl_seconds: number
|
||||
skip_retry_on_failure: boolean
|
||||
include_using_group: boolean
|
||||
include_model_name: boolean
|
||||
include_rule_name: boolean
|
||||
param_override_template_json: string
|
||||
}
|
||||
|
||||
function normalizeStringList(text: string): string[] {
|
||||
return text
|
||||
.split('\n')
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s.length > 0)
|
||||
}
|
||||
|
||||
function normalizeKeySource(src: Partial<KeySource>): KeySource {
|
||||
const type = (src?.type || 'gjson') as KeySource['type']
|
||||
if (type === 'gjson') return { type, key: '', path: src?.path || '' }
|
||||
return { type, key: src?.key || '', path: '' }
|
||||
}
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
rule: AffinityRule | null
|
||||
onSave: (rule: AffinityRule) => void
|
||||
templateKey?: string | null
|
||||
}
|
||||
|
||||
export function RuleEditorDialog(props: Props) {
|
||||
const { t } = useTranslation()
|
||||
const isEdit = !!props.rule?.name
|
||||
const [keySources, setKeySources] = useState<KeySource[]>([
|
||||
{ type: 'gjson', path: '' },
|
||||
])
|
||||
const [advancedOpen, setAdvancedOpen] = useState(false)
|
||||
|
||||
const form = useForm<RuleFormValues>({
|
||||
defaultValues: {
|
||||
name: '',
|
||||
model_regex_text: '',
|
||||
path_regex_text: '',
|
||||
user_agent_include_text: '',
|
||||
value_regex: '',
|
||||
ttl_seconds: 0,
|
||||
skip_retry_on_failure: false,
|
||||
include_using_group: true,
|
||||
include_model_name: false,
|
||||
include_rule_name: true,
|
||||
param_override_template_json: '',
|
||||
},
|
||||
})
|
||||
|
||||
const resetFromRule = (r: Partial<AffinityRule>) => {
|
||||
form.reset({
|
||||
name: r.name || '',
|
||||
model_regex_text: (r.model_regex || []).join('\n'),
|
||||
path_regex_text: (r.path_regex || []).join('\n'),
|
||||
user_agent_include_text: (r.user_agent_include || []).join('\n'),
|
||||
value_regex: r.value_regex || '',
|
||||
ttl_seconds: r.ttl_seconds || 0,
|
||||
skip_retry_on_failure: !!r.skip_retry_on_failure,
|
||||
include_using_group: r.include_using_group ?? true,
|
||||
include_model_name: !!r.include_model_name,
|
||||
include_rule_name: r.include_rule_name ?? true,
|
||||
param_override_template_json: r.param_override_template
|
||||
? JSON.stringify(r.param_override_template, null, 2)
|
||||
: '',
|
||||
})
|
||||
const sources = (r.key_sources || []).map(normalizeKeySource)
|
||||
setKeySources(sources.length > 0 ? sources : [{ type: 'gjson', path: '' }])
|
||||
if (r.param_override_template) setAdvancedOpen(true)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!props.open) return
|
||||
|
||||
if (props.rule) {
|
||||
resetFromRule(props.rule)
|
||||
} else if (props.templateKey && RULE_TEMPLATES[props.templateKey]) {
|
||||
resetFromRule(RULE_TEMPLATES[props.templateKey])
|
||||
} else {
|
||||
form.reset({
|
||||
name: '',
|
||||
model_regex_text: '',
|
||||
path_regex_text: '',
|
||||
user_agent_include_text: '',
|
||||
value_regex: '',
|
||||
ttl_seconds: 0,
|
||||
skip_retry_on_failure: false,
|
||||
include_using_group: true,
|
||||
include_model_name: false,
|
||||
include_rule_name: true,
|
||||
param_override_template_json: '',
|
||||
})
|
||||
setKeySources([{ type: 'gjson', path: '' }])
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [props.open, props.rule, props.templateKey])
|
||||
|
||||
const handleSave = (values: RuleFormValues) => {
|
||||
const modelRegex = normalizeStringList(values.model_regex_text)
|
||||
if (modelRegex.length === 0) {
|
||||
toast.error(t('At least one model regex pattern is required'))
|
||||
return
|
||||
}
|
||||
|
||||
const validKeySources = keySources
|
||||
.map(normalizeKeySource)
|
||||
.filter((s) => s.type && (s.type === 'gjson' ? s.path : s.key))
|
||||
if (validKeySources.length === 0) {
|
||||
toast.error(t('At least one valid key source is required'))
|
||||
return
|
||||
}
|
||||
|
||||
let paramTemplate: Record<string, unknown> | null = null
|
||||
if (values.param_override_template_json.trim()) {
|
||||
try {
|
||||
const parsed = JSON.parse(values.param_override_template_json)
|
||||
if (
|
||||
typeof parsed !== 'object' ||
|
||||
Array.isArray(parsed) ||
|
||||
parsed === null
|
||||
) {
|
||||
toast.error(t('Parameter override template must be a JSON object'))
|
||||
return
|
||||
}
|
||||
paramTemplate = parsed
|
||||
} catch {
|
||||
toast.error(t('Invalid JSON in parameter override template'))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const rule: AffinityRule = {
|
||||
id: props.rule?.id,
|
||||
name: values.name.trim(),
|
||||
model_regex: modelRegex,
|
||||
path_regex: normalizeStringList(values.path_regex_text),
|
||||
user_agent_include: normalizeStringList(values.user_agent_include_text),
|
||||
key_sources: validKeySources,
|
||||
value_regex: values.value_regex.trim(),
|
||||
ttl_seconds: Number(values.ttl_seconds || 0),
|
||||
skip_retry_on_failure: values.skip_retry_on_failure,
|
||||
include_using_group: values.include_using_group,
|
||||
include_model_name: values.include_model_name,
|
||||
include_rule_name: values.include_rule_name,
|
||||
param_override_template: paramTemplate,
|
||||
}
|
||||
|
||||
props.onSave(rule)
|
||||
props.onOpenChange(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={props.open} onOpenChange={props.onOpenChange}>
|
||||
<DialogContent className='max-h-[85vh] max-w-2xl overflow-y-auto'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isEdit ? t('Edit Rule') : t('Add Rule')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={form.handleSubmit(handleSave)} className='space-y-4'>
|
||||
<div className='grid gap-1.5'>
|
||||
<Label>{t('Name')} *</Label>
|
||||
<Input
|
||||
placeholder='prefer-by-conversation-id'
|
||||
{...form.register('name', { required: true })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-2 gap-3'>
|
||||
<div className='grid gap-1.5'>
|
||||
<Label>{t('Model Regex (one per line)')} *</Label>
|
||||
<Textarea
|
||||
rows={4}
|
||||
placeholder={'^gpt-4o.*$\n^claude-3.*$'}
|
||||
{...form.register('model_regex_text', { required: true })}
|
||||
/>
|
||||
</div>
|
||||
<div className='grid gap-1.5'>
|
||||
<Label>{t('Path Regex (one per line)')}</Label>
|
||||
<Textarea
|
||||
rows={4}
|
||||
placeholder='/v1/chat/completions'
|
||||
{...form.register('path_regex_text')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex items-center gap-2'>
|
||||
<Switch
|
||||
checked={form.watch('skip_retry_on_failure')}
|
||||
onCheckedChange={(v) => form.setValue('skip_retry_on_failure', v)}
|
||||
/>
|
||||
<Label>{t('Skip retry on failure')}</Label>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Key Sources */}
|
||||
<div>
|
||||
<div className='mb-2 flex items-center justify-between'>
|
||||
<Label>{t('Key Sources')}</Label>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() =>
|
||||
setKeySources((prev) => [
|
||||
...prev,
|
||||
{ type: 'gjson', path: '' },
|
||||
])
|
||||
}
|
||||
>
|
||||
<Plus className='mr-1 h-3 w-3' />
|
||||
{t('Add')}
|
||||
</Button>
|
||||
</div>
|
||||
<p className='text-muted-foreground mb-2 text-xs'>
|
||||
{t('Common Keys')}: {CONTEXT_KEY_PRESETS.join(', ')}
|
||||
</p>
|
||||
<div className='space-y-2'>
|
||||
{keySources.map((src, idx) => (
|
||||
<div key={idx} className='flex items-center gap-2'>
|
||||
<Select
|
||||
value={src.type}
|
||||
onValueChange={(v: KeySource['type']) => {
|
||||
const next = [...keySources]
|
||||
next[idx] = normalizeKeySource({ ...src, type: v })
|
||||
setKeySources(next)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className='w-[160px]'>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{KEY_SOURCE_TYPES.map((t) => (
|
||||
<SelectItem key={t} value={t}>
|
||||
{t}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input
|
||||
className='flex-1'
|
||||
placeholder={
|
||||
src.type === 'gjson'
|
||||
? 'metadata.conversation_id'
|
||||
: 'user_id'
|
||||
}
|
||||
value={
|
||||
src.type === 'gjson' ? src.path || '' : src.key || ''
|
||||
}
|
||||
onChange={(e) => {
|
||||
const next = [...keySources]
|
||||
if (src.type === 'gjson') {
|
||||
next[idx] = { ...src, path: e.target.value }
|
||||
} else {
|
||||
next[idx] = { ...src, key: e.target.value }
|
||||
}
|
||||
setKeySources(next)
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
onClick={() =>
|
||||
setKeySources((prev) => prev.filter((_, i) => i !== idx))
|
||||
}
|
||||
>
|
||||
<Trash2 className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Advanced */}
|
||||
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
className='w-full justify-start'
|
||||
>
|
||||
{advancedOpen ? '▼' : '▶'} {t('Advanced Settings')}
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className='space-y-3 pt-2'>
|
||||
<div className='grid gap-1.5'>
|
||||
<Label>{t('User-Agent include (one per line)')}</Label>
|
||||
<Textarea
|
||||
rows={3}
|
||||
placeholder='curl PostmanRuntime'
|
||||
{...form.register('user_agent_include_text')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-2 gap-3'>
|
||||
<div className='grid gap-1.5'>
|
||||
<Label>{t('Value Regex')}</Label>
|
||||
<Input
|
||||
placeholder='^[-0-9A-Za-z._:]{1,128}$'
|
||||
{...form.register('value_regex')}
|
||||
/>
|
||||
</div>
|
||||
<div className='grid gap-1.5'>
|
||||
<Label>{t('TTL (seconds, 0 = default)')}</Label>
|
||||
<Input
|
||||
type='number'
|
||||
min={0}
|
||||
{...form.register('ttl_seconds')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='grid gap-1.5'>
|
||||
<Label>{t('Parameter Override Template (JSON)')}</Label>
|
||||
<Textarea
|
||||
rows={5}
|
||||
placeholder='{"operations": [...]}'
|
||||
{...form.register('param_override_template_json')}
|
||||
className='font-mono text-xs'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-3 gap-3'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Switch
|
||||
checked={form.watch('include_using_group')}
|
||||
onCheckedChange={(v) =>
|
||||
form.setValue('include_using_group', v)
|
||||
}
|
||||
/>
|
||||
<Label className='text-xs'>{t('Include Group')}</Label>
|
||||
</div>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Switch
|
||||
checked={form.watch('include_model_name')}
|
||||
onCheckedChange={(v) =>
|
||||
form.setValue('include_model_name', v)
|
||||
}
|
||||
/>
|
||||
<Label className='text-xs'>{t('Include Model')}</Label>
|
||||
</div>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Switch
|
||||
checked={form.watch('include_rule_name')}
|
||||
onCheckedChange={(v) =>
|
||||
form.setValue('include_rule_name', v)
|
||||
}
|
||||
/>
|
||||
<Label className='text-xs'>{t('Include Rule Name')}</Label>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
onClick={() => props.onOpenChange(false)}
|
||||
>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
<Button type='submit'>{t('Save')}</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
export interface KeySource {
|
||||
type: 'context_int' | 'context_string' | 'gjson'
|
||||
key?: string
|
||||
path?: string
|
||||
}
|
||||
|
||||
export interface AffinityRule {
|
||||
id?: number
|
||||
name: string
|
||||
model_regex: string[]
|
||||
path_regex: string[]
|
||||
user_agent_include?: string[]
|
||||
key_sources: KeySource[]
|
||||
value_regex?: string
|
||||
ttl_seconds: number
|
||||
skip_retry_on_failure: boolean
|
||||
include_using_group: boolean
|
||||
include_model_name: boolean
|
||||
include_rule_name: boolean
|
||||
param_override_template?: Record<string, unknown> | null
|
||||
}
|
||||
|
||||
export interface CacheStats {
|
||||
enabled: boolean
|
||||
total: number
|
||||
unknown: number
|
||||
by_rule_name: Record<string, number>
|
||||
cache_capacity: number
|
||||
cache_algo: string
|
||||
}
|
||||
|
||||
export interface ChannelAffinitySettings {
|
||||
'channel_affinity_setting.enabled': boolean
|
||||
'channel_affinity_setting.switch_on_success': boolean
|
||||
'channel_affinity_setting.max_entries': number
|
||||
'channel_affinity_setting.default_ttl_seconds': number
|
||||
'channel_affinity_setting.rules': string
|
||||
}
|
||||
+186
@@ -0,0 +1,186 @@
|
||||
import { z } from 'zod'
|
||||
import { useForm, type Resolver } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { SettingsSection } from '../components/settings-section'
|
||||
import { useUpdateOption } from '../hooks/use-update-option'
|
||||
|
||||
const schema = z.object({
|
||||
enabled: z.boolean(),
|
||||
minQuota: z.coerce.number().int().min(0),
|
||||
maxQuota: z.coerce.number().int().min(0),
|
||||
})
|
||||
|
||||
type Values = z.infer<typeof schema>
|
||||
|
||||
export function CheckinSettingsSection({
|
||||
defaultValues,
|
||||
}: {
|
||||
defaultValues: {
|
||||
enabled: boolean
|
||||
minQuota: number
|
||||
maxQuota: number
|
||||
}
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const updateOption = useUpdateOption()
|
||||
|
||||
const form = useForm<Values>({
|
||||
resolver: zodResolver(schema) as unknown as Resolver<Values>,
|
||||
defaultValues: {
|
||||
enabled: defaultValues.enabled,
|
||||
minQuota: defaultValues.minQuota,
|
||||
maxQuota: defaultValues.maxQuota,
|
||||
},
|
||||
})
|
||||
|
||||
const { isDirty, isSubmitting } = form.formState
|
||||
const enabled = form.watch('enabled')
|
||||
|
||||
async function onSubmit(values: Values) {
|
||||
const updates: Array<{ key: string; value: string }> = []
|
||||
|
||||
if (values.enabled !== defaultValues.enabled) {
|
||||
updates.push({
|
||||
key: 'checkin_setting.enabled',
|
||||
value: String(values.enabled),
|
||||
})
|
||||
}
|
||||
|
||||
if (values.minQuota !== defaultValues.minQuota) {
|
||||
updates.push({
|
||||
key: 'checkin_setting.min_quota',
|
||||
value: String(values.minQuota),
|
||||
})
|
||||
}
|
||||
|
||||
if (values.maxQuota !== defaultValues.maxQuota) {
|
||||
updates.push({
|
||||
key: 'checkin_setting.max_quota',
|
||||
value: String(values.maxQuota),
|
||||
})
|
||||
}
|
||||
|
||||
if (updates.length === 0) {
|
||||
toast.info(t('No changes to save'))
|
||||
return
|
||||
}
|
||||
|
||||
for (const update of updates) {
|
||||
await updateOption.mutateAsync(update)
|
||||
}
|
||||
|
||||
form.reset(values)
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
title={t('Check-in Settings')}
|
||||
description={t('Configure daily check-in rewards for users')}
|
||||
>
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
autoComplete='off'
|
||||
className='space-y-6'
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='enabled'
|
||||
render={({ field }) => (
|
||||
<FormItem className='flex flex-row items-center justify-between rounded-lg border p-4'>
|
||||
<div className='space-y-0.5'>
|
||||
<FormLabel className='text-base'>
|
||||
{t('Enable check-in feature')}
|
||||
</FormLabel>
|
||||
<FormDescription>
|
||||
{t(
|
||||
'Allow users to check in daily for random quota rewards'
|
||||
)}
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
disabled={updateOption.isPending || isSubmitting}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{enabled && (
|
||||
<div className='grid gap-6 sm:grid-cols-2'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='minQuota'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Minimum check-in quota')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type='number'
|
||||
min={0}
|
||||
placeholder={t('1000')}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('Minimum quota amount awarded for check-in')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='maxQuota'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Maximum check-in quota')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type='number'
|
||||
min={0}
|
||||
placeholder={t('10000')}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('Maximum quota amount awarded for check-in')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type='submit'
|
||||
disabled={!isDirty || updateOption.isPending || isSubmitting}
|
||||
>
|
||||
{updateOption.isPending || isSubmitting
|
||||
? t('Saving...')
|
||||
: t('Save check-in settings')}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
</SettingsSection>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { useParams } from '@tanstack/react-router'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { parseCurrencyDisplayType } from '@/lib/currency'
|
||||
import { useSystemOptions, getOptionValue } from '../hooks/use-system-options'
|
||||
import type { GeneralSettings } from '../types'
|
||||
import {
|
||||
GENERAL_DEFAULT_SECTION,
|
||||
getGeneralSectionContent,
|
||||
} from './section-registry.tsx'
|
||||
|
||||
const defaultGeneralSettings: GeneralSettings = {
|
||||
'theme.frontend': 'default',
|
||||
Notice: '',
|
||||
SystemName: 'New API',
|
||||
Logo: '',
|
||||
Footer: '',
|
||||
About: '',
|
||||
HomePageContent: '',
|
||||
ServerAddress: '',
|
||||
'legal.user_agreement': '',
|
||||
'legal.privacy_policy': '',
|
||||
QuotaForNewUser: 0,
|
||||
PreConsumedQuota: 0,
|
||||
QuotaForInviter: 0,
|
||||
QuotaForInvitee: 0,
|
||||
TopUpLink: '',
|
||||
'general_setting.docs_link': '',
|
||||
'quota_setting.enable_free_model_pre_consume': true,
|
||||
QuotaPerUnit: 500000,
|
||||
USDExchangeRate: 7,
|
||||
'general_setting.quota_display_type': 'USD',
|
||||
'general_setting.custom_currency_symbol': '¤',
|
||||
'general_setting.custom_currency_exchange_rate': 1,
|
||||
RetryTimes: 0,
|
||||
DisplayInCurrencyEnabled: true,
|
||||
DisplayTokenStatEnabled: true,
|
||||
DefaultCollapseSidebar: false,
|
||||
DemoSiteEnabled: false,
|
||||
SelfUseModeEnabled: false,
|
||||
'checkin_setting.enabled': false,
|
||||
'checkin_setting.min_quota': 1000,
|
||||
'checkin_setting.max_quota': 10000,
|
||||
'channel_affinity_setting.enabled': false,
|
||||
'channel_affinity_setting.switch_on_success': true,
|
||||
'channel_affinity_setting.max_entries': 100000,
|
||||
'channel_affinity_setting.default_ttl_seconds': 3600,
|
||||
'channel_affinity_setting.rules': '[]',
|
||||
}
|
||||
|
||||
export function GeneralSettings() {
|
||||
const { t } = useTranslation()
|
||||
const { data, isLoading } = useSystemOptions()
|
||||
const params = useParams({
|
||||
from: '/_authenticated/system-settings/general/$section',
|
||||
})
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className='flex items-center justify-center py-12'>
|
||||
<div className='text-muted-foreground'>{t('Loading settings...')}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const settings = getOptionValue(data?.data, defaultGeneralSettings)
|
||||
const quotaDisplayType = parseCurrencyDisplayType(
|
||||
settings['general_setting.quota_display_type']
|
||||
)
|
||||
const activeSection = (params?.section ?? GENERAL_DEFAULT_SECTION) as
|
||||
| 'system-info'
|
||||
| 'quota'
|
||||
| 'pricing'
|
||||
| 'checkin'
|
||||
| 'behavior'
|
||||
| 'channel-affinity'
|
||||
const sectionContent = getGeneralSectionContent(
|
||||
activeSection,
|
||||
settings,
|
||||
quotaDisplayType
|
||||
)
|
||||
|
||||
return (
|
||||
<div className='flex h-full w-full flex-1 flex-col'>
|
||||
<div className='faded-bottom h-full w-full overflow-y-auto scroll-smooth pe-4 pb-12'>
|
||||
<div className='space-y-4'>{sectionContent}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
import * as z from 'zod'
|
||||
import type { Resolver } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { RotateCcw } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { FormDirtyIndicator } from '../components/form-dirty-indicator'
|
||||
import { FormNavigationGuard } from '../components/form-navigation-guard'
|
||||
import { SettingsSection } from '../components/settings-section'
|
||||
import { useSettingsForm } from '../hooks/use-settings-form'
|
||||
import { useUpdateOption } from '../hooks/use-update-option'
|
||||
|
||||
const createPricingSchema = (t: (key: string) => string) =>
|
||||
z
|
||||
.object({
|
||||
QuotaPerUnit: z.coerce.number().min(0, t('Value must be at least 0')),
|
||||
USDExchangeRate: z.coerce
|
||||
.number()
|
||||
.min(0.0001, t('Exchange rate must be greater than 0')),
|
||||
DisplayInCurrencyEnabled: z.boolean(),
|
||||
DisplayTokenStatEnabled: z.boolean(),
|
||||
general_setting: z.object({
|
||||
quota_display_type: z.enum(['USD', 'CNY', 'TOKENS', 'CUSTOM']),
|
||||
custom_currency_symbol: z.string().max(8).optional(),
|
||||
custom_currency_exchange_rate: z.coerce
|
||||
.number()
|
||||
.min(0.0001, t('Exchange rate must be greater than 0'))
|
||||
.optional(),
|
||||
}),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
const displayType = data.general_setting.quota_display_type
|
||||
|
||||
if (displayType === 'CUSTOM') {
|
||||
if (!data.general_setting.custom_currency_symbol?.trim()) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['general_setting', 'custom_currency_symbol'],
|
||||
message: t('Custom currency symbol is required'),
|
||||
})
|
||||
}
|
||||
|
||||
if (data.general_setting.custom_currency_exchange_rate == null) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['general_setting', 'custom_currency_exchange_rate'],
|
||||
message: t('Exchange rate is required'),
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
type PricingFormValues = z.infer<ReturnType<typeof createPricingSchema>>
|
||||
|
||||
type PricingSectionProps = {
|
||||
defaultValues: PricingFormValues
|
||||
}
|
||||
|
||||
export function PricingSection({ defaultValues }: PricingSectionProps) {
|
||||
const { t } = useTranslation()
|
||||
const updateOption = useUpdateOption()
|
||||
|
||||
const pricingSchema = createPricingSchema(t)
|
||||
|
||||
const { form, handleSubmit, handleReset, isDirty, isSubmitting } =
|
||||
useSettingsForm<PricingFormValues>({
|
||||
resolver: zodResolver(pricingSchema) as Resolver<
|
||||
PricingFormValues,
|
||||
unknown,
|
||||
PricingFormValues
|
||||
>,
|
||||
defaultValues,
|
||||
onSubmit: async (_data, changedFields) => {
|
||||
for (const [key, value] of Object.entries(changedFields)) {
|
||||
if (value === undefined || value === null) continue
|
||||
if (typeof value === 'object') continue
|
||||
|
||||
let serialized: string | boolean = value as string | boolean
|
||||
|
||||
if (typeof value === 'boolean') {
|
||||
serialized = String(value)
|
||||
} else if (typeof value === 'number') {
|
||||
serialized = Number.isFinite(value) ? String(value) : '0'
|
||||
}
|
||||
|
||||
await updateOption.mutateAsync({
|
||||
key,
|
||||
value: serialized,
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const displayType = form.watch('general_setting.quota_display_type') ?? 'USD'
|
||||
|
||||
return (
|
||||
<>
|
||||
<FormNavigationGuard when={isDirty} />
|
||||
|
||||
<SettingsSection
|
||||
title={t('Pricing & Display')}
|
||||
description={t('Configure pricing model and display options')}
|
||||
>
|
||||
<Form {...form}>
|
||||
<form onSubmit={handleSubmit} className='space-y-6'>
|
||||
<FormDirtyIndicator isDirty={isDirty} />
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='QuotaPerUnit'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Quota Per Unit')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type='number'
|
||||
step='0.01'
|
||||
value={field.value as number}
|
||||
onChange={(e) => field.onChange(e.target.valueAsNumber)}
|
||||
name={field.name}
|
||||
onBlur={field.onBlur}
|
||||
ref={field.ref}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('Number of tokens per unit quota')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='general_setting.quota_display_type'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Display Mode')}</FormLabel>
|
||||
<Select value={field.value} onValueChange={field.onChange}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t('Select display mode')} />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value='USD'>{t('USD')}</SelectItem>
|
||||
<SelectItem value='CNY'>{t('CNY')}</SelectItem>
|
||||
<SelectItem value='CUSTOM'>
|
||||
{t('Custom Currency')}
|
||||
</SelectItem>
|
||||
<SelectItem value='TOKENS'>{t('Tokens Only')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
{t('Choose how quota values are shown to users')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{displayType !== 'TOKENS' && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='USDExchangeRate'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{displayType === 'CNY'
|
||||
? t('CNY per USD')
|
||||
: displayType === 'USD'
|
||||
? t('USD Exchange Rate')
|
||||
: t('USD Exchange Rate')}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type='number'
|
||||
step='0.01'
|
||||
value={field.value as number}
|
||||
onChange={(e) => field.onChange(e.target.valueAsNumber)}
|
||||
name={field.name}
|
||||
onBlur={field.onBlur}
|
||||
ref={field.ref}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
'Real exchange rate between USD and your payment gateway currency'
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{displayType === 'CUSTOM' && (
|
||||
<div className='grid gap-4 sm:grid-cols-2'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='general_setting.custom_currency_symbol'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Custom Currency Symbol')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type='text'
|
||||
value={field.value ?? ''}
|
||||
onChange={field.onChange}
|
||||
name={field.name}
|
||||
onBlur={field.onBlur}
|
||||
ref={field.ref}
|
||||
maxLength={8}
|
||||
placeholder={t('e.g. ¥ or HK$')}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('Prefix used when displaying prices')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='general_setting.custom_currency_exchange_rate'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Units per USD')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type='number'
|
||||
step='0.01'
|
||||
value={field.value ?? ''}
|
||||
onChange={(e) =>
|
||||
field.onChange(
|
||||
e.target.value === ''
|
||||
? undefined
|
||||
: e.target.valueAsNumber
|
||||
)
|
||||
}
|
||||
name={field.name}
|
||||
onBlur={field.onBlur}
|
||||
ref={field.ref}
|
||||
placeholder={t('e.g. 8 means 1 USD = 8 units')}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('Conversion rate from USD to your custom currency')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='DisplayInCurrencyEnabled'
|
||||
render={({ field }) => (
|
||||
<FormItem className='flex flex-row items-center justify-between rounded-lg border p-4'>
|
||||
<div className='space-y-0.5'>
|
||||
<FormLabel className='text-base'>
|
||||
{t('Display in Currency')}
|
||||
</FormLabel>
|
||||
<FormDescription>
|
||||
{displayType === 'TOKENS'
|
||||
? t(
|
||||
'Tokens-only mode will show raw quota values regardless of this toggle.'
|
||||
)
|
||||
: t('Show prices in currency instead of quota.')}
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='DisplayTokenStatEnabled'
|
||||
render={({ field }) => (
|
||||
<FormItem className='flex flex-row items-center justify-between rounded-lg border p-4'>
|
||||
<div className='space-y-0.5'>
|
||||
<FormLabel className='text-base'>
|
||||
{t('Display Token Statistics')}
|
||||
</FormLabel>
|
||||
<FormDescription>
|
||||
{t('Show token usage statistics in the UI')}
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className='flex gap-2'>
|
||||
<Button
|
||||
type='submit'
|
||||
disabled={updateOption.isPending || isSubmitting}
|
||||
>
|
||||
{updateOption.isPending ? t('Saving...') : t('Save Changes')}
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
onClick={handleReset}
|
||||
disabled={!isDirty || updateOption.isPending || isSubmitting}
|
||||
>
|
||||
<RotateCcw className='mr-2 h-4 w-4' />
|
||||
{t('Reset')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</SettingsSection>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
import * as z from 'zod'
|
||||
import type { Resolver } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { FormDirtyIndicator } from '../components/form-dirty-indicator'
|
||||
import { FormNavigationGuard } from '../components/form-navigation-guard'
|
||||
import { SettingsSection } from '../components/settings-section'
|
||||
import { useSettingsForm } from '../hooks/use-settings-form'
|
||||
import { useUpdateOption } from '../hooks/use-update-option'
|
||||
|
||||
const quotaSchema = z.object({
|
||||
QuotaForNewUser: z.coerce.number().min(0),
|
||||
PreConsumedQuota: z.coerce.number().min(0),
|
||||
QuotaForInviter: z.coerce.number().min(0),
|
||||
QuotaForInvitee: z.coerce.number().min(0),
|
||||
TopUpLink: z.string().url().optional().or(z.literal('')),
|
||||
'general_setting.docs_link': z.string().url().optional().or(z.literal('')),
|
||||
'quota_setting.enable_free_model_pre_consume': z.boolean(),
|
||||
})
|
||||
|
||||
type QuotaFormValues = z.infer<typeof quotaSchema>
|
||||
|
||||
type QuotaSettingsSectionProps = {
|
||||
defaultValues: QuotaFormValues
|
||||
}
|
||||
|
||||
export function QuotaSettingsSection({
|
||||
defaultValues,
|
||||
}: QuotaSettingsSectionProps) {
|
||||
const { t } = useTranslation()
|
||||
const updateOption = useUpdateOption()
|
||||
|
||||
const { form, handleSubmit, isDirty, isSubmitting } =
|
||||
useSettingsForm<QuotaFormValues>({
|
||||
resolver: zodResolver(quotaSchema) as Resolver<
|
||||
QuotaFormValues,
|
||||
unknown,
|
||||
QuotaFormValues
|
||||
>,
|
||||
defaultValues,
|
||||
onSubmit: async (_data, changedFields) => {
|
||||
for (const [key, value] of Object.entries(changedFields)) {
|
||||
await updateOption.mutateAsync({
|
||||
key,
|
||||
value: value as string | number | boolean,
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
title={t('Quota Settings')}
|
||||
description={t('Configure user quota allocation and rewards')}
|
||||
>
|
||||
<FormNavigationGuard when={isDirty} />
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={handleSubmit} className='space-y-6'>
|
||||
<FormDirtyIndicator isDirty={isDirty} />
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='QuotaForNewUser'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('New User Quota')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type='number'
|
||||
value={field.value as number}
|
||||
onChange={(e) => field.onChange(e.target.valueAsNumber)}
|
||||
name={field.name}
|
||||
onBlur={field.onBlur}
|
||||
ref={field.ref}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('Initial quota given to new users')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='PreConsumedQuota'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Pre-Consumed Quota')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type='number'
|
||||
value={field.value as number}
|
||||
onChange={(e) => field.onChange(e.target.valueAsNumber)}
|
||||
name={field.name}
|
||||
onBlur={field.onBlur}
|
||||
ref={field.ref}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('Quota consumed before charging users')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='QuotaForInviter'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Inviter Reward')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type='number'
|
||||
value={field.value as number}
|
||||
onChange={(e) => field.onChange(e.target.valueAsNumber)}
|
||||
name={field.name}
|
||||
onBlur={field.onBlur}
|
||||
ref={field.ref}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('Quota given to users who invite others')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='QuotaForInvitee'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Invitee Reward')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type='number'
|
||||
value={field.value as number}
|
||||
onChange={(e) => field.onChange(e.target.valueAsNumber)}
|
||||
name={field.name}
|
||||
onBlur={field.onBlur}
|
||||
ref={field.ref}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('Quota given to invited users')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='quota_setting.enable_free_model_pre_consume'
|
||||
render={({ field }) => (
|
||||
<FormItem className='flex flex-row items-center justify-between rounded-lg border p-4'>
|
||||
<div className='space-y-0.5'>
|
||||
<FormLabel className='text-base'>
|
||||
{t('Pre-Consume for Free Models')}
|
||||
</FormLabel>
|
||||
<FormDescription>
|
||||
{t(
|
||||
'When enabled, zero-cost models also pre-consume quota before final settlement.'
|
||||
)}
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
disabled={updateOption.isPending}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='TopUpLink'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Top-Up Link')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t('https://example.com/topup')}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('External link for users to purchase quota')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='general_setting.docs_link'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Documentation Link')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t('https://docs.example.com')}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('Link to your documentation site')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type='submit'
|
||||
disabled={updateOption.isPending || isSubmitting}
|
||||
>
|
||||
{updateOption.isPending ? t('Saving...') : t('Save Changes')}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
</SettingsSection>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import type { GeneralSettings } from '../types'
|
||||
import { createSectionRegistry } from '../utils/section-registry'
|
||||
import { ChannelAffinitySection } from './channel-affinity'
|
||||
import { CheckinSettingsSection } from './checkin-settings-section'
|
||||
import { PricingSection } from './pricing-section'
|
||||
import { QuotaSettingsSection } from './quota-settings-section'
|
||||
import { SystemBehaviorSection } from './system-behavior-section'
|
||||
import { SystemInfoSection } from './system-info-section'
|
||||
|
||||
const GENERAL_SECTIONS = [
|
||||
{
|
||||
id: 'system-info',
|
||||
titleKey: 'System Information',
|
||||
descriptionKey: 'Configure basic system information and branding',
|
||||
build: (settings: GeneralSettings) => (
|
||||
<SystemInfoSection
|
||||
defaultValues={{
|
||||
theme: {
|
||||
frontend: settings['theme.frontend'] as 'default' | 'classic',
|
||||
},
|
||||
Notice: settings.Notice,
|
||||
SystemName: settings.SystemName,
|
||||
Logo: settings.Logo,
|
||||
Footer: settings.Footer,
|
||||
About: settings.About,
|
||||
HomePageContent: settings.HomePageContent,
|
||||
ServerAddress: settings.ServerAddress,
|
||||
legal: {
|
||||
user_agreement: settings['legal.user_agreement'],
|
||||
privacy_policy: settings['legal.privacy_policy'],
|
||||
},
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'quota',
|
||||
titleKey: 'Quota Settings',
|
||||
descriptionKey: 'Configure user quota allocation and rewards',
|
||||
build: (settings: GeneralSettings) => (
|
||||
<QuotaSettingsSection
|
||||
defaultValues={{
|
||||
QuotaForNewUser: settings.QuotaForNewUser,
|
||||
PreConsumedQuota: settings.PreConsumedQuota,
|
||||
QuotaForInviter: settings.QuotaForInviter,
|
||||
QuotaForInvitee: settings.QuotaForInvitee,
|
||||
TopUpLink: settings.TopUpLink,
|
||||
'general_setting.docs_link': settings['general_setting.docs_link'],
|
||||
'quota_setting.enable_free_model_pre_consume':
|
||||
settings['quota_setting.enable_free_model_pre_consume'],
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'pricing',
|
||||
titleKey: 'Pricing & Display',
|
||||
descriptionKey: 'Configure pricing model and display options',
|
||||
build: (
|
||||
settings: GeneralSettings,
|
||||
quotaDisplayType: 'USD' | 'CNY' | 'TOKENS' | 'CUSTOM'
|
||||
) => (
|
||||
<PricingSection
|
||||
defaultValues={{
|
||||
QuotaPerUnit: settings.QuotaPerUnit,
|
||||
USDExchangeRate: settings.USDExchangeRate,
|
||||
DisplayInCurrencyEnabled: settings.DisplayInCurrencyEnabled,
|
||||
DisplayTokenStatEnabled: settings.DisplayTokenStatEnabled,
|
||||
general_setting: {
|
||||
quota_display_type: quotaDisplayType,
|
||||
custom_currency_symbol:
|
||||
settings['general_setting.custom_currency_symbol'] ?? '¤',
|
||||
custom_currency_exchange_rate:
|
||||
settings['general_setting.custom_currency_exchange_rate'] ?? 1,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'checkin',
|
||||
titleKey: 'Check-in Settings',
|
||||
descriptionKey: 'Configure daily check-in rewards for users',
|
||||
build: (settings: GeneralSettings) => (
|
||||
<CheckinSettingsSection
|
||||
defaultValues={{
|
||||
enabled: settings['checkin_setting.enabled'],
|
||||
minQuota: settings['checkin_setting.min_quota'],
|
||||
maxQuota: settings['checkin_setting.max_quota'],
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'behavior',
|
||||
titleKey: 'System Behavior',
|
||||
descriptionKey: 'Configure system-wide behavior and defaults',
|
||||
build: (settings: GeneralSettings) => (
|
||||
<SystemBehaviorSection
|
||||
defaultValues={{
|
||||
RetryTimes: settings.RetryTimes,
|
||||
DefaultCollapseSidebar: settings.DefaultCollapseSidebar,
|
||||
DemoSiteEnabled: settings.DemoSiteEnabled,
|
||||
SelfUseModeEnabled: settings.SelfUseModeEnabled,
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'channel-affinity',
|
||||
titleKey: 'Channel Affinity',
|
||||
descriptionKey: 'Configure channel affinity (sticky routing) rules',
|
||||
build: (settings: GeneralSettings) => (
|
||||
<ChannelAffinitySection
|
||||
defaultValues={{
|
||||
'channel_affinity_setting.enabled':
|
||||
settings['channel_affinity_setting.enabled'],
|
||||
'channel_affinity_setting.switch_on_success':
|
||||
settings['channel_affinity_setting.switch_on_success'],
|
||||
'channel_affinity_setting.max_entries':
|
||||
settings['channel_affinity_setting.max_entries'],
|
||||
'channel_affinity_setting.default_ttl_seconds':
|
||||
settings['channel_affinity_setting.default_ttl_seconds'],
|
||||
'channel_affinity_setting.rules':
|
||||
settings['channel_affinity_setting.rules'],
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
] as const
|
||||
|
||||
export type GeneralSectionId = (typeof GENERAL_SECTIONS)[number]['id']
|
||||
|
||||
const generalRegistry = createSectionRegistry<
|
||||
GeneralSectionId,
|
||||
GeneralSettings,
|
||||
['USD' | 'CNY' | 'TOKENS' | 'CUSTOM']
|
||||
>({
|
||||
sections: GENERAL_SECTIONS,
|
||||
defaultSection: 'system-info',
|
||||
basePath: '/system-settings/general',
|
||||
urlStyle: 'path',
|
||||
})
|
||||
|
||||
export const GENERAL_SECTION_IDS = generalRegistry.sectionIds
|
||||
export const GENERAL_DEFAULT_SECTION = generalRegistry.defaultSection
|
||||
export const getGeneralSectionNavItems = generalRegistry.getSectionNavItems
|
||||
export const getGeneralSectionContent = generalRegistry.getSectionContent
|
||||
+166
@@ -0,0 +1,166 @@
|
||||
import * as z from 'zod'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { SettingsSection } from '../components/settings-section'
|
||||
import { useResetForm } from '../hooks/use-reset-form'
|
||||
import { useUpdateOption } from '../hooks/use-update-option'
|
||||
|
||||
const behaviorSchema = z.object({
|
||||
RetryTimes: z.coerce.number().min(0).max(10),
|
||||
DefaultCollapseSidebar: z.boolean(),
|
||||
DemoSiteEnabled: z.boolean(),
|
||||
SelfUseModeEnabled: z.boolean(),
|
||||
})
|
||||
|
||||
type BehaviorFormValues = z.infer<typeof behaviorSchema>
|
||||
|
||||
type SystemBehaviorSectionProps = {
|
||||
defaultValues: BehaviorFormValues
|
||||
}
|
||||
|
||||
export function SystemBehaviorSection({
|
||||
defaultValues,
|
||||
}: SystemBehaviorSectionProps) {
|
||||
const { t } = useTranslation()
|
||||
const updateOption = useUpdateOption()
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(behaviorSchema),
|
||||
defaultValues,
|
||||
})
|
||||
|
||||
useResetForm(form, defaultValues)
|
||||
|
||||
const onSubmit = async (data: BehaviorFormValues) => {
|
||||
const updates = Object.entries(data).filter(
|
||||
([key, value]) => value !== defaultValues[key as keyof BehaviorFormValues]
|
||||
)
|
||||
|
||||
for (const [key, value] of updates) {
|
||||
await updateOption.mutateAsync({ key, value })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
title={t('System Behavior')}
|
||||
description={t('Configure system-wide behavior and defaults')}
|
||||
>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className='space-y-6'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='RetryTimes'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Retry Times')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type='number'
|
||||
min='0'
|
||||
max='10'
|
||||
value={field.value as number}
|
||||
onChange={(e) => field.onChange(e.target.valueAsNumber)}
|
||||
name={field.name}
|
||||
onBlur={field.onBlur}
|
||||
ref={field.ref}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('Number of times to retry failed requests (0-10)')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='DefaultCollapseSidebar'
|
||||
render={({ field }) => (
|
||||
<FormItem className='flex flex-row items-center justify-between rounded-lg border p-4'>
|
||||
<div className='space-y-0.5'>
|
||||
<FormLabel className='text-base'>
|
||||
{t('Default Collapse Sidebar')}
|
||||
</FormLabel>
|
||||
<FormDescription>
|
||||
{t('Sidebar collapsed by default for new users')}
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='DemoSiteEnabled'
|
||||
render={({ field }) => (
|
||||
<FormItem className='flex flex-row items-center justify-between rounded-lg border p-4'>
|
||||
<div className='space-y-0.5'>
|
||||
<FormLabel className='text-base'>
|
||||
{t('Demo Site Mode')}
|
||||
</FormLabel>
|
||||
<FormDescription>
|
||||
{t('Enable demo mode with limited functionality')}
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='SelfUseModeEnabled'
|
||||
render={({ field }) => (
|
||||
<FormItem className='flex flex-row items-center justify-between rounded-lg border p-4'>
|
||||
<div className='space-y-0.5'>
|
||||
<FormLabel className='text-base'>
|
||||
{t('Self-Use Mode')}
|
||||
</FormLabel>
|
||||
<FormDescription>
|
||||
{t('Optimize system for self-hosted single-user usage')}
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button type='submit' disabled={updateOption.isPending}>
|
||||
{updateOption.isPending ? t('Saving...') : t('Save Changes')}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
</SettingsSection>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,395 @@
|
||||
import * as z from 'zod'
|
||||
import type { Resolver } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { RotateCcw } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useHiddenClickUnlock } from '@/hooks/use-hidden-click-unlock'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { FormDirtyIndicator } from '../components/form-dirty-indicator'
|
||||
import { FormNavigationGuard } from '../components/form-navigation-guard'
|
||||
import { SettingsSection } from '../components/settings-section'
|
||||
import { useSettingsForm } from '../hooks/use-settings-form'
|
||||
import { useUpdateOption } from '../hooks/use-update-option'
|
||||
|
||||
const _systemInfoSchema = z.object({
|
||||
theme: z.object({
|
||||
frontend: z.enum(['default', 'classic']),
|
||||
}),
|
||||
Notice: z.string().optional(),
|
||||
SystemName: z.string().min(1),
|
||||
ServerAddress: z.string().optional(),
|
||||
Logo: z.string().url().optional().or(z.literal('')),
|
||||
Footer: z.string().optional(),
|
||||
About: z.string().optional(),
|
||||
HomePageContent: z.string().optional(),
|
||||
legal: z.object({
|
||||
user_agreement: z.string().optional(),
|
||||
privacy_policy: z.string().optional(),
|
||||
}),
|
||||
})
|
||||
|
||||
type SystemInfoFormValues = z.infer<typeof _systemInfoSchema>
|
||||
|
||||
type SystemInfoSectionProps = {
|
||||
defaultValues: SystemInfoFormValues
|
||||
}
|
||||
|
||||
function normalizeValue(value: unknown): string {
|
||||
if (value === undefined || value === null) return ''
|
||||
return typeof value === 'string' ? value : String(value)
|
||||
}
|
||||
|
||||
export function SystemInfoSection({ defaultValues }: SystemInfoSectionProps) {
|
||||
const { t } = useTranslation()
|
||||
const updateOption = useUpdateOption()
|
||||
const frontendThemeUnlock = useHiddenClickUnlock({ requiredClicks: 3 })
|
||||
|
||||
const normalizedDefaults: SystemInfoFormValues = {
|
||||
theme: {
|
||||
frontend:
|
||||
defaultValues.theme?.frontend === 'classic' ? 'classic' : 'default',
|
||||
},
|
||||
Notice: normalizeValue(defaultValues.Notice),
|
||||
SystemName: normalizeValue(defaultValues.SystemName),
|
||||
ServerAddress: normalizeValue(defaultValues.ServerAddress),
|
||||
Logo: normalizeValue(defaultValues.Logo),
|
||||
Footer: normalizeValue(defaultValues.Footer),
|
||||
About: normalizeValue(defaultValues.About),
|
||||
HomePageContent: normalizeValue(defaultValues.HomePageContent),
|
||||
legal: {
|
||||
user_agreement: normalizeValue(defaultValues.legal?.user_agreement),
|
||||
privacy_policy: normalizeValue(defaultValues.legal?.privacy_policy),
|
||||
},
|
||||
}
|
||||
|
||||
const systemInfoSchemaWithI18n = z.object({
|
||||
theme: z.object({
|
||||
frontend: z.enum(['default', 'classic']),
|
||||
}),
|
||||
Notice: z.string().optional(),
|
||||
SystemName: z.string().min(1, {
|
||||
error: () => t('System name is required'),
|
||||
}),
|
||||
ServerAddress: z.string().optional(),
|
||||
Logo: z.string().url().optional().or(z.literal('')),
|
||||
Footer: z.string().optional(),
|
||||
About: z.string().optional(),
|
||||
HomePageContent: z.string().optional(),
|
||||
legal: z.object({
|
||||
user_agreement: z.string().optional(),
|
||||
privacy_policy: z.string().optional(),
|
||||
}),
|
||||
})
|
||||
|
||||
const { form, handleSubmit, handleReset, isDirty, isSubmitting } =
|
||||
useSettingsForm<SystemInfoFormValues>({
|
||||
resolver: zodResolver(systemInfoSchemaWithI18n) as Resolver<
|
||||
SystemInfoFormValues,
|
||||
unknown,
|
||||
SystemInfoFormValues
|
||||
>,
|
||||
defaultValues: normalizedDefaults,
|
||||
onSubmit: async (_data, changedFields) => {
|
||||
for (const [key, value] of Object.entries(changedFields)) {
|
||||
let v = normalizeValue(value)
|
||||
if (key === 'ServerAddress') {
|
||||
v = v.replace(/\/+$/, '')
|
||||
}
|
||||
await updateOption.mutateAsync({
|
||||
key,
|
||||
value: v,
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<FormNavigationGuard when={isDirty} />
|
||||
|
||||
<SettingsSection
|
||||
title={t('System Information')}
|
||||
titleProps={{
|
||||
className: 'cursor-pointer select-none',
|
||||
onClick: frontendThemeUnlock.handleClick,
|
||||
}}
|
||||
description={t('Configure basic system information and branding')}
|
||||
>
|
||||
<Form {...form}>
|
||||
<form onSubmit={handleSubmit} className='space-y-6'>
|
||||
<FormDirtyIndicator isDirty={isDirty} />
|
||||
{frontendThemeUnlock.unlocked && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='theme.frontend'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Frontend Theme')}</FormLabel>
|
||||
<Select onValueChange={field.onChange} value={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger className='w-full'>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value='default'>
|
||||
{t('Default (New Frontend)')}
|
||||
</SelectItem>
|
||||
<SelectItem value='classic'>
|
||||
{t('Classic (Legacy Frontend)')}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
{t(
|
||||
'Switch between the new frontend and the classic frontend. Changes take effect after page reload.'
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='Notice'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Notice')}</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder={t(
|
||||
'Enter announcement content (supports Markdown & HTML)'
|
||||
)}
|
||||
rows={6}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
'Announcement displayed to users (supports Markdown & HTML)'
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='SystemName'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('System Name')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder={t('New API')} {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('The name displayed across the application')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='ServerAddress'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Server Address')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder='https://yourdomain.com' {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
'The public URL of your server, used for OAuth callbacks, webhooks, and other external integrations'
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='Logo'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Logo URL')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t('https://example.com/logo.png')}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('URL to your logo image (optional)')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='Footer'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Footer')}</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder={t(
|
||||
'© 2025 Your Company. All rights reserved.'
|
||||
)}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('Footer text displayed at the bottom of pages')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='About'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('About')}</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder={t(
|
||||
'Enter HTML code (e.g., <p>About us...</p>) or a URL (e.g., https://example.com) to embed as iframe'
|
||||
)}
|
||||
rows={4}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
'Supports HTML markup or iframe embedding. Enter HTML code directly, or provide a complete URL to automatically embed it as an iframe.'
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='HomePageContent'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Home Page Content')}</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder={t('Welcome to our New API...')}
|
||||
rows={6}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
'Content displayed on the home page (supports Markdown)'
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='legal.user_agreement'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('User Agreement')}</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder={t(
|
||||
'Provide Markdown, HTML, or an external URL for the user agreement'
|
||||
)}
|
||||
rows={6}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
'Leave empty to disable the agreement requirement. Supports Markdown, HTML, or a full URL to redirect users.'
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='legal.privacy_policy'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Privacy Policy')}</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder={t(
|
||||
'Provide Markdown, HTML, or an external URL for the privacy policy'
|
||||
)}
|
||||
rows={6}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
'Leave empty to disable the privacy policy requirement. Supports Markdown, HTML, or a full URL to redirect users.'
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className='flex gap-2'>
|
||||
<Button
|
||||
type='submit'
|
||||
disabled={isSubmitting || updateOption.isPending}
|
||||
>
|
||||
{updateOption.isPending ? t('Saving...') : t('Save Changes')}
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
onClick={handleReset}
|
||||
disabled={!isDirty || updateOption.isPending || isSubmitting}
|
||||
>
|
||||
<RotateCcw className='mr-2 h-4 w-4' />
|
||||
{t('Reset')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</SettingsSection>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { useCallback, useEffect, useState, useMemo } from 'react'
|
||||
|
||||
// Simple debounce implementation (no external dependencies)
|
||||
function debounce(
|
||||
fn: (value: string[]) => void,
|
||||
delay: number
|
||||
): ((value: string[]) => void) & { cancel: () => void } {
|
||||
let timeoutId: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const debounced = ((value: string[]) => {
|
||||
if (timeoutId) clearTimeout(timeoutId)
|
||||
timeoutId = setTimeout(() => fn(value), delay)
|
||||
}) as ((value: string[]) => void) & { cancel: () => void }
|
||||
|
||||
debounced.cancel = () => {
|
||||
if (timeoutId) clearTimeout(timeoutId)
|
||||
}
|
||||
|
||||
return debounced
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for managing accordion state persistence in localStorage
|
||||
* Supports multiple accordion items being open simultaneously
|
||||
* Uses debounced writes to reduce I/O operations
|
||||
*/
|
||||
export function useAccordionState(pageId: string) {
|
||||
const [openItems, setOpenItems] = useState<string[]>([])
|
||||
const storageKey = `system-settings-${pageId}-accordion`
|
||||
|
||||
// Initialize state from localStorage (immediate)
|
||||
useEffect(() => {
|
||||
try {
|
||||
const stored = localStorage.getItem(storageKey)
|
||||
if (stored) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setOpenItems(JSON.parse(stored))
|
||||
}
|
||||
} catch (_error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(
|
||||
`Failed to load accordion state from localStorage: ${storageKey}`,
|
||||
_error
|
||||
)
|
||||
}
|
||||
}, [storageKey])
|
||||
|
||||
// Debounced save function (500ms delay)
|
||||
const debouncedSave = useMemo(
|
||||
() =>
|
||||
debounce((value: string[]) => {
|
||||
try {
|
||||
localStorage.setItem(storageKey, JSON.stringify(value))
|
||||
} catch (_error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(
|
||||
`Failed to save accordion state to localStorage: ${storageKey}`,
|
||||
_error
|
||||
)
|
||||
}
|
||||
}, 500),
|
||||
[storageKey]
|
||||
)
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
debouncedSave.cancel()
|
||||
}
|
||||
}, [debouncedSave])
|
||||
|
||||
// Handle accordion value changes (supports multiple open items)
|
||||
const handleAccordionChange = useCallback(
|
||||
(value: string[]) => {
|
||||
setOpenItems(value)
|
||||
debouncedSave(value)
|
||||
},
|
||||
[debouncedSave]
|
||||
)
|
||||
|
||||
return {
|
||||
openItems,
|
||||
handleAccordionChange,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useBlocker } from '@tanstack/react-router'
|
||||
|
||||
/**
|
||||
* @deprecated Use FormNavigationGuard component instead for better UX
|
||||
*
|
||||
* This hook uses browser's native window.confirm() which has poor UX.
|
||||
* The new FormNavigationGuard component uses project's native ConfirmDialog.
|
||||
*
|
||||
* @see {@link ../components/form-navigation-guard.tsx}
|
||||
*
|
||||
* Prevents navigation away from a form with unsaved changes
|
||||
*
|
||||
* Shows a confirmation dialog when user tries to:
|
||||
* - Navigate to a different route
|
||||
* - Close the browser tab/window
|
||||
* - Refresh the page
|
||||
*
|
||||
* @param isDirty - Whether the form has unsaved changes
|
||||
* @param message - Custom warning message (optional)
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* // ❌ Old way (deprecated)
|
||||
* const form = useForm()
|
||||
* useFormDirtyGuard(form.formState.isDirty)
|
||||
*
|
||||
* // ✅ New way (recommended)
|
||||
* <FormNavigationGuard when={form.formState.isDirty} />
|
||||
* ```
|
||||
*/
|
||||
export function useFormDirtyGuard(
|
||||
isDirty: boolean,
|
||||
message: string = 'You have unsaved changes. Are you sure you want to leave?'
|
||||
) {
|
||||
// Block navigation within the app
|
||||
useBlocker({
|
||||
condition: isDirty,
|
||||
blockerFn: () => window.confirm(message),
|
||||
})
|
||||
|
||||
// Block browser navigation (close tab, refresh, etc.)
|
||||
useEffect(() => {
|
||||
if (!isDirty) return
|
||||
|
||||
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
|
||||
e.preventDefault()
|
||||
// Modern browsers ignore custom messages and show their own
|
||||
e.returnValue = message
|
||||
return message
|
||||
}
|
||||
|
||||
window.addEventListener('beforeunload', handleBeforeUnload)
|
||||
return () => window.removeEventListener('beforeunload', handleBeforeUnload)
|
||||
}, [isDirty, message])
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import type { DefaultValues, FieldValues, UseFormReturn } from 'react-hook-form'
|
||||
|
||||
/**
|
||||
* Reset a react-hook-form instance whenever the provided default values change.
|
||||
* Guards against naively resetting on every render by tracking the last
|
||||
* serialized snapshot of the defaults.
|
||||
*/
|
||||
export function useResetForm<TFieldValues extends FieldValues>(
|
||||
form: UseFormReturn<TFieldValues>,
|
||||
values: DefaultValues<TFieldValues> | undefined
|
||||
) {
|
||||
const lastSerializedDefaults = useRef<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!values) return
|
||||
|
||||
const serializedDefaults = JSON.stringify(values)
|
||||
if (serializedDefaults === lastSerializedDefaults.current) {
|
||||
return
|
||||
}
|
||||
|
||||
form.reset(values)
|
||||
lastSerializedDefaults.current = serializedDefaults
|
||||
}, [values, form])
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useMemo } from 'react'
|
||||
import {
|
||||
safeJsonParse,
|
||||
safeJsonParseWithValidation,
|
||||
type SafeJsonParseOptions,
|
||||
type SafeJsonParseWithValidationOptions,
|
||||
} from '../utils/json-parser'
|
||||
|
||||
export function useSafeJsonParse<T>(
|
||||
value: string | undefined | null,
|
||||
options: Required<Pick<SafeJsonParseOptions<T>, 'fallback' | 'context'>> &
|
||||
Omit<SafeJsonParseOptions<T>, 'fallback' | 'context'>
|
||||
): T {
|
||||
return useMemo(
|
||||
() =>
|
||||
safeJsonParse(value, {
|
||||
fallback: options.fallback,
|
||||
context: options.context,
|
||||
silent: options.silent,
|
||||
}),
|
||||
[value, options.fallback, options.context, options.silent]
|
||||
)
|
||||
}
|
||||
|
||||
export function useSafeJsonParseWithValidation<T>(
|
||||
value: string | undefined | null,
|
||||
options: SafeJsonParseWithValidationOptions<T>
|
||||
): T {
|
||||
return useMemo(
|
||||
() => safeJsonParseWithValidation(value, options),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[
|
||||
value,
|
||||
options.fallback,
|
||||
options.validator,
|
||||
options.validatorMessage,
|
||||
options.context,
|
||||
]
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
import { useEffect, useMemo, useRef } from 'react'
|
||||
import {
|
||||
useForm,
|
||||
type UseFormProps,
|
||||
type FieldValues,
|
||||
type FieldNamesMarkedBoolean,
|
||||
} from 'react-hook-form'
|
||||
import i18next from 'i18next'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
type SettingsFormOptions<T extends FieldValues> = UseFormProps<T> & {
|
||||
onSubmit: (data: T, changedFields: Record<string, unknown>) => Promise<void>
|
||||
compareValues?: (a: unknown, b: unknown) => boolean
|
||||
}
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function flattenValues<T extends FieldValues>(
|
||||
values: T,
|
||||
parentKey?: string,
|
||||
result: Record<string, unknown> = {}
|
||||
): Record<string, unknown> {
|
||||
if (!isPlainObject(values)) {
|
||||
if (typeof parentKey === 'string') {
|
||||
result[parentKey] = values
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
Object.entries(values).forEach(([key, value]) => {
|
||||
const nextKey = parentKey ? `${parentKey}.${key}` : key
|
||||
|
||||
if (isPlainObject(value)) {
|
||||
flattenValues(value as FieldValues, nextKey, result)
|
||||
return
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
result[nextKey] = value
|
||||
return
|
||||
}
|
||||
|
||||
result[nextKey] = value
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
function collectDirtyValues<TFieldValues extends FieldValues>(
|
||||
dirtyFields: Partial<FieldNamesMarkedBoolean<TFieldValues>> | boolean,
|
||||
values: unknown,
|
||||
parentKey?: string,
|
||||
result: Record<string, unknown> = {}
|
||||
) {
|
||||
if (dirtyFields === true) {
|
||||
if (typeof parentKey === 'string') {
|
||||
result[parentKey] = values
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
if (!dirtyFields || typeof dirtyFields !== 'object') {
|
||||
return result
|
||||
}
|
||||
|
||||
Object.entries(dirtyFields).forEach(([key, value]) => {
|
||||
const nextKey = parentKey ? `${parentKey}.${key}` : key
|
||||
const source =
|
||||
values && (isPlainObject(values) || Array.isArray(values))
|
||||
? (values as Record<string, unknown>)[key]
|
||||
: undefined
|
||||
|
||||
if (value === true) {
|
||||
result[nextKey] = source
|
||||
return
|
||||
}
|
||||
|
||||
if (value && typeof value === 'object') {
|
||||
collectDirtyValues(
|
||||
value as Partial<FieldNamesMarkedBoolean<FieldValues>>,
|
||||
source as FieldValues,
|
||||
nextKey,
|
||||
result
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
function setDeepValue(
|
||||
target: Record<string, unknown>,
|
||||
path: string,
|
||||
value: unknown
|
||||
) {
|
||||
const segments = path.split('.')
|
||||
let current: Record<string, unknown> = target
|
||||
|
||||
segments.forEach((segment, index) => {
|
||||
const isLast = index === segments.length - 1
|
||||
if (isLast) {
|
||||
current[segment] = value
|
||||
return
|
||||
}
|
||||
|
||||
const next = current[segment]
|
||||
if (isPlainObject(next)) {
|
||||
current = next as Record<string, unknown>
|
||||
return
|
||||
}
|
||||
|
||||
current[segment] = {}
|
||||
current = current[segment] as Record<string, unknown>
|
||||
})
|
||||
}
|
||||
|
||||
function expandDotPaths<T extends FieldValues>(
|
||||
values: T | undefined
|
||||
): T | undefined {
|
||||
if (!values || !isPlainObject(values)) {
|
||||
return values
|
||||
}
|
||||
|
||||
const result: Record<string, unknown> = {}
|
||||
|
||||
Object.entries(values).forEach(([key, value]) => {
|
||||
if (key.includes('.')) {
|
||||
setDeepValue(result, key, value)
|
||||
return
|
||||
}
|
||||
|
||||
if (isPlainObject(value)) {
|
||||
result[key] = expandDotPaths(value as FieldValues)
|
||||
return
|
||||
}
|
||||
|
||||
result[key] = value
|
||||
})
|
||||
|
||||
return result as T
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified hook for system settings forms
|
||||
*
|
||||
* Key features:
|
||||
* - Initializes form with defaultValues only on mount
|
||||
* - No automatic resets that could overwrite user input
|
||||
* - Tracks changed fields to minimize API calls
|
||||
* - Provides manual reset functionality
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* const { form, handleSubmit, handleReset } = useSettingsForm({
|
||||
* resolver: zodResolver(schema),
|
||||
* defaultValues,
|
||||
* onSubmit: async (data, changed) => {
|
||||
* for (const [key, value] of Object.entries(changed)) {
|
||||
* await updateOption.mutateAsync({ key, value })
|
||||
* }
|
||||
* }
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
export function useSettingsForm<T extends FieldValues>({
|
||||
onSubmit,
|
||||
compareValues,
|
||||
defaultValues,
|
||||
...formOptions
|
||||
}: SettingsFormOptions<T>) {
|
||||
const expandedDefaults = useMemo(
|
||||
() => expandDotPaths(defaultValues),
|
||||
[defaultValues]
|
||||
)
|
||||
|
||||
const form = useForm<T>({ ...formOptions, defaultValues: expandedDefaults })
|
||||
|
||||
const defaultValuesRef = useRef<T>((expandedDefaults ?? ({} as T)) as T)
|
||||
const baselineRef = useRef<Record<string, unknown>>(
|
||||
flattenValues((expandedDefaults ?? ({} as T)) as T)
|
||||
)
|
||||
|
||||
/* eslint-disable react-hooks/refs */
|
||||
const serializedDefaultsRef = useRef<string>(
|
||||
JSON.stringify(baselineRef.current)
|
||||
)
|
||||
/* eslint-enable react-hooks/refs */
|
||||
|
||||
useEffect(() => {
|
||||
if (!expandedDefaults) return
|
||||
|
||||
const flattened = flattenValues(expandedDefaults as T)
|
||||
const serialized = JSON.stringify(flattened)
|
||||
|
||||
if (serialized === serializedDefaultsRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
baselineRef.current = flattened
|
||||
defaultValuesRef.current = expandedDefaults as T
|
||||
serializedDefaultsRef.current = serialized
|
||||
form.reset(expandedDefaults as T)
|
||||
}, [expandedDefaults, form])
|
||||
|
||||
const defaultCompare = (a: unknown, b: unknown): boolean => {
|
||||
if (a === b) return true
|
||||
|
||||
if (Array.isArray(a) && Array.isArray(b)) {
|
||||
return JSON.stringify(a) === JSON.stringify(b)
|
||||
}
|
||||
|
||||
if (typeof a !== typeof b) return false
|
||||
|
||||
// Handle arrays
|
||||
// Handle objects (but not null)
|
||||
if (a && b && typeof a === 'object' && typeof b === 'object') {
|
||||
return JSON.stringify(a) === JSON.stringify(b)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
const compare = compareValues || defaultCompare
|
||||
|
||||
const handleSubmit = async (data: T) => {
|
||||
const dirtyValues = collectDirtyValues(form.formState.dirtyFields, data)
|
||||
const changedEntries = Object.entries(dirtyValues).filter(
|
||||
([key, value]) => !compare(value, baselineRef.current[key])
|
||||
)
|
||||
|
||||
if (changedEntries.length === 0) {
|
||||
toast.info(i18next.t('No changes to save'))
|
||||
return
|
||||
}
|
||||
|
||||
const changedFields = changedEntries.reduce<Record<string, unknown>>(
|
||||
(acc, [key, value]) => {
|
||||
acc[key] = value
|
||||
return acc
|
||||
},
|
||||
{}
|
||||
)
|
||||
|
||||
await onSubmit(data, changedFields)
|
||||
|
||||
const flattenedValues = flattenValues(data)
|
||||
baselineRef.current = flattenedValues
|
||||
defaultValuesRef.current = data
|
||||
serializedDefaultsRef.current = JSON.stringify(flattenedValues)
|
||||
form.reset(data)
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
form.reset(defaultValuesRef.current)
|
||||
toast.success(i18next.t('Form reset to saved values'))
|
||||
}
|
||||
|
||||
return {
|
||||
form,
|
||||
// eslint-disable-next-line react-hooks/refs
|
||||
handleSubmit: form.handleSubmit(handleSubmit),
|
||||
handleReset,
|
||||
isDirty: form.formState.isDirty,
|
||||
isSubmitting: form.formState.isSubmitting,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { getSystemOptions } from '../api'
|
||||
|
||||
export function useSystemOptions() {
|
||||
return useQuery({
|
||||
queryKey: ['system-options'],
|
||||
queryFn: getSystemOptions,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
})
|
||||
}
|
||||
|
||||
type ParseResult<T> =
|
||||
| { success: true; value: T }
|
||||
| { success: false; error: string; fallback: T }
|
||||
|
||||
function parseOptionValueSafe<T>(
|
||||
value: string,
|
||||
defaultValue: T
|
||||
): ParseResult<T> {
|
||||
if (typeof defaultValue === 'boolean') {
|
||||
return {
|
||||
success: true,
|
||||
value: (value === 'true' || value === '1') as T,
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof defaultValue === 'number') {
|
||||
const trimmed = value.trim()
|
||||
if (trimmed === '') {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Empty string for number field',
|
||||
fallback: defaultValue,
|
||||
}
|
||||
}
|
||||
const parsed = Number(trimmed)
|
||||
if (Number.isNaN(parsed)) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Invalid number: "${value}"`,
|
||||
fallback: defaultValue,
|
||||
}
|
||||
}
|
||||
return { success: true, value: parsed as T }
|
||||
}
|
||||
|
||||
if (Array.isArray(defaultValue)) {
|
||||
try {
|
||||
const parsed = JSON.parse(value)
|
||||
if (!Array.isArray(parsed)) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Expected array but got non-array JSON',
|
||||
fallback: defaultValue,
|
||||
}
|
||||
}
|
||||
|
||||
if (defaultValue.length > 0) {
|
||||
const expectedType = typeof defaultValue[0]
|
||||
const invalidElement = parsed.find((el) => typeof el !== expectedType)
|
||||
if (invalidElement !== undefined) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Array element type mismatch: expected ${expectedType}, got ${typeof invalidElement}`,
|
||||
fallback: defaultValue,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true, value: parsed as T }
|
||||
} catch (e) {
|
||||
return {
|
||||
success: false,
|
||||
error: `JSON parse failed: ${e instanceof Error ? e.message : String(e)}`,
|
||||
fallback: defaultValue,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true, value: value as T }
|
||||
}
|
||||
|
||||
export function getOptionValue<
|
||||
T extends Record<string, string | number | boolean | unknown[]>,
|
||||
>(options: Array<{ key: string; value: string }> | undefined, defaults: T): T {
|
||||
if (!options) return defaults
|
||||
|
||||
const result = { ...defaults }
|
||||
const errors: Array<{ key: string; error: string }> = []
|
||||
|
||||
options.forEach((option) => {
|
||||
if (option.key in defaults) {
|
||||
const parseResult = parseOptionValueSafe(
|
||||
option.value,
|
||||
defaults[option.key as keyof T]
|
||||
)
|
||||
|
||||
if (parseResult.success) {
|
||||
result[option.key as keyof T] = parseResult.value as T[keyof T]
|
||||
} else {
|
||||
result[option.key as keyof T] = parseResult.fallback as T[keyof T]
|
||||
errors.push({ key: option.key, error: parseResult.error })
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if (errors.length > 0 && import.meta.env.DEV) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[System Options] Parsing errors detected:', errors)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import i18next from 'i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { updateSystemOption } from '../api'
|
||||
import type { UpdateOptionRequest } from '../types'
|
||||
|
||||
// Configuration keys that require status refresh
|
||||
const STATUS_RELATED_KEYS = [
|
||||
'theme.frontend',
|
||||
'HeaderNavModules',
|
||||
'SidebarModulesAdmin',
|
||||
'Notice',
|
||||
'LogConsumeEnabled',
|
||||
'QuotaPerUnit',
|
||||
'USDExchangeRate',
|
||||
'DisplayInCurrencyEnabled',
|
||||
'DisplayTokenStatEnabled',
|
||||
'general_setting.quota_display_type',
|
||||
'general_setting.custom_currency_symbol',
|
||||
'general_setting.custom_currency_exchange_rate',
|
||||
]
|
||||
|
||||
export function useUpdateOption() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (request: UpdateOptionRequest) => updateSystemOption(request),
|
||||
onSuccess: (data, variables) => {
|
||||
if (data.success) {
|
||||
// Always refresh system-options
|
||||
queryClient.invalidateQueries({ queryKey: ['system-options'] })
|
||||
|
||||
// If updating frontend-display-related config, also refresh status
|
||||
if (STATUS_RELATED_KEYS.includes(variables.key)) {
|
||||
queryClient.invalidateQueries({ queryKey: ['status'] })
|
||||
}
|
||||
|
||||
toast.success(i18next.t('Setting updated successfully'))
|
||||
} else {
|
||||
toast.error(data.message || i18next.t('Failed to update setting'))
|
||||
}
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || i18next.t('Failed to update setting'))
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Outlet } from '@tanstack/react-router'
|
||||
import { Main } from '@/components/layout'
|
||||
import { AppHeader } from '@/components/layout/components/app-header'
|
||||
|
||||
export function SystemSettings() {
|
||||
return (
|
||||
<>
|
||||
<AppHeader />
|
||||
|
||||
<Main>
|
||||
<div className='min-h-0 flex-1 px-4 pt-6 pb-4'>
|
||||
<Outlet />
|
||||
</div>
|
||||
</Main>
|
||||
</>
|
||||
)
|
||||
}
|
||||
+198
@@ -0,0 +1,198 @@
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import * as z from 'zod'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import { Input } from '@/components/ui/input'
|
||||
|
||||
const createAmountDiscountDialogSchema = (t: (key: string) => string) =>
|
||||
z.object({
|
||||
amount: z
|
||||
.number()
|
||||
.positive(t('Amount must be greater than 0'))
|
||||
.int(t('Amount must be a whole number')),
|
||||
discountRate: z
|
||||
.number()
|
||||
.positive(t('Discount rate must be greater than 0'))
|
||||
.max(1, t('Discount rate must be ≤ 1')),
|
||||
})
|
||||
|
||||
type AmountDiscountDialogFormValues = z.infer<
|
||||
ReturnType<typeof createAmountDiscountDialogSchema>
|
||||
>
|
||||
|
||||
export type AmountDiscountData = {
|
||||
amount: number
|
||||
discountRate: number
|
||||
}
|
||||
|
||||
type AmountDiscountDialogProps = {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onSave: (data: AmountDiscountData) => void
|
||||
editData?: AmountDiscountData | null
|
||||
}
|
||||
|
||||
export function AmountDiscountDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onSave,
|
||||
editData,
|
||||
}: AmountDiscountDialogProps) {
|
||||
const { t } = useTranslation()
|
||||
const isEditMode = !!editData
|
||||
const amountDiscountDialogSchema = createAmountDiscountDialogSchema(t)
|
||||
|
||||
const form = useForm<AmountDiscountDialogFormValues>({
|
||||
resolver: zodResolver(amountDiscountDialogSchema),
|
||||
defaultValues: {
|
||||
amount: 0,
|
||||
discountRate: 1,
|
||||
},
|
||||
})
|
||||
|
||||
const discountRate = form.watch('discountRate')
|
||||
|
||||
const discountPercentage = useMemo(() => {
|
||||
if (!discountRate || discountRate >= 1) return 0
|
||||
return Math.round((1 - discountRate) * 100)
|
||||
}, [discountRate])
|
||||
|
||||
useEffect(() => {
|
||||
if (editData) {
|
||||
form.reset(editData)
|
||||
} else {
|
||||
form.reset({
|
||||
amount: 0,
|
||||
discountRate: 1,
|
||||
})
|
||||
}
|
||||
}, [editData, form, open])
|
||||
|
||||
const handleSubmit = (values: AmountDiscountDialogFormValues) => {
|
||||
onSave({
|
||||
amount: values.amount,
|
||||
discountRate: values.discountRate,
|
||||
})
|
||||
form.reset()
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className='sm:max-w-[500px]'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{isEditMode ? t('Edit discount tier') : t('Add discount tier')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('Set a discount rate for a specific recharge amount threshold.')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(handleSubmit)}
|
||||
className='space-y-4'
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='amount'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Recharge Amount (USD)')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type='number'
|
||||
step='1'
|
||||
min='1'
|
||||
placeholder={t('e.g., 100')}
|
||||
{...field}
|
||||
onChange={(e) =>
|
||||
field.onChange(parseInt(e.target.value) || 0)
|
||||
}
|
||||
disabled={isEditMode}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{isEditMode
|
||||
? t('Amount cannot be changed when editing.')
|
||||
: t(
|
||||
'Minimum recharge amount to qualify for this discount.'
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='discountRate'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Discount Rate')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type='number'
|
||||
step='0.01'
|
||||
min='0.01'
|
||||
max='1'
|
||||
placeholder={t('e.g., 0.95')}
|
||||
{...field}
|
||||
onChange={(e) =>
|
||||
field.onChange(parseFloat(e.target.value) || 0)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('Final price multiplier (0.95 = 5% discount')}
|
||||
{discountPercentage > 0 && (
|
||||
<span className='ml-1 font-medium text-green-600 dark:text-green-400'>
|
||||
= {discountPercentage}
|
||||
{t('% off')}
|
||||
</span>
|
||||
)}
|
||||
)
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
<Button type='submit'>
|
||||
{isEditMode ? t('Update') : t('Add')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
+264
@@ -0,0 +1,264 @@
|
||||
import { useState, useMemo } from 'react'
|
||||
import { Pencil, Plus, Trash2 } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { safeJsonParseWithValidation } from '../utils/json-parser'
|
||||
import { isObjectRecord } from '../utils/json-validators'
|
||||
import {
|
||||
AmountDiscountDialog,
|
||||
type AmountDiscountData,
|
||||
} from './amount-discount-dialog'
|
||||
|
||||
type AmountDiscountVisualEditorProps = {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
}
|
||||
|
||||
export function AmountDiscountVisualEditor({
|
||||
value,
|
||||
onChange,
|
||||
}: AmountDiscountVisualEditorProps) {
|
||||
const { t } = useTranslation()
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [editData, setEditData] = useState<AmountDiscountData | null>(null)
|
||||
|
||||
const discounts = useMemo(() => {
|
||||
const parsed = safeJsonParseWithValidation<Record<string, unknown>>(value, {
|
||||
fallback: {},
|
||||
validator: isObjectRecord,
|
||||
validatorMessage: 'Amount discount must be a JSON object',
|
||||
context: 'amount discounts',
|
||||
})
|
||||
|
||||
return Object.entries(parsed)
|
||||
.map(([amount, rate]) => ({
|
||||
amount: parseInt(amount, 10),
|
||||
discountRate:
|
||||
typeof rate === 'number' ? rate : parseFloat(String(rate)),
|
||||
}))
|
||||
.filter((item) => !isNaN(item.amount) && !isNaN(item.discountRate))
|
||||
.sort((a, b) => a.amount - b.amount)
|
||||
}, [value])
|
||||
|
||||
const handleSave = (data: AmountDiscountData) => {
|
||||
const discountObject = safeJsonParseWithValidation<Record<string, unknown>>(
|
||||
value,
|
||||
{
|
||||
fallback: {},
|
||||
validator: isObjectRecord,
|
||||
silent: true,
|
||||
}
|
||||
)
|
||||
|
||||
if (editData && editData.amount !== data.amount) {
|
||||
delete discountObject[editData.amount.toString()]
|
||||
}
|
||||
|
||||
discountObject[data.amount.toString()] = data.discountRate
|
||||
|
||||
onChange(JSON.stringify(discountObject, null, 2))
|
||||
}
|
||||
|
||||
const handleDelete = (amount: number) => {
|
||||
const discountObject = safeJsonParseWithValidation<Record<string, unknown>>(
|
||||
value,
|
||||
{
|
||||
fallback: {},
|
||||
validator: isObjectRecord,
|
||||
silent: true,
|
||||
}
|
||||
)
|
||||
|
||||
delete discountObject[amount.toString()]
|
||||
|
||||
onChange(JSON.stringify(discountObject, null, 2))
|
||||
}
|
||||
|
||||
const handleEdit = (discount: AmountDiscountData) => {
|
||||
setEditData(discount)
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
const handleAdd = () => {
|
||||
setEditData(null)
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
const formatPercentage = (rate: number) => {
|
||||
if (rate >= 1) return '0%'
|
||||
const discount = Math.round((1 - rate) * 100)
|
||||
return `${discount}%`
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='space-y-4'>
|
||||
<div className='flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between'>
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
{t('Configure discount rates based on recharge amounts')}
|
||||
</p>
|
||||
<Button
|
||||
type='button'
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
handleAdd()
|
||||
}}
|
||||
size='sm'
|
||||
className='w-full sm:w-auto'
|
||||
>
|
||||
<Plus className='h-4 w-4 sm:mr-2' />
|
||||
<span className='sm:inline'>{t('Add discount tier')}</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{discounts.length === 0 ? (
|
||||
<div className='text-muted-foreground rounded-lg border border-dashed p-6 text-center text-sm'>
|
||||
{t(
|
||||
'No discount tiers configured. Click "Add discount tier" to get started.'
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className='rounded-md border'>
|
||||
{/* Desktop table view */}
|
||||
<div className='hidden sm:block'>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t('Recharge Amount')}</TableHead>
|
||||
<TableHead>{t('Discount Rate')}</TableHead>
|
||||
<TableHead>{t('Discount')}</TableHead>
|
||||
<TableHead className='text-right'>{t('Actions')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{discounts.map((discount) => (
|
||||
<TableRow key={discount.amount}>
|
||||
<TableCell>
|
||||
<span className='font-mono text-sm'>
|
||||
${discount.amount}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<code className='bg-muted rounded px-1.5 py-0.5 text-xs'>
|
||||
{discount.discountRate.toFixed(2)}
|
||||
</code>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<StatusBadge
|
||||
variant={discount.discountRate < 1 ? 'info' : 'neutral'}
|
||||
className='font-mono'
|
||||
copyable={false}
|
||||
>
|
||||
{formatPercentage(discount.discountRate)} {t('off')}
|
||||
</StatusBadge>
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<div className='flex justify-end gap-2'>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
handleEdit(discount)
|
||||
}}
|
||||
>
|
||||
<Pencil className='h-4 w-4' />
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
handleDelete(discount.amount)
|
||||
}}
|
||||
>
|
||||
<Trash2 className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* Mobile card view */}
|
||||
<div className='divide-y sm:hidden'>
|
||||
{discounts.map((discount) => (
|
||||
<div key={discount.amount} className='p-4'>
|
||||
<div className='mb-3 flex items-start justify-between'>
|
||||
<div className='flex-1'>
|
||||
<div className='mb-2 font-mono text-base font-medium'>
|
||||
${discount.amount}
|
||||
</div>
|
||||
<StatusBadge
|
||||
variant={discount.discountRate < 1 ? 'info' : 'neutral'}
|
||||
className='font-mono'
|
||||
copyable={false}
|
||||
>
|
||||
{formatPercentage(discount.discountRate)} {t('off')}
|
||||
</StatusBadge>
|
||||
</div>
|
||||
<div className='flex gap-1'>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
handleEdit(discount)
|
||||
}}
|
||||
>
|
||||
<Pencil className='h-4 w-4' />
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
handleDelete(discount.amount)
|
||||
}}
|
||||
>
|
||||
<Trash2 className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className='text-sm'>
|
||||
<span className='text-muted-foreground'>
|
||||
{t('Discount Rate:')}{' '}
|
||||
</span>
|
||||
<code className='bg-muted rounded px-1.5 py-0.5 text-xs'>
|
||||
{discount.discountRate.toFixed(2)}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AmountDiscountDialog
|
||||
open={dialogOpen}
|
||||
onOpenChange={setDialogOpen}
|
||||
onSave={handleSave}
|
||||
editData={editData}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+148
@@ -0,0 +1,148 @@
|
||||
import { useState, useMemo } from 'react'
|
||||
import { Plus, X } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { safeJsonParseWithValidation } from '../utils/json-parser'
|
||||
import { isArray } from '../utils/json-validators'
|
||||
|
||||
type AmountOptionsVisualEditorProps = {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
}
|
||||
|
||||
export function AmountOptionsVisualEditor({
|
||||
value,
|
||||
onChange,
|
||||
}: AmountOptionsVisualEditorProps) {
|
||||
const { t } = useTranslation()
|
||||
const [newAmount, setNewAmount] = useState('')
|
||||
|
||||
const amounts = useMemo(() => {
|
||||
const parsed = safeJsonParseWithValidation<unknown[]>(value, {
|
||||
fallback: [],
|
||||
validator: isArray,
|
||||
validatorMessage: t('Amount options must be a JSON array'),
|
||||
context: 'amount options',
|
||||
})
|
||||
|
||||
return parsed
|
||||
.filter((item) => typeof item === 'number' || !isNaN(Number(item)))
|
||||
.map(Number)
|
||||
.sort((a, b) => a - b)
|
||||
}, [value, t])
|
||||
|
||||
const handleAdd = () => {
|
||||
const amount = parseFloat(newAmount)
|
||||
if (isNaN(amount) || amount <= 0) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const updatedAmounts = [...amounts, amount]
|
||||
.filter((v, i, a) => a.indexOf(v) === i) // Remove duplicates
|
||||
.sort((a, b) => a - b)
|
||||
|
||||
onChange(JSON.stringify(updatedAmounts, null, 2))
|
||||
setNewAmount('')
|
||||
} catch (_error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Failed to add amount:', _error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemove = (amount: number) => {
|
||||
try {
|
||||
const updatedAmounts = amounts.filter((a) => a !== amount)
|
||||
onChange(JSON.stringify(updatedAmounts, null, 2))
|
||||
} catch (_error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Failed to remove amount:', _error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
handleAdd()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='space-y-4'>
|
||||
<div>
|
||||
<p className='text-muted-foreground mb-3 text-sm'>
|
||||
{t('Preset recharge amounts displayed to users')}
|
||||
</p>
|
||||
|
||||
{amounts.length === 0 ? (
|
||||
<div className='text-muted-foreground rounded-lg border border-dashed p-6 text-center text-sm'>
|
||||
{t(
|
||||
'No amount options configured. Add amounts below to get started.'
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
{amounts.map((amount) => (
|
||||
<StatusBadge
|
||||
key={amount}
|
||||
variant='neutral'
|
||||
className='text-base'
|
||||
copyable={false}
|
||||
>
|
||||
<span className='font-mono'>${amount}</span>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='icon-sm'
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
handleRemove(amount)
|
||||
}}
|
||||
className='hover:bg-muted-foreground/20 size-auto rounded-full p-0.5'
|
||||
aria-label={t('Remove ${{amount}}', { amount })}
|
||||
>
|
||||
<X className='h-3.5 w-3.5' />
|
||||
</Button>
|
||||
</StatusBadge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col gap-2 sm:flex-row sm:items-end'>
|
||||
<div className='flex-1'>
|
||||
<Label htmlFor='new-amount' className='mb-2 block'>
|
||||
{t('Add new amount')}
|
||||
</Label>
|
||||
<Input
|
||||
id='new-amount'
|
||||
type='number'
|
||||
step='0.01'
|
||||
min='0'
|
||||
placeholder={t('e.g., 100')}
|
||||
value={newAmount}
|
||||
onChange={(e) => setNewAmount(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type='button'
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
handleAdd()
|
||||
}}
|
||||
disabled={!newAmount || parseFloat(newAmount) <= 0}
|
||||
className='w-full sm:w-auto'
|
||||
>
|
||||
<Plus className='h-4 w-4 sm:mr-2' />
|
||||
<span className='sm:inline'>{t('Add')}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+241
@@ -0,0 +1,241 @@
|
||||
import { useEffect } from 'react'
|
||||
import * as z from 'zod'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import type { CreemProduct } from '@/features/wallet/types'
|
||||
|
||||
const creemProductDialogSchema = z.object({
|
||||
name: z.string().min(1, 'Product name is required'),
|
||||
productId: z.string().min(1, 'Product ID is required'),
|
||||
price: z.number().min(0.01, 'Price must be greater than 0'),
|
||||
quota: z.number().min(1, 'Quota must be at least 1'),
|
||||
currency: z.enum(['USD', 'EUR']),
|
||||
})
|
||||
|
||||
type CreemProductDialogFormValues = z.infer<typeof creemProductDialogSchema>
|
||||
|
||||
// Re-export for backwards compatibility
|
||||
export type CreemProductData = CreemProduct
|
||||
|
||||
type CreemProductDialogProps = {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onSave: (data: CreemProduct) => void
|
||||
editData?: CreemProduct | null
|
||||
}
|
||||
|
||||
export function CreemProductDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onSave,
|
||||
editData,
|
||||
}: CreemProductDialogProps) {
|
||||
const { t } = useTranslation()
|
||||
const isEditMode = !!editData
|
||||
|
||||
const form = useForm<CreemProductDialogFormValues>({
|
||||
resolver: zodResolver(creemProductDialogSchema),
|
||||
defaultValues: {
|
||||
name: '',
|
||||
productId: '',
|
||||
price: 0,
|
||||
quota: 0,
|
||||
currency: 'USD',
|
||||
},
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (editData) {
|
||||
form.reset(editData)
|
||||
} else {
|
||||
form.reset({
|
||||
name: '',
|
||||
productId: '',
|
||||
price: 0,
|
||||
quota: 0,
|
||||
currency: 'USD',
|
||||
})
|
||||
}
|
||||
}, [editData, form, open])
|
||||
|
||||
const handleSubmit = (values: CreemProductDialogFormValues) => {
|
||||
const data: CreemProduct = {
|
||||
name: values.name,
|
||||
productId: values.productId,
|
||||
price: values.price,
|
||||
quota: values.quota,
|
||||
currency: values.currency,
|
||||
}
|
||||
onSave(data)
|
||||
form.reset()
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className='sm:max-w-[500px]'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{isEditMode ? t('Edit product') : t('Add product')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('Configure a Creem product for user recharge options.')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(handleSubmit)}
|
||||
className='space-y-4'
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='name'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Product Name')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder={t('e.g., Basic Package')} {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('Display name shown to users.')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='productId'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Product ID')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t('e.g., prod_xxx')}
|
||||
disabled={isEditMode}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('Creem product ID from your Creem dashboard.')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className='grid gap-4 sm:grid-cols-2'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='currency'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Currency')}</FormLabel>
|
||||
<Select onValueChange={field.onChange} value={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t('Select currency')} />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value='USD'>USD ($)</SelectItem>
|
||||
<SelectItem value='EUR'>EUR (€)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='price'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Price')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type='number'
|
||||
step='0.01'
|
||||
min={0.01}
|
||||
placeholder='10.00'
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(e.target.valueAsNumber)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='quota'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Quota')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type='number'
|
||||
min={1}
|
||||
placeholder={t('e.g., 500000')}
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(e.target.valueAsNumber)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('Amount of quota to credit to user account.')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
<Button type='submit'>
|
||||
{isEditMode ? t('Update') : t('Add')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
+304
@@ -0,0 +1,304 @@
|
||||
import { useState, useMemo } from 'react'
|
||||
import { Pencil, Plus, Search, Trash2 } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import {
|
||||
formatCreemPrice,
|
||||
formatQuotaShort,
|
||||
} from '@/features/wallet/lib/format'
|
||||
import { safeJsonParseWithValidation } from '../utils/json-parser'
|
||||
import { isArray } from '../utils/json-validators'
|
||||
import {
|
||||
CreemProductDialog,
|
||||
type CreemProductData,
|
||||
} from './creem-product-dialog'
|
||||
|
||||
type CreemProductsVisualEditorProps = {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
}
|
||||
|
||||
export function CreemProductsVisualEditor({
|
||||
value,
|
||||
onChange,
|
||||
}: CreemProductsVisualEditorProps) {
|
||||
const { t } = useTranslation()
|
||||
const [searchText, setSearchText] = useState('')
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [editData, setEditData] = useState<CreemProductData | null>(null)
|
||||
|
||||
const products = useMemo(() => {
|
||||
const parsed = safeJsonParseWithValidation<unknown[]>(value, {
|
||||
fallback: [],
|
||||
validator: isArray,
|
||||
validatorMessage: t('Creem products must be a JSON array'),
|
||||
context: 'creem products',
|
||||
})
|
||||
|
||||
return parsed.filter(
|
||||
(item): item is CreemProductData =>
|
||||
typeof item === 'object' &&
|
||||
item !== null &&
|
||||
'name' in item &&
|
||||
'productId' in item &&
|
||||
'price' in item &&
|
||||
'quota' in item &&
|
||||
'currency' in item &&
|
||||
typeof item.name === 'string' &&
|
||||
typeof item.productId === 'string' &&
|
||||
typeof item.price === 'number' &&
|
||||
typeof item.quota === 'number' &&
|
||||
(item.currency === 'USD' || item.currency === 'EUR')
|
||||
)
|
||||
}, [value, t])
|
||||
|
||||
const filteredProducts = useMemo(() => {
|
||||
if (!searchText) return products
|
||||
const lowerSearch = searchText.toLowerCase()
|
||||
return products.filter(
|
||||
(product) =>
|
||||
product.name.toLowerCase().includes(lowerSearch) ||
|
||||
product.productId.toLowerCase().includes(lowerSearch)
|
||||
)
|
||||
}, [products, searchText])
|
||||
|
||||
const handleSave = (data: CreemProductData) => {
|
||||
const parsed = safeJsonParseWithValidation<unknown[]>(value, {
|
||||
fallback: [],
|
||||
validator: isArray,
|
||||
silent: true,
|
||||
})
|
||||
|
||||
const updatedArray = [...parsed]
|
||||
|
||||
if (editData) {
|
||||
const index = updatedArray.findIndex(
|
||||
(item): item is CreemProductData =>
|
||||
typeof item === 'object' &&
|
||||
item !== null &&
|
||||
'productId' in item &&
|
||||
item.productId === editData.productId
|
||||
)
|
||||
if (index !== -1) {
|
||||
updatedArray[index] = data
|
||||
} else {
|
||||
updatedArray.push(data)
|
||||
}
|
||||
} else {
|
||||
updatedArray.push(data)
|
||||
}
|
||||
|
||||
onChange(JSON.stringify(updatedArray, null, 2))
|
||||
}
|
||||
|
||||
const handleDelete = (product: CreemProductData) => {
|
||||
const parsed = safeJsonParseWithValidation<unknown[]>(value, {
|
||||
fallback: [],
|
||||
validator: isArray,
|
||||
silent: true,
|
||||
})
|
||||
|
||||
const updatedArray = parsed.filter(
|
||||
(item) =>
|
||||
!(
|
||||
typeof item === 'object' &&
|
||||
item !== null &&
|
||||
'productId' in item &&
|
||||
item.productId === product.productId
|
||||
)
|
||||
)
|
||||
|
||||
onChange(JSON.stringify(updatedArray, null, 2))
|
||||
}
|
||||
|
||||
const handleEdit = (product: CreemProductData) => {
|
||||
setEditData(product)
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
const handleAdd = () => {
|
||||
setEditData(null)
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='space-y-4'>
|
||||
<div className='flex flex-col gap-3 sm:flex-row sm:items-center'>
|
||||
<div className='relative flex-1'>
|
||||
<Search className='text-muted-foreground absolute top-2.5 left-2.5 h-4 w-4' />
|
||||
<Input
|
||||
placeholder={t('Search products...')}
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
className='pl-9'
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type='button'
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
handleAdd()
|
||||
}}
|
||||
className='flex-1 sm:flex-none'
|
||||
>
|
||||
<Plus className='h-4 w-4 sm:mr-2' />
|
||||
<span className='sm:inline'>{t('Add product')}</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{filteredProducts.length === 0 ? (
|
||||
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center text-sm'>
|
||||
{searchText
|
||||
? t('No products match your search')
|
||||
: t('No products configured. Click "Add product" to get started.')}
|
||||
</div>
|
||||
) : (
|
||||
<div className='rounded-md border'>
|
||||
{/* Desktop table view */}
|
||||
<div className='hidden md:block'>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t('Name')}</TableHead>
|
||||
<TableHead>{t('Product ID')}</TableHead>
|
||||
<TableHead>{t('Price')}</TableHead>
|
||||
<TableHead>{t('Quota')}</TableHead>
|
||||
<TableHead className='text-right'>{t('Actions')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredProducts.map((product) => (
|
||||
<TableRow key={product.productId}>
|
||||
<TableCell className='font-medium'>
|
||||
{product.name}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<code className='bg-muted rounded px-1.5 py-0.5 text-xs'>
|
||||
{product.productId}
|
||||
</code>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className='font-mono text-sm'>
|
||||
{formatCreemPrice(product.price, product.currency)}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className='font-mono text-sm'>
|
||||
{formatQuotaShort(product.quota)}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<div className='flex justify-end gap-2'>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
handleEdit(product)
|
||||
}}
|
||||
>
|
||||
<Pencil className='h-4 w-4' />
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
handleDelete(product)
|
||||
}}
|
||||
>
|
||||
<Trash2 className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* Mobile card view */}
|
||||
<div className='divide-y md:hidden'>
|
||||
{filteredProducts.map((product) => (
|
||||
<div key={product.productId} className='p-4'>
|
||||
<div className='mb-3 flex items-start justify-between'>
|
||||
<div className='flex-1'>
|
||||
<div className='mb-1 font-medium'>{product.name}</div>
|
||||
<code className='bg-muted rounded px-1.5 py-0.5 text-xs'>
|
||||
{product.productId}
|
||||
</code>
|
||||
</div>
|
||||
<div className='flex gap-1'>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
handleEdit(product)
|
||||
}}
|
||||
>
|
||||
<Pencil className='h-4 w-4' />
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
handleDelete(product)
|
||||
}}
|
||||
>
|
||||
<Trash2 className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className='space-y-2 text-sm'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<span className='text-muted-foreground min-w-16'>
|
||||
{t('Price')}:
|
||||
</span>
|
||||
<span className='font-mono'>
|
||||
{formatCreemPrice(product.price, product.currency)}
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex items-center gap-2'>
|
||||
<span className='text-muted-foreground min-w-16'>
|
||||
{t('Quota')}:
|
||||
</span>
|
||||
<span className='font-mono'>
|
||||
{formatQuotaShort(product.quota)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CreemProductDialog
|
||||
open={dialogOpen}
|
||||
onOpenChange={setDialogOpen}
|
||||
onSave={handleSave}
|
||||
editData={editData}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+300
@@ -0,0 +1,300 @@
|
||||
import * as z from 'zod'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { SettingsSection } from '../components/settings-section'
|
||||
import { useResetForm } from '../hooks/use-reset-form'
|
||||
import { useUpdateOption } from '../hooks/use-update-option'
|
||||
|
||||
const createEmailSchema = (t: (key: string) => string) =>
|
||||
z.object({
|
||||
SMTPServer: z.string(),
|
||||
SMTPPort: z.string().refine((value) => {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) return true
|
||||
return /^\d+$/.test(trimmed)
|
||||
}, t('Port must be a positive integer')),
|
||||
SMTPAccount: z.string(),
|
||||
SMTPFrom: z.string().refine((value) => {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) return true
|
||||
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmed)
|
||||
}, t('Enter a valid email or leave blank')),
|
||||
SMTPToken: z.string(),
|
||||
SMTPSSLEnabled: z.boolean(),
|
||||
SMTPForceAuthLogin: z.boolean(),
|
||||
})
|
||||
|
||||
type EmailFormValues = z.infer<ReturnType<typeof createEmailSchema>>
|
||||
|
||||
type EmailSettingsSectionProps = {
|
||||
defaultValues: EmailFormValues
|
||||
}
|
||||
|
||||
export function EmailSettingsSection({
|
||||
defaultValues,
|
||||
}: EmailSettingsSectionProps) {
|
||||
const { t } = useTranslation()
|
||||
const updateOption = useUpdateOption()
|
||||
const emailSchema = createEmailSchema(t)
|
||||
|
||||
const form = useForm<EmailFormValues>({
|
||||
resolver: zodResolver(emailSchema),
|
||||
defaultValues,
|
||||
})
|
||||
|
||||
useResetForm(form, defaultValues)
|
||||
|
||||
const onSubmit = async (values: EmailFormValues) => {
|
||||
const sanitized = {
|
||||
SMTPServer: values.SMTPServer.trim(),
|
||||
SMTPPort: values.SMTPPort.trim(),
|
||||
SMTPAccount: values.SMTPAccount.trim(),
|
||||
SMTPFrom: values.SMTPFrom.trim(),
|
||||
SMTPToken: values.SMTPToken.trim(),
|
||||
SMTPSSLEnabled: values.SMTPSSLEnabled,
|
||||
SMTPForceAuthLogin: values.SMTPForceAuthLogin,
|
||||
}
|
||||
|
||||
const initial = {
|
||||
SMTPServer: defaultValues.SMTPServer.trim(),
|
||||
SMTPPort: defaultValues.SMTPPort.trim(),
|
||||
SMTPAccount: defaultValues.SMTPAccount.trim(),
|
||||
SMTPFrom: defaultValues.SMTPFrom.trim(),
|
||||
SMTPToken: defaultValues.SMTPToken.trim(),
|
||||
SMTPSSLEnabled: defaultValues.SMTPSSLEnabled,
|
||||
SMTPForceAuthLogin: defaultValues.SMTPForceAuthLogin,
|
||||
}
|
||||
|
||||
const updates: Array<{ key: string; value: string | boolean }> = []
|
||||
|
||||
if (sanitized.SMTPServer !== initial.SMTPServer) {
|
||||
updates.push({ key: 'SMTPServer', value: sanitized.SMTPServer })
|
||||
}
|
||||
|
||||
if (sanitized.SMTPPort !== initial.SMTPPort) {
|
||||
updates.push({ key: 'SMTPPort', value: sanitized.SMTPPort })
|
||||
}
|
||||
|
||||
if (sanitized.SMTPAccount !== initial.SMTPAccount) {
|
||||
updates.push({ key: 'SMTPAccount', value: sanitized.SMTPAccount })
|
||||
}
|
||||
|
||||
if (sanitized.SMTPFrom !== initial.SMTPFrom) {
|
||||
updates.push({ key: 'SMTPFrom', value: sanitized.SMTPFrom })
|
||||
}
|
||||
|
||||
if (sanitized.SMTPToken && sanitized.SMTPToken !== initial.SMTPToken) {
|
||||
updates.push({ key: 'SMTPToken', value: sanitized.SMTPToken })
|
||||
}
|
||||
|
||||
if (sanitized.SMTPSSLEnabled !== initial.SMTPSSLEnabled) {
|
||||
updates.push({
|
||||
key: 'SMTPSSLEnabled',
|
||||
value: sanitized.SMTPSSLEnabled,
|
||||
})
|
||||
}
|
||||
|
||||
if (sanitized.SMTPForceAuthLogin !== initial.SMTPForceAuthLogin) {
|
||||
updates.push({
|
||||
key: 'SMTPForceAuthLogin',
|
||||
value: sanitized.SMTPForceAuthLogin,
|
||||
})
|
||||
}
|
||||
|
||||
for (const update of updates) {
|
||||
await updateOption.mutateAsync(update)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
title={t('SMTP Email')}
|
||||
description={t('Configure outgoing email server for notifications')}
|
||||
>
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className='space-y-6'
|
||||
autoComplete='off'
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='SMTPServer'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('SMTP Host')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
autoComplete='off'
|
||||
placeholder={t('smtp.example.com')}
|
||||
{...field}
|
||||
onChange={(event) => field.onChange(event.target.value)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('Hostname or IP of your SMTP provider')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className='grid gap-6 md:grid-cols-2'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='SMTPPort'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Port')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
autoComplete='off'
|
||||
type='number'
|
||||
placeholder='587'
|
||||
{...field}
|
||||
onChange={(event) => field.onChange(event.target.value)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('Common ports include 25, 465, and 587')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='SMTPSSLEnabled'
|
||||
render={({ field }) => (
|
||||
<FormItem className='flex flex-row items-center justify-between rounded-lg border p-4'>
|
||||
<div className='space-y-0.5'>
|
||||
<FormLabel className='text-base'>
|
||||
{t('Enable SSL/TLS')}
|
||||
</FormLabel>
|
||||
<FormDescription>
|
||||
{t('Use secure connection when sending emails')}
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='SMTPForceAuthLogin'
|
||||
render={({ field }) => (
|
||||
<FormItem className='flex flex-row items-center justify-between rounded-lg border p-4'>
|
||||
<div className='space-y-0.5'>
|
||||
<FormLabel className='text-base'>
|
||||
{t('Force AUTH LOGIN')}
|
||||
</FormLabel>
|
||||
<FormDescription>
|
||||
{t('Force SMTP authentication using AUTH LOGIN method')}
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='SMTPAccount'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Username')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
autoComplete='off'
|
||||
placeholder={t('noreply@example.com')}
|
||||
{...field}
|
||||
onChange={(event) => field.onChange(event.target.value)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('Account used when authenticating with the SMTP server')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='SMTPFrom'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('From Address')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
autoComplete='off'
|
||||
placeholder={t('New API <noreply@example.com>')}
|
||||
{...field}
|
||||
onChange={(event) => field.onChange(event.target.value)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('Display name and email used in outgoing messages')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='SMTPToken'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Password / Access Token')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
autoComplete='off'
|
||||
type='password'
|
||||
placeholder={t('Enter new token to update')}
|
||||
{...field}
|
||||
onChange={(event) => field.onChange(event.target.value)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('Leave blank to keep the existing credential')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button type='submit' disabled={updateOption.isPending}>
|
||||
{updateOption.isPending ? t('Saving...') : t('Save SMTP settings')}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
</SettingsSection>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { SettingsPage } from '../components/settings-page'
|
||||
import type { IntegrationSettings as IntegrationSettingsType } from '../types'
|
||||
import {
|
||||
INTEGRATIONS_DEFAULT_SECTION,
|
||||
getIntegrationsSectionContent,
|
||||
} from './section-registry.tsx'
|
||||
|
||||
const defaultIntegrationSettings: IntegrationSettingsType = {
|
||||
SMTPServer: '',
|
||||
SMTPPort: '',
|
||||
SMTPAccount: '',
|
||||
SMTPFrom: '',
|
||||
SMTPToken: '',
|
||||
SMTPSSLEnabled: false,
|
||||
SMTPForceAuthLogin: false,
|
||||
WorkerUrl: '',
|
||||
WorkerValidKey: '',
|
||||
WorkerAllowHttpImageRequestEnabled: false,
|
||||
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,
|
||||
'model_deployment.ionet.api_key': '',
|
||||
'model_deployment.ionet.enabled': false,
|
||||
PayAddress: '',
|
||||
EpayId: '',
|
||||
EpayKey: '',
|
||||
Price: 7.3,
|
||||
MinTopUp: 1,
|
||||
CustomCallbackAddress: '',
|
||||
PayMethods: '',
|
||||
'payment_setting.amount_options': '',
|
||||
'payment_setting.amount_discount': '',
|
||||
StripeApiSecret: '',
|
||||
StripeWebhookSecret: '',
|
||||
StripePriceId: '',
|
||||
StripeUnitPrice: 8.0,
|
||||
StripeMinTopUp: 1,
|
||||
StripePromotionCodesEnabled: false,
|
||||
CreemApiKey: '',
|
||||
CreemWebhookSecret: '',
|
||||
CreemTestMode: false,
|
||||
CreemProducts: '[]',
|
||||
WaffoEnabled: false,
|
||||
WaffoApiKey: '',
|
||||
WaffoPrivateKey: '',
|
||||
WaffoPublicCert: '',
|
||||
WaffoSandboxPublicCert: '',
|
||||
WaffoSandboxApiKey: '',
|
||||
WaffoSandboxPrivateKey: '',
|
||||
WaffoSandbox: false,
|
||||
WaffoMerchantId: '',
|
||||
WaffoCurrency: 'USD',
|
||||
WaffoUnitPrice: 1,
|
||||
WaffoMinTopUp: 1,
|
||||
WaffoNotifyUrl: '',
|
||||
WaffoReturnUrl: '',
|
||||
WaffoPayMethods: '[]',
|
||||
WaffoPancakeEnabled: false,
|
||||
WaffoPancakeSandbox: false,
|
||||
WaffoPancakeMerchantID: '',
|
||||
WaffoPancakePrivateKey: '',
|
||||
WaffoPancakeWebhookPublicKey: '',
|
||||
WaffoPancakeWebhookTestKey: '',
|
||||
WaffoPancakeStoreID: '',
|
||||
WaffoPancakeProductID: '',
|
||||
WaffoPancakeReturnURL: '',
|
||||
WaffoPancakeCurrency: 'USD',
|
||||
WaffoPancakeUnitPrice: 1,
|
||||
WaffoPancakeMinTopUp: 1,
|
||||
}
|
||||
|
||||
export function IntegrationSettings() {
|
||||
return (
|
||||
<SettingsPage
|
||||
routePath='/_authenticated/system-settings/integrations/$section'
|
||||
defaultSettings={defaultIntegrationSettings}
|
||||
defaultSection={INTEGRATIONS_DEFAULT_SECTION}
|
||||
getSectionContent={getIntegrationsSectionContent}
|
||||
/>
|
||||
)
|
||||
}
|
||||
Vendored
+251
@@ -0,0 +1,251 @@
|
||||
import { useState } from 'react'
|
||||
import { z } from 'zod'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { CheckCircle2, Loader2, XCircle } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { testDeploymentConnectionWithKey } from '@/features/models/api'
|
||||
import { SettingsSection } from '../components/settings-section'
|
||||
import { useUpdateOption } from '../hooks/use-update-option'
|
||||
|
||||
const schema = z.object({
|
||||
enabled: z.boolean(),
|
||||
apiKey: z.string().optional(),
|
||||
})
|
||||
|
||||
// NOTE: react-hook-form resolver uses the schema input type
|
||||
type Values = z.input<typeof schema>
|
||||
|
||||
export function IoNetDeploymentSettingsSection({
|
||||
defaultValues,
|
||||
}: {
|
||||
defaultValues: {
|
||||
enabled: boolean
|
||||
apiKey: string
|
||||
}
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const updateOption = useUpdateOption()
|
||||
|
||||
const form = useForm<Values>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: {
|
||||
enabled: defaultValues.enabled,
|
||||
apiKey: defaultValues.apiKey ?? '',
|
||||
},
|
||||
})
|
||||
|
||||
const { isDirty, isSubmitting } = form.formState
|
||||
const enabled = form.watch('enabled')
|
||||
|
||||
const [testState, setTestState] = useState<{
|
||||
loading: boolean
|
||||
ok: boolean | null
|
||||
error: string | null
|
||||
}>({ loading: false, ok: null, error: null })
|
||||
|
||||
async function onSubmit(values: Values) {
|
||||
const updates: Array<{ key: string; value: string }> = []
|
||||
|
||||
if (values.enabled !== defaultValues.enabled) {
|
||||
updates.push({
|
||||
key: 'model_deployment.ionet.enabled',
|
||||
value: String(values.enabled),
|
||||
})
|
||||
}
|
||||
|
||||
if ((values.apiKey || '') !== (defaultValues.apiKey || '')) {
|
||||
updates.push({
|
||||
key: 'model_deployment.ionet.api_key',
|
||||
value: String(values.apiKey || ''),
|
||||
})
|
||||
}
|
||||
|
||||
if (updates.length === 0) {
|
||||
toast.info(t('No changes to save'))
|
||||
return
|
||||
}
|
||||
|
||||
for (const update of updates) {
|
||||
await updateOption.mutateAsync(update)
|
||||
}
|
||||
|
||||
form.reset(values)
|
||||
}
|
||||
|
||||
const handleTestConnection = async () => {
|
||||
setTestState({ loading: true, ok: null, error: null })
|
||||
try {
|
||||
const apiKey = form.getValues('apiKey')
|
||||
const res = await testDeploymentConnectionWithKey(apiKey)
|
||||
if (res?.success) {
|
||||
setTestState({ loading: false, ok: true, error: null })
|
||||
return
|
||||
}
|
||||
setTestState({
|
||||
loading: false,
|
||||
ok: false,
|
||||
error: res?.message || t('Connection failed'),
|
||||
})
|
||||
} catch (err) {
|
||||
setTestState({
|
||||
loading: false,
|
||||
ok: false,
|
||||
error: err instanceof Error ? err.message : t('Connection failed'),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
title={t('io.net Deployments')}
|
||||
description={t('Configure io.net API key for model deployments')}
|
||||
>
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
autoComplete='off'
|
||||
className='space-y-6'
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='enabled'
|
||||
render={({ field }) => (
|
||||
<FormItem className='flex flex-row items-center justify-between rounded-lg border p-4'>
|
||||
<div className='space-y-0.5'>
|
||||
<FormLabel className='text-base'>
|
||||
{t('Enable io.net deployments')}
|
||||
</FormLabel>
|
||||
<FormDescription>
|
||||
{t('Enable io.net model deployment service in console')}
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={(v) => field.onChange(v)}
|
||||
disabled={updateOption.isPending || isSubmitting}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{enabled ? (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='apiKey'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('io.net API Key')}</FormLabel>
|
||||
<div className='flex gap-2'>
|
||||
<FormControl>
|
||||
<Input
|
||||
type='password'
|
||||
placeholder={t('Enter API Key')}
|
||||
autoComplete='off'
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<Button
|
||||
type='button'
|
||||
variant='secondary'
|
||||
onClick={handleTestConnection}
|
||||
disabled={testState.loading || updateOption.isPending}
|
||||
className='shrink-0'
|
||||
>
|
||||
{testState.loading ? (
|
||||
<Loader2 className='me-2 size-4 animate-spin' />
|
||||
) : null}
|
||||
{t('Test Connection')}
|
||||
</Button>
|
||||
</div>
|
||||
<FormDescription>
|
||||
{t('Used to authenticate with io.net deployment API')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Alert variant='default'>
|
||||
<AlertTitle>{t('How to get an io.net API Key')}</AlertTitle>
|
||||
<AlertDescription>
|
||||
<div className='space-y-2'>
|
||||
<ul className='list-disc space-y-1 pl-5'>
|
||||
<li>{t('Open the io.net console API Keys page')}</li>
|
||||
<li>
|
||||
{t(
|
||||
'Set Project to io.cloud when creating/selecting key'
|
||||
)}
|
||||
</li>
|
||||
<li>{t('Copy the key and paste it here')}</li>
|
||||
</ul>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
onClick={() =>
|
||||
window.open('https://ai.io.net/ai/api-keys', '_blank')
|
||||
}
|
||||
>
|
||||
{t('Go to io.net API Keys')}
|
||||
</Button>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
{testState.ok === true ? (
|
||||
<Alert variant='default' className='flex items-center gap-2'>
|
||||
<CheckCircle2 className='size-4 text-green-600' />
|
||||
<div>
|
||||
<AlertTitle>{t('Connection successful')}</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t('Connected to io.net service normally.')}
|
||||
</AlertDescription>
|
||||
</div>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{testState.ok === false && testState.error ? (
|
||||
<Alert
|
||||
variant='destructive'
|
||||
className='flex items-center gap-2'
|
||||
>
|
||||
<XCircle className='size-4' />
|
||||
<div>
|
||||
<AlertTitle>{t('Connection failed')}</AlertTitle>
|
||||
<AlertDescription>{t(testState.error)}</AlertDescription>
|
||||
</div>
|
||||
</Alert>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<Button
|
||||
type='submit'
|
||||
disabled={!isDirty || updateOption.isPending || isSubmitting}
|
||||
>
|
||||
{updateOption.isPending || isSubmitting
|
||||
? t('Saving...')
|
||||
: t('Save io.net settings')}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
</SettingsSection>
|
||||
)
|
||||
}
|
||||
+487
@@ -0,0 +1,487 @@
|
||||
import { useMemo, useRef } from 'react'
|
||||
import * as z from 'zod'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { parseHttpStatusCodeRules } from '@/lib/http-status-code-rules'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { SettingsSection } from '../components/settings-section'
|
||||
import { useResetForm } from '../hooks/use-reset-form'
|
||||
import { useUpdateOption } from '../hooks/use-update-option'
|
||||
|
||||
const numericString = z.string().refine((value) => {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) return true
|
||||
return !Number.isNaN(Number(trimmed)) && Number(trimmed) >= 0
|
||||
}, 'Enter a non-negative number or leave empty')
|
||||
|
||||
const monitoringSchema = z
|
||||
.object({
|
||||
ChannelDisableThreshold: numericString,
|
||||
QuotaRemindThreshold: numericString,
|
||||
AutomaticDisableChannelEnabled: z.boolean(),
|
||||
AutomaticEnableChannelEnabled: z.boolean(),
|
||||
AutomaticDisableKeywords: z.string(),
|
||||
AutomaticDisableStatusCodes: z.string(),
|
||||
AutomaticRetryStatusCodes: z.string(),
|
||||
monitor_setting: z.object({
|
||||
auto_test_channel_enabled: z.boolean(),
|
||||
auto_test_channel_minutes: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.min(1, 'Interval must be at least 1 minute'),
|
||||
}),
|
||||
})
|
||||
.superRefine((values, ctx) => {
|
||||
const disableParsed = parseHttpStatusCodeRules(
|
||||
values.AutomaticDisableStatusCodes
|
||||
)
|
||||
if (!disableParsed.ok) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
path: ['AutomaticDisableStatusCodes'],
|
||||
message: `Invalid status code rules: ${disableParsed.invalidTokens.join(
|
||||
', '
|
||||
)}`,
|
||||
})
|
||||
}
|
||||
|
||||
const retryParsed = parseHttpStatusCodeRules(
|
||||
values.AutomaticRetryStatusCodes
|
||||
)
|
||||
if (!retryParsed.ok) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
path: ['AutomaticRetryStatusCodes'],
|
||||
message: `Invalid status code rules: ${retryParsed.invalidTokens.join(
|
||||
', '
|
||||
)}`,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
type MonitoringFormValues = z.output<typeof monitoringSchema>
|
||||
type MonitoringFormInput = z.input<typeof monitoringSchema>
|
||||
|
||||
type MonitoringSettingsSectionProps = {
|
||||
defaultValues: {
|
||||
ChannelDisableThreshold: string
|
||||
QuotaRemindThreshold: string
|
||||
AutomaticDisableChannelEnabled: boolean
|
||||
AutomaticEnableChannelEnabled: boolean
|
||||
AutomaticDisableKeywords: string
|
||||
AutomaticDisableStatusCodes: string
|
||||
AutomaticRetryStatusCodes: string
|
||||
'monitor_setting.auto_test_channel_enabled': boolean
|
||||
'monitor_setting.auto_test_channel_minutes': number
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeLineEndings(value: string) {
|
||||
return value.replace(/\r\n/g, '\n')
|
||||
}
|
||||
|
||||
type NormalizedMonitoringValues = {
|
||||
ChannelDisableThreshold: string
|
||||
QuotaRemindThreshold: string
|
||||
AutomaticDisableChannelEnabled: boolean
|
||||
AutomaticEnableChannelEnabled: boolean
|
||||
AutomaticDisableKeywords: string
|
||||
AutomaticDisableStatusCodes: string
|
||||
AutomaticRetryStatusCodes: string
|
||||
'monitor_setting.auto_test_channel_enabled': boolean
|
||||
'monitor_setting.auto_test_channel_minutes': number
|
||||
}
|
||||
|
||||
const buildFormDefaults = (
|
||||
defaults: MonitoringSettingsSectionProps['defaultValues']
|
||||
): MonitoringFormInput => ({
|
||||
ChannelDisableThreshold: defaults.ChannelDisableThreshold ?? '',
|
||||
QuotaRemindThreshold: defaults.QuotaRemindThreshold ?? '',
|
||||
AutomaticDisableChannelEnabled: defaults.AutomaticDisableChannelEnabled,
|
||||
AutomaticEnableChannelEnabled: defaults.AutomaticEnableChannelEnabled,
|
||||
AutomaticDisableKeywords: normalizeLineEndings(
|
||||
defaults.AutomaticDisableKeywords ?? ''
|
||||
),
|
||||
AutomaticDisableStatusCodes: defaults.AutomaticDisableStatusCodes ?? '',
|
||||
AutomaticRetryStatusCodes: defaults.AutomaticRetryStatusCodes ?? '',
|
||||
monitor_setting: {
|
||||
auto_test_channel_enabled:
|
||||
defaults['monitor_setting.auto_test_channel_enabled'],
|
||||
auto_test_channel_minutes:
|
||||
defaults['monitor_setting.auto_test_channel_minutes'],
|
||||
},
|
||||
})
|
||||
|
||||
const normalizeDefaults = (
|
||||
defaults: MonitoringSettingsSectionProps['defaultValues']
|
||||
): NormalizedMonitoringValues => ({
|
||||
ChannelDisableThreshold: (defaults.ChannelDisableThreshold ?? '').trim(),
|
||||
QuotaRemindThreshold: (defaults.QuotaRemindThreshold ?? '').trim(),
|
||||
AutomaticDisableChannelEnabled: defaults.AutomaticDisableChannelEnabled,
|
||||
AutomaticEnableChannelEnabled: defaults.AutomaticEnableChannelEnabled,
|
||||
AutomaticDisableKeywords: normalizeLineEndings(
|
||||
defaults.AutomaticDisableKeywords ?? ''
|
||||
),
|
||||
AutomaticDisableStatusCodes: parseHttpStatusCodeRules(
|
||||
defaults.AutomaticDisableStatusCodes ?? ''
|
||||
).normalized,
|
||||
AutomaticRetryStatusCodes: parseHttpStatusCodeRules(
|
||||
defaults.AutomaticRetryStatusCodes ?? ''
|
||||
).normalized,
|
||||
'monitor_setting.auto_test_channel_enabled':
|
||||
defaults['monitor_setting.auto_test_channel_enabled'],
|
||||
'monitor_setting.auto_test_channel_minutes':
|
||||
defaults['monitor_setting.auto_test_channel_minutes'],
|
||||
})
|
||||
|
||||
const normalizeFormValues = (
|
||||
values: MonitoringFormValues
|
||||
): NormalizedMonitoringValues => ({
|
||||
ChannelDisableThreshold: values.ChannelDisableThreshold.trim(),
|
||||
QuotaRemindThreshold: values.QuotaRemindThreshold.trim(),
|
||||
AutomaticDisableChannelEnabled: values.AutomaticDisableChannelEnabled,
|
||||
AutomaticEnableChannelEnabled: values.AutomaticEnableChannelEnabled,
|
||||
AutomaticDisableKeywords: normalizeLineEndings(
|
||||
values.AutomaticDisableKeywords
|
||||
),
|
||||
AutomaticDisableStatusCodes: parseHttpStatusCodeRules(
|
||||
values.AutomaticDisableStatusCodes
|
||||
).normalized,
|
||||
AutomaticRetryStatusCodes: parseHttpStatusCodeRules(
|
||||
values.AutomaticRetryStatusCodes
|
||||
).normalized,
|
||||
'monitor_setting.auto_test_channel_enabled':
|
||||
values.monitor_setting.auto_test_channel_enabled,
|
||||
'monitor_setting.auto_test_channel_minutes':
|
||||
values.monitor_setting.auto_test_channel_minutes,
|
||||
})
|
||||
|
||||
export function MonitoringSettingsSection({
|
||||
defaultValues,
|
||||
}: MonitoringSettingsSectionProps) {
|
||||
const { t } = useTranslation()
|
||||
const updateOption = useUpdateOption()
|
||||
const baselineRef = useRef<NormalizedMonitoringValues>(
|
||||
normalizeDefaults(defaultValues)
|
||||
)
|
||||
|
||||
const formDefaults = useMemo(
|
||||
() => buildFormDefaults(defaultValues),
|
||||
[defaultValues]
|
||||
)
|
||||
|
||||
const form = useForm<MonitoringFormInput, unknown, MonitoringFormValues>({
|
||||
resolver: zodResolver(monitoringSchema),
|
||||
defaultValues: formDefaults,
|
||||
})
|
||||
|
||||
useResetForm(form, formDefaults)
|
||||
|
||||
const autoDisableStatusCodes = form.watch('AutomaticDisableStatusCodes')
|
||||
const autoRetryStatusCodes = form.watch('AutomaticRetryStatusCodes')
|
||||
const autoDisableParsed = useMemo(
|
||||
() => parseHttpStatusCodeRules(autoDisableStatusCodes),
|
||||
[autoDisableStatusCodes]
|
||||
)
|
||||
const autoRetryParsed = useMemo(
|
||||
() => parseHttpStatusCodeRules(autoRetryStatusCodes),
|
||||
[autoRetryStatusCodes]
|
||||
)
|
||||
|
||||
const onSubmit = async (values: MonitoringFormValues) => {
|
||||
const normalized = normalizeFormValues(values)
|
||||
const updates = (
|
||||
Object.keys(normalized) as Array<keyof NormalizedMonitoringValues>
|
||||
).filter((key) => normalized[key] !== baselineRef.current[key])
|
||||
|
||||
if (updates.length === 0) {
|
||||
toast.info(t('No changes to save'))
|
||||
return
|
||||
}
|
||||
|
||||
for (const key of updates) {
|
||||
const value = normalized[key]
|
||||
await updateOption.mutateAsync({
|
||||
key,
|
||||
value,
|
||||
})
|
||||
}
|
||||
|
||||
baselineRef.current = normalized
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
title={t('Monitoring & Alerts')}
|
||||
description={t(
|
||||
'Automatically test channels and notify users when limits are hit'
|
||||
)}
|
||||
>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className='space-y-6'>
|
||||
<div className='grid gap-6 md:grid-cols-2'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='monitor_setting.auto_test_channel_enabled'
|
||||
render={({ field }) => (
|
||||
<FormItem className='flex flex-row items-center justify-between rounded-lg border p-4'>
|
||||
<div className='space-y-0.5'>
|
||||
<FormLabel className='text-base'>
|
||||
{t('Scheduled channel tests')}
|
||||
</FormLabel>
|
||||
<FormDescription>
|
||||
{t('Automatically probe all channels in the background')}
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='monitor_setting.auto_test_channel_minutes'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Test interval (minutes)')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type='number'
|
||||
min={1}
|
||||
step={1}
|
||||
value={
|
||||
typeof field.value === 'number' &&
|
||||
Number.isFinite(field.value)
|
||||
? field.value
|
||||
: ''
|
||||
}
|
||||
onChange={(event) =>
|
||||
field.onChange(event.target.valueAsNumber)
|
||||
}
|
||||
name={field.name}
|
||||
onBlur={field.onBlur}
|
||||
ref={field.ref}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('How frequently the system tests all channels')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='grid gap-6 md:grid-cols-2'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='ChannelDisableThreshold'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Disable threshold (seconds)')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type='number'
|
||||
min={0}
|
||||
step={1}
|
||||
value={field.value}
|
||||
onChange={(event) => field.onChange(event.target.value)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
'Automatically disable channels exceeding this response time'
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='QuotaRemindThreshold'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Quota reminder (tokens)')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type='number'
|
||||
min={0}
|
||||
step={1}
|
||||
value={field.value}
|
||||
onChange={(event) => field.onChange(event.target.value)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('Send email alerts when a user falls below this quota')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='grid gap-6 md:grid-cols-2'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='AutomaticDisableChannelEnabled'
|
||||
render={({ field }) => (
|
||||
<FormItem className='flex flex-row items-center justify-between rounded-lg border p-4'>
|
||||
<div className='space-y-0.5'>
|
||||
<FormLabel className='text-base'>
|
||||
{t('Disable on failure')}
|
||||
</FormLabel>
|
||||
<FormDescription>
|
||||
{t('Automatically disable channels when tests fail')}
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='AutomaticEnableChannelEnabled'
|
||||
render={({ field }) => (
|
||||
<FormItem className='flex flex-row items-center justify-between rounded-lg border p-4'>
|
||||
<div className='space-y-0.5'>
|
||||
<FormLabel className='text-base'>
|
||||
{t('Re-enable on success')}
|
||||
</FormLabel>
|
||||
<FormDescription>
|
||||
{t('Bring channels back online after successful checks')}
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='AutomaticDisableKeywords'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Failure keywords')}</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
rows={6}
|
||||
placeholder={t('one keyword per line')}
|
||||
{...field}
|
||||
onChange={(event) => field.onChange(event.target.value)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
'If an upstream error contains any of these keywords (case insensitive), the channel will be disabled automatically.'
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className='grid gap-6 md:grid-cols-2'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='AutomaticDisableStatusCodes'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Auto-disable status codes')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t('e.g. 401, 403, 429, 500-599')}
|
||||
value={field.value}
|
||||
onChange={(event) => field.onChange(event.target.value)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
'Accepts comma-separated status codes and inclusive ranges.'
|
||||
)}{' '}
|
||||
{autoDisableParsed.ok &&
|
||||
autoDisableParsed.normalized &&
|
||||
autoDisableParsed.normalized !== field.value.trim() && (
|
||||
<span className='text-muted-foreground'>
|
||||
{t('Normalized:')} {autoDisableParsed.normalized}
|
||||
</span>
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='AutomaticRetryStatusCodes'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Auto-retry status codes')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t('e.g. 401, 403, 429, 500-599')}
|
||||
value={field.value}
|
||||
onChange={(event) => field.onChange(event.target.value)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
'Accepts comma-separated status codes and inclusive ranges.'
|
||||
)}{' '}
|
||||
{autoRetryParsed.ok &&
|
||||
autoRetryParsed.normalized &&
|
||||
autoRetryParsed.normalized !== field.value.trim() && (
|
||||
<span className='text-muted-foreground'>
|
||||
{t('Normalized:')} {autoRetryParsed.normalized}
|
||||
</span>
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button type='submit' disabled={updateOption.isPending}>
|
||||
{updateOption.isPending
|
||||
? t('Saving...')
|
||||
: t('Save monitoring rules')}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
</SettingsSection>
|
||||
)
|
||||
}
|
||||
+284
@@ -0,0 +1,284 @@
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import * as z from 'zod'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Combobox } from '@/components/ui/combobox'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import { Input } from '@/components/ui/input'
|
||||
|
||||
const createPaymentMethodDialogSchema = (t: (key: string) => string) =>
|
||||
z.object({
|
||||
name: z.string().min(1, t('Payment method name is required')),
|
||||
type: z.string().min(1, t('Payment method type is required')),
|
||||
color: z.string().min(1, t('Color is required')),
|
||||
min_topup: z.string().optional(),
|
||||
})
|
||||
|
||||
type PaymentMethodDialogFormValues = z.infer<
|
||||
ReturnType<typeof createPaymentMethodDialogSchema>
|
||||
>
|
||||
|
||||
export type PaymentMethodData = {
|
||||
name: string
|
||||
type: string
|
||||
color: string
|
||||
min_topup?: string
|
||||
}
|
||||
|
||||
type PaymentMethodDialogProps = {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onSave: (data: PaymentMethodData) => void
|
||||
editData?: PaymentMethodData | null
|
||||
}
|
||||
|
||||
const PAYMENT_TYPES = [
|
||||
{ value: 'alipay', label: 'Alipay' },
|
||||
{ value: 'wxpay', label: 'WeChat Pay' },
|
||||
{ value: 'stripe', label: 'Stripe' },
|
||||
]
|
||||
|
||||
const getColorPreview = (color: string) => {
|
||||
if (color.includes('var(--')) {
|
||||
return null
|
||||
}
|
||||
return color
|
||||
}
|
||||
|
||||
const COLOR_PRESETS = [
|
||||
{ value: '#1677FF', label: 'Blue (Alipay)' },
|
||||
{ value: '#07C160', label: 'Green (WeChat)' },
|
||||
{ value: '#635BFF', label: 'Purple (Stripe)' },
|
||||
{ value: '#1890FF', label: 'Sky Blue' },
|
||||
{ value: '#52C41A', label: 'Lime Green' },
|
||||
{ value: 'black', label: 'Black' },
|
||||
{ value: '#FF4D4F', label: 'Red' },
|
||||
{ value: '#FFA940', label: 'Orange' },
|
||||
].map((preset) => {
|
||||
const previewColor = getColorPreview(preset.value)
|
||||
return {
|
||||
...preset,
|
||||
icon: previewColor ? (
|
||||
<div
|
||||
className='size-4 rounded border'
|
||||
style={{ backgroundColor: previewColor }}
|
||||
/>
|
||||
) : (
|
||||
<div className='bg-muted size-4 rounded border' />
|
||||
),
|
||||
}
|
||||
})
|
||||
|
||||
export function PaymentMethodDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onSave,
|
||||
editData,
|
||||
}: PaymentMethodDialogProps) {
|
||||
const { t } = useTranslation()
|
||||
const isEditMode = !!editData
|
||||
const paymentMethodDialogSchema = createPaymentMethodDialogSchema(t)
|
||||
|
||||
const form = useForm<PaymentMethodDialogFormValues>({
|
||||
resolver: zodResolver(paymentMethodDialogSchema),
|
||||
defaultValues: {
|
||||
name: '',
|
||||
type: '',
|
||||
color: '',
|
||||
min_topup: '',
|
||||
},
|
||||
})
|
||||
|
||||
const colorValue = form.watch('color')
|
||||
|
||||
const colorPreview = useMemo(() => {
|
||||
if (!colorValue) return null
|
||||
try {
|
||||
// For CSS variables like rgba(var(--semi-blue-5), 1), we can't preview accurately
|
||||
// but we can detect common patterns
|
||||
if (colorValue.includes('var(--')) {
|
||||
return null // Can't preview CSS variables reliably
|
||||
}
|
||||
return colorValue
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}, [colorValue])
|
||||
|
||||
useEffect(() => {
|
||||
if (editData) {
|
||||
form.reset(editData)
|
||||
} else {
|
||||
form.reset({
|
||||
name: '',
|
||||
type: '',
|
||||
color: '',
|
||||
min_topup: '',
|
||||
})
|
||||
}
|
||||
}, [editData, form, open])
|
||||
|
||||
const handleSubmit = (values: PaymentMethodDialogFormValues) => {
|
||||
const data: PaymentMethodData = {
|
||||
name: values.name,
|
||||
type: values.type,
|
||||
color: values.color,
|
||||
}
|
||||
if (values.min_topup && values.min_topup.trim() !== '') {
|
||||
data.min_topup = values.min_topup
|
||||
}
|
||||
onSave(data)
|
||||
form.reset()
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className='sm:max-w-[500px]'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{isEditMode ? t('Edit payment method') : t('Add payment method')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('Configure a payment method for user recharge options.')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(handleSubmit)}
|
||||
className='space-y-4'
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='name'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Name')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder={t('e.g., Alipay, WeChat')} {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('Display name for this payment method.')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='type'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Type')}</FormLabel>
|
||||
<FormControl>
|
||||
<Combobox
|
||||
options={PAYMENT_TYPES}
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
placeholder={t('Select or enter payment type')}
|
||||
searchPlaceholder={t('Search payment types...')}
|
||||
allowCustomValue
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('Select from presets or type custom identifier.')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='color'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Color')}</FormLabel>
|
||||
<FormControl>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Combobox
|
||||
options={COLOR_PRESETS}
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
placeholder={t('Select or enter color value')}
|
||||
searchPlaceholder={t('Search colors...')}
|
||||
allowCustomValue
|
||||
className='flex-1'
|
||||
/>
|
||||
{colorPreview && (
|
||||
<div
|
||||
className='size-9 shrink-0 rounded border'
|
||||
style={{ backgroundColor: colorPreview }}
|
||||
title={colorPreview}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('Select preset or enter custom CSS color value.')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='min_topup'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Minimum top-up (optional)')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type='number'
|
||||
step='0.01'
|
||||
placeholder={t('e.g., 50')}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('Optional minimum recharge amount for this method.')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
<Button type='submit'>
|
||||
{isEditMode ? t('Update') : t('Add')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
+438
@@ -0,0 +1,438 @@
|
||||
import { useState, useMemo } from 'react'
|
||||
import { Lightbulb, Pencil, Plus, Search, Trash2 } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { safeJsonParseWithValidation } from '../utils/json-parser'
|
||||
import { isArray } from '../utils/json-validators'
|
||||
import {
|
||||
PaymentMethodDialog,
|
||||
type PaymentMethodData,
|
||||
} from './payment-method-dialog'
|
||||
|
||||
type PaymentMethodsVisualEditorProps = {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
}
|
||||
|
||||
const PAYMENT_TEMPLATES = [
|
||||
{
|
||||
name: 'Alipay',
|
||||
template: {
|
||||
color: 'rgba(var(--semi-blue-5), 1)',
|
||||
name: '支付宝',
|
||||
type: 'alipay',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'WeChat Pay',
|
||||
template: {
|
||||
color: 'rgba(var(--semi-green-5), 1)',
|
||||
name: '微信',
|
||||
type: 'wxpay',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Stripe',
|
||||
template: {
|
||||
color: 'rgba(var(--semi-green-5), 1)',
|
||||
name: 'Stripe',
|
||||
type: 'stripe',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Custom',
|
||||
template: {
|
||||
color: 'black',
|
||||
min_topup: '50',
|
||||
name: '自定义1',
|
||||
type: 'custom1',
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
export function PaymentMethodsVisualEditor({
|
||||
value,
|
||||
onChange,
|
||||
}: PaymentMethodsVisualEditorProps) {
|
||||
const { t } = useTranslation()
|
||||
const [searchText, setSearchText] = useState('')
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [editData, setEditData] = useState<PaymentMethodData | null>(null)
|
||||
|
||||
const paymentMethods = useMemo(() => {
|
||||
const parsed = safeJsonParseWithValidation<unknown[]>(value, {
|
||||
fallback: [],
|
||||
validator: isArray,
|
||||
validatorMessage: 'Payment methods must be a JSON array',
|
||||
context: 'payment methods',
|
||||
})
|
||||
|
||||
return parsed.filter(
|
||||
(item): item is PaymentMethodData =>
|
||||
typeof item === 'object' &&
|
||||
item !== null &&
|
||||
'name' in item &&
|
||||
'type' in item &&
|
||||
'color' in item &&
|
||||
typeof item.name === 'string' &&
|
||||
typeof item.type === 'string' &&
|
||||
typeof item.color === 'string'
|
||||
)
|
||||
}, [value])
|
||||
|
||||
const filteredMethods = useMemo(() => {
|
||||
if (!searchText) return paymentMethods
|
||||
const lowerSearch = searchText.toLowerCase()
|
||||
return paymentMethods.filter(
|
||||
(method) =>
|
||||
method.name.toLowerCase().includes(lowerSearch) ||
|
||||
method.type.toLowerCase().includes(lowerSearch)
|
||||
)
|
||||
}, [paymentMethods, searchText])
|
||||
|
||||
const handleSave = (data: PaymentMethodData) => {
|
||||
const parsed = safeJsonParseWithValidation<unknown[]>(value, {
|
||||
fallback: [],
|
||||
validator: isArray,
|
||||
silent: true,
|
||||
})
|
||||
|
||||
const updatedArray = [...parsed]
|
||||
|
||||
if (editData) {
|
||||
const index = updatedArray.findIndex(
|
||||
(item): item is PaymentMethodData =>
|
||||
typeof item === 'object' &&
|
||||
item !== null &&
|
||||
'name' in item &&
|
||||
'type' in item &&
|
||||
item.name === editData.name &&
|
||||
item.type === editData.type
|
||||
)
|
||||
if (index !== -1) {
|
||||
updatedArray[index] = data
|
||||
} else {
|
||||
updatedArray.push(data)
|
||||
}
|
||||
} else {
|
||||
updatedArray.push(data)
|
||||
}
|
||||
|
||||
onChange(JSON.stringify(updatedArray, null, 2))
|
||||
}
|
||||
|
||||
const handleDelete = (method: PaymentMethodData) => {
|
||||
const parsed = safeJsonParseWithValidation<unknown[]>(value, {
|
||||
fallback: [],
|
||||
validator: isArray,
|
||||
silent: true,
|
||||
})
|
||||
|
||||
const updatedArray = parsed.filter(
|
||||
(item) =>
|
||||
!(
|
||||
typeof item === 'object' &&
|
||||
item !== null &&
|
||||
'name' in item &&
|
||||
'type' in item &&
|
||||
item.name === method.name &&
|
||||
item.type === method.type
|
||||
)
|
||||
)
|
||||
|
||||
onChange(JSON.stringify(updatedArray, null, 2))
|
||||
}
|
||||
|
||||
const handleEdit = (method: PaymentMethodData) => {
|
||||
setEditData(method)
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
const handleAdd = () => {
|
||||
setEditData(null)
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
const handleInsertTemplate = (template: PaymentMethodData) => {
|
||||
const parsed = safeJsonParseWithValidation<unknown[]>(value, {
|
||||
fallback: [],
|
||||
validator: isArray,
|
||||
silent: true,
|
||||
})
|
||||
|
||||
// Check if template already exists
|
||||
const exists = parsed.some(
|
||||
(item) =>
|
||||
typeof item === 'object' &&
|
||||
item !== null &&
|
||||
'type' in item &&
|
||||
'name' in item &&
|
||||
item.type === template.type &&
|
||||
item.name === template.name
|
||||
)
|
||||
|
||||
if (!exists) {
|
||||
parsed.push(template)
|
||||
onChange(JSON.stringify(parsed, null, 2))
|
||||
}
|
||||
}
|
||||
|
||||
const getColorPreview = (color: string) => {
|
||||
// For CSS variables, show a placeholder
|
||||
if (color.includes('var(--')) {
|
||||
return null
|
||||
}
|
||||
return color
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='space-y-4'>
|
||||
<div className='flex flex-col gap-3 sm:flex-row sm:items-center'>
|
||||
<div className='relative flex-1'>
|
||||
<Search className='text-muted-foreground absolute top-2.5 left-2.5 h-4 w-4' />
|
||||
<Input
|
||||
placeholder={t('Search payment methods...')}
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
className='pl-9'
|
||||
/>
|
||||
</div>
|
||||
<div className='flex gap-2'>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant='outline' className='flex-1 sm:flex-none'>
|
||||
<Lightbulb className='h-4 w-4 sm:mr-2' />
|
||||
<span className='sm:inline'>{t('Templates')}</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className='w-60'>
|
||||
<div className='space-y-2'>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{t('Quick insert common payment methods')}
|
||||
</p>
|
||||
<div className='space-y-1'>
|
||||
{PAYMENT_TEMPLATES.map((item) => (
|
||||
<Button
|
||||
key={item.name}
|
||||
type='button'
|
||||
variant='ghost'
|
||||
className='w-full justify-start text-sm'
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
handleInsertTemplate(item.template)
|
||||
}}
|
||||
>
|
||||
<Plus className='mr-2 h-3 w-3' />
|
||||
{item.name}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<Button
|
||||
type='button'
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
handleAdd()
|
||||
}}
|
||||
className='flex-1 sm:flex-none'
|
||||
>
|
||||
<Plus className='h-4 w-4 sm:mr-2' />
|
||||
<span className='sm:inline'>{t('Add method')}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filteredMethods.length === 0 ? (
|
||||
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center text-sm'>
|
||||
{searchText
|
||||
? t('No payment methods match your search')
|
||||
: t(
|
||||
'No payment methods configured. Click "Add method" or use templates to get started.'
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className='rounded-md border'>
|
||||
{/* Desktop table view */}
|
||||
<div className='hidden md:block'>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t('Name')}</TableHead>
|
||||
<TableHead>{t('Type')}</TableHead>
|
||||
<TableHead>{t('Color')}</TableHead>
|
||||
<TableHead>{t('Min Top-up')}</TableHead>
|
||||
<TableHead className='text-right'>{t('Actions')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredMethods.map((method, index) => {
|
||||
const colorPreview = getColorPreview(method.color)
|
||||
return (
|
||||
<TableRow key={`${method.type}-${index}`}>
|
||||
<TableCell className='font-medium'>
|
||||
{method.name}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<code className='bg-muted rounded px-1.5 py-0.5 text-xs'>
|
||||
{method.type}
|
||||
</code>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className='flex items-center gap-2'>
|
||||
{colorPreview && (
|
||||
<div
|
||||
className='size-5 shrink-0 rounded border'
|
||||
style={{ backgroundColor: colorPreview }}
|
||||
/>
|
||||
)}
|
||||
<span className='text-muted-foreground truncate font-mono text-xs'>
|
||||
{method.color}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{method.min_topup ? (
|
||||
<span className='font-mono text-sm'>
|
||||
{method.min_topup}
|
||||
</span>
|
||||
) : (
|
||||
<span className='text-muted-foreground text-sm'>
|
||||
—
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<div className='flex justify-end gap-2'>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
handleEdit(method)
|
||||
}}
|
||||
>
|
||||
<Pencil className='h-4 w-4' />
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
handleDelete(method)
|
||||
}}
|
||||
>
|
||||
<Trash2 className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* Mobile card view */}
|
||||
<div className='divide-y md:hidden'>
|
||||
{filteredMethods.map((method, index) => {
|
||||
const colorPreview = getColorPreview(method.color)
|
||||
return (
|
||||
<div key={`${method.type}-${index}`} className='p-4'>
|
||||
<div className='mb-3 flex items-start justify-between'>
|
||||
<div className='flex-1'>
|
||||
<div className='mb-1 font-medium'>{method.name}</div>
|
||||
<code className='bg-muted rounded px-1.5 py-0.5 text-xs'>
|
||||
{method.type}
|
||||
</code>
|
||||
</div>
|
||||
<div className='flex gap-1'>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
handleEdit(method)
|
||||
}}
|
||||
>
|
||||
<Pencil className='h-4 w-4' />
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
handleDelete(method)
|
||||
}}
|
||||
>
|
||||
<Trash2 className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className='space-y-2 text-sm'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<span className='text-muted-foreground min-w-20'>
|
||||
{t('Color:')}
|
||||
</span>
|
||||
<div className='flex items-center gap-2'>
|
||||
{colorPreview && (
|
||||
<div
|
||||
className='size-5 shrink-0 rounded border'
|
||||
style={{ backgroundColor: colorPreview }}
|
||||
/>
|
||||
)}
|
||||
<span className='text-muted-foreground truncate font-mono text-xs'>
|
||||
{method.color}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{method.min_topup && (
|
||||
<div className='flex items-center gap-2'>
|
||||
<span className='text-muted-foreground min-w-20'>
|
||||
{t('Min Top-up:')}
|
||||
</span>
|
||||
<span className='font-mono'>{method.min_topup}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<PaymentMethodDialog
|
||||
open={dialogOpen}
|
||||
onOpenChange={setDialogOpen}
|
||||
onSave={handleSave}
|
||||
editData={editData}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+1297
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,160 @@
|
||||
import type { IntegrationSettings } from '../types'
|
||||
import { createSectionRegistry } from '../utils/section-registry'
|
||||
import { EmailSettingsSection } from './email-settings-section'
|
||||
import { IoNetDeploymentSettingsSection } from './ionet-deployment-settings-section'
|
||||
import { MonitoringSettingsSection } from './monitoring-settings-section'
|
||||
import { PaymentSettingsSection } from './payment-settings-section'
|
||||
import { WorkerSettingsSection } from './worker-settings-section'
|
||||
|
||||
const INTEGRATIONS_SECTIONS = [
|
||||
{
|
||||
id: 'payment',
|
||||
titleKey: 'Payment Gateway',
|
||||
descriptionKey: 'Configure payment gateway integrations',
|
||||
build: (settings: IntegrationSettings) => (
|
||||
<PaymentSettingsSection
|
||||
defaultValues={{
|
||||
PayAddress: settings.PayAddress,
|
||||
EpayId: settings.EpayId,
|
||||
EpayKey: settings.EpayKey,
|
||||
Price: settings.Price,
|
||||
MinTopUp: settings.MinTopUp,
|
||||
CustomCallbackAddress: settings.CustomCallbackAddress,
|
||||
PayMethods: settings.PayMethods,
|
||||
AmountOptions: settings['payment_setting.amount_options'],
|
||||
AmountDiscount: settings['payment_setting.amount_discount'],
|
||||
StripeApiSecret: settings.StripeApiSecret,
|
||||
StripeWebhookSecret: settings.StripeWebhookSecret,
|
||||
StripePriceId: settings.StripePriceId,
|
||||
StripeUnitPrice: settings.StripeUnitPrice,
|
||||
StripeMinTopUp: settings.StripeMinTopUp,
|
||||
StripePromotionCodesEnabled: settings.StripePromotionCodesEnabled,
|
||||
CreemApiKey: settings.CreemApiKey,
|
||||
CreemWebhookSecret: settings.CreemWebhookSecret,
|
||||
CreemTestMode: settings.CreemTestMode,
|
||||
CreemProducts: settings.CreemProducts,
|
||||
}}
|
||||
waffoDefaultValues={{
|
||||
WaffoEnabled: settings.WaffoEnabled ?? false,
|
||||
WaffoApiKey: settings.WaffoApiKey ?? '',
|
||||
WaffoPrivateKey: settings.WaffoPrivateKey ?? '',
|
||||
WaffoPublicCert: settings.WaffoPublicCert ?? '',
|
||||
WaffoSandboxPublicCert: settings.WaffoSandboxPublicCert ?? '',
|
||||
WaffoSandboxApiKey: settings.WaffoSandboxApiKey ?? '',
|
||||
WaffoSandboxPrivateKey: settings.WaffoSandboxPrivateKey ?? '',
|
||||
WaffoSandbox: settings.WaffoSandbox ?? false,
|
||||
WaffoMerchantId: settings.WaffoMerchantId ?? '',
|
||||
WaffoCurrency: settings.WaffoCurrency ?? 'USD',
|
||||
WaffoUnitPrice: settings.WaffoUnitPrice ?? 1,
|
||||
WaffoMinTopUp: settings.WaffoMinTopUp ?? 1,
|
||||
WaffoNotifyUrl: settings.WaffoNotifyUrl ?? '',
|
||||
WaffoReturnUrl: settings.WaffoReturnUrl ?? '',
|
||||
WaffoPayMethods: settings.WaffoPayMethods ?? '[]',
|
||||
}}
|
||||
waffoPancakeDefaultValues={{
|
||||
WaffoPancakeEnabled: settings.WaffoPancakeEnabled ?? false,
|
||||
WaffoPancakeSandbox: settings.WaffoPancakeSandbox ?? false,
|
||||
WaffoPancakeMerchantID: settings.WaffoPancakeMerchantID ?? '',
|
||||
WaffoPancakePrivateKey: settings.WaffoPancakePrivateKey ?? '',
|
||||
WaffoPancakeWebhookPublicKey:
|
||||
settings.WaffoPancakeWebhookPublicKey ?? '',
|
||||
WaffoPancakeWebhookTestKey: settings.WaffoPancakeWebhookTestKey ?? '',
|
||||
WaffoPancakeStoreID: settings.WaffoPancakeStoreID ?? '',
|
||||
WaffoPancakeProductID: settings.WaffoPancakeProductID ?? '',
|
||||
WaffoPancakeReturnURL: settings.WaffoPancakeReturnURL ?? '',
|
||||
WaffoPancakeCurrency: settings.WaffoPancakeCurrency ?? 'USD',
|
||||
WaffoPancakeUnitPrice: settings.WaffoPancakeUnitPrice ?? 1,
|
||||
WaffoPancakeMinTopUp: settings.WaffoPancakeMinTopUp ?? 1,
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'email',
|
||||
titleKey: 'SMTP Email',
|
||||
descriptionKey: 'Configure SMTP email settings',
|
||||
build: (settings: IntegrationSettings) => (
|
||||
<EmailSettingsSection
|
||||
defaultValues={{
|
||||
SMTPServer: settings.SMTPServer,
|
||||
SMTPPort: settings.SMTPPort,
|
||||
SMTPAccount: settings.SMTPAccount,
|
||||
SMTPFrom: settings.SMTPFrom,
|
||||
SMTPToken: settings.SMTPToken,
|
||||
SMTPSSLEnabled: settings.SMTPSSLEnabled,
|
||||
SMTPForceAuthLogin: settings.SMTPForceAuthLogin,
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'worker',
|
||||
titleKey: 'Worker Proxy',
|
||||
descriptionKey: 'Configure worker service settings',
|
||||
build: (settings: IntegrationSettings) => (
|
||||
<WorkerSettingsSection
|
||||
defaultValues={{
|
||||
WorkerUrl: settings.WorkerUrl,
|
||||
WorkerValidKey: settings.WorkerValidKey,
|
||||
WorkerAllowHttpImageRequestEnabled:
|
||||
settings.WorkerAllowHttpImageRequestEnabled,
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'ionet',
|
||||
titleKey: 'io.net Deployments',
|
||||
descriptionKey: 'Configure IoNet model deployment settings',
|
||||
build: (settings: IntegrationSettings) => (
|
||||
<IoNetDeploymentSettingsSection
|
||||
defaultValues={{
|
||||
enabled: settings['model_deployment.ionet.enabled'],
|
||||
apiKey: settings['model_deployment.ionet.api_key'],
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'monitoring',
|
||||
titleKey: 'Monitoring & Alerts',
|
||||
descriptionKey: 'Configure channel monitoring and automation',
|
||||
build: (settings: IntegrationSettings) => (
|
||||
<MonitoringSettingsSection
|
||||
defaultValues={{
|
||||
ChannelDisableThreshold: settings.ChannelDisableThreshold,
|
||||
QuotaRemindThreshold: settings.QuotaRemindThreshold,
|
||||
AutomaticDisableChannelEnabled:
|
||||
settings.AutomaticDisableChannelEnabled,
|
||||
AutomaticEnableChannelEnabled: settings.AutomaticEnableChannelEnabled,
|
||||
AutomaticDisableKeywords: settings.AutomaticDisableKeywords,
|
||||
AutomaticDisableStatusCodes: settings.AutomaticDisableStatusCodes,
|
||||
AutomaticRetryStatusCodes: settings.AutomaticRetryStatusCodes,
|
||||
'monitor_setting.auto_test_channel_enabled':
|
||||
settings['monitor_setting.auto_test_channel_enabled'],
|
||||
'monitor_setting.auto_test_channel_minutes':
|
||||
settings['monitor_setting.auto_test_channel_minutes'],
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
] as const
|
||||
|
||||
export type IntegrationSectionId = (typeof INTEGRATIONS_SECTIONS)[number]['id']
|
||||
|
||||
const integrationsRegistry = createSectionRegistry<
|
||||
IntegrationSectionId,
|
||||
IntegrationSettings
|
||||
>({
|
||||
sections: INTEGRATIONS_SECTIONS,
|
||||
defaultSection: 'payment',
|
||||
basePath: '/system-settings/integrations',
|
||||
urlStyle: 'path',
|
||||
})
|
||||
|
||||
export const INTEGRATIONS_SECTION_IDS = integrationsRegistry.sectionIds
|
||||
export const INTEGRATIONS_DEFAULT_SECTION = integrationsRegistry.defaultSection
|
||||
export const getIntegrationsSectionNavItems =
|
||||
integrationsRegistry.getSectionNavItems
|
||||
export const getIntegrationsSectionContent =
|
||||
integrationsRegistry.getSectionContent
|
||||
@@ -0,0 +1,110 @@
|
||||
export function removeTrailingSlash(value: string) {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) return ''
|
||||
return trimmed.replace(/\/+$/, '')
|
||||
}
|
||||
|
||||
export function formatJsonForEditor(value: string) {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) return ''
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(trimmed), null, 2)
|
||||
} catch {
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeJsonForComparison(value: string) {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) return ''
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(trimmed))
|
||||
} catch {
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
|
||||
function extractErrorPosition(
|
||||
error: unknown,
|
||||
jsonString: string
|
||||
): { line?: number; column?: number } {
|
||||
if (!(error instanceof Error)) return {}
|
||||
|
||||
const message = error.message
|
||||
const positionMatch = message.match(/at position (\d+)/i)
|
||||
|
||||
if (positionMatch) {
|
||||
const position = parseInt(positionMatch[1], 10)
|
||||
const lines = jsonString.substring(0, position).split('\n')
|
||||
return {
|
||||
line: lines.length,
|
||||
column: lines[lines.length - 1].length + 1,
|
||||
}
|
||||
}
|
||||
|
||||
const lineColMatch = message.match(/at line (\d+) column (\d+)/i)
|
||||
if (lineColMatch) {
|
||||
return {
|
||||
line: parseInt(lineColMatch[1], 10),
|
||||
column: parseInt(lineColMatch[2], 10),
|
||||
}
|
||||
}
|
||||
|
||||
return {}
|
||||
}
|
||||
|
||||
function formatJsonError(error: unknown, jsonString: string): string {
|
||||
if (!(error instanceof Error)) return 'Invalid JSON'
|
||||
|
||||
const position = extractErrorPosition(error, jsonString)
|
||||
const message = error.message
|
||||
|
||||
const isMissingCommaError =
|
||||
message.includes("Expected ','") ||
|
||||
message.includes('Expected property name') ||
|
||||
message.includes('Unexpected string')
|
||||
|
||||
if (position.line && position.column) {
|
||||
let hint = ''
|
||||
if (isMissingCommaError && position.line > 1) {
|
||||
hint = ` (check line ${position.line - 1} for missing comma)`
|
||||
}
|
||||
return `Error at line ${position.line}, column ${position.column}: ${message}${hint}`
|
||||
}
|
||||
|
||||
return message
|
||||
}
|
||||
|
||||
export function isValidJson(
|
||||
value: string,
|
||||
predicate?: (parsed: unknown) => boolean
|
||||
): boolean {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) return true
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed)
|
||||
if (predicate && !predicate(parsed)) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function getJsonError(
|
||||
value: string,
|
||||
predicate?: (parsed: unknown) => boolean
|
||||
): string | null {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) return null
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed)
|
||||
if (predicate && !predicate(parsed)) {
|
||||
return 'JSON structure is invalid'
|
||||
}
|
||||
return null
|
||||
} catch (error) {
|
||||
return formatJsonError(error, trimmed)
|
||||
}
|
||||
}
|
||||
+290
@@ -0,0 +1,290 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { SettingsSection } from '../components/settings-section'
|
||||
import { useUpdateOption } from '../hooks/use-update-option'
|
||||
import { removeTrailingSlash } from './utils'
|
||||
|
||||
export interface WaffoPancakeSettingsValues {
|
||||
WaffoPancakeEnabled: boolean
|
||||
WaffoPancakeSandbox: boolean
|
||||
WaffoPancakeMerchantID: string
|
||||
WaffoPancakePrivateKey: string
|
||||
WaffoPancakeWebhookPublicKey: string
|
||||
WaffoPancakeWebhookTestKey: string
|
||||
WaffoPancakeStoreID: string
|
||||
WaffoPancakeProductID: string
|
||||
WaffoPancakeReturnURL: string
|
||||
WaffoPancakeCurrency: string
|
||||
WaffoPancakeUnitPrice: number
|
||||
WaffoPancakeMinTopUp: number
|
||||
}
|
||||
|
||||
interface Props {
|
||||
defaultValues: WaffoPancakeSettingsValues
|
||||
}
|
||||
|
||||
export function WaffoPancakeSettingsSection(props: Props) {
|
||||
const { t } = useTranslation()
|
||||
const updateOption = useUpdateOption()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const form = useForm<WaffoPancakeSettingsValues>({
|
||||
defaultValues: props.defaultValues,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
form.reset(props.defaultValues)
|
||||
}, [props.defaultValues, form])
|
||||
|
||||
const handleSave = async () => {
|
||||
const values = form.getValues()
|
||||
const enabled = !!values.WaffoPancakeEnabled
|
||||
const sandbox = !!values.WaffoPancakeSandbox
|
||||
|
||||
if (enabled && !values.WaffoPancakeMerchantID.trim()) {
|
||||
toast.error(t('Merchant ID is required'))
|
||||
return
|
||||
}
|
||||
|
||||
if (enabled && !values.WaffoPancakeStoreID.trim()) {
|
||||
toast.error(t('Store ID is required'))
|
||||
return
|
||||
}
|
||||
|
||||
if (enabled && !values.WaffoPancakeProductID.trim()) {
|
||||
toast.error(t('Product ID is required'))
|
||||
return
|
||||
}
|
||||
|
||||
const requiredWebhookKey = sandbox
|
||||
? values.WaffoPancakeWebhookTestKey
|
||||
: values.WaffoPancakeWebhookPublicKey
|
||||
if (enabled && !String(requiredWebhookKey || '').trim()) {
|
||||
toast.error(
|
||||
sandbox
|
||||
? t('Webhook public key (sandbox) is required')
|
||||
: t('Webhook public key (production) is required')
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (enabled && Number(values.WaffoPancakeUnitPrice) <= 0) {
|
||||
toast.error(t('Unit price must be greater than 0'))
|
||||
return
|
||||
}
|
||||
|
||||
if (enabled && Number(values.WaffoPancakeMinTopUp) < 1) {
|
||||
toast.error(t('Minimum top-up amount must be at least 1'))
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
const options: { key: string; value: string }[] = [
|
||||
{ key: 'WaffoPancakeEnabled', value: enabled ? 'true' : 'false' },
|
||||
{ key: 'WaffoPancakeSandbox', value: sandbox ? '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 ?? 1),
|
||||
},
|
||||
{
|
||||
key: 'WaffoPancakeMinTopUp',
|
||||
value: String(values.WaffoPancakeMinTopUp ?? 1),
|
||||
},
|
||||
]
|
||||
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
for (const option of options) {
|
||||
await updateOption.mutateAsync(option)
|
||||
}
|
||||
toast.success(t('Updated successfully'))
|
||||
} catch {
|
||||
toast.error(t('Update failed'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
title={t('Waffo Pancake Payment Gateway')}
|
||||
description={t(
|
||||
'Configure Waffo Pancake hosted checkout integration for USD-priced top-ups'
|
||||
)}
|
||||
>
|
||||
<Alert>
|
||||
<AlertDescription className='text-xs'>
|
||||
{t(
|
||||
'Obtain the merchant, store, product and signing keys from your Waffo dashboard. Webhook URL: <ServerAddress>/api/waffo-pancake/webhook'
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className='grid grid-cols-3 gap-4'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Switch
|
||||
checked={form.watch('WaffoPancakeEnabled')}
|
||||
onCheckedChange={(value) =>
|
||||
form.setValue('WaffoPancakeEnabled', value)
|
||||
}
|
||||
/>
|
||||
<Label>{t('Enable Waffo Pancake')}</Label>
|
||||
</div>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Switch
|
||||
checked={form.watch('WaffoPancakeSandbox')}
|
||||
onCheckedChange={(value) =>
|
||||
form.setValue('WaffoPancakeSandbox', value)
|
||||
}
|
||||
/>
|
||||
<Label>{t('Sandbox mode')}</Label>
|
||||
</div>
|
||||
<div className='grid gap-1.5'>
|
||||
<Label>{t('Currency')}</Label>
|
||||
<Input placeholder='USD' {...form.register('WaffoPancakeCurrency')} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-3 gap-4'>
|
||||
<div className='grid gap-1.5'>
|
||||
<Label>{t('Merchant ID')}</Label>
|
||||
<Input
|
||||
placeholder='MER_xxx'
|
||||
{...form.register('WaffoPancakeMerchantID')}
|
||||
/>
|
||||
</div>
|
||||
<div className='grid gap-1.5'>
|
||||
<Label>{t('Store ID')}</Label>
|
||||
<Input
|
||||
placeholder='STO_xxx'
|
||||
{...form.register('WaffoPancakeStoreID')}
|
||||
/>
|
||||
</div>
|
||||
<div className='grid gap-1.5'>
|
||||
<Label>{t('Product ID')}</Label>
|
||||
<Input
|
||||
placeholder='PROD_xxx'
|
||||
{...form.register('WaffoPancakeProductID')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-2 gap-4'>
|
||||
<div className='grid gap-1.5'>
|
||||
<Label>{t('API Private Key')}</Label>
|
||||
<Textarea
|
||||
rows={3}
|
||||
placeholder={t('Leave blank to keep the existing key')}
|
||||
{...form.register('WaffoPancakePrivateKey')}
|
||||
className='font-mono text-xs'
|
||||
/>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{t('Stored value is not echoed back for security')}
|
||||
</p>
|
||||
</div>
|
||||
<div className='grid gap-1.5'>
|
||||
<Label>{t('Payment return URL')}</Label>
|
||||
<Input
|
||||
placeholder='https://example.com/console/topup'
|
||||
{...form.register('WaffoPancakeReturnURL')}
|
||||
/>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{t('Defaults to the wallet page when empty')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-2 gap-4'>
|
||||
<div className='grid gap-1.5'>
|
||||
<Label>{t('Webhook public key (production)')}</Label>
|
||||
<Textarea
|
||||
rows={3}
|
||||
placeholder={t('Leave blank to keep the existing key')}
|
||||
{...form.register('WaffoPancakeWebhookPublicKey')}
|
||||
className='font-mono text-xs'
|
||||
/>
|
||||
</div>
|
||||
<div className='grid gap-1.5'>
|
||||
<Label>{t('Webhook public key (sandbox)')}</Label>
|
||||
<Textarea
|
||||
rows={3}
|
||||
placeholder={t('Leave blank to keep the existing key')}
|
||||
{...form.register('WaffoPancakeWebhookTestKey')}
|
||||
className='font-mono text-xs'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-2 gap-4'>
|
||||
<div className='grid gap-1.5'>
|
||||
<Label>{t('Unit price (local currency / USD)')}</Label>
|
||||
<Input
|
||||
type='number'
|
||||
step={0.01}
|
||||
min={0}
|
||||
{...form.register('WaffoPancakeUnitPrice', { valueAsNumber: true })}
|
||||
/>
|
||||
</div>
|
||||
<div className='grid gap-1.5'>
|
||||
<Label>{t('Minimum top-up (USD)')}</Label>
|
||||
<Input
|
||||
type='number'
|
||||
min={1}
|
||||
{...form.register('WaffoPancakeMinTopUp', { valueAsNumber: true })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button onClick={handleSave} disabled={loading}>
|
||||
{loading ? t('Saving...') : t('Save Waffo Pancake settings')}
|
||||
</Button>
|
||||
</SettingsSection>
|
||||
)
|
||||
}
|
||||
+510
@@ -0,0 +1,510 @@
|
||||
import { type ChangeEvent, useEffect, useRef, useState } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { Plus, Pencil, Trash2 } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { SettingsSection } from '../components/settings-section'
|
||||
import { useUpdateOption } from '../hooks/use-update-option'
|
||||
|
||||
export interface WaffoSettingsValues {
|
||||
WaffoEnabled: boolean
|
||||
WaffoApiKey: string
|
||||
WaffoPrivateKey: string
|
||||
WaffoPublicCert: string
|
||||
WaffoSandboxPublicCert: string
|
||||
WaffoSandboxApiKey: string
|
||||
WaffoSandboxPrivateKey: string
|
||||
WaffoSandbox: boolean
|
||||
WaffoMerchantId: string
|
||||
WaffoCurrency: string
|
||||
WaffoUnitPrice: number
|
||||
WaffoMinTopUp: number
|
||||
WaffoNotifyUrl: string
|
||||
WaffoReturnUrl: string
|
||||
WaffoPayMethods: string
|
||||
}
|
||||
|
||||
interface PayMethod {
|
||||
name: string
|
||||
icon: string
|
||||
payMethodType: string
|
||||
payMethodName: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
defaultValues: WaffoSettingsValues
|
||||
}
|
||||
|
||||
export function WaffoSettingsSection(props: Props) {
|
||||
const { t } = useTranslation()
|
||||
const updateOption = useUpdateOption()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const iconFileInputRef = useRef<HTMLInputElement | null>(null)
|
||||
|
||||
const form = useForm<Omit<WaffoSettingsValues, 'WaffoPayMethods'>>({
|
||||
defaultValues: props.defaultValues,
|
||||
})
|
||||
|
||||
const [payMethods, setPayMethods] = useState<PayMethod[]>(() => {
|
||||
try {
|
||||
return JSON.parse(props.defaultValues.WaffoPayMethods || '[]')
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
})
|
||||
const [methodDialogOpen, setMethodDialogOpen] = useState(false)
|
||||
const [editingIdx, setEditingIdx] = useState(-1)
|
||||
const [methodForm, setMethodForm] = useState<PayMethod>({
|
||||
name: '',
|
||||
icon: '',
|
||||
payMethodType: '',
|
||||
payMethodName: '',
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
form.reset(props.defaultValues)
|
||||
try {
|
||||
setPayMethods(JSON.parse(props.defaultValues.WaffoPayMethods || '[]'))
|
||||
} catch {
|
||||
setPayMethods([])
|
||||
}
|
||||
}, [props.defaultValues, form])
|
||||
|
||||
const handleSave = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const values = form.getValues()
|
||||
const options: { key: string; value: string }[] = [
|
||||
{ key: 'WaffoEnabled', value: String(values.WaffoEnabled) },
|
||||
{ key: 'WaffoSandbox', value: String(values.WaffoSandbox) },
|
||||
{ key: 'WaffoMerchantId', value: values.WaffoMerchantId || '' },
|
||||
{ key: 'WaffoCurrency', value: values.WaffoCurrency || 'USD' },
|
||||
{ key: 'WaffoUnitPrice', value: String(values.WaffoUnitPrice || 1) },
|
||||
{ key: 'WaffoMinTopUp', value: String(values.WaffoMinTopUp || 1) },
|
||||
{ key: 'WaffoNotifyUrl', value: values.WaffoNotifyUrl || '' },
|
||||
{ key: 'WaffoReturnUrl', value: values.WaffoReturnUrl || '' },
|
||||
{ key: 'WaffoPublicCert', value: values.WaffoPublicCert || '' },
|
||||
{
|
||||
key: 'WaffoSandboxPublicCert',
|
||||
value: values.WaffoSandboxPublicCert || '',
|
||||
},
|
||||
{ key: 'WaffoPayMethods', value: JSON.stringify(payMethods) },
|
||||
]
|
||||
if (values.WaffoApiKey)
|
||||
options.push({ key: 'WaffoApiKey', value: values.WaffoApiKey })
|
||||
if (values.WaffoPrivateKey)
|
||||
options.push({ key: 'WaffoPrivateKey', value: values.WaffoPrivateKey })
|
||||
if (values.WaffoSandboxApiKey)
|
||||
options.push({
|
||||
key: 'WaffoSandboxApiKey',
|
||||
value: values.WaffoSandboxApiKey,
|
||||
})
|
||||
if (values.WaffoSandboxPrivateKey)
|
||||
options.push({
|
||||
key: 'WaffoSandboxPrivateKey',
|
||||
value: values.WaffoSandboxPrivateKey,
|
||||
})
|
||||
|
||||
for (const opt of options) {
|
||||
await updateOption.mutateAsync(opt)
|
||||
}
|
||||
toast.success(t('Updated successfully'))
|
||||
} catch {
|
||||
toast.error(t('Update failed'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const openAdd = () => {
|
||||
setEditingIdx(-1)
|
||||
setMethodForm({ name: '', icon: '', payMethodType: '', payMethodName: '' })
|
||||
setMethodDialogOpen(true)
|
||||
}
|
||||
|
||||
const openEdit = (idx: number) => {
|
||||
setEditingIdx(idx)
|
||||
setMethodForm({ ...payMethods[idx] })
|
||||
setMethodDialogOpen(true)
|
||||
}
|
||||
|
||||
const saveMethod = () => {
|
||||
if (!methodForm.name.trim())
|
||||
return toast.error(t('Payment method name is required'))
|
||||
if (editingIdx === -1) {
|
||||
setPayMethods((prev) => [...prev, methodForm])
|
||||
} else {
|
||||
setPayMethods((prev) =>
|
||||
prev.map((m, i) => (i === editingIdx ? methodForm : m))
|
||||
)
|
||||
}
|
||||
setMethodDialogOpen(false)
|
||||
}
|
||||
|
||||
const handleIconFileChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0]
|
||||
|
||||
if (!file) {
|
||||
return
|
||||
}
|
||||
|
||||
const maxIconSize = 100 * 1024
|
||||
|
||||
if (file.size > maxIconSize) {
|
||||
toast.error(t('Icon file must be 100 KB or smaller'))
|
||||
event.target.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
const reader = new FileReader()
|
||||
reader.onload = (loadEvent) => {
|
||||
setMethodForm((previous) => ({
|
||||
...previous,
|
||||
icon:
|
||||
typeof loadEvent.target?.result === 'string'
|
||||
? loadEvent.target.result
|
||||
: '',
|
||||
}))
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
event.target.value = ''
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsSection
|
||||
title={t('Waffo Payment Gateway')}
|
||||
description={t(
|
||||
'Configure Waffo payment aggregation platform integration'
|
||||
)}
|
||||
>
|
||||
<Alert>
|
||||
<AlertDescription className='text-xs'>
|
||||
{t(
|
||||
'Obtain the API key, merchant ID, and RSA key pair from the Waffo dashboard, and configure the callback URL.'
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className='grid grid-cols-2 gap-4'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Switch
|
||||
checked={form.watch('WaffoEnabled')}
|
||||
onCheckedChange={(v) => form.setValue('WaffoEnabled', v)}
|
||||
/>
|
||||
<Label>{t('Enable Waffo')}</Label>
|
||||
</div>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Switch
|
||||
checked={form.watch('WaffoSandbox')}
|
||||
onCheckedChange={(v) => form.setValue('WaffoSandbox', v)}
|
||||
/>
|
||||
<Label>{t('Sandbox mode')}</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-2 gap-4'>
|
||||
<div className='grid gap-1.5'>
|
||||
<Label>{t('API Key (Production)')}</Label>
|
||||
<Input type='password' {...form.register('WaffoApiKey')} />
|
||||
</div>
|
||||
<div className='grid gap-1.5'>
|
||||
<Label>{t('API Key (Sandbox)')}</Label>
|
||||
<Input type='password' {...form.register('WaffoSandboxApiKey')} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='grid gap-1.5'>
|
||||
<Label>{t('Merchant ID')}</Label>
|
||||
<Input {...form.register('WaffoMerchantId')} />
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-2 gap-4'>
|
||||
<div className='grid gap-1.5'>
|
||||
<Label>{t('RSA Private Key (Production)')}</Label>
|
||||
<Textarea
|
||||
rows={3}
|
||||
{...form.register('WaffoPrivateKey')}
|
||||
className='font-mono text-xs'
|
||||
/>
|
||||
</div>
|
||||
<div className='grid gap-1.5'>
|
||||
<Label>{t('RSA Private Key (Sandbox)')}</Label>
|
||||
<Textarea
|
||||
rows={3}
|
||||
{...form.register('WaffoSandboxPrivateKey')}
|
||||
className='font-mono text-xs'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-2 gap-4'>
|
||||
<div className='grid gap-1.5'>
|
||||
<Label>{t('Waffo Public Key (Production)')}</Label>
|
||||
<Textarea
|
||||
rows={3}
|
||||
{...form.register('WaffoPublicCert')}
|
||||
className='font-mono text-xs'
|
||||
/>
|
||||
</div>
|
||||
<div className='grid gap-1.5'>
|
||||
<Label>{t('Waffo Public Key (Sandbox)')}</Label>
|
||||
<Textarea
|
||||
rows={3}
|
||||
{...form.register('WaffoSandboxPublicCert')}
|
||||
className='font-mono text-xs'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-3 gap-4'>
|
||||
<div className='grid gap-1.5'>
|
||||
<Label>{t('Currency')}</Label>
|
||||
<Input {...form.register('WaffoCurrency')} disabled />
|
||||
</div>
|
||||
<div className='grid gap-1.5'>
|
||||
<Label>{t('Unit price (USD)')}</Label>
|
||||
<Input
|
||||
type='number'
|
||||
step={0.1}
|
||||
min={0}
|
||||
{...form.register('WaffoUnitPrice')}
|
||||
/>
|
||||
</div>
|
||||
<div className='grid gap-1.5'>
|
||||
<Label>{t('Minimum top-up quantity')}</Label>
|
||||
<Input type='number' min={1} {...form.register('WaffoMinTopUp')} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-2 gap-4'>
|
||||
<div className='grid gap-1.5'>
|
||||
<Label>{t('Callback notification URL')}</Label>
|
||||
<Input
|
||||
placeholder='https://example.com/api/waffo/webhook'
|
||||
{...form.register('WaffoNotifyUrl')}
|
||||
/>
|
||||
</div>
|
||||
<div className='grid gap-1.5'>
|
||||
<Label>{t('Payment return URL')}</Label>
|
||||
<Input
|
||||
placeholder='https://example.com/console/topup'
|
||||
{...form.register('WaffoReturnUrl')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className='flex items-center justify-between'>
|
||||
<h4 className='font-medium'>{t('Payment Methods')}</h4>
|
||||
<Button variant='outline' size='sm' onClick={openAdd}>
|
||||
<Plus className='mr-1 h-3 w-3' />
|
||||
{t('Add payment method')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className='rounded-md border'>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t('Display name')}</TableHead>
|
||||
<TableHead>{t('Icon')}</TableHead>
|
||||
<TableHead>{t('Payment method type')}</TableHead>
|
||||
<TableHead>{t('Payment method name')}</TableHead>
|
||||
<TableHead className='text-right'>{t('Actions')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{payMethods.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={5}
|
||||
className='text-muted-foreground py-8 text-center'
|
||||
>
|
||||
{t('No payment methods configured')}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
payMethods.map((m, idx) => (
|
||||
<TableRow key={idx}>
|
||||
<TableCell>{m.name}</TableCell>
|
||||
<TableCell>
|
||||
{m.icon ? (
|
||||
<img
|
||||
src={m.icon}
|
||||
alt={m.name}
|
||||
className='h-6 w-6 rounded object-contain'
|
||||
/>
|
||||
) : (
|
||||
<span className='text-muted-foreground'>-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>{m.payMethodType || '-'}</TableCell>
|
||||
<TableCell>{m.payMethodName || '-'}</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<div className='flex justify-end gap-1'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='h-7 w-7'
|
||||
onClick={() => openEdit(idx)}
|
||||
>
|
||||
<Pencil className='h-3 w-3' />
|
||||
</Button>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='h-7 w-7'
|
||||
onClick={() =>
|
||||
setPayMethods((prev) =>
|
||||
prev.filter((_, i) => i !== idx)
|
||||
)
|
||||
}
|
||||
>
|
||||
<Trash2 className='h-3 w-3' />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<Button onClick={handleSave} disabled={loading}>
|
||||
{loading ? t('Saving...') : t('Save Changes')}
|
||||
</Button>
|
||||
</SettingsSection>
|
||||
|
||||
<Dialog open={methodDialogOpen} onOpenChange={setMethodDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{editingIdx === -1
|
||||
? t('Add payment method')
|
||||
: t('Edit payment method')}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className='space-y-3'>
|
||||
<div className='grid gap-1.5'>
|
||||
<Label>{t('Display name')} *</Label>
|
||||
<Input
|
||||
value={methodForm.name}
|
||||
onChange={(e) =>
|
||||
setMethodForm((p) => ({ ...p, name: e.target.value }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className='grid gap-2'>
|
||||
<Label>{t('Icon')}</Label>
|
||||
<div className='flex items-center gap-3'>
|
||||
{methodForm.icon ? (
|
||||
<img
|
||||
src={methodForm.icon}
|
||||
alt={methodForm.name || t('Icon')}
|
||||
className='h-10 w-10 rounded border object-contain p-1'
|
||||
/>
|
||||
) : (
|
||||
<div className='bg-muted text-muted-foreground flex h-10 w-10 items-center justify-center rounded border text-xs'>
|
||||
{t('Icon')}
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
ref={iconFileInputRef}
|
||||
type='file'
|
||||
accept='image/png,image/jpeg,image/svg+xml,image/webp'
|
||||
className='hidden'
|
||||
onChange={handleIconFileChange}
|
||||
/>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
onClick={() => iconFileInputRef.current?.click()}
|
||||
>
|
||||
{t('Upload')}
|
||||
</Button>
|
||||
{methodForm.icon ? (
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
onClick={() =>
|
||||
setMethodForm((previous) => ({
|
||||
...previous,
|
||||
icon: '',
|
||||
}))
|
||||
}
|
||||
>
|
||||
{t('Clear')}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{t(
|
||||
'Supports PNG, JPG, SVG, or WebP. Recommended size: 128×128 or smaller.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className='grid gap-1.5'>
|
||||
<Label>{t('Payment method type')}</Label>
|
||||
<Input
|
||||
value={methodForm.payMethodType}
|
||||
onChange={(e) =>
|
||||
setMethodForm((p) => ({
|
||||
...p,
|
||||
payMethodType: e.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder='CREDITCARD,DEBITCARD'
|
||||
/>
|
||||
</div>
|
||||
<div className='grid gap-1.5'>
|
||||
<Label>{t('Payment method name')}</Label>
|
||||
<Input
|
||||
value={methodForm.payMethodName}
|
||||
onChange={(e) =>
|
||||
setMethodForm((p) => ({
|
||||
...p,
|
||||
payMethodName: e.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant='outline'
|
||||
onClick={() => setMethodDialogOpen(false)}
|
||||
>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
<Button onClick={saveMethod}>{t('Confirm')}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
+182
@@ -0,0 +1,182 @@
|
||||
import * as z from 'zod'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { SettingsSection } from '../components/settings-section'
|
||||
import { useResetForm } from '../hooks/use-reset-form'
|
||||
import { useUpdateOption } from '../hooks/use-update-option'
|
||||
import { removeTrailingSlash } from './utils'
|
||||
|
||||
const createWorkerSchema = (t: (key: string) => string) =>
|
||||
z.object({
|
||||
WorkerUrl: z.string().refine((value) => {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) return true
|
||||
return /^https?:\/\//.test(trimmed)
|
||||
}, t('Provide a valid URL starting with http:// or https://')),
|
||||
WorkerValidKey: z.string(),
|
||||
WorkerAllowHttpImageRequestEnabled: z.boolean(),
|
||||
})
|
||||
|
||||
type WorkerFormValues = z.infer<ReturnType<typeof createWorkerSchema>>
|
||||
|
||||
type WorkerSettingsSectionProps = {
|
||||
defaultValues: WorkerFormValues
|
||||
}
|
||||
|
||||
export function WorkerSettingsSection({
|
||||
defaultValues,
|
||||
}: WorkerSettingsSectionProps) {
|
||||
const { t } = useTranslation()
|
||||
const updateOption = useUpdateOption()
|
||||
const workerSchema = createWorkerSchema(t)
|
||||
|
||||
const form = useForm<WorkerFormValues>({
|
||||
resolver: zodResolver(workerSchema),
|
||||
defaultValues,
|
||||
})
|
||||
|
||||
useResetForm(form, defaultValues)
|
||||
|
||||
const onSubmit = async (values: WorkerFormValues) => {
|
||||
const sanitizedUrl = removeTrailingSlash(values.WorkerUrl)
|
||||
const sanitizedKey = values.WorkerValidKey.trim()
|
||||
const initialUrl = removeTrailingSlash(defaultValues.WorkerUrl)
|
||||
const initialKey = defaultValues.WorkerValidKey.trim()
|
||||
|
||||
const updates: Array<{ key: string; value: string | boolean }> = []
|
||||
|
||||
if (sanitizedUrl !== initialUrl) {
|
||||
updates.push({ key: 'WorkerUrl', value: sanitizedUrl })
|
||||
}
|
||||
|
||||
if (sanitizedKey !== initialKey || sanitizedUrl === '') {
|
||||
updates.push({ key: 'WorkerValidKey', value: sanitizedKey })
|
||||
}
|
||||
|
||||
if (
|
||||
values.WorkerAllowHttpImageRequestEnabled !==
|
||||
defaultValues.WorkerAllowHttpImageRequestEnabled
|
||||
) {
|
||||
updates.push({
|
||||
key: 'WorkerAllowHttpImageRequestEnabled',
|
||||
value: values.WorkerAllowHttpImageRequestEnabled,
|
||||
})
|
||||
}
|
||||
|
||||
for (const update of updates) {
|
||||
await updateOption.mutateAsync(update)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
title={t('Worker Proxy')}
|
||||
description={t(
|
||||
'Configure upstream worker or proxy service for outbound requests'
|
||||
)}
|
||||
>
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
autoComplete='off'
|
||||
className='space-y-6'
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='WorkerUrl'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Worker URL')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type='url'
|
||||
inputMode='url'
|
||||
placeholder={t('https://worker.example.workers.dev')}
|
||||
autoComplete='off'
|
||||
{...field}
|
||||
onChange={(event) => field.onChange(event.target.value)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
'Requests will be forwarded to this worker. Trailing slashes are removed automatically.'
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='WorkerValidKey'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Worker Access Key')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type='password'
|
||||
placeholder={t('Enter new key to update')}
|
||||
autoComplete='new-password'
|
||||
{...field}
|
||||
onChange={(event) => field.onChange(event.target.value)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
'Used to authenticate with the worker. Leave blank to keep the existing secret.'
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='WorkerAllowHttpImageRequestEnabled'
|
||||
render={({ field }) => (
|
||||
<FormItem className='flex flex-row items-center justify-between rounded-lg border p-4'>
|
||||
<div className='space-y-0.5'>
|
||||
<FormLabel className='text-base'>
|
||||
{t('Allow HTTP image requests')}
|
||||
</FormLabel>
|
||||
<FormDescription>
|
||||
{t(
|
||||
'Enable when proxying workers that fetch images over HTTP.'
|
||||
)}
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button type='submit' disabled={updateOption.isPending}>
|
||||
{updateOption.isPending
|
||||
? t('Saving...')
|
||||
: t('Save Worker settings')}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
</SettingsSection>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import type { MaintenanceSettings } from '../types'
|
||||
|
||||
export type HeaderNavPricingConfig = {
|
||||
enabled: boolean
|
||||
requireAuth: boolean
|
||||
}
|
||||
|
||||
export type HeaderNavModulesConfig = {
|
||||
home: boolean
|
||||
console: boolean
|
||||
pricing: HeaderNavPricingConfig
|
||||
docs: boolean
|
||||
about: boolean
|
||||
[key: string]: boolean | HeaderNavPricingConfig
|
||||
}
|
||||
|
||||
export type SidebarSectionConfig = {
|
||||
enabled: boolean
|
||||
[key: string]: boolean
|
||||
}
|
||||
|
||||
export type SidebarModulesAdminConfig = Record<string, SidebarSectionConfig>
|
||||
|
||||
export const HEADER_NAV_DEFAULT: HeaderNavModulesConfig = {
|
||||
home: true,
|
||||
console: true,
|
||||
pricing: {
|
||||
enabled: true,
|
||||
requireAuth: false,
|
||||
},
|
||||
docs: true,
|
||||
about: true,
|
||||
}
|
||||
|
||||
export const SIDEBAR_MODULES_DEFAULT: SidebarModulesAdminConfig = {
|
||||
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,
|
||||
redemption: true,
|
||||
user: true,
|
||||
setting: true,
|
||||
subscription: true,
|
||||
},
|
||||
}
|
||||
|
||||
export const DEFAULT_MAINTENANCE_SETTINGS: MaintenanceSettings = {
|
||||
Notice: '',
|
||||
LogConsumeEnabled: false,
|
||||
HeaderNavModules: JSON.stringify(HEADER_NAV_DEFAULT),
|
||||
SidebarModulesAdmin: JSON.stringify(SIDEBAR_MODULES_DEFAULT),
|
||||
'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 toBoolean = (value: unknown, fallback: boolean): boolean => {
|
||||
if (typeof value === 'boolean') return value
|
||||
if (typeof value === 'number') return value === 1
|
||||
if (typeof value === 'string') {
|
||||
const normalized = value.trim().toLowerCase()
|
||||
if (normalized === 'true' || normalized === '1') return true
|
||||
if (normalized === 'false' || normalized === '0') return false
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
const cloneHeaderNavDefault = (): HeaderNavModulesConfig => ({
|
||||
...HEADER_NAV_DEFAULT,
|
||||
pricing: { ...HEADER_NAV_DEFAULT.pricing },
|
||||
})
|
||||
|
||||
const cloneSidebarDefault = (): SidebarModulesAdminConfig =>
|
||||
Object.entries(SIDEBAR_MODULES_DEFAULT).reduce<SidebarModulesAdminConfig>(
|
||||
(acc, [section, config]) => {
|
||||
acc[section] = { ...config }
|
||||
return acc
|
||||
},
|
||||
{}
|
||||
)
|
||||
|
||||
export function parseHeaderNavModules(
|
||||
value: string | null | undefined
|
||||
): HeaderNavModulesConfig {
|
||||
const base = cloneHeaderNavDefault()
|
||||
if (!value) {
|
||||
return base
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(value) as Record<string, unknown>
|
||||
const result: HeaderNavModulesConfig = {
|
||||
...base,
|
||||
pricing: { ...base.pricing },
|
||||
}
|
||||
|
||||
Object.entries(parsed).forEach(([key, raw]) => {
|
||||
if (key === 'pricing') {
|
||||
if (raw && typeof raw === 'object') {
|
||||
const rawPricing = raw as Record<string, unknown>
|
||||
result.pricing = {
|
||||
enabled: toBoolean(
|
||||
rawPricing.enabled,
|
||||
base.pricing?.enabled ?? true
|
||||
),
|
||||
requireAuth: toBoolean(
|
||||
rawPricing.requireAuth,
|
||||
base.pricing?.requireAuth ?? false
|
||||
),
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (typeof raw === 'boolean') {
|
||||
result[key] = raw
|
||||
return
|
||||
}
|
||||
if (typeof raw === 'string' || typeof raw === 'number') {
|
||||
result[key] = toBoolean(raw, Boolean(base[key]))
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
return result
|
||||
} catch {
|
||||
return base
|
||||
}
|
||||
}
|
||||
|
||||
export function serializeHeaderNavModules(
|
||||
config: HeaderNavModulesConfig
|
||||
): string {
|
||||
return JSON.stringify(config)
|
||||
}
|
||||
|
||||
export function parseSidebarModulesAdmin(
|
||||
value: string | null | undefined
|
||||
): SidebarModulesAdminConfig {
|
||||
const defaults = cloneSidebarDefault()
|
||||
// If empty string, null, or undefined, use default config
|
||||
if (!value || value.trim() === '') return defaults
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(value) as Record<string, unknown>
|
||||
const result: SidebarModulesAdminConfig = {}
|
||||
|
||||
Object.entries(parsed).forEach(([sectionKey, raw]) => {
|
||||
if (!raw || typeof raw !== 'object') return
|
||||
|
||||
const defaultSection = defaults[sectionKey] ?? { enabled: true }
|
||||
const sectionConfig: SidebarSectionConfig = {
|
||||
enabled: toBoolean(
|
||||
(raw as Record<string, unknown>).enabled,
|
||||
defaultSection.enabled ?? true
|
||||
),
|
||||
}
|
||||
|
||||
Object.entries(raw as Record<string, unknown>).forEach(
|
||||
([moduleKey, moduleValue]) => {
|
||||
if (moduleKey === 'enabled') return
|
||||
sectionConfig[moduleKey] = toBoolean(
|
||||
moduleValue,
|
||||
defaultSection[moduleKey] ?? true
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
result[sectionKey] = sectionConfig
|
||||
})
|
||||
|
||||
// Merge defaults to ensure expected sections exist
|
||||
Object.entries(defaults).forEach(([sectionKey, config]) => {
|
||||
if (!result[sectionKey]) {
|
||||
result[sectionKey] = { ...config }
|
||||
return
|
||||
}
|
||||
|
||||
Object.entries(config).forEach(([moduleKey, moduleValue]) => {
|
||||
if (!(moduleKey in result[sectionKey])) {
|
||||
result[sectionKey][moduleKey] = moduleValue
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
return result
|
||||
} catch {
|
||||
return defaults
|
||||
}
|
||||
}
|
||||
|
||||
export function serializeSidebarModulesAdmin(
|
||||
config: SidebarModulesAdminConfig
|
||||
): string {
|
||||
return JSON.stringify(config)
|
||||
}
|
||||
+238
@@ -0,0 +1,238 @@
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import * as z from 'zod'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { SettingsSection } from '../components/settings-section'
|
||||
import { useUpdateOption } from '../hooks/use-update-option'
|
||||
import {
|
||||
HEADER_NAV_DEFAULT,
|
||||
type HeaderNavModulesConfig,
|
||||
serializeHeaderNavModules,
|
||||
} from './config'
|
||||
|
||||
const headerNavSchema = z.object({
|
||||
home: z.boolean(),
|
||||
console: z.boolean(),
|
||||
pricingEnabled: z.boolean(),
|
||||
pricingRequireAuth: z.boolean(),
|
||||
docs: z.boolean(),
|
||||
about: z.boolean(),
|
||||
})
|
||||
|
||||
type HeaderNavFormValues = z.infer<typeof headerNavSchema>
|
||||
|
||||
type HeaderNavigationSectionProps = {
|
||||
config: HeaderNavModulesConfig
|
||||
initialSerialized: string
|
||||
}
|
||||
|
||||
const toFormValues = (config: HeaderNavModulesConfig): HeaderNavFormValues => ({
|
||||
home:
|
||||
config.home === undefined ? HEADER_NAV_DEFAULT.home : Boolean(config.home),
|
||||
console:
|
||||
config.console === undefined
|
||||
? HEADER_NAV_DEFAULT.console
|
||||
: Boolean(config.console),
|
||||
pricingEnabled:
|
||||
config.pricing?.enabled === undefined
|
||||
? HEADER_NAV_DEFAULT.pricing.enabled
|
||||
: Boolean(config.pricing.enabled),
|
||||
pricingRequireAuth:
|
||||
config.pricing?.requireAuth === undefined
|
||||
? HEADER_NAV_DEFAULT.pricing.requireAuth
|
||||
: Boolean(config.pricing.requireAuth),
|
||||
docs:
|
||||
config.docs === undefined ? HEADER_NAV_DEFAULT.docs : Boolean(config.docs),
|
||||
about:
|
||||
config.about === undefined
|
||||
? HEADER_NAV_DEFAULT.about
|
||||
: Boolean(config.about),
|
||||
})
|
||||
|
||||
export function HeaderNavigationSection({
|
||||
config,
|
||||
initialSerialized,
|
||||
}: HeaderNavigationSectionProps) {
|
||||
const { t } = useTranslation()
|
||||
const updateOption = useUpdateOption()
|
||||
const formDefaults = useMemo(() => toFormValues(config), [config])
|
||||
|
||||
const form = useForm<HeaderNavFormValues>({
|
||||
resolver: zodResolver(headerNavSchema),
|
||||
defaultValues: formDefaults,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
form.reset(formDefaults)
|
||||
}, [formDefaults, form])
|
||||
|
||||
const onSubmit = async (values: HeaderNavFormValues) => {
|
||||
const payload: HeaderNavModulesConfig = {
|
||||
...config,
|
||||
home: values.home,
|
||||
console: values.console,
|
||||
docs: values.docs,
|
||||
about: values.about,
|
||||
pricing: {
|
||||
...(config.pricing ?? HEADER_NAV_DEFAULT.pricing),
|
||||
enabled: values.pricingEnabled,
|
||||
requireAuth: values.pricingRequireAuth,
|
||||
},
|
||||
}
|
||||
|
||||
const serialized = serializeHeaderNavModules(payload)
|
||||
if (serialized === initialSerialized) {
|
||||
return
|
||||
}
|
||||
|
||||
await updateOption.mutateAsync({
|
||||
key: 'HeaderNavModules',
|
||||
value: serialized,
|
||||
})
|
||||
}
|
||||
|
||||
const resetToDefault = () => {
|
||||
form.reset(toFormValues(HEADER_NAV_DEFAULT))
|
||||
}
|
||||
|
||||
const modules: Array<{
|
||||
key: keyof HeaderNavFormValues
|
||||
title: string
|
||||
description: string
|
||||
}> = [
|
||||
{
|
||||
key: 'home',
|
||||
title: t('Home'),
|
||||
description: t('Landing page with system overview.'),
|
||||
},
|
||||
{
|
||||
key: 'console',
|
||||
title: t('Console'),
|
||||
description: t('User dashboard and quota controls.'),
|
||||
},
|
||||
{
|
||||
key: 'docs',
|
||||
title: t('Docs'),
|
||||
description: t('Documentation or external knowledge base.'),
|
||||
},
|
||||
{
|
||||
key: 'about',
|
||||
title: t('About'),
|
||||
description: t('Static page describing the platform.'),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
title={t('Header navigation')}
|
||||
description={t('Enable or disable top navigation modules globally.')}
|
||||
>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className='space-y-6'>
|
||||
<div className='grid gap-4 md:grid-cols-2'>
|
||||
{modules.map((module) => (
|
||||
<FormField
|
||||
key={module.key}
|
||||
control={form.control}
|
||||
name={module.key}
|
||||
render={({ field }) => (
|
||||
<FormItem className='flex flex-row items-start justify-between rounded-lg border p-4'>
|
||||
<div className='space-y-0.5 pe-4'>
|
||||
<FormLabel className='text-base'>
|
||||
{module.title}
|
||||
</FormLabel>
|
||||
<FormDescription>{module.description}</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className='rounded-lg border p-4'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='pricingEnabled'
|
||||
render={({ field }) => (
|
||||
<FormItem className='flex flex-row items-start justify-between rounded-lg border p-4'>
|
||||
<div className='space-y-0.5 pe-4'>
|
||||
<FormLabel className='text-base'>
|
||||
{t('Models directory')}
|
||||
</FormLabel>
|
||||
<FormDescription>
|
||||
{t(
|
||||
'Exposes the pricing/models catalog in the top navigation.'
|
||||
)}
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='pricingRequireAuth'
|
||||
render={({ field }) => (
|
||||
<FormItem className='mt-4 flex flex-row items-start justify-between rounded-lg border border-dashed p-4'>
|
||||
<div className='space-y-0.5 pe-4'>
|
||||
<FormLabel className='text-base'>
|
||||
{t('Require login to view models')}
|
||||
</FormLabel>
|
||||
<FormDescription>
|
||||
{t(
|
||||
'Visitors must authenticate before accessing the pricing directory.'
|
||||
)}
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
disabled={!form.watch('pricingEnabled')}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='flex flex-wrap gap-3'>
|
||||
<Button type='button' variant='outline' onClick={resetToDefault}>
|
||||
{t('Reset to default')}
|
||||
</Button>
|
||||
<Button type='submit' disabled={updateOption.isPending}>
|
||||
{updateOption.isPending ? t('Saving...') : t('Save navigation')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</SettingsSection>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useParams } from '@tanstack/react-router'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useStatus } from '@/hooks/use-status'
|
||||
import { getOptionValue, useSystemOptions } from '../hooks/use-system-options'
|
||||
import { DEFAULT_MAINTENANCE_SETTINGS } from './config'
|
||||
import {
|
||||
MAINTENANCE_DEFAULT_SECTION,
|
||||
getMaintenanceSectionContent,
|
||||
} from './section-registry.tsx'
|
||||
|
||||
export function MaintenanceSettings() {
|
||||
const { t } = useTranslation()
|
||||
const { data, isLoading } = useSystemOptions()
|
||||
const { status } = useStatus()
|
||||
const params = useParams({
|
||||
from: '/_authenticated/system-settings/maintenance/$section',
|
||||
})
|
||||
|
||||
const settings = useMemo(
|
||||
() => getOptionValue(data?.data, DEFAULT_MAINTENANCE_SETTINGS),
|
||||
[data?.data]
|
||||
)
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className='text-muted-foreground flex h-full w-full flex-1 items-center justify-center'>
|
||||
{t('Loading maintenance settings...')}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const activeSection = (params?.section ?? MAINTENANCE_DEFAULT_SECTION) as
|
||||
| 'update-checker'
|
||||
| 'notice'
|
||||
| 'logs'
|
||||
| 'header-navigation'
|
||||
| 'sidebar-modules'
|
||||
| 'performance'
|
||||
const sectionContent = getMaintenanceSectionContent(
|
||||
activeSection,
|
||||
settings,
|
||||
status?.version as string | undefined,
|
||||
status?.start_time as number | null | undefined
|
||||
)
|
||||
|
||||
return (
|
||||
<div className='flex h-full w-full flex-1 flex-col'>
|
||||
<div className='faded-bottom h-full w-full overflow-y-auto scroll-smooth pe-4 pb-12'>
|
||||
<div className='space-y-4'>{sectionContent}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+243
@@ -0,0 +1,243 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import * as z from 'zod'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { formatTimestampToDate } from '@/lib/format'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { DateTimePicker } from '@/components/datetime-picker'
|
||||
import { deleteLogsBefore } from '../api'
|
||||
import { SettingsSection } from '../components/settings-section'
|
||||
import { useUpdateOption } from '../hooks/use-update-option'
|
||||
|
||||
const logSettingsSchema = z.object({
|
||||
LogConsumeEnabled: z.boolean(),
|
||||
})
|
||||
|
||||
type LogSettingsFormValues = z.infer<typeof logSettingsSchema>
|
||||
|
||||
type LogSettingsSectionProps = {
|
||||
defaultEnabled: boolean
|
||||
}
|
||||
|
||||
const HOURS_IN_DAY = 24
|
||||
|
||||
const getDateHoursAgo = (hours: number) => {
|
||||
const date = new Date()
|
||||
date.setHours(date.getHours() - hours)
|
||||
return date
|
||||
}
|
||||
|
||||
const getDateDaysAgo = (days: number) => getDateHoursAgo(days * HOURS_IN_DAY)
|
||||
|
||||
const quickSelectOptions = [
|
||||
{
|
||||
label: '24 hours ago',
|
||||
getValue: () => getDateHoursAgo(24),
|
||||
},
|
||||
{
|
||||
label: '7 days ago',
|
||||
getValue: () => getDateDaysAgo(7),
|
||||
},
|
||||
{
|
||||
label: '30 days ago',
|
||||
getValue: () => getDateDaysAgo(30),
|
||||
},
|
||||
]
|
||||
|
||||
export function LogSettingsSection({
|
||||
defaultEnabled,
|
||||
}: LogSettingsSectionProps) {
|
||||
const { t } = useTranslation()
|
||||
const updateOption = useUpdateOption()
|
||||
const form = useForm<LogSettingsFormValues>({
|
||||
resolver: zodResolver(logSettingsSchema),
|
||||
defaultValues: {
|
||||
LogConsumeEnabled: defaultEnabled,
|
||||
},
|
||||
})
|
||||
|
||||
const [purgeDate, setPurgeDate] = useState<Date | undefined>(() =>
|
||||
getDateDaysAgo(30)
|
||||
)
|
||||
const [isCleaning, setIsCleaning] = useState(false)
|
||||
const [showConfirmDialog, setShowConfirmDialog] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
form.reset({ LogConsumeEnabled: defaultEnabled })
|
||||
}, [defaultEnabled, form])
|
||||
|
||||
const purgeTimestamp = useMemo(() => {
|
||||
if (!purgeDate) return null
|
||||
return Math.floor(purgeDate.getTime() / 1000)
|
||||
}, [purgeDate])
|
||||
|
||||
const formattedPurgeDate = useMemo(() => {
|
||||
if (!purgeDate) return ''
|
||||
return formatTimestampToDate(purgeDate.getTime(), 'milliseconds')
|
||||
}, [purgeDate])
|
||||
|
||||
const onSubmit = async (values: LogSettingsFormValues) => {
|
||||
if (values.LogConsumeEnabled === defaultEnabled) return
|
||||
await updateOption.mutateAsync({
|
||||
key: 'LogConsumeEnabled',
|
||||
value: values.LogConsumeEnabled,
|
||||
})
|
||||
}
|
||||
|
||||
const handleRequestCleanLogs = () => {
|
||||
if (!purgeTimestamp) {
|
||||
toast.error(t('Select a timestamp before clearing logs.'))
|
||||
return
|
||||
}
|
||||
|
||||
setShowConfirmDialog(true)
|
||||
}
|
||||
|
||||
const handleCleanLogs = async () => {
|
||||
if (!purgeTimestamp) {
|
||||
toast.error(t('Select a timestamp before clearing logs.'))
|
||||
return
|
||||
}
|
||||
|
||||
setIsCleaning(true)
|
||||
try {
|
||||
const res = await deleteLogsBefore(purgeTimestamp)
|
||||
if (!res.success) {
|
||||
throw new Error(res.message || t('Failed to clean logs'))
|
||||
}
|
||||
const count = res.data ?? 0
|
||||
toast.success(
|
||||
count > 0
|
||||
? t('{{count}} log entries removed.', { count })
|
||||
: t('No log entries matched the selected time.')
|
||||
)
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : t('Failed to clean logs')
|
||||
toast.error(message)
|
||||
} finally {
|
||||
setIsCleaning(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
title={t('Log Maintenance')}
|
||||
description={t('Control log retention and clean historical data.')}
|
||||
>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className='space-y-6'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='LogConsumeEnabled'
|
||||
render={({ field }) => (
|
||||
<FormItem className='flex flex-row items-start justify-between rounded-lg border p-4'>
|
||||
<div className='space-y-0.5 pe-4'>
|
||||
<FormLabel className='text-base'>
|
||||
{t('Record quota usage')}
|
||||
</FormLabel>
|
||||
<FormDescription>
|
||||
{t(
|
||||
'Track per-request consumption to power usage analytics. Keeping this on increases database writes.'
|
||||
)}
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className='space-y-4 rounded-lg border p-4'>
|
||||
<div>
|
||||
<h4 className='text-sm font-medium'>{t('Clean history logs')}</h4>
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
{t(
|
||||
'Remove all log entries created before the selected timestamp.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<DateTimePicker value={purgeDate} onChange={setPurgeDate} />
|
||||
<div className='flex flex-wrap gap-3'>
|
||||
{quickSelectOptions.map((option) => (
|
||||
<Button
|
||||
key={option.label}
|
||||
type='button'
|
||||
variant='outline'
|
||||
onClick={() => setPurgeDate(option.getValue())}
|
||||
>
|
||||
{t(option.label)}
|
||||
</Button>
|
||||
))}
|
||||
<Button
|
||||
type='button'
|
||||
variant='destructive'
|
||||
onClick={handleRequestCleanLogs}
|
||||
disabled={isCleaning}
|
||||
>
|
||||
{isCleaning ? t('Cleaning...') : t('Clean logs')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button type='submit' disabled={updateOption.isPending}>
|
||||
{updateOption.isPending ? t('Saving...') : t('Save log settings')}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
<AlertDialog open={showConfirmDialog} onOpenChange={setShowConfirmDialog}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t('Confirm log cleanup')}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{formattedPurgeDate
|
||||
? t(
|
||||
'This will permanently remove all log entries created before {{date}}.',
|
||||
{ date: formattedPurgeDate }
|
||||
)
|
||||
: t(
|
||||
'This will permanently remove log entries before the selected timestamp.'
|
||||
)}{' '}
|
||||
{t('This action cannot be undone.')}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isCleaning}>
|
||||
{t('Cancel')}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleCleanLogs} disabled={isCleaning}>
|
||||
{isCleaning ? t('Cleaning...') : t('Delete logs')}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</SettingsSection>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { useEffect } from 'react'
|
||||
import * as z from 'zod'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { SettingsSection } from '../components/settings-section'
|
||||
import { useUpdateOption } from '../hooks/use-update-option'
|
||||
|
||||
const noticeSchema = z.object({
|
||||
Notice: z.string().optional(),
|
||||
})
|
||||
|
||||
type NoticeFormValues = z.infer<typeof noticeSchema>
|
||||
|
||||
type NoticeSectionProps = {
|
||||
defaultValue: string
|
||||
}
|
||||
|
||||
export function NoticeSection({ defaultValue }: NoticeSectionProps) {
|
||||
const { t } = useTranslation()
|
||||
const updateOption = useUpdateOption()
|
||||
const form = useForm<NoticeFormValues>({
|
||||
resolver: zodResolver(noticeSchema),
|
||||
defaultValues: {
|
||||
Notice: defaultValue ?? '',
|
||||
},
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
form.reset({ Notice: defaultValue ?? '' })
|
||||
}, [defaultValue, form])
|
||||
|
||||
const onSubmit = async (values: NoticeFormValues) => {
|
||||
const normalized = values.Notice ?? ''
|
||||
if (normalized === (defaultValue ?? '')) {
|
||||
return
|
||||
}
|
||||
await updateOption.mutateAsync({
|
||||
key: 'Notice',
|
||||
value: normalized,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
title={t('System Notice')}
|
||||
description={t(
|
||||
'Broadcast a global banner to users. Markdown is supported.'
|
||||
)}
|
||||
>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className='space-y-6'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='Notice'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Announcement content')}</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
rows={8}
|
||||
placeholder={t(
|
||||
'Planned maintenance on Friday at 22:00 UTC...'
|
||||
)}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button type='submit' disabled={updateOption.isPending}>
|
||||
{updateOption.isPending ? t('Saving...') : t('Save notice')}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
</SettingsSection>
|
||||
)
|
||||
}
|
||||
+781
@@ -0,0 +1,781 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import * as z from 'zod'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { api } from '@/lib/api'
|
||||
import dayjs from '@/lib/dayjs'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
} from '@/components/ui/form'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { SettingsSection } from '../components/settings-section'
|
||||
import { useResetForm } from '../hooks/use-reset-form'
|
||||
import { useUpdateOption } from '../hooks/use-update-option'
|
||||
|
||||
const perfSchema = z.object({
|
||||
'performance_setting.disk_cache_enabled': z.boolean(),
|
||||
'performance_setting.disk_cache_threshold_mb': z.coerce.number().min(1),
|
||||
'performance_setting.disk_cache_max_size_mb': z.coerce.number().min(100),
|
||||
'performance_setting.disk_cache_path': z.string().optional(),
|
||||
'performance_setting.monitor_enabled': z.boolean(),
|
||||
'performance_setting.monitor_cpu_threshold': z.coerce.number().min(0),
|
||||
'performance_setting.monitor_memory_threshold': z.coerce
|
||||
.number()
|
||||
.min(0)
|
||||
.max(100),
|
||||
'performance_setting.monitor_disk_threshold': z.coerce
|
||||
.number()
|
||||
.min(0)
|
||||
.max(100),
|
||||
})
|
||||
|
||||
type PerfFormValues = z.infer<typeof perfSchema>
|
||||
|
||||
function formatBytes(bytes: number, decimals = 2): string {
|
||||
if (!bytes || isNaN(bytes)) return '0 Bytes'
|
||||
if (bytes === 0) return '0 Bytes'
|
||||
if (bytes < 0) return '-' + formatBytes(-bytes, decimals)
|
||||
const k = 1024
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']
|
||||
const i = Math.floor(Math.log(Math.abs(bytes)) / Math.log(k))
|
||||
if (i < 0 || i >= sizes.length) return bytes + ' Bytes'
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(decimals)) + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
interface Props {
|
||||
defaultValues: PerfFormValues
|
||||
}
|
||||
|
||||
type LogInfo = {
|
||||
enabled: boolean
|
||||
log_dir: string
|
||||
file_count: number
|
||||
total_size: number
|
||||
oldest_time?: string
|
||||
newest_time?: string
|
||||
}
|
||||
|
||||
type PerformanceStats = {
|
||||
cache_stats?: {
|
||||
current_disk_usage_bytes: number
|
||||
disk_cache_max_bytes: number
|
||||
active_disk_files: number
|
||||
disk_cache_hits: number
|
||||
current_memory_usage_bytes: number
|
||||
active_memory_buffers: number
|
||||
memory_cache_hits: number
|
||||
}
|
||||
disk_space_info?: {
|
||||
total: number
|
||||
free: number
|
||||
used: number
|
||||
used_percent: number
|
||||
}
|
||||
memory_stats?: {
|
||||
alloc: number
|
||||
total_alloc: number
|
||||
sys: number
|
||||
num_gc: number
|
||||
num_goroutine: number
|
||||
}
|
||||
disk_cache_info?: {
|
||||
path: string
|
||||
file_count: number
|
||||
total_size: number
|
||||
}
|
||||
config?: {
|
||||
is_running_in_container: boolean
|
||||
}
|
||||
}
|
||||
|
||||
export function PerformanceSection(props: Props) {
|
||||
const { t } = useTranslation()
|
||||
const updateOption = useUpdateOption()
|
||||
const [stats, setStats] = useState<PerformanceStats | null>(null)
|
||||
const [logInfo, setLogInfo] = useState<LogInfo | null>(null)
|
||||
const [logCleanupMode, setLogCleanupMode] = useState('by_count')
|
||||
const [logCleanupValue, setLogCleanupValue] = useState(10)
|
||||
const [logCleanupLoading, setLogCleanupLoading] = useState(false)
|
||||
|
||||
const form = useForm<PerfFormValues>({
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
resolver: zodResolver(perfSchema) as any,
|
||||
defaultValues: props.defaultValues,
|
||||
})
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
useResetForm(form as any, props.defaultValues)
|
||||
|
||||
const fetchStats = useCallback(async () => {
|
||||
try {
|
||||
const res = await api.get('/api/performance/stats')
|
||||
if (res.data.success) setStats(res.data.data)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, [])
|
||||
|
||||
const fetchLogInfo = useCallback(async () => {
|
||||
try {
|
||||
const res = await api.get('/api/performance/logs')
|
||||
if (res.data.success) setLogInfo(res.data.data)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchStats()
|
||||
fetchLogInfo()
|
||||
}, [fetchStats, fetchLogInfo])
|
||||
|
||||
const onSubmit = async (data: PerfFormValues) => {
|
||||
const entries = Object.entries(data) as [string, unknown][]
|
||||
const updates = entries.filter(
|
||||
([key, value]) =>
|
||||
value !== (props.defaultValues[key as keyof PerfFormValues] as unknown)
|
||||
)
|
||||
if (updates.length === 0) {
|
||||
toast.info(t('No changes to save'))
|
||||
return
|
||||
}
|
||||
for (const [key, value] of updates) {
|
||||
await updateOption.mutateAsync({
|
||||
key,
|
||||
value: value as string | number | boolean,
|
||||
})
|
||||
}
|
||||
toast.success(t('Saved successfully'))
|
||||
fetchStats()
|
||||
}
|
||||
|
||||
const clearDiskCache = async () => {
|
||||
try {
|
||||
const res = await api.delete('/api/performance/disk_cache')
|
||||
if (res.data.success) {
|
||||
toast.success(t('Disk cache cleared'))
|
||||
fetchStats()
|
||||
}
|
||||
} catch {
|
||||
toast.error(t('Cleanup failed'))
|
||||
}
|
||||
}
|
||||
|
||||
const resetStats = async () => {
|
||||
try {
|
||||
const res = await api.post('/api/performance/reset_stats')
|
||||
if (res.data.success) {
|
||||
toast.success(t('Statistics reset'))
|
||||
fetchStats()
|
||||
}
|
||||
} catch {
|
||||
toast.error(t('Reset failed'))
|
||||
}
|
||||
}
|
||||
|
||||
const forceGC = async () => {
|
||||
try {
|
||||
const res = await api.post('/api/performance/gc')
|
||||
if (res.data.success) {
|
||||
toast.success(t('GC executed'))
|
||||
fetchStats()
|
||||
}
|
||||
} catch {
|
||||
toast.error(t('GC execution failed'))
|
||||
}
|
||||
}
|
||||
|
||||
const cleanupLogFiles = async () => {
|
||||
if (!logCleanupValue || isNaN(logCleanupValue) || logCleanupValue < 1) {
|
||||
toast.error(t('Please enter a valid number'))
|
||||
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
|
||||
toast.success(
|
||||
t('Cleaned up {{count}} log files, freed {{size}}', {
|
||||
count: deleted_count,
|
||||
size: formatBytes(freed_bytes),
|
||||
})
|
||||
)
|
||||
} else {
|
||||
toast.error(res.data.message || t('Cleanup failed'))
|
||||
}
|
||||
fetchLogInfo()
|
||||
} catch {
|
||||
toast.error(t('Cleanup failed'))
|
||||
} finally {
|
||||
setLogCleanupLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const diskEnabled = form.watch('performance_setting.disk_cache_enabled')
|
||||
const monitorEnabled = form.watch('performance_setting.monitor_enabled')
|
||||
const maxCacheSizeMb = form.watch(
|
||||
'performance_setting.disk_cache_max_size_mb'
|
||||
)
|
||||
|
||||
const lowDiskSpace =
|
||||
diskEnabled &&
|
||||
stats?.disk_space_info &&
|
||||
stats.disk_space_info.free > 0 &&
|
||||
maxCacheSizeMb > 0 &&
|
||||
stats.disk_space_info.free < maxCacheSizeMb * 1024 * 1024
|
||||
|
||||
const diskCachePercent =
|
||||
stats?.cache_stats?.disk_cache_max_bytes &&
|
||||
stats.cache_stats.disk_cache_max_bytes > 0
|
||||
? Math.round(
|
||||
(stats.cache_stats.current_disk_usage_bytes /
|
||||
stats.cache_stats.disk_cache_max_bytes) *
|
||||
100
|
||||
)
|
||||
: 0
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
title={t('Performance Settings')}
|
||||
description={t(
|
||||
'Disk cache, system performance monitoring, and operation statistics'
|
||||
)}
|
||||
>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className='space-y-6'>
|
||||
{/* Disk Cache Settings */}
|
||||
<div>
|
||||
<h4 className='font-medium'>{t('Disk Cache Settings')}</h4>
|
||||
<p className='text-muted-foreground mt-1 text-xs'>
|
||||
{t(
|
||||
'When enabled, large request bodies are temporarily stored on disk instead of memory, significantly reducing memory usage. SSD recommended.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-1 gap-4 md:grid-cols-3'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='performance_setting.disk_cache_enabled'
|
||||
render={({ field }) => (
|
||||
<FormItem className='flex items-center gap-2'>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormLabel>{t('Enable Disk Cache')}</FormLabel>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='performance_setting.disk_cache_threshold_mb'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Disk Cache Threshold (MB)')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input type='number' {...field} disabled={!diskEnabled} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('Use disk cache when request body exceeds this size')}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='performance_setting.disk_cache_max_size_mb'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Max Disk Cache Size (MB)')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input type='number' {...field} disabled={!diskEnabled} />
|
||||
</FormControl>
|
||||
{stats?.disk_space_info &&
|
||||
stats.disk_space_info.total > 0 && (
|
||||
<FormDescription>
|
||||
{t('Free: {{free}} / Total: {{total}}', {
|
||||
free: formatBytes(stats.disk_space_info.free),
|
||||
total: formatBytes(stats.disk_space_info.total),
|
||||
})}
|
||||
</FormDescription>
|
||||
)}
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{lowDiskSpace && (
|
||||
<Alert variant='destructive'>
|
||||
<AlertDescription>
|
||||
{`${t('Warning')}: ${t('Available disk space')} (${formatBytes(stats?.disk_space_info?.free ?? 0)}) ${t('is less than the configured maximum cache size')} (${maxCacheSizeMb} MB). ${t('This may cause cache failures.')}`}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{!stats?.config?.is_running_in_container && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='performance_setting.disk_cache_path'
|
||||
render={({ field }) => (
|
||||
<FormItem className='max-w-md'>
|
||||
<FormLabel>{t('Cache Directory')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t(
|
||||
'Leave empty to use system temp directory'
|
||||
)}
|
||||
{...field}
|
||||
value={field.value ?? ''}
|
||||
disabled={!diskEnabled}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* System Performance Monitor */}
|
||||
<div>
|
||||
<h4 className='font-medium'>
|
||||
{t('System Performance Monitoring')}
|
||||
</h4>
|
||||
<p className='text-muted-foreground mt-1 text-xs'>
|
||||
{t(
|
||||
'When performance monitoring is enabled and system resource usage exceeds the set threshold, new Relay requests will be rejected.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-1 gap-4 md:grid-cols-4'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='performance_setting.monitor_enabled'
|
||||
render={({ field }) => (
|
||||
<FormItem className='flex items-center gap-2'>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormLabel>{t('Enable Performance Monitoring')}</FormLabel>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='performance_setting.monitor_cpu_threshold'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('CPU Threshold (%)')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type='number'
|
||||
{...field}
|
||||
disabled={!monitorEnabled}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='performance_setting.monitor_memory_threshold'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Memory Threshold (%)')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type='number'
|
||||
{...field}
|
||||
disabled={!monitorEnabled}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='performance_setting.monitor_disk_threshold'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Disk Threshold (%)')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type='number'
|
||||
{...field}
|
||||
disabled={!monitorEnabled}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button type='submit' disabled={updateOption.isPending}>
|
||||
{updateOption.isPending ? t('Saving...') : t('Save Changes')}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Server Log Management */}
|
||||
<div className='space-y-4'>
|
||||
<div>
|
||||
<h4 className='font-medium'>{t('Server Log Management')}</h4>
|
||||
<p className='text-muted-foreground mt-1 text-xs'>
|
||||
{t(
|
||||
'Manage server log files. Log files accumulate over time; regular cleanup is recommended to free disk space.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{logInfo === null ? null : logInfo.enabled ? (
|
||||
<div className='space-y-4'>
|
||||
<div className='rounded-lg border p-4'>
|
||||
<div className='grid grid-cols-2 gap-2 text-sm md:grid-cols-4'>
|
||||
<div>
|
||||
<span className='text-muted-foreground'>
|
||||
{t('Log Directory')}:
|
||||
</span>{' '}
|
||||
<span className='font-mono text-xs'>{logInfo.log_dir}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className='text-muted-foreground'>
|
||||
{t('Log File Count')}:
|
||||
</span>{' '}
|
||||
{logInfo.file_count}
|
||||
</div>
|
||||
<div>
|
||||
<span className='text-muted-foreground'>
|
||||
{t('Total Log Size')}:
|
||||
</span>{' '}
|
||||
{formatBytes(logInfo.total_size)}
|
||||
</div>
|
||||
{logInfo.oldest_time && logInfo.newest_time && (
|
||||
<div>
|
||||
<span className='text-muted-foreground'>
|
||||
{t('Date Range')}:
|
||||
</span>{' '}
|
||||
{dayjs(logInfo.oldest_time).format('YYYY-MM-DD')} ~{' '}
|
||||
{dayjs(logInfo.newest_time).format('YYYY-MM-DD')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex flex-wrap items-end gap-3'>
|
||||
<div className='grid gap-1.5'>
|
||||
<Label className='text-xs'>{t('Cleanup Mode')}</Label>
|
||||
<Select
|
||||
value={logCleanupMode}
|
||||
onValueChange={setLogCleanupMode}
|
||||
>
|
||||
<SelectTrigger className='w-[160px]'>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='by_count'>
|
||||
{t('Retain last N files')}
|
||||
</SelectItem>
|
||||
<SelectItem value='by_days'>
|
||||
{t('Retain last N days')}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className='grid gap-1.5'>
|
||||
<Label className='text-xs'>
|
||||
{logCleanupMode === 'by_count'
|
||||
? t('Files to Retain')
|
||||
: t('Days to Retain')}
|
||||
</Label>
|
||||
<Input
|
||||
type='number'
|
||||
min={1}
|
||||
max={logCleanupMode === 'by_count' ? 1000 : 3650}
|
||||
value={logCleanupValue}
|
||||
onChange={(e) => setLogCleanupValue(Number(e.target.value))}
|
||||
className='w-[120px]'
|
||||
/>
|
||||
</div>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant='destructive'
|
||||
size='sm'
|
||||
disabled={logCleanupLoading}
|
||||
>
|
||||
{logCleanupLoading
|
||||
? t('Cleaning...')
|
||||
: t('Clean Up Log Files')}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t('Confirm log file cleanup?')}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{logCleanupMode === 'by_count'
|
||||
? t(
|
||||
'Only the last {{value}} log files will be retained; the rest will be deleted.',
|
||||
{
|
||||
value: logCleanupValue,
|
||||
}
|
||||
)
|
||||
: t(
|
||||
'Log files older than {{value}} days will be deleted.',
|
||||
{
|
||||
value: logCleanupValue,
|
||||
}
|
||||
)}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t('Cancel')}</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={cleanupLogFiles}>
|
||||
{t('Confirm Cleanup')}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
{t(
|
||||
'Server logging is not enabled (log directory not configured)'
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Performance Stats Dashboard */}
|
||||
<div className='space-y-4'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<h4 className='font-medium'>{t('Performance Monitor')}</h4>
|
||||
<Button variant='outline' size='sm' onClick={fetchStats}>
|
||||
{t('Refresh Stats')}
|
||||
</Button>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant='outline' size='sm'>
|
||||
{t('Clean up inactive cache')}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t('Confirm cleanup of inactive disk cache?')}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t(
|
||||
'This will delete temporary cache files that have not been used for more than 10 minutes'
|
||||
)}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t('Cancel')}</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={clearDiskCache}>
|
||||
{t('Confirm')}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
<Button variant='outline' size='sm' onClick={resetStats}>
|
||||
{t('Reset Stats')}
|
||||
</Button>
|
||||
<Button variant='outline' size='sm' onClick={forceGC}>
|
||||
{t('Run GC')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{stats && (
|
||||
<>
|
||||
<div className='grid grid-cols-1 gap-4 md:grid-cols-2'>
|
||||
<div className='space-y-2 rounded-lg border p-4'>
|
||||
<p className='text-sm font-medium'>
|
||||
{t('Request Body Disk Cache')}
|
||||
</p>
|
||||
<Progress value={diskCachePercent} />
|
||||
<div className='text-muted-foreground flex justify-between text-xs'>
|
||||
<span>
|
||||
{formatBytes(
|
||||
stats.cache_stats?.current_disk_usage_bytes ?? 0
|
||||
)}{' '}
|
||||
/{' '}
|
||||
{formatBytes(stats.cache_stats?.disk_cache_max_bytes ?? 0)}
|
||||
</span>
|
||||
<span>
|
||||
{t('Active Files')}:{' '}
|
||||
{stats.cache_stats?.active_disk_files ?? 0}
|
||||
</span>
|
||||
</div>
|
||||
<StatusBadge variant='neutral' copyable={false}>
|
||||
{t('Disk Hits')}: {stats.cache_stats?.disk_cache_hits ?? 0}
|
||||
</StatusBadge>
|
||||
</div>
|
||||
<div className='space-y-2 rounded-lg border p-4'>
|
||||
<p className='text-sm font-medium'>
|
||||
{t('Request Body Memory Cache')}
|
||||
</p>
|
||||
<div className='text-muted-foreground flex justify-between text-xs'>
|
||||
<span>
|
||||
{t('Current Cache Size')}:{' '}
|
||||
{formatBytes(
|
||||
stats.cache_stats?.current_memory_usage_bytes ?? 0
|
||||
)}
|
||||
</span>
|
||||
<span>
|
||||
{t('Active Cache Count')}:{' '}
|
||||
{stats.cache_stats?.active_memory_buffers ?? 0}
|
||||
</span>
|
||||
</div>
|
||||
<StatusBadge variant='neutral' copyable={false}>
|
||||
{t('Memory Hits')}:{' '}
|
||||
{stats.cache_stats?.memory_cache_hits ?? 0}
|
||||
</StatusBadge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{stats.disk_space_info && stats.disk_space_info.total > 0 && (
|
||||
<div className='rounded-lg border p-4'>
|
||||
<p className='mb-2 text-sm font-medium'>
|
||||
{t('Cache Directory Disk Space')}
|
||||
</p>
|
||||
<Progress
|
||||
value={Math.round(stats.disk_space_info.used_percent)}
|
||||
/>
|
||||
<div className='text-muted-foreground mt-2 flex justify-between text-xs'>
|
||||
<span>
|
||||
{t('Used')}: {formatBytes(stats.disk_space_info.used)}
|
||||
</span>
|
||||
<span>
|
||||
{t('Available')}: {formatBytes(stats.disk_space_info.free)}
|
||||
</span>
|
||||
<span>
|
||||
{t('Total')}: {formatBytes(stats.disk_space_info.total)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{stats.memory_stats && (
|
||||
<div className='rounded-lg border p-4'>
|
||||
<p className='mb-2 text-sm font-medium'>
|
||||
{t('System Memory Stats')}
|
||||
</p>
|
||||
<div className='grid grid-cols-2 gap-2 text-xs md:grid-cols-5'>
|
||||
<div>
|
||||
<span className='text-muted-foreground'>
|
||||
{t('Allocated Memory')}:
|
||||
</span>{' '}
|
||||
{formatBytes(stats.memory_stats.alloc)}
|
||||
</div>
|
||||
<div>
|
||||
<span className='text-muted-foreground'>
|
||||
{t('Total Allocated')}:
|
||||
</span>{' '}
|
||||
{formatBytes(stats.memory_stats.total_alloc)}
|
||||
</div>
|
||||
<div>
|
||||
<span className='text-muted-foreground'>
|
||||
{t('System Memory')}:
|
||||
</span>{' '}
|
||||
{formatBytes(stats.memory_stats.sys)}
|
||||
</div>
|
||||
<div>
|
||||
<span className='text-muted-foreground'>
|
||||
{t('GC Count')}:
|
||||
</span>{' '}
|
||||
{stats.memory_stats.num_gc}
|
||||
</div>
|
||||
<div>
|
||||
<span className='text-muted-foreground'>Goroutines:</span>{' '}
|
||||
{stats.memory_stats.num_goroutine}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{stats.disk_cache_info && (
|
||||
<div className='rounded-lg border p-4'>
|
||||
<p className='mb-2 text-sm font-medium'>
|
||||
{t('Cache Directory Info')}
|
||||
</p>
|
||||
<div className='grid grid-cols-3 gap-2 text-xs'>
|
||||
<div>
|
||||
<span className='text-muted-foreground'>
|
||||
{t('Cache Directory')}:
|
||||
</span>{' '}
|
||||
<span className='font-mono'>
|
||||
{stats.disk_cache_info.path}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className='text-muted-foreground'>
|
||||
{t('Directory File Count')}:
|
||||
</span>{' '}
|
||||
{stats.disk_cache_info.file_count}
|
||||
</div>
|
||||
<div>
|
||||
<span className='text-muted-foreground'>
|
||||
{t('Directory Total Size')}:
|
||||
</span>{' '}
|
||||
{formatBytes(stats.disk_cache_info.total_size)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</SettingsSection>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import type { MaintenanceSettings } from '../types'
|
||||
import { createSectionRegistry } from '../utils/section-registry'
|
||||
import {
|
||||
parseHeaderNavModules,
|
||||
parseSidebarModulesAdmin,
|
||||
serializeHeaderNavModules,
|
||||
serializeSidebarModulesAdmin,
|
||||
} from './config'
|
||||
import { HeaderNavigationSection } from './header-navigation-section'
|
||||
import { LogSettingsSection } from './log-settings-section'
|
||||
import { NoticeSection } from './notice-section'
|
||||
import { PerformanceSection } from './performance-section'
|
||||
import { SidebarModulesSection } from './sidebar-modules-section'
|
||||
import { UpdateCheckerSection } from './update-checker-section'
|
||||
|
||||
const MAINTENANCE_SECTIONS = [
|
||||
{
|
||||
id: 'update-checker',
|
||||
titleKey: 'System maintenance',
|
||||
descriptionKey: 'Check for system updates',
|
||||
build: (
|
||||
_settings: MaintenanceSettings,
|
||||
currentVersion?: string | null,
|
||||
startTime?: number | null
|
||||
) => (
|
||||
<UpdateCheckerSection
|
||||
currentVersion={currentVersion}
|
||||
startTime={startTime}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'notice',
|
||||
titleKey: 'System Notice',
|
||||
descriptionKey: 'Configure system maintenance notice',
|
||||
build: (settings: MaintenanceSettings) => (
|
||||
<NoticeSection defaultValue={settings.Notice ?? ''} />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'logs',
|
||||
titleKey: 'Log Maintenance',
|
||||
descriptionKey: 'Configure log consumption settings',
|
||||
build: (settings: MaintenanceSettings) => (
|
||||
<LogSettingsSection
|
||||
defaultEnabled={Boolean(settings.LogConsumeEnabled)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'header-navigation',
|
||||
titleKey: 'Header navigation',
|
||||
descriptionKey: 'Configure header navigation modules',
|
||||
build: (settings: MaintenanceSettings) => {
|
||||
const headerNavConfig = parseHeaderNavModules(settings.HeaderNavModules)
|
||||
const headerNavSerialized = serializeHeaderNavModules(headerNavConfig)
|
||||
return (
|
||||
<HeaderNavigationSection
|
||||
config={headerNavConfig}
|
||||
initialSerialized={headerNavSerialized}
|
||||
/>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'sidebar-modules',
|
||||
titleKey: 'Sidebar modules',
|
||||
descriptionKey: 'Configure sidebar modules for admin',
|
||||
build: (settings: MaintenanceSettings) => {
|
||||
const sidebarConfig = parseSidebarModulesAdmin(
|
||||
settings.SidebarModulesAdmin
|
||||
)
|
||||
const sidebarSerialized = serializeSidebarModulesAdmin(sidebarConfig)
|
||||
return (
|
||||
<SidebarModulesSection
|
||||
config={sidebarConfig}
|
||||
initialSerialized={sidebarSerialized}
|
||||
/>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'performance',
|
||||
titleKey: 'Performance',
|
||||
descriptionKey: 'Disk cache, system monitoring and performance stats',
|
||||
build: (settings: MaintenanceSettings) => (
|
||||
<PerformanceSection
|
||||
defaultValues={{
|
||||
'performance_setting.disk_cache_enabled':
|
||||
settings['performance_setting.disk_cache_enabled'] ?? false,
|
||||
'performance_setting.disk_cache_threshold_mb':
|
||||
settings['performance_setting.disk_cache_threshold_mb'] ?? 10,
|
||||
'performance_setting.disk_cache_max_size_mb':
|
||||
settings['performance_setting.disk_cache_max_size_mb'] ?? 1024,
|
||||
'performance_setting.disk_cache_path':
|
||||
settings['performance_setting.disk_cache_path'] ?? '',
|
||||
'performance_setting.monitor_enabled':
|
||||
settings['performance_setting.monitor_enabled'] ?? false,
|
||||
'performance_setting.monitor_cpu_threshold':
|
||||
settings['performance_setting.monitor_cpu_threshold'] ?? 90,
|
||||
'performance_setting.monitor_memory_threshold':
|
||||
settings['performance_setting.monitor_memory_threshold'] ?? 90,
|
||||
'performance_setting.monitor_disk_threshold':
|
||||
settings['performance_setting.monitor_disk_threshold'] ?? 95,
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
] as const
|
||||
|
||||
export type MaintenanceSectionId = (typeof MAINTENANCE_SECTIONS)[number]['id']
|
||||
|
||||
const maintenanceRegistry = createSectionRegistry<
|
||||
MaintenanceSectionId,
|
||||
MaintenanceSettings,
|
||||
[string | null | undefined, number | null | undefined]
|
||||
>({
|
||||
sections: MAINTENANCE_SECTIONS,
|
||||
defaultSection: 'update-checker',
|
||||
basePath: '/system-settings/maintenance',
|
||||
urlStyle: 'path',
|
||||
})
|
||||
|
||||
export const MAINTENANCE_SECTION_IDS = maintenanceRegistry.sectionIds
|
||||
export const MAINTENANCE_DEFAULT_SECTION = maintenanceRegistry.defaultSection
|
||||
export const getMaintenanceSectionNavItems =
|
||||
maintenanceRegistry.getSectionNavItems
|
||||
export const getMaintenanceSectionContent =
|
||||
maintenanceRegistry.getSectionContent
|
||||
+259
@@ -0,0 +1,259 @@
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
} from '@/components/ui/form'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { SettingsSection } from '../components/settings-section'
|
||||
import { useUpdateOption } from '../hooks/use-update-option'
|
||||
import {
|
||||
SIDEBAR_MODULES_DEFAULT,
|
||||
type SidebarModulesAdminConfig,
|
||||
serializeSidebarModulesAdmin,
|
||||
} from './config'
|
||||
|
||||
type SidebarModulesSectionProps = {
|
||||
config: SidebarModulesAdminConfig
|
||||
initialSerialized: string
|
||||
}
|
||||
|
||||
type SidebarFormValues = SidebarModulesAdminConfig
|
||||
|
||||
const toTitleCase = (value: string) =>
|
||||
value.replace(/[_-]+/g, ' ').replace(/\b\w/g, (char) => char.toUpperCase())
|
||||
|
||||
export function SidebarModulesSection({
|
||||
config,
|
||||
initialSerialized,
|
||||
}: SidebarModulesSectionProps) {
|
||||
const { t } = useTranslation()
|
||||
const updateOption = useUpdateOption()
|
||||
|
||||
const sectionMeta: Record<string, { title: string; description: string }> = {
|
||||
chat: {
|
||||
title: t('Chat area'),
|
||||
description: t('Playground experiments and live conversations.'),
|
||||
},
|
||||
console: {
|
||||
title: t('Console area'),
|
||||
description: t('Dashboards, tokens, and usage analytics.'),
|
||||
},
|
||||
personal: {
|
||||
title: t('Personal area'),
|
||||
description: t('Wallet management and personal preferences.'),
|
||||
},
|
||||
admin: {
|
||||
title: t('Admin area'),
|
||||
description: t('Global configuration and administrative tools.'),
|
||||
},
|
||||
}
|
||||
|
||||
const moduleMeta: Record<
|
||||
string,
|
||||
Record<string, { title: string; description: string }>
|
||||
> = {
|
||||
chat: {
|
||||
playground: {
|
||||
title: t('Playground'),
|
||||
description: t('Experiment with prompts and models in real time.'),
|
||||
},
|
||||
chat: {
|
||||
title: t('Chat'),
|
||||
description: t('Access previous conversations and start new ones.'),
|
||||
},
|
||||
},
|
||||
console: {
|
||||
detail: {
|
||||
title: t('Dashboard'),
|
||||
description: t('Aggregated usage metrics and trend charts.'),
|
||||
},
|
||||
token: {
|
||||
title: t('Token management'),
|
||||
description: t('Create, revoke, and audit API tokens.'),
|
||||
},
|
||||
log: {
|
||||
title: t('Usage logs'),
|
||||
description: t('Detailed request logs for investigations.'),
|
||||
},
|
||||
midjourney: {
|
||||
title: t('Drawing logs'),
|
||||
description: t('History of Midjourney-style image tasks.'),
|
||||
},
|
||||
task: {
|
||||
title: t('Task logs'),
|
||||
description: t('Background job tracker for queued work.'),
|
||||
},
|
||||
},
|
||||
personal: {
|
||||
topup: {
|
||||
title: t('Wallet'),
|
||||
description: t('Top up balance and view billing history.'),
|
||||
},
|
||||
personal: {
|
||||
title: t('Profile'),
|
||||
description: t('Personal settings and profile management.'),
|
||||
},
|
||||
},
|
||||
admin: {
|
||||
channel: {
|
||||
title: t('Channels'),
|
||||
description: t('Configure upstream providers and routing.'),
|
||||
},
|
||||
models: {
|
||||
title: t('Models'),
|
||||
description: t('Manage catalog visibility and pricing.'),
|
||||
},
|
||||
redemption: {
|
||||
title: t('Redeem codes'),
|
||||
description: t('Create and review invite or credit codes.'),
|
||||
},
|
||||
user: {
|
||||
title: t('Users'),
|
||||
description: t('Administer user accounts and roles.'),
|
||||
},
|
||||
setting: {
|
||||
title: t('System settings'),
|
||||
description: t('Advanced platform configuration.'),
|
||||
},
|
||||
subscription: {
|
||||
title: t('Subscription Management'),
|
||||
description: t('Manage subscription plans and pricing.'),
|
||||
},
|
||||
},
|
||||
}
|
||||
const formDefaults = useMemo(() => config, [config])
|
||||
|
||||
const form = useForm<SidebarFormValues>({
|
||||
defaultValues: formDefaults,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
form.reset(formDefaults)
|
||||
}, [formDefaults, form])
|
||||
|
||||
const onSubmit = async (values: SidebarFormValues) => {
|
||||
const serialized = serializeSidebarModulesAdmin(values)
|
||||
if (serialized === initialSerialized) {
|
||||
return
|
||||
}
|
||||
|
||||
await updateOption.mutateAsync({
|
||||
key: 'SidebarModulesAdmin',
|
||||
value: serialized,
|
||||
})
|
||||
}
|
||||
|
||||
const resetToDefault = () => {
|
||||
form.reset(SIDEBAR_MODULES_DEFAULT)
|
||||
}
|
||||
|
||||
const sections = Object.entries(config)
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
title={t('Sidebar modules')}
|
||||
description={t(
|
||||
'Control which sidebar areas and modules are available to all users.'
|
||||
)}
|
||||
>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className='space-y-6'>
|
||||
{sections.map(([sectionKey, sectionConfig]) => {
|
||||
const sectionInfo = sectionMeta[sectionKey] ?? {
|
||||
title: toTitleCase(sectionKey),
|
||||
description: t('Custom sidebar section'),
|
||||
}
|
||||
const modules = Object.entries(sectionConfig).filter(
|
||||
([moduleKey]) => moduleKey !== 'enabled'
|
||||
)
|
||||
|
||||
return (
|
||||
<div key={sectionKey} className='rounded-lg border p-4'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
name={`${sectionKey}.enabled` as any}
|
||||
render={({ field }) => (
|
||||
<FormItem className='flex flex-row items-start justify-between rounded-lg border p-4'>
|
||||
<div className='space-y-0.5 pe-4'>
|
||||
<FormLabel className='text-base'>
|
||||
{sectionInfo.title}
|
||||
</FormLabel>
|
||||
<FormDescription>
|
||||
{sectionInfo.description}
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={Boolean(field.value)}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className='mt-4 grid gap-4 md:grid-cols-2'>
|
||||
{modules.map(([moduleKey]) => {
|
||||
const moduleInfo = moduleMeta[sectionKey]?.[moduleKey] ?? {
|
||||
title: toTitleCase(moduleKey),
|
||||
description: t('Custom module'),
|
||||
}
|
||||
return (
|
||||
<FormField
|
||||
key={`${sectionKey}.${moduleKey}`}
|
||||
control={form.control}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
name={`${sectionKey}.${moduleKey}` as any}
|
||||
render={({ field }) => (
|
||||
<FormItem className='flex flex-row items-start justify-between rounded-lg border p-4'>
|
||||
<div className='space-y-0.5 pe-4'>
|
||||
<FormLabel className='text-base'>
|
||||
{moduleInfo.title}
|
||||
</FormLabel>
|
||||
<FormDescription>
|
||||
{moduleInfo.description}
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={Boolean(field.value)}
|
||||
onCheckedChange={field.onChange}
|
||||
disabled={
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
!form.watch(`${sectionKey}.enabled` as any)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
<div className='flex flex-wrap gap-3'>
|
||||
<Button type='button' variant='outline' onClick={resetToDefault}>
|
||||
{t('Reset to default')}
|
||||
</Button>
|
||||
<Button type='submit' disabled={updateOption.isPending}>
|
||||
{updateOption.isPending
|
||||
? t('Saving...')
|
||||
: t('Save sidebar modules')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</SettingsSection>
|
||||
)
|
||||
}
|
||||
+178
@@ -0,0 +1,178 @@
|
||||
import { useState } from 'react'
|
||||
import { ExternalLinkIcon, RefreshCcwIcon } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { formatTimestamp, formatTimestampToDate } from '@/lib/format'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Markdown } from '@/components/ui/markdown'
|
||||
import { SettingsSection } from '../components/settings-section'
|
||||
|
||||
type ReleaseInfo = {
|
||||
tag_name: string
|
||||
name?: string
|
||||
body?: string
|
||||
html_url?: string
|
||||
published_at?: string
|
||||
}
|
||||
|
||||
type UpdateCheckerSectionProps = {
|
||||
currentVersion?: string | null
|
||||
startTime?: number | null
|
||||
}
|
||||
|
||||
export function UpdateCheckerSection({
|
||||
currentVersion,
|
||||
startTime,
|
||||
}: UpdateCheckerSectionProps) {
|
||||
const { t } = useTranslation()
|
||||
const [checking, setChecking] = useState(false)
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [release, setRelease] = useState<ReleaseInfo | null>(null)
|
||||
|
||||
const uptime = startTime ? formatTimestamp(startTime) : t('Unknown')
|
||||
const version = currentVersion || t('Unknown')
|
||||
|
||||
const handleCheckUpdates = async () => {
|
||||
setChecking(true)
|
||||
try {
|
||||
const response = await fetch(
|
||||
'https://api.github.com/repos/Calcium-Ion/new-api/releases/latest',
|
||||
{
|
||||
headers: {
|
||||
Accept: 'application/vnd.github+json',
|
||||
'User-Agent': 'new-api-dashboard',
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(t('Failed to contact GitHub releases API'))
|
||||
}
|
||||
|
||||
const data = (await response.json()) as ReleaseInfo
|
||||
if (!data?.tag_name) {
|
||||
throw new Error(t('Unexpected release payload'))
|
||||
}
|
||||
|
||||
if (currentVersion && data.tag_name === currentVersion) {
|
||||
toast.success(
|
||||
t('You are running the latest version ({{version}}).', {
|
||||
version: data.tag_name,
|
||||
})
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
setRelease(data)
|
||||
setDialogOpen(true)
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t('Failed to check for updates')
|
||||
toast.error(message)
|
||||
} finally {
|
||||
setChecking(false)
|
||||
}
|
||||
}
|
||||
|
||||
const goToRelease = () => {
|
||||
if (release?.html_url) {
|
||||
window.open(release.html_url, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsSection
|
||||
title={t('System maintenance')}
|
||||
description={t('Review current version and fetch release notes.')}
|
||||
>
|
||||
<div className='space-y-6'>
|
||||
<div className='grid gap-4 md:grid-cols-2'>
|
||||
<div className='rounded-lg border p-4'>
|
||||
<div className='text-muted-foreground text-sm'>
|
||||
{t('Current version')}
|
||||
</div>
|
||||
<div className='text-lg font-semibold'>{version}</div>
|
||||
</div>
|
||||
<div className='rounded-lg border p-4'>
|
||||
<div className='text-muted-foreground text-sm'>
|
||||
{t('Uptime since')}
|
||||
</div>
|
||||
<div className='text-lg font-semibold'>{uptime}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button onClick={handleCheckUpdates} disabled={checking}>
|
||||
{checking ? (
|
||||
t('Checking updates...')
|
||||
) : (
|
||||
<>
|
||||
<RefreshCcwIcon className='me-2 h-4 w-4' />
|
||||
{t('Check for updates')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent className='max-h-[80vh] overflow-y-auto'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{release?.tag_name
|
||||
? t('New version available: {{version}}', {
|
||||
version: release.tag_name,
|
||||
})
|
||||
: t('Release details')}
|
||||
</DialogTitle>
|
||||
{release?.published_at && (
|
||||
<DialogDescription>
|
||||
{t('Published')}{' '}
|
||||
{formatTimestampToDate(
|
||||
new Date(release.published_at).getTime(),
|
||||
'milliseconds'
|
||||
)}
|
||||
</DialogDescription>
|
||||
)}
|
||||
</DialogHeader>
|
||||
|
||||
<div className='space-y-4'>
|
||||
{release?.body ? (
|
||||
<Markdown>{release.body}</Markdown>
|
||||
) : (
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
{t('No release notes provided.')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type='button'
|
||||
variant='secondary'
|
||||
onClick={() => setDialogOpen(false)}
|
||||
>
|
||||
{t('Close')}
|
||||
</Button>
|
||||
{release?.html_url && (
|
||||
<Button type='button' onClick={goToRelease}>
|
||||
<ExternalLinkIcon className='me-2 h-4 w-4' />
|
||||
{t('Open release')}
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,388 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import {
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getFilteredRowModel,
|
||||
getPaginationRowModel,
|
||||
useReactTable,
|
||||
type ColumnDef,
|
||||
type RowSelectionState,
|
||||
} from '@tanstack/react-table'
|
||||
import { Search } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { DataTablePagination } from '@/components/data-table/pagination'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import type { UpstreamChannel } from '../types'
|
||||
import {
|
||||
CHANNEL_STATUS_CONFIG,
|
||||
DEFAULT_ENDPOINT,
|
||||
ENDPOINT_OPTIONS,
|
||||
MODELS_DEV_PRESET_ID,
|
||||
OFFICIAL_CHANNEL_ID,
|
||||
} from './constants'
|
||||
|
||||
type ChannelSelectorDialogProps = {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
channels: UpstreamChannel[]
|
||||
selectedChannelIds: number[]
|
||||
onSelectedChannelIdsChange: (ids: number[]) => void
|
||||
channelEndpoints: Record<number, string>
|
||||
onChannelEndpointsChange: (endpoints: Record<number, string>) => void
|
||||
onConfirm: (selectedIds: number[]) => void
|
||||
}
|
||||
|
||||
// Synthesized presets from `controller/ratio_sync.go` always carry stable
|
||||
// negative IDs, so matching by ID alone is reliable and self-documenting.
|
||||
function isOfficialChannel(channel: UpstreamChannel): boolean {
|
||||
return (
|
||||
channel.id === OFFICIAL_CHANNEL_ID || channel.id === MODELS_DEV_PRESET_ID
|
||||
)
|
||||
}
|
||||
|
||||
export function ChannelSelectorDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
channels,
|
||||
selectedChannelIds,
|
||||
onSelectedChannelIdsChange,
|
||||
channelEndpoints,
|
||||
onChannelEndpointsChange,
|
||||
onConfirm,
|
||||
}: ChannelSelectorDialogProps) {
|
||||
const { t } = useTranslation()
|
||||
const [search, setSearch] = useState('')
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedChannelIds.length) {
|
||||
setRowSelection({})
|
||||
return
|
||||
}
|
||||
|
||||
const availableChannelIds = new Set(channels.map((channel) => channel.id))
|
||||
const newSelection: RowSelectionState = {}
|
||||
|
||||
selectedChannelIds.forEach((id) => {
|
||||
if (availableChannelIds.has(id)) {
|
||||
newSelection[id.toString()] = true
|
||||
}
|
||||
})
|
||||
|
||||
setRowSelection(newSelection)
|
||||
}, [selectedChannelIds, channels])
|
||||
|
||||
const updateEndpoint = useCallback(
|
||||
(channelId: number, endpoint: string) => {
|
||||
onChannelEndpointsChange({
|
||||
...channelEndpoints,
|
||||
[channelId]: endpoint,
|
||||
})
|
||||
},
|
||||
[channelEndpoints, onChannelEndpointsChange]
|
||||
)
|
||||
|
||||
const getEndpointType = (endpoint: string) => {
|
||||
const option = ENDPOINT_OPTIONS.find((opt) => opt.value === endpoint)
|
||||
return option ? endpoint : 'custom'
|
||||
}
|
||||
|
||||
const columns = useMemo<ColumnDef<UpstreamChannel>[]>(
|
||||
() => [
|
||||
{
|
||||
id: 'select',
|
||||
header: ({ table }) => (
|
||||
<Checkbox
|
||||
checked={
|
||||
table.getIsAllPageRowsSelected() ||
|
||||
(table.getIsSomePageRowsSelected() && 'indeterminate')
|
||||
}
|
||||
onCheckedChange={(value) =>
|
||||
table.toggleAllPageRowsSelected(!!value)
|
||||
}
|
||||
aria-label='Select all'
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<Checkbox
|
||||
checked={row.getIsSelected()}
|
||||
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||||
aria-label='Select row'
|
||||
/>
|
||||
),
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
},
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: t('Name'),
|
||||
cell: ({ row }) => {
|
||||
const name = row.getValue('name') as string
|
||||
const channel = row.original
|
||||
const isOfficial = isOfficialChannel(channel)
|
||||
|
||||
return (
|
||||
<div className='flex items-center gap-2'>
|
||||
<span className='font-medium'>{name}</span>
|
||||
{isOfficial && (
|
||||
<StatusBadge
|
||||
label={t('Official')}
|
||||
variant='success'
|
||||
size='sm'
|
||||
copyable={false}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'base_url',
|
||||
header: t('Base URL'),
|
||||
cell: ({ row }) => {
|
||||
const url = row.getValue('base_url') as string
|
||||
return (
|
||||
<span
|
||||
className='text-muted-foreground block max-w-xs truncate font-mono text-xs'
|
||||
title={url}
|
||||
>
|
||||
{url}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'status',
|
||||
header: t('Status'),
|
||||
cell: ({ row }) => {
|
||||
const status = row.getValue('status') as number
|
||||
const config =
|
||||
CHANNEL_STATUS_CONFIG[status as keyof typeof CHANNEL_STATUS_CONFIG]
|
||||
|
||||
if (!config) {
|
||||
return (
|
||||
<StatusBadge
|
||||
label={t('Unknown')}
|
||||
variant='neutral'
|
||||
size='sm'
|
||||
copyable={false}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<StatusBadge
|
||||
label={config.label}
|
||||
variant={config.variant}
|
||||
size='sm'
|
||||
copyable={false}
|
||||
/>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'endpoint',
|
||||
header: t('Sync Endpoint'),
|
||||
cell: ({ row }) => {
|
||||
const channel = row.original
|
||||
const currentEndpoint =
|
||||
channelEndpoints[channel.id] || DEFAULT_ENDPOINT
|
||||
const endpointType = getEndpointType(currentEndpoint)
|
||||
|
||||
const handleTypeChange = (value: string) => {
|
||||
if (value === 'custom') {
|
||||
updateEndpoint(channel.id, '')
|
||||
} else {
|
||||
updateEndpoint(channel.id, value)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex items-center gap-2'>
|
||||
<Select value={endpointType} onValueChange={handleTypeChange}>
|
||||
<SelectTrigger className='h-8 w-32'>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ENDPOINT_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{endpointType === 'custom' && (
|
||||
<Input
|
||||
value={currentEndpoint}
|
||||
onChange={(e) => updateEndpoint(channel.id, e.target.value)}
|
||||
placeholder={t('/your/endpoint')}
|
||||
className='h-8 w-40 font-mono text-xs'
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
],
|
||||
[channelEndpoints, t, updateEndpoint]
|
||||
)
|
||||
|
||||
const filteredChannels = useMemo(() => {
|
||||
if (!search.trim()) return channels
|
||||
|
||||
const searchLower = search.toLowerCase()
|
||||
return channels.filter(
|
||||
(ch) =>
|
||||
ch.name.toLowerCase().includes(searchLower) ||
|
||||
ch.base_url.toLowerCase().includes(searchLower)
|
||||
)
|
||||
}, [channels, search])
|
||||
|
||||
const sortedChannels = useMemo(() => {
|
||||
return [...filteredChannels].sort((a, b) => {
|
||||
const aIsOfficial = isOfficialChannel(a)
|
||||
const bIsOfficial = isOfficialChannel(b)
|
||||
if (aIsOfficial && !bIsOfficial) return -1
|
||||
if (!aIsOfficial && bIsOfficial) return 1
|
||||
return 0
|
||||
})
|
||||
}, [filteredChannels])
|
||||
|
||||
const table = useReactTable({
|
||||
data: sortedChannels,
|
||||
columns,
|
||||
state: {
|
||||
rowSelection,
|
||||
},
|
||||
getRowId: (row) => row.id.toString(),
|
||||
enableRowSelection: true,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
initialState: {
|
||||
pagination: {
|
||||
pageSize: 10,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const handleConfirm = () => {
|
||||
const selectedRows = table.getSelectedRowModel().rows
|
||||
const selectedIds = selectedRows.map((row) => row.original.id)
|
||||
onSelectedChannelIdsChange(selectedIds)
|
||||
onOpenChange(false)
|
||||
onConfirm(selectedIds)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className='flex max-h-[90vh] max-w-[calc(100%-2rem)] flex-col sm:max-w-[90vw] xl:max-w-[1400px]'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('Select Sync Channels')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('Choose channels to sync upstream ratio configurations from')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className='flex flex-1 flex-col gap-4 overflow-hidden'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<div className='relative flex-1'>
|
||||
<Search className='text-muted-foreground absolute top-1/2 left-2 h-4 w-4 -translate-y-1/2' />
|
||||
<Input
|
||||
placeholder={t('Search by name or URL...')}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className='ps-8'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex-1 overflow-auto rounded-md border'>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHead key={header.id}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && 'selected'}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext()
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={columns.length}
|
||||
className='h-24 text-center'
|
||||
>
|
||||
{t('No channels found')}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<DataTablePagination table={table} />
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant='outline' onClick={() => onOpenChange(false)}>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleConfirm}>{t('Confirm Selection')}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import * as z from 'zod'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { SettingsSection } from '../components/settings-section'
|
||||
import { useUpdateOption } from '../hooks/use-update-option'
|
||||
import {
|
||||
formatJsonForTextarea,
|
||||
normalizeJsonString,
|
||||
validateJsonString,
|
||||
} from './utils'
|
||||
|
||||
const schema = z.object({
|
||||
claude: z.object({
|
||||
model_headers_settings: z.string().superRefine((value, ctx) => {
|
||||
const result = validateJsonString(value)
|
||||
if (!result.valid) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: result.message || 'Invalid JSON',
|
||||
})
|
||||
}
|
||||
}),
|
||||
default_max_tokens: z.string().superRefine((value, ctx) => {
|
||||
const result = validateJsonString(value)
|
||||
if (!result.valid) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: result.message || 'Invalid JSON',
|
||||
})
|
||||
}
|
||||
}),
|
||||
thinking_adapter_enabled: z.boolean(),
|
||||
thinking_adapter_budget_tokens_percentage: z.coerce
|
||||
.number()
|
||||
.min(0.1, { message: 'Must be at least 0.1' })
|
||||
.max(1, { message: 'Must be 1 or less' }),
|
||||
}),
|
||||
})
|
||||
|
||||
type ClaudeSettingsFormValues = z.output<typeof schema>
|
||||
type ClaudeSettingsFormInput = z.input<typeof schema>
|
||||
|
||||
type FlatClaudeSettings = {
|
||||
'claude.model_headers_settings': string
|
||||
'claude.default_max_tokens': string
|
||||
'claude.thinking_adapter_enabled': boolean
|
||||
'claude.thinking_adapter_budget_tokens_percentage': number
|
||||
}
|
||||
|
||||
type ClaudeSettingsCardProps = {
|
||||
defaultValues: ClaudeSettingsFormInput
|
||||
}
|
||||
|
||||
export function ClaudeSettingsCard({ defaultValues }: ClaudeSettingsCardProps) {
|
||||
const { t } = useTranslation()
|
||||
const updateOption = useUpdateOption()
|
||||
const normalizedDefaultsRef = useRef<FlatClaudeSettings>({
|
||||
'claude.model_headers_settings': normalizeJsonString(
|
||||
defaultValues.claude.model_headers_settings
|
||||
),
|
||||
'claude.default_max_tokens': normalizeJsonString(
|
||||
defaultValues.claude.default_max_tokens
|
||||
),
|
||||
'claude.thinking_adapter_enabled':
|
||||
defaultValues.claude.thinking_adapter_enabled,
|
||||
'claude.thinking_adapter_budget_tokens_percentage': Number(
|
||||
defaultValues.claude.thinking_adapter_budget_tokens_percentage
|
||||
),
|
||||
})
|
||||
|
||||
const buildFormDefaults = (
|
||||
values: ClaudeSettingsFormInput
|
||||
): ClaudeSettingsFormInput => ({
|
||||
claude: {
|
||||
model_headers_settings: formatJsonForTextarea(
|
||||
values.claude.model_headers_settings
|
||||
),
|
||||
default_max_tokens: formatJsonForTextarea(
|
||||
values.claude.default_max_tokens
|
||||
),
|
||||
thinking_adapter_enabled: values.claude.thinking_adapter_enabled,
|
||||
thinking_adapter_budget_tokens_percentage:
|
||||
values.claude.thinking_adapter_budget_tokens_percentage,
|
||||
},
|
||||
})
|
||||
|
||||
const form = useForm<
|
||||
ClaudeSettingsFormInput,
|
||||
unknown,
|
||||
ClaudeSettingsFormValues
|
||||
>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: buildFormDefaults(defaultValues),
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
normalizedDefaultsRef.current = {
|
||||
'claude.model_headers_settings': normalizeJsonString(
|
||||
defaultValues.claude.model_headers_settings
|
||||
),
|
||||
'claude.default_max_tokens': normalizeJsonString(
|
||||
defaultValues.claude.default_max_tokens
|
||||
),
|
||||
'claude.thinking_adapter_enabled':
|
||||
defaultValues.claude.thinking_adapter_enabled,
|
||||
'claude.thinking_adapter_budget_tokens_percentage': Number(
|
||||
defaultValues.claude.thinking_adapter_budget_tokens_percentage
|
||||
),
|
||||
}
|
||||
|
||||
form.reset(buildFormDefaults(defaultValues))
|
||||
}, [defaultValues, form])
|
||||
|
||||
const onSubmit = async (values: ClaudeSettingsFormValues) => {
|
||||
const normalized: FlatClaudeSettings = {
|
||||
'claude.model_headers_settings': normalizeJsonString(
|
||||
values.claude.model_headers_settings
|
||||
),
|
||||
'claude.default_max_tokens': normalizeJsonString(
|
||||
values.claude.default_max_tokens
|
||||
),
|
||||
'claude.thinking_adapter_enabled': values.claude.thinking_adapter_enabled,
|
||||
'claude.thinking_adapter_budget_tokens_percentage':
|
||||
values.claude.thinking_adapter_budget_tokens_percentage,
|
||||
}
|
||||
|
||||
const updates = (
|
||||
Object.keys(normalized) as Array<keyof FlatClaudeSettings>
|
||||
).filter((key) => normalized[key] !== normalizedDefaultsRef.current[key])
|
||||
|
||||
if (updates.length === 0) {
|
||||
toast.info(t('No changes to save'))
|
||||
return
|
||||
}
|
||||
|
||||
for (const key of updates) {
|
||||
await updateOption.mutateAsync({ key, value: normalized[key] })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
title={t('Claude')}
|
||||
description={t(
|
||||
'Override Anthropic headers, defaults, and thinking adapter behavior'
|
||||
)}
|
||||
>
|
||||
<Form {...form}>
|
||||
{/* eslint-disable-next-line react-hooks/refs */}
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className='space-y-6'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='claude.model_headers_settings'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Request Header Overrides')}</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea rows={8} {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
'Provide per-model header overrides as JSON. Useful for enabling beta features such as expanded context windows.'
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='claude.default_max_tokens'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Default Max Tokens')}</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea rows={8} {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('Example')}{' '}
|
||||
{`{ "default": 8192, "claude-3-haiku-20240307": 4096, "claude-3-opus-20240229": 4096, "claude-3-7-sonnet-20250219-thinking": 8192 }`}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className='space-y-4 rounded-lg border p-4'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='claude.thinking_adapter_enabled'
|
||||
render={({ field }) => (
|
||||
<FormItem className='flex flex-row items-center justify-between'>
|
||||
<div className='space-y-0.5'>
|
||||
<FormLabel className='text-base'>
|
||||
{t('Thinking Adapter')}
|
||||
</FormLabel>
|
||||
<FormDescription>
|
||||
{t(
|
||||
'Translate `-thinking` suffixes into Anthropic native thinking models while keeping pricing predictable.'
|
||||
)}
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='claude.thinking_adapter_budget_tokens_percentage'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Budget Tokens Ratio')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
value={String(field.value ?? '')}
|
||||
onChange={(event) => field.onChange(event.target.value)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
'Budget tokens = max tokens × ratio. Accepts a decimal between 0.1 and 1.'
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button type='submit' disabled={updateOption.isPending}>
|
||||
{updateOption.isPending ? t('Saving...') : t('Save Changes')}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
</SettingsSection>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
|
||||
export type ConflictItem = {
|
||||
channel: string
|
||||
model: string
|
||||
current: string
|
||||
newVal: string
|
||||
}
|
||||
|
||||
type ConflictConfirmDialogProps = {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
conflicts: ConflictItem[]
|
||||
onConfirm: () => void
|
||||
isLoading?: boolean
|
||||
}
|
||||
|
||||
export function ConflictConfirmDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
conflicts,
|
||||
onConfirm,
|
||||
isLoading = false,
|
||||
}: ConflictConfirmDialogProps) {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||
<AlertDialogContent className='max-w-4xl'>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t('Confirm Billing Conflicts')}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t(
|
||||
'The following models have billing type conflicts (fixed price vs ratio billing). Confirm to proceed with the changes.'
|
||||
)}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
|
||||
<div className='max-h-96 overflow-y-auto rounded-md border'>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t('Channel')}</TableHead>
|
||||
<TableHead>{t('Model')}</TableHead>
|
||||
<TableHead>{t('Current Billing')}</TableHead>
|
||||
<TableHead>{t('Change To')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{conflicts.map((conflict, index) => (
|
||||
<TableRow key={index}>
|
||||
<TableCell className='font-medium'>
|
||||
{conflict.channel}
|
||||
</TableCell>
|
||||
<TableCell className='font-mono text-sm'>
|
||||
{conflict.model}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<pre className='text-xs whitespace-pre-wrap'>
|
||||
{conflict.current}
|
||||
</pre>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<pre className='text-xs whitespace-pre-wrap'>
|
||||
{conflict.newVal}
|
||||
</pre>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isLoading}>
|
||||
{t('Cancel')}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={onConfirm} disabled={isLoading}>
|
||||
{isLoading ? t('Applying...') : t('Confirm Changes')}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
export const DEFAULT_ENDPOINT = '/api/pricing'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Built-in upstream ratio presets
|
||||
//
|
||||
// The backend (`controller/ratio_sync.go`) synthesizes two virtual channels and
|
||||
// returns them in the syncable channels response. The constants below mirror
|
||||
// the backend literals one-to-one; do NOT translate the *_NAME values because
|
||||
// they are wire-protocol identifiers, not user-facing labels.
|
||||
//
|
||||
// Identification on the frontend should rely on the stable negative ID alone.
|
||||
// `*_NAME` and `*_BASE_URL` are kept for diagnostics, custom channel
|
||||
// detection, and backwards compatibility with the classic frontend.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const OFFICIAL_CHANNEL_ID = -100
|
||||
export const OFFICIAL_CHANNEL_NAME = '官方倍率预设'
|
||||
export const OFFICIAL_CHANNEL_BASE_URL = 'https://basellm.github.io'
|
||||
export const OFFICIAL_CHANNEL_ENDPOINT =
|
||||
'/llm-metadata/api/newapi/ratio_config-v1-base.json'
|
||||
|
||||
export const MODELS_DEV_PRESET_ID = -101
|
||||
export const MODELS_DEV_PRESET_NAME = 'models.dev 价格预设'
|
||||
export const MODELS_DEV_PRESET_BASE_URL = 'https://models.dev'
|
||||
export const MODELS_DEV_PRESET_ENDPOINT = 'https://models.dev/api.json'
|
||||
|
||||
export const OPENROUTER_ENDPOINT = 'openrouter'
|
||||
|
||||
// Backend channel type for OpenRouter (see constant/channel.go: ChannelTypeOpenRouter = 20)
|
||||
export const OPENROUTER_CHANNEL_TYPE = 20
|
||||
|
||||
export const ENDPOINT_OPTIONS = [
|
||||
{ label: 'pricing', value: '/api/pricing' },
|
||||
{ label: 'ratio_config', value: '/api/ratio_config' },
|
||||
{ label: 'OpenRouter', value: OPENROUTER_ENDPOINT },
|
||||
{ label: 'custom', value: 'custom' },
|
||||
] as const
|
||||
|
||||
// Labels reuse the existing sentence-case i18n keys defined for form fields
|
||||
// (e.g. `Model ratio`, `Audio completion ratio`). Do NOT switch to Title Case
|
||||
// here without updating the i18n catalog; otherwise we end up with two keys per
|
||||
// ratio type that only differ in capitalization.
|
||||
export const RATIO_TYPE_OPTIONS = [
|
||||
{ label: 'Model ratio', value: 'model_ratio' },
|
||||
{ label: 'Completion ratio', value: 'completion_ratio' },
|
||||
{ label: 'Cache ratio', value: 'cache_ratio' },
|
||||
{ label: 'Create cache ratio', value: 'create_cache_ratio' },
|
||||
{ label: 'Image ratio', value: 'image_ratio' },
|
||||
{ label: 'Audio ratio', value: 'audio_ratio' },
|
||||
{ label: 'Audio completion ratio', value: 'audio_completion_ratio' },
|
||||
{ label: 'Fixed price', value: 'model_price' },
|
||||
{ label: 'Expression billing', value: 'billing_expr' },
|
||||
] as const
|
||||
|
||||
export const CHANNEL_STATUS_CONFIG = {
|
||||
1: { label: 'Enabled', variant: 'success' as const },
|
||||
2: { label: 'Disabled', variant: 'danger' as const },
|
||||
3: { label: 'Auto-Disabled', variant: 'warning' as const },
|
||||
} as const
|
||||
@@ -0,0 +1,397 @@
|
||||
import { useEffect, useMemo, useRef } from 'react'
|
||||
import * as z from 'zod'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { SettingsSection } from '../components/settings-section'
|
||||
import { useUpdateOption } from '../hooks/use-update-option'
|
||||
import {
|
||||
formatJsonForTextarea,
|
||||
normalizeJsonString,
|
||||
validateJsonString,
|
||||
} from './utils'
|
||||
|
||||
const schema = z.object({
|
||||
gemini: z.object({
|
||||
safety_settings: z.string().superRefine((value, ctx) => {
|
||||
const result = validateJsonString(value)
|
||||
if (!result.valid) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: result.message || 'Invalid JSON',
|
||||
})
|
||||
}
|
||||
}),
|
||||
version_settings: z.string().superRefine((value, ctx) => {
|
||||
const result = validateJsonString(value)
|
||||
if (!result.valid) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: result.message || 'Invalid JSON',
|
||||
})
|
||||
}
|
||||
}),
|
||||
supported_imagine_models: z.string().superRefine((value, ctx) => {
|
||||
const result = validateJsonString(value, {
|
||||
predicate: (parsed) =>
|
||||
Array.isArray(parsed) &&
|
||||
parsed.every((item) => typeof item === 'string'),
|
||||
predicateMessage: 'Expected a JSON array of model identifiers',
|
||||
})
|
||||
if (!result.valid) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: result.message || 'Invalid JSON array',
|
||||
})
|
||||
}
|
||||
}),
|
||||
thinking_adapter_enabled: z.boolean(),
|
||||
thinking_adapter_budget_tokens_percentage: z.coerce
|
||||
.number()
|
||||
.min(0.002, { message: 'Must be at least 0.002' })
|
||||
.max(1, { message: 'Must be 1 or less' }),
|
||||
function_call_thought_signature_enabled: z.boolean(),
|
||||
remove_function_response_id_enabled: z.boolean(),
|
||||
}),
|
||||
})
|
||||
|
||||
type GeminiSettingsFormValues = z.output<typeof schema>
|
||||
type GeminiSettingsFormInput = z.input<typeof schema>
|
||||
|
||||
type FlatGeminiSettings = {
|
||||
'gemini.safety_settings': string
|
||||
'gemini.version_settings': string
|
||||
'gemini.supported_imagine_models': string
|
||||
'gemini.thinking_adapter_enabled': boolean
|
||||
'gemini.thinking_adapter_budget_tokens_percentage': number
|
||||
'gemini.function_call_thought_signature_enabled': boolean
|
||||
'gemini.remove_function_response_id_enabled': boolean
|
||||
}
|
||||
|
||||
type GeminiSettingsCardProps = {
|
||||
defaultValues: GeminiSettingsFormInput
|
||||
}
|
||||
|
||||
export function GeminiSettingsCard({ defaultValues }: GeminiSettingsCardProps) {
|
||||
const { t } = useTranslation()
|
||||
const updateOption = useUpdateOption()
|
||||
const normalizedDefaultsRef = useRef<FlatGeminiSettings>({
|
||||
'gemini.safety_settings': normalizeJsonString(
|
||||
defaultValues.gemini.safety_settings
|
||||
),
|
||||
'gemini.version_settings': normalizeJsonString(
|
||||
defaultValues.gemini.version_settings
|
||||
),
|
||||
'gemini.supported_imagine_models': normalizeJsonString(
|
||||
defaultValues.gemini.supported_imagine_models
|
||||
),
|
||||
'gemini.thinking_adapter_enabled':
|
||||
defaultValues.gemini.thinking_adapter_enabled,
|
||||
'gemini.thinking_adapter_budget_tokens_percentage': Number(
|
||||
defaultValues.gemini.thinking_adapter_budget_tokens_percentage
|
||||
),
|
||||
'gemini.function_call_thought_signature_enabled':
|
||||
defaultValues.gemini.function_call_thought_signature_enabled ?? true,
|
||||
'gemini.remove_function_response_id_enabled':
|
||||
defaultValues.gemini.remove_function_response_id_enabled ?? true,
|
||||
})
|
||||
|
||||
const buildFormDefaults = (
|
||||
values: GeminiSettingsFormInput
|
||||
): GeminiSettingsFormInput => ({
|
||||
gemini: {
|
||||
safety_settings: formatJsonForTextarea(values.gemini.safety_settings),
|
||||
version_settings: formatJsonForTextarea(values.gemini.version_settings),
|
||||
supported_imagine_models: formatJsonForTextarea(
|
||||
values.gemini.supported_imagine_models
|
||||
),
|
||||
thinking_adapter_enabled: values.gemini.thinking_adapter_enabled,
|
||||
thinking_adapter_budget_tokens_percentage:
|
||||
values.gemini.thinking_adapter_budget_tokens_percentage,
|
||||
function_call_thought_signature_enabled:
|
||||
values.gemini.function_call_thought_signature_enabled ?? true,
|
||||
remove_function_response_id_enabled:
|
||||
values.gemini.remove_function_response_id_enabled ?? true,
|
||||
},
|
||||
})
|
||||
|
||||
const form = useForm<
|
||||
GeminiSettingsFormInput,
|
||||
unknown,
|
||||
GeminiSettingsFormValues
|
||||
>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: buildFormDefaults(defaultValues),
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
normalizedDefaultsRef.current = {
|
||||
'gemini.safety_settings': normalizeJsonString(
|
||||
defaultValues.gemini.safety_settings
|
||||
),
|
||||
'gemini.version_settings': normalizeJsonString(
|
||||
defaultValues.gemini.version_settings
|
||||
),
|
||||
'gemini.supported_imagine_models': normalizeJsonString(
|
||||
defaultValues.gemini.supported_imagine_models
|
||||
),
|
||||
'gemini.thinking_adapter_enabled':
|
||||
defaultValues.gemini.thinking_adapter_enabled,
|
||||
'gemini.thinking_adapter_budget_tokens_percentage': Number(
|
||||
defaultValues.gemini.thinking_adapter_budget_tokens_percentage
|
||||
),
|
||||
'gemini.function_call_thought_signature_enabled':
|
||||
defaultValues.gemini.function_call_thought_signature_enabled ?? true,
|
||||
'gemini.remove_function_response_id_enabled':
|
||||
defaultValues.gemini.remove_function_response_id_enabled ?? true,
|
||||
}
|
||||
|
||||
form.reset(buildFormDefaults(defaultValues))
|
||||
}, [defaultValues, form])
|
||||
|
||||
const isAdapterEnabled = form.watch('gemini.thinking_adapter_enabled')
|
||||
|
||||
const onSubmit = async (values: GeminiSettingsFormValues) => {
|
||||
const normalized: FlatGeminiSettings = {
|
||||
'gemini.safety_settings': normalizeJsonString(
|
||||
values.gemini.safety_settings
|
||||
),
|
||||
'gemini.version_settings': normalizeJsonString(
|
||||
values.gemini.version_settings
|
||||
),
|
||||
'gemini.supported_imagine_models': normalizeJsonString(
|
||||
values.gemini.supported_imagine_models
|
||||
),
|
||||
'gemini.thinking_adapter_enabled': values.gemini.thinking_adapter_enabled,
|
||||
'gemini.thinking_adapter_budget_tokens_percentage':
|
||||
values.gemini.thinking_adapter_budget_tokens_percentage,
|
||||
'gemini.function_call_thought_signature_enabled':
|
||||
values.gemini.function_call_thought_signature_enabled,
|
||||
'gemini.remove_function_response_id_enabled':
|
||||
values.gemini.remove_function_response_id_enabled,
|
||||
}
|
||||
|
||||
const updates = (
|
||||
Object.keys(normalized) as Array<keyof FlatGeminiSettings>
|
||||
).filter((key) => normalized[key] !== normalizedDefaultsRef.current[key])
|
||||
|
||||
if (updates.length === 0) {
|
||||
toast.info(t('No changes to save'))
|
||||
return
|
||||
}
|
||||
|
||||
for (const key of updates) {
|
||||
await updateOption.mutateAsync({
|
||||
key,
|
||||
value: normalized[key],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const imaginePlaceholder = useMemo(
|
||||
() => JSON.stringify(['gemini-2.0-flash-exp-image-generation'], null, 2),
|
||||
[]
|
||||
)
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
title={t('Gemini')}
|
||||
description={t(
|
||||
'Configure Gemini safety behavior, version overrides, and thinking adapter'
|
||||
)}
|
||||
>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className='space-y-6'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='gemini.safety_settings'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Safety Settings')}</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea rows={8} {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
'Provide per-category safety overrides as JSON. Use `default` for fallback values.'
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='gemini.version_settings'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Version Overrides')}</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea rows={8} {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
'Map model identifiers to Gemini API versions. A `default` entry applies when no specific match is found.'
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='gemini.supported_imagine_models'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Supported Imagine Models')}</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
rows={6}
|
||||
placeholder={imaginePlaceholder}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
'Accepts a JSON array of model identifiers that support the Imagine API.'
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className='space-y-4 rounded-lg border p-4'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='gemini.thinking_adapter_enabled'
|
||||
render={({ field }) => (
|
||||
<FormItem className='flex flex-row items-center justify-between'>
|
||||
<div className='space-y-0.5'>
|
||||
<FormLabel className='text-base'>
|
||||
{t('Thinking Adapter')}
|
||||
</FormLabel>
|
||||
<FormDescription>
|
||||
{t('Supports `-thinking`, `-thinking-')}
|
||||
{'{{budget}}'}
|
||||
{t(
|
||||
'`, and `-nothinking` suffixes while routing to the correct Gemini variant.'
|
||||
)}
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='gemini.thinking_adapter_budget_tokens_percentage'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Budget Tokens Ratio')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
value={String(field.value ?? '')}
|
||||
onChange={(event) => field.onChange(event.target.value)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
'Budget tokens = max tokens × ratio. Accepts a decimal between 0.002 and 1. Recommended to keep aligned with upstream billing.'
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{!isAdapterEnabled && (
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
{t(
|
||||
'Gemini will continue to auto-detect thinking mode even with the adapter disabled. Enable this only when you need finer control over pricing and budgeting.'
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='gemini.function_call_thought_signature_enabled'
|
||||
render={({ field }) => (
|
||||
<FormItem className='flex flex-row items-center justify-between rounded-lg border p-4'>
|
||||
<div className='space-y-0.5'>
|
||||
<FormLabel className='text-base'>
|
||||
{t('Enable FunctionCall thoughtSignature Fill')}
|
||||
</FormLabel>
|
||||
<FormDescription>
|
||||
{t(
|
||||
'Fill thoughtSignature only for Gemini/Vertex channels using the OpenAI format'
|
||||
)}
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='gemini.remove_function_response_id_enabled'
|
||||
render={({ field }) => (
|
||||
<FormItem className='flex flex-row items-center justify-between rounded-lg border p-4'>
|
||||
<div className='space-y-0.5'>
|
||||
<FormLabel className='text-base'>
|
||||
{t('Remove functionResponse.id field')}
|
||||
</FormLabel>
|
||||
<FormDescription>
|
||||
{t(
|
||||
'Vertex AI does not support functionResponse.id. Enable this to remove the field automatically.'
|
||||
)}
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button type='submit' disabled={updateOption.isPending}>
|
||||
{updateOption.isPending ? t('Saving...') : t('Save Changes')}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
</SettingsSection>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,395 @@
|
||||
import { useEffect } from 'react'
|
||||
import * as z from 'zod'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { SettingsSection } from '../components/settings-section'
|
||||
import { useUpdateOption } from '../hooks/use-update-option'
|
||||
|
||||
const thinkingBlacklistExample = JSON.stringify(
|
||||
['moonshotai/kimi-k2-thinking', 'kimi-k2-thinking'],
|
||||
null,
|
||||
2
|
||||
)
|
||||
|
||||
const chatToResponsesPolicyExample = JSON.stringify(
|
||||
{
|
||||
enabled: true,
|
||||
all_channels: false,
|
||||
channel_ids: [1, 2],
|
||||
model_patterns: ['^gpt-4o.*$', '^gpt-5.*$'],
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
|
||||
const chatToResponsesPolicyAllChannelsExample = JSON.stringify(
|
||||
{
|
||||
enabled: true,
|
||||
all_channels: true,
|
||||
model_patterns: ['^gpt-4o.*$', '^gpt-5.*$'],
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
|
||||
const jsonString = z.string().refine((value) => {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) return true
|
||||
try {
|
||||
JSON.parse(trimmed)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}, 'Invalid JSON format')
|
||||
|
||||
const schema = z.object({
|
||||
global: z.object({
|
||||
pass_through_request_enabled: z.boolean(),
|
||||
thinking_model_blacklist: jsonString,
|
||||
chat_completions_to_responses_policy: jsonString,
|
||||
}),
|
||||
general_setting: z.object({
|
||||
ping_interval_enabled: z.boolean(),
|
||||
ping_interval_seconds: z.coerce.number().min(1),
|
||||
}),
|
||||
})
|
||||
|
||||
type GlobalModelSettingsFormValues = z.output<typeof schema>
|
||||
type GlobalModelSettingsFormInput = z.input<typeof schema>
|
||||
|
||||
type FlatGlobalModelSettings = {
|
||||
'global.pass_through_request_enabled': boolean
|
||||
'global.thinking_model_blacklist': string
|
||||
'global.chat_completions_to_responses_policy': string
|
||||
'general_setting.ping_interval_enabled': boolean
|
||||
'general_setting.ping_interval_seconds': number
|
||||
}
|
||||
|
||||
const flattenGlobalValues = (
|
||||
values: GlobalModelSettingsFormValues
|
||||
): FlatGlobalModelSettings => ({
|
||||
'global.pass_through_request_enabled':
|
||||
values.global.pass_through_request_enabled,
|
||||
'global.thinking_model_blacklist': normalizeJsonText(
|
||||
values.global.thinking_model_blacklist,
|
||||
'[]'
|
||||
),
|
||||
'global.chat_completions_to_responses_policy': normalizeJsonText(
|
||||
values.global.chat_completions_to_responses_policy,
|
||||
'{}'
|
||||
),
|
||||
'general_setting.ping_interval_enabled':
|
||||
values.general_setting.ping_interval_enabled,
|
||||
'general_setting.ping_interval_seconds':
|
||||
values.general_setting.ping_interval_seconds,
|
||||
})
|
||||
|
||||
function normalizeJsonText(value: string, fallback: string) {
|
||||
const trimmed = (value ?? '').toString().trim()
|
||||
return trimmed ? trimmed : fallback
|
||||
}
|
||||
|
||||
type GlobalSettingsCardProps = {
|
||||
defaultValues: GlobalModelSettingsFormValues
|
||||
}
|
||||
|
||||
export function GlobalSettingsCard({ defaultValues }: GlobalSettingsCardProps) {
|
||||
const { t } = useTranslation()
|
||||
const updateOption = useUpdateOption()
|
||||
|
||||
const form = useForm<
|
||||
GlobalModelSettingsFormInput,
|
||||
unknown,
|
||||
GlobalModelSettingsFormValues
|
||||
>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: defaultValues as GlobalModelSettingsFormInput,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
form.reset(defaultValues as GlobalModelSettingsFormInput)
|
||||
}, [defaultValues, form])
|
||||
|
||||
const pingEnabled = form.watch('general_setting.ping_interval_enabled')
|
||||
|
||||
const formatJsonField = (
|
||||
field:
|
||||
| 'global.thinking_model_blacklist'
|
||||
| 'global.chat_completions_to_responses_policy'
|
||||
) => {
|
||||
const raw = form.getValues(field)
|
||||
if (!raw || !raw.trim()) return
|
||||
try {
|
||||
const formatted = JSON.stringify(JSON.parse(raw), null, 2)
|
||||
form.setValue(field, formatted, { shouldDirty: true })
|
||||
} catch {
|
||||
toast.error(t('Invalid JSON format'))
|
||||
}
|
||||
}
|
||||
|
||||
const onSubmit = async (values: GlobalModelSettingsFormValues) => {
|
||||
const flattenedDefaults = flattenGlobalValues(defaultValues)
|
||||
const flattenedValues = flattenGlobalValues(values)
|
||||
const updates = Object.entries(flattenedValues).filter(
|
||||
([key, value]) =>
|
||||
value !== flattenedDefaults[key as keyof FlatGlobalModelSettings]
|
||||
)
|
||||
|
||||
if (updates.length === 0) {
|
||||
toast.info(t('No changes to save'))
|
||||
return
|
||||
}
|
||||
|
||||
for (const [key, value] of updates) {
|
||||
await updateOption.mutateAsync({
|
||||
key,
|
||||
value,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
title={t('Global Model Configuration')}
|
||||
description={t(
|
||||
'Control passthrough behavior and connection keep-alive settings'
|
||||
)}
|
||||
>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className='space-y-6'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='global.pass_through_request_enabled'
|
||||
render={({ field }) => (
|
||||
<FormItem className='flex flex-row items-center justify-between rounded-lg border p-4'>
|
||||
<div className='space-y-0.5'>
|
||||
<FormLabel className='text-base'>
|
||||
{t('Enable Request Passthrough')}
|
||||
</FormLabel>
|
||||
<FormDescription>
|
||||
{t(
|
||||
'Forward requests directly to upstream providers without any post-processing.'
|
||||
)}
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='global.thinking_model_blacklist'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Disable thinking processing models')}</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
rows={4}
|
||||
placeholder={`${t('Example:')}\n${thinkingBlacklistExample}`}
|
||||
{...field}
|
||||
onChange={(event) => field.onChange(event.target.value)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
'Models listed here will not automatically append or remove -thinking / -nothinking suffixes.'
|
||||
)}
|
||||
</FormDescription>
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() =>
|
||||
formatJsonField('global.thinking_model_blacklist')
|
||||
}
|
||||
>
|
||||
{t('Format JSON')}
|
||||
</Button>
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className='space-y-4'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<h3 className='text-base font-semibold'>
|
||||
{t('ChatCompletions -> Responses Compatibility')}
|
||||
</h3>
|
||||
<StatusBadge
|
||||
label={t('Preview')}
|
||||
variant='neutral'
|
||||
copyable={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Alert>
|
||||
<AlertTitle>{t('Warning')}</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t(
|
||||
'This feature is experimental. Configuration format and behavior may change.'
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='global.chat_completions_to_responses_policy'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Policy JSON')}</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
rows={8}
|
||||
placeholder={`${t('Example (specific channels):')}\n${chatToResponsesPolicyExample}\n\n${t('Example (all channels):')}\n${chatToResponsesPolicyAllChannelsExample}`}
|
||||
{...field}
|
||||
onChange={(event) => field.onChange(event.target.value)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('Empty value will be saved as {}.')}
|
||||
</FormDescription>
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() =>
|
||||
form.setValue(
|
||||
'global.chat_completions_to_responses_policy',
|
||||
chatToResponsesPolicyExample,
|
||||
{ shouldDirty: true }
|
||||
)
|
||||
}
|
||||
>
|
||||
{t('Fill example (specific channels)')}
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() =>
|
||||
form.setValue(
|
||||
'global.chat_completions_to_responses_policy',
|
||||
chatToResponsesPolicyAllChannelsExample,
|
||||
{ shouldDirty: true }
|
||||
)
|
||||
}
|
||||
>
|
||||
{t('Fill example (all channels)')}
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() =>
|
||||
formatJsonField(
|
||||
'global.chat_completions_to_responses_policy'
|
||||
)
|
||||
}
|
||||
>
|
||||
{t('Format JSON')}
|
||||
</Button>
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='general_setting.ping_interval_enabled'
|
||||
render={({ field }) => (
|
||||
<FormItem className='flex flex-row items-center justify-between rounded-lg border p-4'>
|
||||
<div className='space-y-0.5'>
|
||||
<FormLabel className='text-base'>
|
||||
{t('Keep-alive Ping')}
|
||||
</FormLabel>
|
||||
<FormDescription>
|
||||
{t(
|
||||
'Periodically send ping frames to keep streaming connections active.'
|
||||
)}
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='general_setting.ping_interval_seconds'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Ping Interval (seconds)')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type='number'
|
||||
min={1}
|
||||
disabled={!pingEnabled}
|
||||
className='w-24'
|
||||
value={
|
||||
field.value === undefined || field.value === null
|
||||
? ''
|
||||
: String(field.value)
|
||||
}
|
||||
onChange={(event) => field.onChange(event.target.value)}
|
||||
onBlur={field.onBlur}
|
||||
name={field.name}
|
||||
ref={field.ref}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
'Recommended to keep this high to avoid upstream throttling.'
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button type='submit' disabled={updateOption.isPending}>
|
||||
{updateOption.isPending ? t('Saving...') : t('Save Changes')}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
</SettingsSection>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import * as z from 'zod'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
} from '@/components/ui/form'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { SettingsSection } from '../components/settings-section'
|
||||
import { useResetForm } from '../hooks/use-reset-form'
|
||||
import { useUpdateOption } from '../hooks/use-update-option'
|
||||
|
||||
const XAI_VIOLATION_FEE_DOC_URL =
|
||||
'https://docs.x.ai/docs/models#usage-guidelines-violation-fee'
|
||||
|
||||
const grokSchema = z.object({
|
||||
'grok.violation_deduction_enabled': z.boolean(),
|
||||
'grok.violation_deduction_amount': z.coerce.number().min(0),
|
||||
})
|
||||
|
||||
type GrokFormValues = z.infer<typeof grokSchema>
|
||||
|
||||
interface Props {
|
||||
defaultValues: GrokFormValues
|
||||
}
|
||||
|
||||
export function GrokSettingsCard(props: Props) {
|
||||
const { t } = useTranslation()
|
||||
const updateOption = useUpdateOption()
|
||||
|
||||
const form = useForm<GrokFormValues>({
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
resolver: zodResolver(grokSchema) as any,
|
||||
defaultValues: props.defaultValues,
|
||||
})
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
useResetForm(form as any, props.defaultValues)
|
||||
|
||||
const onSubmit = async (data: GrokFormValues) => {
|
||||
const entries = Object.entries(data) as [string, unknown][]
|
||||
const updates = entries.filter(
|
||||
([key, value]) =>
|
||||
value !== (props.defaultValues[key as keyof GrokFormValues] as unknown)
|
||||
)
|
||||
for (const [key, value] of updates) {
|
||||
await updateOption.mutateAsync({
|
||||
key,
|
||||
value: value as string | number | boolean,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const enabled = form.watch('grok.violation_deduction_enabled')
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
title={t('Grok Settings')}
|
||||
description={t('Configure xAI Grok model specific settings')}
|
||||
>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className='space-y-6'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='grok.violation_deduction_enabled'
|
||||
render={({ field }) => (
|
||||
<FormItem className='flex items-center gap-2'>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<div>
|
||||
<FormLabel>{t('Enable violation deduction')}</FormLabel>
|
||||
<FormDescription>
|
||||
{t(
|
||||
'When enabled, violation requests will incur additional charges.'
|
||||
)}{' '}
|
||||
<a
|
||||
href={XAI_VIOLATION_FEE_DOC_URL}
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
className='underline'
|
||||
>
|
||||
{t('Official documentation')}
|
||||
</a>
|
||||
</FormDescription>
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='grok.violation_deduction_amount'
|
||||
render={({ field }) => (
|
||||
<FormItem className='max-w-xs'>
|
||||
<FormLabel>{t('Violation deduction amount')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type='number'
|
||||
step={0.01}
|
||||
min={0}
|
||||
{...field}
|
||||
disabled={!enabled}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
'Base amount. Actual deduction = base amount × system group rate.'
|
||||
)}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button type='submit' disabled={updateOption.isPending}>
|
||||
{updateOption.isPending ? t('Saving...') : t('Save Changes')}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
</SettingsSection>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
import { memo, useCallback, useState } from 'react'
|
||||
import { type UseFormReturn } from 'react-hook-form'
|
||||
import { Code2, Eye } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { GroupRatioVisualEditor } from './group-ratio-visual-editor'
|
||||
import { GroupSpecialUsableRulesEditor } from './group-special-usable-editor'
|
||||
|
||||
type GroupFormValues = {
|
||||
GroupRatio: string
|
||||
TopupGroupRatio: string
|
||||
UserUsableGroups: string
|
||||
GroupGroupRatio: string
|
||||
AutoGroups: string
|
||||
DefaultUseAutoGroup: boolean
|
||||
GroupSpecialUsableGroup: string
|
||||
}
|
||||
|
||||
type GroupRatioFormProps = {
|
||||
form: UseFormReturn<GroupFormValues>
|
||||
onSave: (values: GroupFormValues) => Promise<void>
|
||||
isSaving: boolean
|
||||
}
|
||||
|
||||
export const GroupRatioForm = memo(function GroupRatioForm({
|
||||
form,
|
||||
onSave,
|
||||
isSaving,
|
||||
}: GroupRatioFormProps) {
|
||||
const { t } = useTranslation()
|
||||
const [editMode, setEditMode] = useState<'visual' | 'json'>('visual')
|
||||
|
||||
const handleFieldChange = useCallback(
|
||||
(field: keyof GroupFormValues, value: string) => {
|
||||
form.setValue(field, value, {
|
||||
shouldValidate: true,
|
||||
shouldDirty: true,
|
||||
})
|
||||
},
|
||||
[form]
|
||||
)
|
||||
|
||||
const toggleEditMode = useCallback(() => {
|
||||
setEditMode((prev) => (prev === 'visual' ? 'json' : 'visual'))
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className='space-y-6'>
|
||||
<div className='flex justify-end'>
|
||||
<Button variant='outline' size='sm' onClick={toggleEditMode}>
|
||||
{editMode === 'visual' ? (
|
||||
<>
|
||||
<Code2 className='mr-2 h-4 w-4' />
|
||||
{t('Switch to JSON')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Eye className='mr-2 h-4 w-4' />
|
||||
{t('Switch to Visual')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Form {...form}>
|
||||
{editMode === 'visual' ? (
|
||||
<div className='space-y-6'>
|
||||
<GroupRatioVisualEditor
|
||||
groupRatio={form.watch('GroupRatio')}
|
||||
topupGroupRatio={form.watch('TopupGroupRatio')}
|
||||
userUsableGroups={form.watch('UserUsableGroups')}
|
||||
groupGroupRatio={form.watch('GroupGroupRatio')}
|
||||
autoGroups={form.watch('AutoGroups')}
|
||||
onChange={(field, value) =>
|
||||
handleFieldChange(field as keyof GroupFormValues, value)
|
||||
}
|
||||
/>
|
||||
|
||||
<GroupSpecialUsableRulesEditor
|
||||
value={form.watch('GroupSpecialUsableGroup')}
|
||||
onChange={(value) =>
|
||||
handleFieldChange('GroupSpecialUsableGroup', value)
|
||||
}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='DefaultUseAutoGroup'
|
||||
render={({ field }) => (
|
||||
<FormItem className='flex flex-row items-center justify-between rounded-lg border p-4'>
|
||||
<div className='space-y-0.5'>
|
||||
<FormLabel className='text-base'>
|
||||
{t('Default to auto groups')}
|
||||
</FormLabel>
|
||||
<FormDescription>
|
||||
{t(
|
||||
'When enabled, newly created tokens start in the first auto group.'
|
||||
)}
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button onClick={form.handleSubmit(onSave)} disabled={isSaving}>
|
||||
{isSaving ? t('Saving...') : t('Save group ratios')}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={form.handleSubmit(onSave)} className='space-y-6'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='GroupRatio'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Group ratios')}</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea rows={8} {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
'JSON map of group → ratio applied when the user selects the group explicitly.'
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='TopupGroupRatio'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Top-up group ratios')}</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea rows={6} {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
'Optional multiplier per user group used when calculating recharge pricing. Provide a JSON object such as'
|
||||
)}
|
||||
{` { "default": 1, "vip": 1.2 }`}.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='UserUsableGroups'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Selectable groups')}</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea rows={6} {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
'JSON map of group → description exposed when users create API keys.'
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='GroupGroupRatio'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Inter-group overrides')}</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea rows={8} {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('Nested JSON: source group →')}{' '}
|
||||
{`{ targetGroup: ratio }`}{' '}
|
||||
{t(
|
||||
'to override billing when a user in one group uses a token of another group.'
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='AutoGroups'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Auto assignment order')}</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea rows={6} {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
'JSON array of group identifiers. When enabled below, new tokens rotate through this list.'
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='GroupSpecialUsableGroup'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Special usable group rules')}</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea rows={8} {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
'Nested JSON defining per-group rules for adding (+:), removing (-:), or appending usable groups.'
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='DefaultUseAutoGroup'
|
||||
render={({ field }) => (
|
||||
<FormItem className='flex flex-row items-center justify-between rounded-lg border p-4'>
|
||||
<div className='space-y-0.5'>
|
||||
<FormLabel className='text-base'>
|
||||
{t('Default to auto groups')}
|
||||
</FormLabel>
|
||||
<FormDescription>
|
||||
{t(
|
||||
'When enabled, newly created tokens start in the first auto group.'
|
||||
)}
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button type='submit' disabled={isSaving}>
|
||||
{isSaving ? t('Saving...') : t('Save group ratios')}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</Form>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
+1115
File diff suppressed because it is too large
Load Diff
+394
@@ -0,0 +1,394 @@
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { ChevronDown, ChevronUp, Plus, Trash2 } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
|
||||
const OP_ADD = 'add' as const
|
||||
const OP_REMOVE = 'remove' as const
|
||||
const OP_APPEND = 'append' as const
|
||||
|
||||
type OpType = typeof OP_ADD | typeof OP_REMOVE | typeof OP_APPEND
|
||||
|
||||
type Rule = {
|
||||
_id: string
|
||||
userGroup: string
|
||||
op: OpType
|
||||
targetGroup: string
|
||||
description: string
|
||||
}
|
||||
|
||||
let _idCounter = 0
|
||||
function uid() {
|
||||
return `gsu_${++_idCounter}`
|
||||
}
|
||||
|
||||
function parsePrefix(rawKey: string): { op: OpType; groupName: string } {
|
||||
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: OpType, groupName: string): string {
|
||||
if (op === OP_ADD) return `+:${groupName}`
|
||||
if (op === OP_REMOVE) return `-:${groupName}`
|
||||
return groupName
|
||||
}
|
||||
|
||||
function safeParseJson(str: string): Record<string, Record<string, string>> {
|
||||
if (!str || !str.trim()) return {}
|
||||
try {
|
||||
return JSON.parse(str) as Record<string, Record<string, string>>
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
function flattenRules(nested: Record<string, Record<string, string>>): Rule[] {
|
||||
const rules: Rule[] = []
|
||||
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: Rule[]): Record<string, Record<string, string>> {
|
||||
const result: Record<string, Record<string, string>> = {}
|
||||
for (const { userGroup, op, targetGroup, description } of rules) {
|
||||
if (!userGroup || !targetGroup) continue
|
||||
if (!result[userGroup]) result[userGroup] = {}
|
||||
result[userGroup][toRawKey(op, targetGroup)] = description
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function serializeRules(rules: Rule[]): string {
|
||||
const nested = nestRules(rules)
|
||||
return Object.keys(nested).length === 0
|
||||
? '{}'
|
||||
: JSON.stringify(nested, null, 2)
|
||||
}
|
||||
|
||||
const OP_BADGE_MAP: Record<
|
||||
OpType,
|
||||
{ variant: 'info' | 'danger' | 'neutral'; label: string }
|
||||
> = {
|
||||
[OP_ADD]: { variant: 'info', label: 'Add (+:)' },
|
||||
[OP_REMOVE]: { variant: 'danger', label: 'Remove (-:)' },
|
||||
[OP_APPEND]: { variant: 'neutral', label: 'Append' },
|
||||
}
|
||||
|
||||
type GroupSpecialUsableRulesEditorProps = {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
}
|
||||
|
||||
type GroupSectionProps = {
|
||||
groupName: string
|
||||
items: Rule[]
|
||||
onUpdate: (id: string, field: keyof Rule, val: string) => void
|
||||
onRemove: (id: string) => void
|
||||
onAdd: (groupName: string) => void
|
||||
onRemoveGroup: (groupName: string) => void
|
||||
}
|
||||
|
||||
function GroupSection(props: GroupSectionProps) {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<Collapsible open={open} onOpenChange={setOpen}>
|
||||
<div className='rounded-lg border'>
|
||||
<div className='flex items-center justify-between p-3'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button variant='ghost' size='sm' className='h-6 w-6 p-0'>
|
||||
{open ? (
|
||||
<ChevronUp className='h-4 w-4' />
|
||||
) : (
|
||||
<ChevronDown className='h-4 w-4' />
|
||||
)}
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<span className='font-semibold'>{props.groupName}</span>
|
||||
<StatusBadge variant='neutral' copyable={false}>
|
||||
{props.items.length} {t('rules')}
|
||||
</StatusBadge>
|
||||
</div>
|
||||
<div className='flex items-center gap-1'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='h-7 w-7 p-0'
|
||||
onClick={() => props.onAdd(props.groupName)}
|
||||
>
|
||||
<Plus className='h-4 w-4' />
|
||||
</Button>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='text-destructive h-7 w-7 p-0'
|
||||
onClick={() => props.onRemoveGroup(props.groupName)}
|
||||
>
|
||||
<Trash2 className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<CollapsibleContent>
|
||||
<div className='space-y-2 border-t p-3'>
|
||||
{props.items.map((rule) => (
|
||||
<div key={rule._id} className='flex items-center gap-2'>
|
||||
<Select
|
||||
value={rule.op}
|
||||
onValueChange={(v) => props.onUpdate(rule._id, 'op', v)}
|
||||
>
|
||||
<SelectTrigger className='w-[130px]'>
|
||||
<SelectValue>
|
||||
<StatusBadge
|
||||
label={t(OP_BADGE_MAP[rule.op].label)}
|
||||
variant={OP_BADGE_MAP[rule.op].variant}
|
||||
copyable={false}
|
||||
/>
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={OP_ADD}>
|
||||
<StatusBadge
|
||||
label={t(OP_BADGE_MAP[OP_ADD].label)}
|
||||
variant={OP_BADGE_MAP[OP_ADD].variant}
|
||||
copyable={false}
|
||||
/>
|
||||
</SelectItem>
|
||||
<SelectItem value={OP_REMOVE}>
|
||||
<StatusBadge
|
||||
label={t(OP_BADGE_MAP[OP_REMOVE].label)}
|
||||
variant={OP_BADGE_MAP[OP_REMOVE].variant}
|
||||
copyable={false}
|
||||
/>
|
||||
</SelectItem>
|
||||
<SelectItem value={OP_APPEND}>
|
||||
<StatusBadge
|
||||
label={t(OP_BADGE_MAP[OP_APPEND].label)}
|
||||
variant={OP_BADGE_MAP[OP_APPEND].variant}
|
||||
copyable={false}
|
||||
/>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input
|
||||
className='flex-1'
|
||||
value={rule.targetGroup}
|
||||
placeholder={t('Group name')}
|
||||
onChange={(e) =>
|
||||
props.onUpdate(rule._id, 'targetGroup', e.target.value)
|
||||
}
|
||||
/>
|
||||
{rule.op !== OP_REMOVE ? (
|
||||
<Input
|
||||
className='flex-1'
|
||||
value={rule.description}
|
||||
placeholder={t('Description')}
|
||||
onChange={(e) =>
|
||||
props.onUpdate(rule._id, 'description', e.target.value)
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className='text-muted-foreground flex-1 px-3 text-sm'>
|
||||
-
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='text-destructive h-8 w-8 p-0'
|
||||
onClick={() => props.onRemove(rule._id)}
|
||||
>
|
||||
<Trash2 className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</div>
|
||||
</Collapsible>
|
||||
)
|
||||
}
|
||||
|
||||
export function GroupSpecialUsableRulesEditor(
|
||||
props: GroupSpecialUsableRulesEditorProps
|
||||
) {
|
||||
const { t } = useTranslation()
|
||||
const [rules, setRules] = useState<Rule[]>(() =>
|
||||
flattenRules(safeParseJson(props.value))
|
||||
)
|
||||
const [newGroupName, setNewGroupName] = useState('')
|
||||
|
||||
const { onChange } = props
|
||||
const emitChange = useCallback(
|
||||
(newRules: Rule[]) => {
|
||||
setRules(newRules)
|
||||
onChange(serializeRules(newRules))
|
||||
},
|
||||
[onChange]
|
||||
)
|
||||
|
||||
const updateRule = useCallback(
|
||||
(id: string, field: keyof Rule, val: string) => {
|
||||
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: string) => emitChange(rules.filter((r) => r._id !== id)),
|
||||
[rules, emitChange]
|
||||
)
|
||||
|
||||
const removeGroup = useCallback(
|
||||
(groupName: string) =>
|
||||
emitChange(rules.filter((r) => r.userGroup !== groupName)),
|
||||
[rules, emitChange]
|
||||
)
|
||||
|
||||
const addRuleToGroup = useCallback(
|
||||
(groupName: string) => {
|
||||
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 grouped = useMemo(() => {
|
||||
const map: Record<string, Rule[]> = {}
|
||||
const order: string[] = []
|
||||
for (const r of rules) {
|
||||
if (!r.userGroup) continue
|
||||
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])
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('Special usable group rules')}</CardTitle>
|
||||
<CardDescription>
|
||||
{t(
|
||||
'Define per-group rules to add, remove, or append selectable groups for specific user groups.'
|
||||
)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className='space-y-3'>
|
||||
{grouped.length === 0 ? (
|
||||
<p className='text-muted-foreground py-4 text-center text-sm'>
|
||||
{t('No rules yet. Add a group below to get started.')}
|
||||
</p>
|
||||
) : (
|
||||
grouped.map((group) => (
|
||||
<GroupSection
|
||||
key={group.name}
|
||||
groupName={group.name}
|
||||
items={group.items}
|
||||
onUpdate={updateRule}
|
||||
onRemove={removeRule}
|
||||
onAdd={addRuleToGroup}
|
||||
onRemoveGroup={removeGroup}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
|
||||
<div className='flex items-center justify-center gap-2 pt-2'>
|
||||
<Input
|
||||
className='w-[200px]'
|
||||
value={newGroupName}
|
||||
placeholder={t('User group name')}
|
||||
onChange={(e) => setNewGroupName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
addNewGroup()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button variant='outline' size='sm' onClick={addNewGroup}>
|
||||
<Plus className='mr-1 h-4 w-4' />
|
||||
{t('Add group rules')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { SettingsPage } from '../components/settings-page'
|
||||
import type { ModelSettings } from '../types'
|
||||
import {
|
||||
MODELS_DEFAULT_SECTION,
|
||||
getModelsSectionContent,
|
||||
} from './section-registry.tsx'
|
||||
|
||||
const defaultModelSettings: ModelSettings = {
|
||||
'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,
|
||||
'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,
|
||||
'claude.model_headers_settings': '',
|
||||
'claude.default_max_tokens': '',
|
||||
'claude.thinking_adapter_enabled': true,
|
||||
'claude.thinking_adapter_budget_tokens_percentage': 0.8,
|
||||
'grok.violation_deduction_enabled': true,
|
||||
'grok.violation_deduction_amount': 0.05,
|
||||
ModelPrice: '',
|
||||
ModelRatio: '',
|
||||
CacheRatio: '',
|
||||
CreateCacheRatio: '',
|
||||
CompletionRatio: '',
|
||||
ImageRatio: '',
|
||||
AudioRatio: '',
|
||||
AudioCompletionRatio: '',
|
||||
ExposeRatioEnabled: false,
|
||||
'billing_setting.billing_mode': '{}',
|
||||
'billing_setting.billing_expr': '{}',
|
||||
'tool_price_setting.prices': '{}',
|
||||
TopupGroupRatio: '',
|
||||
GroupRatio: '',
|
||||
UserUsableGroups: '',
|
||||
GroupGroupRatio: '',
|
||||
AutoGroups: '',
|
||||
DefaultUseAutoGroup: false,
|
||||
'group_ratio_setting.group_special_usable_group': '{}',
|
||||
}
|
||||
|
||||
export function ModelSettings() {
|
||||
return (
|
||||
<SettingsPage
|
||||
routePath='/_authenticated/system-settings/models/$section'
|
||||
defaultSettings={defaultModelSettings}
|
||||
defaultSection={MODELS_DEFAULT_SECTION}
|
||||
getSectionContent={getModelsSectionContent}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,654 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import * as z from 'zod'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
|
||||
import { TieredPricingEditor } from './tiered-pricing-editor'
|
||||
|
||||
const createModelDialogSchema = (t: (key: string) => string) =>
|
||||
z.object({
|
||||
name: z.string().min(1, t('Model name is required')),
|
||||
price: z.string().optional(),
|
||||
ratio: z.string().optional(),
|
||||
cacheRatio: z.string().optional(),
|
||||
createCacheRatio: z.string().optional(),
|
||||
completionRatio: z.string().optional(),
|
||||
imageRatio: z.string().optional(),
|
||||
audioRatio: z.string().optional(),
|
||||
audioCompletionRatio: z.string().optional(),
|
||||
})
|
||||
|
||||
type ModelDialogFormValues = z.infer<ReturnType<typeof createModelDialogSchema>>
|
||||
|
||||
type PricingMode = 'per-token' | 'per-request' | 'tiered_expr'
|
||||
type PricingSubMode = 'ratio' | 'price'
|
||||
|
||||
export type ModelRatioData = {
|
||||
name: string
|
||||
price?: string
|
||||
ratio?: string
|
||||
cacheRatio?: string
|
||||
createCacheRatio?: string
|
||||
completionRatio?: string
|
||||
imageRatio?: string
|
||||
audioRatio?: string
|
||||
audioCompletionRatio?: string
|
||||
billingMode?: 'per-token' | 'per-request' | 'tiered_expr'
|
||||
billingExpr?: string
|
||||
requestRuleExpr?: string
|
||||
}
|
||||
|
||||
type ModelRatioDialogProps = {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onSave: (data: ModelRatioData) => void
|
||||
editData?: ModelRatioData | null
|
||||
}
|
||||
|
||||
export function ModelRatioDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onSave,
|
||||
editData,
|
||||
}: ModelRatioDialogProps) {
|
||||
const { t } = useTranslation()
|
||||
const [pricingMode, setPricingMode] = useState<PricingMode>('per-token')
|
||||
const [pricingSubMode, setPricingSubMode] = useState<PricingSubMode>('ratio')
|
||||
const [advancedOpen, setAdvancedOpen] = useState(false)
|
||||
const [promptPrice, setPromptPrice] = useState('')
|
||||
const [completionPrice, setCompletionPrice] = useState('')
|
||||
const [billingExpr, setBillingExpr] = useState('')
|
||||
const [requestRuleExpr, setRequestRuleExpr] = useState('')
|
||||
const isEditMode = !!editData
|
||||
|
||||
const form = useForm<ModelDialogFormValues>({
|
||||
resolver: zodResolver(createModelDialogSchema(t)),
|
||||
defaultValues: {
|
||||
name: '',
|
||||
price: '',
|
||||
ratio: '',
|
||||
cacheRatio: '',
|
||||
createCacheRatio: '',
|
||||
completionRatio: '',
|
||||
imageRatio: '',
|
||||
audioRatio: '',
|
||||
audioCompletionRatio: '',
|
||||
},
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (editData) {
|
||||
form.reset(editData)
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setBillingExpr(editData.billingExpr || '')
|
||||
setRequestRuleExpr(editData.requestRuleExpr || '')
|
||||
|
||||
if (editData.billingMode === 'tiered_expr') {
|
||||
setPricingMode('tiered_expr')
|
||||
} else if (editData.price && editData.price !== '') {
|
||||
setPricingMode('per-request')
|
||||
} else {
|
||||
setPricingMode('per-token')
|
||||
if (editData.ratio) {
|
||||
const tokenPrice = parseFloat(editData.ratio) * 2
|
||||
setPromptPrice(tokenPrice.toString())
|
||||
if (editData.completionRatio) {
|
||||
const compPrice = tokenPrice * parseFloat(editData.completionRatio)
|
||||
setCompletionPrice(compPrice.toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
form.reset({
|
||||
name: '',
|
||||
price: '',
|
||||
ratio: '',
|
||||
cacheRatio: '',
|
||||
createCacheRatio: '',
|
||||
completionRatio: '',
|
||||
imageRatio: '',
|
||||
audioRatio: '',
|
||||
audioCompletionRatio: '',
|
||||
})
|
||||
setPricingMode('per-token')
|
||||
setPricingSubMode('ratio')
|
||||
setPromptPrice('')
|
||||
setCompletionPrice('')
|
||||
setBillingExpr('')
|
||||
setRequestRuleExpr('')
|
||||
setAdvancedOpen(false)
|
||||
}
|
||||
}, [editData, form, open])
|
||||
|
||||
const handleSubmit = (values: ModelDialogFormValues) => {
|
||||
// Always pass through every field. The visual editor decides what to
|
||||
// persist based on `billingMode`, and tiered_expr models also keep the
|
||||
// ratio/price values as fallback during multi-instance sync delays
|
||||
// (the backend's ModelPriceHelper checks billing_mode first, so these
|
||||
// fallbacks only kick in when billing_setting hasn't propagated yet).
|
||||
const data: ModelRatioData = {
|
||||
name: values.name,
|
||||
billingMode: pricingMode,
|
||||
price: values.price || '',
|
||||
ratio: values.ratio || '',
|
||||
cacheRatio: values.cacheRatio || '',
|
||||
createCacheRatio: values.createCacheRatio || '',
|
||||
completionRatio: values.completionRatio || '',
|
||||
imageRatio: values.imageRatio || '',
|
||||
audioRatio: values.audioRatio || '',
|
||||
audioCompletionRatio: values.audioCompletionRatio || '',
|
||||
}
|
||||
|
||||
if (pricingMode === 'tiered_expr') {
|
||||
data.billingExpr = billingExpr
|
||||
data.requestRuleExpr = requestRuleExpr
|
||||
}
|
||||
|
||||
onSave(data)
|
||||
form.reset()
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
const validateNumber = (value: string) => {
|
||||
if (value === '') return true
|
||||
return !isNaN(parseFloat(value))
|
||||
}
|
||||
|
||||
const handlePromptPriceChange = (value: string) => {
|
||||
setPromptPrice(value)
|
||||
if (value && !isNaN(parseFloat(value))) {
|
||||
const ratio = parseFloat(value) / 2
|
||||
form.setValue('ratio', ratio.toString())
|
||||
} else {
|
||||
form.setValue('ratio', '')
|
||||
}
|
||||
}
|
||||
|
||||
const handleCompletionPriceChange = (value: string) => {
|
||||
setCompletionPrice(value)
|
||||
if (
|
||||
value &&
|
||||
!isNaN(parseFloat(value)) &&
|
||||
promptPrice &&
|
||||
!isNaN(parseFloat(promptPrice)) &&
|
||||
parseFloat(promptPrice) > 0
|
||||
) {
|
||||
const completionRatio = parseFloat(value) / parseFloat(promptPrice)
|
||||
form.setValue('completionRatio', completionRatio.toString())
|
||||
} else {
|
||||
form.setValue('completionRatio', '')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className='max-h-[90vh] overflow-y-auto sm:max-w-[680px]'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{isEditMode ? t('Edit model') : t('Add model')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('Configure pricing ratios for a specific model.')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(handleSubmit)}
|
||||
className='space-y-6'
|
||||
autoComplete='off'
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='name'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Model name')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t('gpt-4')}
|
||||
{...field}
|
||||
disabled={isEditMode}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('The exact model identifier as used in API requests.')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className='space-y-4'>
|
||||
<Label>{t('Pricing mode')}</Label>
|
||||
<RadioGroup
|
||||
value={pricingMode}
|
||||
onValueChange={(value) => setPricingMode(value as PricingMode)}
|
||||
>
|
||||
<div className='flex items-center space-x-2'>
|
||||
<RadioGroupItem value='per-token' id='per-token' />
|
||||
<Label htmlFor='per-token' className='font-normal'>
|
||||
{t('Per-token (ratio based)')}
|
||||
</Label>
|
||||
</div>
|
||||
<div className='flex items-center space-x-2'>
|
||||
<RadioGroupItem value='per-request' id='per-request' />
|
||||
<Label htmlFor='per-request' className='font-normal'>
|
||||
{t('Per-request (fixed price)')}
|
||||
</Label>
|
||||
</div>
|
||||
<div className='flex items-center space-x-2'>
|
||||
<RadioGroupItem value='tiered_expr' id='tiered_expr' />
|
||||
<Label htmlFor='tiered_expr' className='font-normal'>
|
||||
{t('Tiered (billing expression)')}
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
{pricingMode === 'tiered_expr' ? (
|
||||
<TieredPricingEditor
|
||||
modelName={form.getValues('name')}
|
||||
billingExpr={billingExpr}
|
||||
requestRuleExpr={requestRuleExpr}
|
||||
onBillingExprChange={setBillingExpr}
|
||||
onRequestRuleExprChange={setRequestRuleExpr}
|
||||
/>
|
||||
) : pricingMode === 'per-request' ? (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='price'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Fixed price (USD)')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type='text'
|
||||
placeholder='0.01'
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value
|
||||
if (validateNumber(value)) {
|
||||
field.onChange(value)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('Cost in USD per request, regardless of tokens used.')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className='space-y-4'>
|
||||
<Label>{t('Input mode')}</Label>
|
||||
<RadioGroup
|
||||
value={pricingSubMode}
|
||||
onValueChange={(value) =>
|
||||
setPricingSubMode(value as PricingSubMode)
|
||||
}
|
||||
>
|
||||
<div className='flex items-center space-x-2'>
|
||||
<RadioGroupItem value='ratio' id='ratio' />
|
||||
<Label htmlFor='ratio' className='font-normal'>
|
||||
{t('Ratio mode')}
|
||||
</Label>
|
||||
</div>
|
||||
<div className='flex items-center space-x-2'>
|
||||
<RadioGroupItem value='price' id='price' />
|
||||
<Label htmlFor='price' className='font-normal'>
|
||||
{t('Price mode (USD per 1M tokens)')}
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
{pricingSubMode === 'ratio' ? (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='ratio'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Model ratio')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type='text'
|
||||
placeholder='1.0'
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value
|
||||
if (validateNumber(value)) {
|
||||
field.onChange(value)
|
||||
if (value) {
|
||||
setPromptPrice(
|
||||
(parseFloat(value) * 2).toString()
|
||||
)
|
||||
} else {
|
||||
setPromptPrice('')
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{field.value && !isNaN(parseFloat(field.value))
|
||||
? t(
|
||||
'Calculated price: ${{price}} per 1M tokens',
|
||||
{
|
||||
price: (
|
||||
parseFloat(field.value) * 2
|
||||
).toFixed(4),
|
||||
}
|
||||
)
|
||||
: t('Multiplier for prompt tokens.')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='completionRatio'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Completion ratio')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type='text'
|
||||
placeholder='1.0'
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value
|
||||
if (validateNumber(value)) {
|
||||
field.onChange(value)
|
||||
const ratio = form.getValues('ratio')
|
||||
if (value && ratio) {
|
||||
const compPrice =
|
||||
parseFloat(ratio) * 2 * parseFloat(value)
|
||||
setCompletionPrice(compPrice.toString())
|
||||
} else {
|
||||
setCompletionPrice('')
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{field.value &&
|
||||
!isNaN(parseFloat(field.value)) &&
|
||||
promptPrice &&
|
||||
!isNaN(parseFloat(promptPrice))
|
||||
? t(
|
||||
'Calculated price: ${{price}} per 1M tokens',
|
||||
{
|
||||
price: (
|
||||
parseFloat(promptPrice) *
|
||||
parseFloat(field.value)
|
||||
).toFixed(4),
|
||||
}
|
||||
)
|
||||
: t('Multiplier for completion tokens.')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className='space-y-4'>
|
||||
<div className='space-y-2'>
|
||||
<Label>{t('Prompt price ($/1M tokens)')}</Label>
|
||||
<Input
|
||||
type='text'
|
||||
placeholder='2.0'
|
||||
value={promptPrice}
|
||||
onChange={(e) =>
|
||||
handlePromptPriceChange(e.target.value)
|
||||
}
|
||||
/>
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
{promptPrice && !isNaN(parseFloat(promptPrice))
|
||||
? t('Calculated ratio: {{ratio}}', {
|
||||
ratio: (parseFloat(promptPrice) / 2).toFixed(4),
|
||||
})
|
||||
: t('Enter Input price to calculate ratio')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<Label>{t('Completion price ($/1M tokens)')}</Label>
|
||||
<Input
|
||||
type='text'
|
||||
placeholder='4.0'
|
||||
value={completionPrice}
|
||||
onChange={(e) =>
|
||||
handleCompletionPriceChange(e.target.value)
|
||||
}
|
||||
/>
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
{completionPrice &&
|
||||
!isNaN(parseFloat(completionPrice)) &&
|
||||
promptPrice &&
|
||||
!isNaN(parseFloat(promptPrice)) &&
|
||||
parseFloat(promptPrice) > 0
|
||||
? t('Calculated ratio: {{ratio}}', {
|
||||
ratio: (
|
||||
parseFloat(completionPrice) /
|
||||
parseFloat(promptPrice)
|
||||
).toFixed(4),
|
||||
})
|
||||
: t('Enter Completion price to calculate ratio')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
className='flex w-full items-center justify-between'
|
||||
>
|
||||
{t('Advanced options')}
|
||||
<ChevronDown
|
||||
className={`h-4 w-4 transition-transform duration-200 ${
|
||||
advancedOpen ? 'rotate-180' : ''
|
||||
}`}
|
||||
/>
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className='space-y-6 pt-6'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='cacheRatio'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Cache ratio')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type='text'
|
||||
placeholder='0.1'
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value
|
||||
if (validateNumber(value)) {
|
||||
field.onChange(value)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('Discount ratio for cache hits.')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='createCacheRatio'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Create cache ratio')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type='text'
|
||||
placeholder='1.25'
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value
|
||||
if (validateNumber(value)) {
|
||||
field.onChange(value)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
'Ratio applied when creating cache entries for supported models.'
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='imageRatio'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Image ratio')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type='text'
|
||||
placeholder='1.0'
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value
|
||||
if (validateNumber(value)) {
|
||||
field.onChange(value)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('Multiplier for image processing.')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='audioRatio'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Audio ratio')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type='text'
|
||||
placeholder='1.0'
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value
|
||||
if (validateNumber(value)) {
|
||||
field.onChange(value)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('Multiplier for audio inputs.')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='audioCompletionRatio'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Audio completion ratio')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type='text'
|
||||
placeholder='1.0'
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value
|
||||
if (validateNumber(value)) {
|
||||
field.onChange(value)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('Multiplier for audio outputs.')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
<Button type='submit'>
|
||||
{isEditMode ? t('Update') : t('Add')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
import { memo, useCallback, useState } from 'react'
|
||||
import { type UseFormReturn } from 'react-hook-form'
|
||||
import { Code2, Eye } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { ModelRatioVisualEditor } from './model-ratio-visual-editor'
|
||||
|
||||
type ModelFormValues = {
|
||||
ModelPrice: string
|
||||
ModelRatio: string
|
||||
CacheRatio: string
|
||||
CreateCacheRatio: string
|
||||
CompletionRatio: string
|
||||
ImageRatio: string
|
||||
AudioRatio: string
|
||||
AudioCompletionRatio: string
|
||||
ExposeRatioEnabled: boolean
|
||||
BillingMode: string
|
||||
BillingExpr: string
|
||||
}
|
||||
|
||||
type ModelRatioFormProps = {
|
||||
form: UseFormReturn<ModelFormValues>
|
||||
onSave: (values: ModelFormValues) => Promise<void>
|
||||
onReset: () => void
|
||||
isSaving: boolean
|
||||
isResetting: boolean
|
||||
}
|
||||
|
||||
export const ModelRatioForm = memo(function ModelRatioForm({
|
||||
form,
|
||||
onSave,
|
||||
onReset,
|
||||
isSaving,
|
||||
isResetting,
|
||||
}: ModelRatioFormProps) {
|
||||
const { t } = useTranslation()
|
||||
const [editMode, setEditMode] = useState<'visual' | 'json'>('visual')
|
||||
|
||||
const handleFieldChange = useCallback(
|
||||
(field: keyof ModelFormValues, value: string) => {
|
||||
form.setValue(field, value, {
|
||||
shouldValidate: true,
|
||||
shouldDirty: true,
|
||||
})
|
||||
},
|
||||
[form]
|
||||
)
|
||||
|
||||
const toggleEditMode = useCallback(() => {
|
||||
setEditMode((prev) => (prev === 'visual' ? 'json' : 'visual'))
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className='space-y-6'>
|
||||
<div className='flex justify-end'>
|
||||
<Button variant='outline' size='sm' onClick={toggleEditMode}>
|
||||
{editMode === 'visual' ? (
|
||||
<>
|
||||
<Code2 className='mr-2 h-4 w-4' />
|
||||
{t('Switch to JSON')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Eye className='mr-2 h-4 w-4' />
|
||||
{t('Switch to Visual')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Form {...form}>
|
||||
{editMode === 'visual' ? (
|
||||
<div className='space-y-6'>
|
||||
<ModelRatioVisualEditor
|
||||
modelPrice={form.watch('ModelPrice')}
|
||||
modelRatio={form.watch('ModelRatio')}
|
||||
cacheRatio={form.watch('CacheRatio')}
|
||||
createCacheRatio={form.watch('CreateCacheRatio')}
|
||||
completionRatio={form.watch('CompletionRatio')}
|
||||
imageRatio={form.watch('ImageRatio')}
|
||||
audioRatio={form.watch('AudioRatio')}
|
||||
audioCompletionRatio={form.watch('AudioCompletionRatio')}
|
||||
billingMode={form.watch('BillingMode')}
|
||||
billingExpr={form.watch('BillingExpr')}
|
||||
onChange={(field, value) => {
|
||||
const fieldMap: Record<string, keyof ModelFormValues> = {
|
||||
'billing_setting.billing_mode': 'BillingMode',
|
||||
'billing_setting.billing_expr': 'BillingExpr',
|
||||
}
|
||||
const formField =
|
||||
fieldMap[field] || (field as keyof ModelFormValues)
|
||||
handleFieldChange(formField, value)
|
||||
}}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='ExposeRatioEnabled'
|
||||
render={({ field }) => (
|
||||
<FormItem className='flex flex-row items-center justify-between rounded-lg border p-4'>
|
||||
<div className='space-y-0.5'>
|
||||
<FormLabel className='text-base'>
|
||||
{t('Expose ratio API')}
|
||||
</FormLabel>
|
||||
<FormDescription>
|
||||
{t(
|
||||
'Allow clients to query configured ratios via `/api/ratio`.'
|
||||
)}
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className='flex flex-wrap gap-4'>
|
||||
<Button onClick={form.handleSubmit(onSave)} disabled={isSaving}>
|
||||
{isSaving ? t('Saving...') : t('Save model ratios')}
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
variant='destructive'
|
||||
onClick={onReset}
|
||||
disabled={isResetting}
|
||||
>
|
||||
{t('Reset ratios')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={form.handleSubmit(onSave)} className='space-y-6'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='ModelPrice'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Model fixed pricing')}</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea rows={8} {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
'JSON map of model → USD cost per request. Takes precedence over ratio based billing.'
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='ModelRatio'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Model ratio')}</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea rows={8} {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
'JSON map of model → multiplier applied to quota billing.'
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='CacheRatio'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Prompt cache ratio')}</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea rows={8} {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('Optional ratio used when upstream cache hits occur.')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='CreateCacheRatio'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Create cache ratio')}</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea rows={8} {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
'Ratio applied when creating cache entries for supported models.'
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='CompletionRatio'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Completion ratio')}</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea rows={8} {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
'Applies to custom completion endpoints. JSON map of model → ratio.'
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='ImageRatio'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Image ratio')}</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea rows={6} {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
'Configure per-model ratio for image inputs or outputs.'
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='AudioRatio'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Audio ratio')}</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea rows={6} {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
'Ratio applied to audio inputs where supported by the upstream model.'
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='AudioCompletionRatio'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Audio completion ratio')}</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea rows={6} {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
'Ratio applied to audio completions for streaming models.'
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='ExposeRatioEnabled'
|
||||
render={({ field }) => (
|
||||
<FormItem className='flex flex-row items-center justify-between rounded-lg border p-4'>
|
||||
<div className='space-y-0.5'>
|
||||
<FormLabel className='text-base'>
|
||||
{t('Expose ratio API')}
|
||||
</FormLabel>
|
||||
<FormDescription>
|
||||
{t(
|
||||
'Allow clients to query configured ratios via `/api/ratio`.'
|
||||
)}
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className='flex flex-wrap gap-4'>
|
||||
<Button type='submit' disabled={isSaving}>
|
||||
{isSaving ? t('Saving...') : t('Save model ratios')}
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
variant='destructive'
|
||||
onClick={onReset}
|
||||
disabled={isResetting}
|
||||
>
|
||||
{t('Reset ratios')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</Form>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
+786
@@ -0,0 +1,786 @@
|
||||
import { useState, useMemo, memo, useCallback, useEffect } from 'react'
|
||||
import {
|
||||
type ColumnDef,
|
||||
type PaginationState,
|
||||
type VisibilityState,
|
||||
type SortingState,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getFilteredRowModel,
|
||||
getSortedRowModel,
|
||||
getPaginationRowModel,
|
||||
useReactTable,
|
||||
} from '@tanstack/react-table'
|
||||
import { Pencil, Plus, Trash2 } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import {
|
||||
DataTableColumnHeader,
|
||||
DataTableToolbar,
|
||||
DataTablePagination,
|
||||
} from '@/components/data-table'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import {
|
||||
combineBillingExpr,
|
||||
splitBillingExprAndRequestRules,
|
||||
} from '@/features/pricing/lib/billing-expr'
|
||||
import { safeJsonParse } from '../utils/json-parser'
|
||||
import { ModelRatioDialog, type ModelRatioData } from './model-ratio-dialog'
|
||||
|
||||
type ModelRatioVisualEditorProps = {
|
||||
modelPrice: string
|
||||
modelRatio: string
|
||||
cacheRatio: string
|
||||
createCacheRatio: string
|
||||
completionRatio: string
|
||||
imageRatio: string
|
||||
audioRatio: string
|
||||
audioCompletionRatio: string
|
||||
billingMode: string
|
||||
billingExpr: string
|
||||
onChange: (field: string, value: string) => void
|
||||
}
|
||||
|
||||
type ModelRow = {
|
||||
name: string
|
||||
price?: string
|
||||
ratio?: string
|
||||
cacheRatio?: string
|
||||
createCacheRatio?: string
|
||||
completionRatio?: string
|
||||
imageRatio?: string
|
||||
audioRatio?: string
|
||||
audioCompletionRatio?: string
|
||||
billingMode?: string
|
||||
billingExpr?: string
|
||||
requestRuleExpr?: string
|
||||
hasConflict: boolean
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'model-ratio-column-visibility'
|
||||
|
||||
const formatValue = (value?: string) => {
|
||||
if (!value || value === '') return '—'
|
||||
return value
|
||||
}
|
||||
|
||||
export const ModelRatioVisualEditor = memo(
|
||||
function ModelRatioVisualEditor({
|
||||
modelPrice,
|
||||
modelRatio,
|
||||
cacheRatio,
|
||||
createCacheRatio,
|
||||
completionRatio,
|
||||
imageRatio,
|
||||
audioRatio,
|
||||
audioCompletionRatio,
|
||||
billingMode,
|
||||
billingExpr,
|
||||
onChange,
|
||||
}: ModelRatioVisualEditorProps) {
|
||||
const { t } = useTranslation()
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [editData, setEditData] = useState<ModelRatioData | null>(null)
|
||||
const [sorting, setSorting] = useState<SortingState>([])
|
||||
const [pagination, setPagination] = useState<PaginationState>({
|
||||
pageIndex: 0,
|
||||
pageSize: 10,
|
||||
})
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>(
|
||||
() => {
|
||||
const saved = localStorage.getItem(STORAGE_KEY)
|
||||
if (saved) {
|
||||
try {
|
||||
return safeJsonParse<VisibilityState>(saved, {
|
||||
fallback: {
|
||||
cacheRatio: false,
|
||||
imageRatio: false,
|
||||
audioRatio: false,
|
||||
audioCompletionRatio: false,
|
||||
},
|
||||
silent: true,
|
||||
})
|
||||
} catch {
|
||||
return {
|
||||
cacheRatio: false,
|
||||
createCacheRatio: false,
|
||||
imageRatio: false,
|
||||
audioRatio: false,
|
||||
audioCompletionRatio: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
cacheRatio: false,
|
||||
createCacheRatio: false,
|
||||
imageRatio: false,
|
||||
audioRatio: false,
|
||||
audioCompletionRatio: false,
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(columnVisibility))
|
||||
}, [columnVisibility])
|
||||
|
||||
const models = useMemo(() => {
|
||||
const priceMap = safeJsonParse<Record<string, number>>(modelPrice, {
|
||||
fallback: {},
|
||||
context: 'model prices',
|
||||
})
|
||||
const ratioMap = safeJsonParse<Record<string, number>>(modelRatio, {
|
||||
fallback: {},
|
||||
context: 'model ratios',
|
||||
})
|
||||
const cacheMap = safeJsonParse<Record<string, number>>(cacheRatio, {
|
||||
fallback: {},
|
||||
context: 'cache ratios',
|
||||
})
|
||||
const createCacheMap = safeJsonParse<Record<string, number>>(
|
||||
createCacheRatio,
|
||||
{ fallback: {}, context: 'create cache ratios' }
|
||||
)
|
||||
const completionMap = safeJsonParse<Record<string, number>>(
|
||||
completionRatio,
|
||||
{ fallback: {}, context: 'completion ratios' }
|
||||
)
|
||||
const imageMap = safeJsonParse<Record<string, number>>(imageRatio, {
|
||||
fallback: {},
|
||||
context: 'image ratios',
|
||||
})
|
||||
const audioMap = safeJsonParse<Record<string, number>>(audioRatio, {
|
||||
fallback: {},
|
||||
context: 'audio ratios',
|
||||
})
|
||||
const audioCompletionMap = safeJsonParse<Record<string, number>>(
|
||||
audioCompletionRatio,
|
||||
{ fallback: {}, context: 'audio completion ratios' }
|
||||
)
|
||||
const billingModeMap = safeJsonParse<Record<string, string>>(
|
||||
billingMode,
|
||||
{
|
||||
fallback: {},
|
||||
context: 'billing mode',
|
||||
}
|
||||
)
|
||||
const billingExprMap = safeJsonParse<Record<string, string>>(
|
||||
billingExpr,
|
||||
{
|
||||
fallback: {},
|
||||
context: 'billing expression',
|
||||
}
|
||||
)
|
||||
|
||||
const modelNames = new Set([
|
||||
...Object.keys(priceMap),
|
||||
...Object.keys(ratioMap),
|
||||
...Object.keys(cacheMap),
|
||||
...Object.keys(createCacheMap),
|
||||
...Object.keys(completionMap),
|
||||
...Object.keys(imageMap),
|
||||
...Object.keys(audioMap),
|
||||
...Object.keys(audioCompletionMap),
|
||||
...Object.keys(billingModeMap),
|
||||
...Object.keys(billingExprMap),
|
||||
])
|
||||
|
||||
const modelData: ModelRow[] = Array.from(modelNames).map((name) => {
|
||||
const price = priceMap[name]?.toString() || ''
|
||||
const ratio = ratioMap[name]?.toString() || ''
|
||||
const cache = cacheMap[name]?.toString() || ''
|
||||
const createCache = createCacheMap[name]?.toString() || ''
|
||||
const completion = completionMap[name]?.toString() || ''
|
||||
const image = imageMap[name]?.toString() || ''
|
||||
const audio = audioMap[name]?.toString() || ''
|
||||
const audioCompletion = audioCompletionMap[name]?.toString() || ''
|
||||
|
||||
const modeForModel = billingModeMap[name]
|
||||
if (modeForModel === 'tiered_expr') {
|
||||
// Tiered_expr models may also retain ratio/price values as fallback
|
||||
// during multi-instance sync delays. We preserve them in the row so
|
||||
// the edit dialog round-trip and the next save don't drop them.
|
||||
const fullExpr = billingExprMap[name] || ''
|
||||
const { billingExpr: pureExpr, requestRuleExpr } =
|
||||
splitBillingExprAndRequestRules(fullExpr)
|
||||
return {
|
||||
name,
|
||||
billingMode: 'tiered_expr',
|
||||
billingExpr: pureExpr,
|
||||
requestRuleExpr,
|
||||
price,
|
||||
ratio,
|
||||
cacheRatio: cache,
|
||||
createCacheRatio: createCache,
|
||||
completionRatio: completion,
|
||||
imageRatio: image,
|
||||
audioRatio: audio,
|
||||
audioCompletionRatio: audioCompletion,
|
||||
hasConflict: false,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
name,
|
||||
price,
|
||||
ratio,
|
||||
cacheRatio: cache,
|
||||
createCacheRatio: createCache,
|
||||
completionRatio: completion,
|
||||
imageRatio: image,
|
||||
audioRatio: audio,
|
||||
audioCompletionRatio: audioCompletion,
|
||||
billingMode: price !== '' ? 'per-request' : 'per-token',
|
||||
hasConflict:
|
||||
price !== '' &&
|
||||
(ratio !== '' ||
|
||||
completion !== '' ||
|
||||
cache !== '' ||
|
||||
createCache !== '' ||
|
||||
image !== '' ||
|
||||
audio !== '' ||
|
||||
audioCompletion !== ''),
|
||||
}
|
||||
})
|
||||
|
||||
return modelData.sort((a, b) => a.name.localeCompare(b.name))
|
||||
}, [
|
||||
modelPrice,
|
||||
modelRatio,
|
||||
cacheRatio,
|
||||
createCacheRatio,
|
||||
completionRatio,
|
||||
imageRatio,
|
||||
audioRatio,
|
||||
audioCompletionRatio,
|
||||
billingMode,
|
||||
billingExpr,
|
||||
])
|
||||
|
||||
const handleEdit = useCallback((model: ModelRow) => {
|
||||
setEditData({
|
||||
name: model.name,
|
||||
price: model.price,
|
||||
ratio: model.ratio,
|
||||
cacheRatio: model.cacheRatio,
|
||||
createCacheRatio: model.createCacheRatio,
|
||||
completionRatio: model.completionRatio,
|
||||
imageRatio: model.imageRatio,
|
||||
audioRatio: model.audioRatio,
|
||||
audioCompletionRatio: model.audioCompletionRatio,
|
||||
billingMode:
|
||||
model.billingMode === 'tiered_expr'
|
||||
? 'tiered_expr'
|
||||
: model.price && model.price !== ''
|
||||
? 'per-request'
|
||||
: 'per-token',
|
||||
billingExpr: model.billingExpr,
|
||||
requestRuleExpr: model.requestRuleExpr,
|
||||
})
|
||||
setDialogOpen(true)
|
||||
}, [])
|
||||
|
||||
const handleAdd = useCallback(() => {
|
||||
setEditData(null)
|
||||
setDialogOpen(true)
|
||||
}, [])
|
||||
|
||||
const handleDelete = useCallback(
|
||||
(name: string) => {
|
||||
const priceMap = safeJsonParse<Record<string, number>>(modelPrice, {
|
||||
fallback: {},
|
||||
silent: true,
|
||||
})
|
||||
const ratioMap = safeJsonParse<Record<string, number>>(modelRatio, {
|
||||
fallback: {},
|
||||
silent: true,
|
||||
})
|
||||
const cacheMap = safeJsonParse<Record<string, number>>(cacheRatio, {
|
||||
fallback: {},
|
||||
silent: true,
|
||||
})
|
||||
const createCacheMap = safeJsonParse<Record<string, number>>(
|
||||
createCacheRatio,
|
||||
{ fallback: {}, silent: true }
|
||||
)
|
||||
const completionMap = safeJsonParse<Record<string, number>>(
|
||||
completionRatio,
|
||||
{ fallback: {}, silent: true }
|
||||
)
|
||||
const imageMap = safeJsonParse<Record<string, number>>(imageRatio, {
|
||||
fallback: {},
|
||||
silent: true,
|
||||
})
|
||||
const audioMap = safeJsonParse<Record<string, number>>(audioRatio, {
|
||||
fallback: {},
|
||||
silent: true,
|
||||
})
|
||||
const audioCompletionMap = safeJsonParse<Record<string, number>>(
|
||||
audioCompletionRatio,
|
||||
{ fallback: {}, silent: true }
|
||||
)
|
||||
const billingModeMap = safeJsonParse<Record<string, string>>(
|
||||
billingMode,
|
||||
{ fallback: {}, silent: true }
|
||||
)
|
||||
const billingExprMap = safeJsonParse<Record<string, string>>(
|
||||
billingExpr,
|
||||
{ fallback: {}, silent: true }
|
||||
)
|
||||
|
||||
delete priceMap[name]
|
||||
delete ratioMap[name]
|
||||
delete cacheMap[name]
|
||||
delete createCacheMap[name]
|
||||
delete completionMap[name]
|
||||
delete imageMap[name]
|
||||
delete audioMap[name]
|
||||
delete audioCompletionMap[name]
|
||||
delete billingModeMap[name]
|
||||
delete billingExprMap[name]
|
||||
|
||||
onChange('ModelPrice', JSON.stringify(priceMap, null, 2))
|
||||
onChange('ModelRatio', JSON.stringify(ratioMap, null, 2))
|
||||
onChange('CacheRatio', JSON.stringify(cacheMap, null, 2))
|
||||
onChange('CreateCacheRatio', JSON.stringify(createCacheMap, null, 2))
|
||||
onChange('CompletionRatio', JSON.stringify(completionMap, null, 2))
|
||||
onChange('ImageRatio', JSON.stringify(imageMap, null, 2))
|
||||
onChange('AudioRatio', JSON.stringify(audioMap, null, 2))
|
||||
onChange(
|
||||
'AudioCompletionRatio',
|
||||
JSON.stringify(audioCompletionMap, null, 2)
|
||||
)
|
||||
onChange(
|
||||
'billing_setting.billing_mode',
|
||||
JSON.stringify(billingModeMap, null, 2)
|
||||
)
|
||||
onChange(
|
||||
'billing_setting.billing_expr',
|
||||
JSON.stringify(billingExprMap, null, 2)
|
||||
)
|
||||
},
|
||||
[
|
||||
modelPrice,
|
||||
modelRatio,
|
||||
cacheRatio,
|
||||
createCacheRatio,
|
||||
completionRatio,
|
||||
imageRatio,
|
||||
audioRatio,
|
||||
audioCompletionRatio,
|
||||
billingMode,
|
||||
billingExpr,
|
||||
onChange,
|
||||
]
|
||||
)
|
||||
|
||||
const columns = useMemo<ColumnDef<ModelRow>[]>(() => {
|
||||
// Ratio fields are not the primary pricing when a per-request fixed
|
||||
// price is set, or when the model is in tiered_expr mode (the
|
||||
// expression is primary; ratios are fallback during sync delays).
|
||||
const isFallbackRow = (row: ModelRow) =>
|
||||
row.billingMode === 'tiered_expr' || !!row.price
|
||||
const fallbackClass = (row: ModelRow) =>
|
||||
isFallbackRow(row) ? 'text-muted-foreground' : ''
|
||||
|
||||
return [
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('Model name')} />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<div className='flex items-center gap-2 font-medium'>
|
||||
{row.getValue('name')}
|
||||
{row.original.billingMode === 'tiered_expr' && (
|
||||
<StatusBadge
|
||||
label={t('Tiered')}
|
||||
variant='info'
|
||||
copyable={false}
|
||||
/>
|
||||
)}
|
||||
{row.original.hasConflict && (
|
||||
<StatusBadge
|
||||
label={t('Conflict')}
|
||||
variant='danger'
|
||||
copyable={false}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
enableHiding: false,
|
||||
},
|
||||
{
|
||||
accessorKey: 'price',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('Fixed price')} />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span
|
||||
className={
|
||||
row.original.billingMode === 'tiered_expr'
|
||||
? 'text-muted-foreground'
|
||||
: ''
|
||||
}
|
||||
>
|
||||
{formatValue(row.getValue('price'))}
|
||||
</span>
|
||||
),
|
||||
meta: { label: 'Fixed price' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'ratio',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('Ratio')} />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className={fallbackClass(row.original)}>
|
||||
{formatValue(row.getValue('ratio'))}
|
||||
</span>
|
||||
),
|
||||
meta: { label: 'Ratio' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'completionRatio',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('Completion')} />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className={fallbackClass(row.original)}>
|
||||
{formatValue(row.getValue('completionRatio'))}
|
||||
</span>
|
||||
),
|
||||
meta: { label: 'Completion' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'cacheRatio',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('Cache')} />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className={fallbackClass(row.original)}>
|
||||
{formatValue(row.getValue('cacheRatio'))}
|
||||
</span>
|
||||
),
|
||||
meta: { label: 'Cache' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'createCacheRatio',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('Create cache')} />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className={fallbackClass(row.original)}>
|
||||
{formatValue(row.getValue('createCacheRatio'))}
|
||||
</span>
|
||||
),
|
||||
meta: { label: 'Create cache' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'imageRatio',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('Image')} />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className={fallbackClass(row.original)}>
|
||||
{formatValue(row.getValue('imageRatio'))}
|
||||
</span>
|
||||
),
|
||||
meta: { label: 'Image' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'audioRatio',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('Audio')} />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className={fallbackClass(row.original)}>
|
||||
{formatValue(row.getValue('audioRatio'))}
|
||||
</span>
|
||||
),
|
||||
meta: { label: 'Audio' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'audioCompletionRatio',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('Audio comp.')} />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className={fallbackClass(row.original)}>
|
||||
{formatValue(row.getValue('audioCompletionRatio'))}
|
||||
</span>
|
||||
),
|
||||
meta: { label: 'Audio comp.' },
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
cell: ({ row }) => (
|
||||
<div className='flex justify-end gap-2'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={() => handleEdit(row.original)}
|
||||
>
|
||||
<Pencil className='h-4 w-4' />
|
||||
</Button>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={() => handleDelete(row.original.name)}
|
||||
>
|
||||
<Trash2 className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
enableHiding: false,
|
||||
},
|
||||
]
|
||||
}, [handleEdit, handleDelete, t])
|
||||
|
||||
const table = useReactTable({
|
||||
data: models,
|
||||
columns,
|
||||
state: {
|
||||
sorting,
|
||||
columnVisibility,
|
||||
pagination,
|
||||
},
|
||||
onSortingChange: setSorting,
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
onPaginationChange: setPagination,
|
||||
autoResetPageIndex: false,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
globalFilterFn: (row, _columnId, filterValue) => {
|
||||
const searchValue = String(filterValue).toLowerCase()
|
||||
return row.original.name.toLowerCase().includes(searchValue)
|
||||
},
|
||||
})
|
||||
|
||||
const handleSave = useCallback(
|
||||
(data: ModelRatioData) => {
|
||||
const priceMap = safeJsonParse<Record<string, number>>(modelPrice, {
|
||||
fallback: {},
|
||||
silent: true,
|
||||
})
|
||||
const ratioMap = safeJsonParse<Record<string, number>>(modelRatio, {
|
||||
fallback: {},
|
||||
silent: true,
|
||||
})
|
||||
const cacheMap = safeJsonParse<Record<string, number>>(cacheRatio, {
|
||||
fallback: {},
|
||||
silent: true,
|
||||
})
|
||||
const createCacheMap = safeJsonParse<Record<string, number>>(
|
||||
createCacheRatio,
|
||||
{ fallback: {}, silent: true }
|
||||
)
|
||||
const completionMap = safeJsonParse<Record<string, number>>(
|
||||
completionRatio,
|
||||
{ fallback: {}, silent: true }
|
||||
)
|
||||
const imageMap = safeJsonParse<Record<string, number>>(imageRatio, {
|
||||
fallback: {},
|
||||
silent: true,
|
||||
})
|
||||
const audioMap = safeJsonParse<Record<string, number>>(audioRatio, {
|
||||
fallback: {},
|
||||
silent: true,
|
||||
})
|
||||
const audioCompletionMap = safeJsonParse<Record<string, number>>(
|
||||
audioCompletionRatio,
|
||||
{ fallback: {}, silent: true }
|
||||
)
|
||||
const billingModeMap = safeJsonParse<Record<string, string>>(
|
||||
billingMode,
|
||||
{ fallback: {}, silent: true }
|
||||
)
|
||||
const billingExprMap = safeJsonParse<Record<string, string>>(
|
||||
billingExpr,
|
||||
{ fallback: {}, silent: true }
|
||||
)
|
||||
|
||||
delete priceMap[data.name]
|
||||
delete ratioMap[data.name]
|
||||
delete cacheMap[data.name]
|
||||
delete createCacheMap[data.name]
|
||||
delete completionMap[data.name]
|
||||
delete imageMap[data.name]
|
||||
delete audioMap[data.name]
|
||||
delete audioCompletionMap[data.name]
|
||||
delete billingModeMap[data.name]
|
||||
delete billingExprMap[data.name]
|
||||
|
||||
const setIfPresent = (
|
||||
target: Record<string, number>,
|
||||
value: string | undefined
|
||||
) => {
|
||||
if (!value || value === '') return
|
||||
const parsed = parseFloat(value)
|
||||
if (Number.isFinite(parsed)) target[data.name] = parsed
|
||||
}
|
||||
|
||||
if (data.billingMode === 'tiered_expr') {
|
||||
const combined = combineBillingExpr(
|
||||
data.billingExpr || '',
|
||||
data.requestRuleExpr || ''
|
||||
)
|
||||
if (combined) {
|
||||
billingModeMap[data.name] = 'tiered_expr'
|
||||
billingExprMap[data.name] = combined
|
||||
}
|
||||
// Always serialize ratio/price values for tiered_expr models so they
|
||||
// serve as fallback during multi-instance sync delays. The backend's
|
||||
// ModelPriceHelper checks billing_mode first, so these values are
|
||||
// only consulted when billing_setting hasn't propagated yet.
|
||||
setIfPresent(priceMap, data.price)
|
||||
setIfPresent(ratioMap, data.ratio)
|
||||
setIfPresent(cacheMap, data.cacheRatio)
|
||||
setIfPresent(createCacheMap, data.createCacheRatio)
|
||||
setIfPresent(completionMap, data.completionRatio)
|
||||
setIfPresent(imageMap, data.imageRatio)
|
||||
setIfPresent(audioMap, data.audioRatio)
|
||||
setIfPresent(audioCompletionMap, data.audioCompletionRatio)
|
||||
} else if (data.price && data.price !== '') {
|
||||
setIfPresent(priceMap, data.price)
|
||||
} else {
|
||||
setIfPresent(ratioMap, data.ratio)
|
||||
setIfPresent(cacheMap, data.cacheRatio)
|
||||
setIfPresent(createCacheMap, data.createCacheRatio)
|
||||
setIfPresent(completionMap, data.completionRatio)
|
||||
setIfPresent(imageMap, data.imageRatio)
|
||||
setIfPresent(audioMap, data.audioRatio)
|
||||
setIfPresent(audioCompletionMap, data.audioCompletionRatio)
|
||||
}
|
||||
|
||||
onChange('ModelPrice', JSON.stringify(priceMap, null, 2))
|
||||
onChange('ModelRatio', JSON.stringify(ratioMap, null, 2))
|
||||
onChange('CacheRatio', JSON.stringify(cacheMap, null, 2))
|
||||
onChange('CreateCacheRatio', JSON.stringify(createCacheMap, null, 2))
|
||||
onChange('CompletionRatio', JSON.stringify(completionMap, null, 2))
|
||||
onChange('ImageRatio', JSON.stringify(imageMap, null, 2))
|
||||
onChange('AudioRatio', JSON.stringify(audioMap, null, 2))
|
||||
onChange(
|
||||
'AudioCompletionRatio',
|
||||
JSON.stringify(audioCompletionMap, null, 2)
|
||||
)
|
||||
onChange(
|
||||
'billing_setting.billing_mode',
|
||||
JSON.stringify(billingModeMap, null, 2)
|
||||
)
|
||||
onChange(
|
||||
'billing_setting.billing_expr',
|
||||
JSON.stringify(billingExprMap, null, 2)
|
||||
)
|
||||
},
|
||||
[
|
||||
modelPrice,
|
||||
modelRatio,
|
||||
cacheRatio,
|
||||
createCacheRatio,
|
||||
completionRatio,
|
||||
imageRatio,
|
||||
audioRatio,
|
||||
audioCompletionRatio,
|
||||
billingMode,
|
||||
billingExpr,
|
||||
onChange,
|
||||
]
|
||||
)
|
||||
|
||||
return (
|
||||
<div className='space-y-4'>
|
||||
<div className='flex items-center justify-between gap-4'>
|
||||
<DataTableToolbar
|
||||
table={table}
|
||||
searchPlaceholder={t('Search models...')}
|
||||
/>
|
||||
<Button onClick={handleAdd}>
|
||||
<Plus className='mr-2 h-4 w-4' />
|
||||
{t('Add model')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{table.getRowModel().rows.length === 0 ? (
|
||||
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center'>
|
||||
{table.getState().globalFilter
|
||||
? t('No models match your search')
|
||||
: t('No models configured. Click "Add model" to get started.')}
|
||||
</div>
|
||||
) : (
|
||||
<div className='overflow-hidden rounded-md border'>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHead key={header.id} colSpan={header.colSpan}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows.map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext()
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{table.getRowModel().rows.length > 0 && (
|
||||
<DataTablePagination table={table} />
|
||||
)}
|
||||
|
||||
<ModelRatioDialog
|
||||
open={dialogOpen}
|
||||
onOpenChange={setDialogOpen}
|
||||
onSave={handleSave}
|
||||
editData={editData}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
// Custom equality check - only re-render if JSON props actually changed
|
||||
(prevProps, nextProps) => {
|
||||
return (
|
||||
prevProps.modelPrice === nextProps.modelPrice &&
|
||||
prevProps.modelRatio === nextProps.modelRatio &&
|
||||
prevProps.cacheRatio === nextProps.cacheRatio &&
|
||||
prevProps.createCacheRatio === nextProps.createCacheRatio &&
|
||||
prevProps.completionRatio === nextProps.completionRatio &&
|
||||
prevProps.imageRatio === nextProps.imageRatio &&
|
||||
prevProps.audioRatio === nextProps.audioRatio &&
|
||||
prevProps.audioCompletionRatio === nextProps.audioCompletionRatio &&
|
||||
prevProps.billingMode === nextProps.billingMode &&
|
||||
prevProps.billingExpr === nextProps.billingExpr &&
|
||||
prevProps.onChange === nextProps.onChange
|
||||
)
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,488 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import * as z from 'zod'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import { resetModelRatios } from '../api'
|
||||
import { SettingsSection } from '../components/settings-section'
|
||||
import { useUpdateOption } from '../hooks/use-update-option'
|
||||
import { GroupRatioForm } from './group-ratio-form'
|
||||
import { ModelRatioForm } from './model-ratio-form'
|
||||
import { ToolPriceSettings } from './tool-price-settings'
|
||||
import { UpstreamRatioSync } from './upstream-ratio-sync'
|
||||
import {
|
||||
formatJsonForTextarea,
|
||||
normalizeJsonString,
|
||||
validateJsonString,
|
||||
} from './utils'
|
||||
|
||||
const modelSchema = z.object({
|
||||
ModelPrice: z.string().superRefine((value, ctx) => {
|
||||
const result = validateJsonString(value)
|
||||
if (!result.valid) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: result.message || 'Invalid JSON',
|
||||
})
|
||||
}
|
||||
}),
|
||||
ModelRatio: z.string().superRefine((value, ctx) => {
|
||||
const result = validateJsonString(value)
|
||||
if (!result.valid) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: result.message || 'Invalid JSON',
|
||||
})
|
||||
}
|
||||
}),
|
||||
CacheRatio: z.string().superRefine((value, ctx) => {
|
||||
const result = validateJsonString(value)
|
||||
if (!result.valid) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: result.message || 'Invalid JSON',
|
||||
})
|
||||
}
|
||||
}),
|
||||
CreateCacheRatio: z.string().superRefine((value, ctx) => {
|
||||
const result = validateJsonString(value)
|
||||
if (!result.valid) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: result.message || 'Invalid JSON',
|
||||
})
|
||||
}
|
||||
}),
|
||||
CompletionRatio: z.string().superRefine((value, ctx) => {
|
||||
const result = validateJsonString(value)
|
||||
if (!result.valid) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: result.message || 'Invalid JSON',
|
||||
})
|
||||
}
|
||||
}),
|
||||
ImageRatio: z.string().superRefine((value, ctx) => {
|
||||
const result = validateJsonString(value)
|
||||
if (!result.valid) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: result.message || 'Invalid JSON',
|
||||
})
|
||||
}
|
||||
}),
|
||||
AudioRatio: z.string().superRefine((value, ctx) => {
|
||||
const result = validateJsonString(value)
|
||||
if (!result.valid) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: result.message || 'Invalid JSON',
|
||||
})
|
||||
}
|
||||
}),
|
||||
AudioCompletionRatio: z.string().superRefine((value, ctx) => {
|
||||
const result = validateJsonString(value)
|
||||
if (!result.valid) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: result.message || 'Invalid JSON',
|
||||
})
|
||||
}
|
||||
}),
|
||||
ExposeRatioEnabled: z.boolean(),
|
||||
BillingMode: z.string().superRefine((value, ctx) => {
|
||||
const result = validateJsonString(value)
|
||||
if (!result.valid) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: result.message || 'Invalid JSON',
|
||||
})
|
||||
}
|
||||
}),
|
||||
BillingExpr: z.string().superRefine((value, ctx) => {
|
||||
const result = validateJsonString(value)
|
||||
if (!result.valid) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: result.message || 'Invalid JSON',
|
||||
})
|
||||
}
|
||||
}),
|
||||
})
|
||||
|
||||
const groupSchema = z.object({
|
||||
GroupRatio: z.string().superRefine((value, ctx) => {
|
||||
const result = validateJsonString(value)
|
||||
if (!result.valid) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: result.message || 'Invalid JSON',
|
||||
})
|
||||
}
|
||||
}),
|
||||
TopupGroupRatio: z.string().superRefine((value, ctx) => {
|
||||
const result = validateJsonString(value)
|
||||
if (!result.valid) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: result.message || 'Invalid JSON',
|
||||
})
|
||||
}
|
||||
}),
|
||||
UserUsableGroups: z.string().superRefine((value, ctx) => {
|
||||
const result = validateJsonString(value)
|
||||
if (!result.valid) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: result.message || 'Invalid JSON',
|
||||
})
|
||||
}
|
||||
}),
|
||||
GroupGroupRatio: z.string().superRefine((value, ctx) => {
|
||||
const result = validateJsonString(value)
|
||||
if (!result.valid) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: result.message || 'Invalid JSON',
|
||||
})
|
||||
}
|
||||
}),
|
||||
AutoGroups: z.string().superRefine((value, ctx) => {
|
||||
const result = validateJsonString(value, {
|
||||
predicate: (parsed) =>
|
||||
Array.isArray(parsed) &&
|
||||
parsed.every((item) => typeof item === 'string'),
|
||||
predicateMessage: 'Expected a JSON array of group identifiers',
|
||||
})
|
||||
if (!result.valid) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: result.message || 'Invalid JSON array',
|
||||
})
|
||||
}
|
||||
}),
|
||||
DefaultUseAutoGroup: z.boolean(),
|
||||
GroupSpecialUsableGroup: z.string().superRefine((value, ctx) => {
|
||||
const result = validateJsonString(value)
|
||||
if (!result.valid) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: result.message || 'Invalid JSON',
|
||||
})
|
||||
}
|
||||
}),
|
||||
})
|
||||
|
||||
type ModelFormValues = z.infer<typeof modelSchema>
|
||||
type GroupFormValues = z.infer<typeof groupSchema>
|
||||
|
||||
type RatioSettingsCardProps = {
|
||||
modelDefaults: ModelFormValues
|
||||
groupDefaults: GroupFormValues
|
||||
toolPricesDefault: string
|
||||
}
|
||||
|
||||
export function RatioSettingsCard({
|
||||
modelDefaults,
|
||||
groupDefaults,
|
||||
toolPricesDefault,
|
||||
}: RatioSettingsCardProps) {
|
||||
const { t } = useTranslation()
|
||||
const updateOption = useUpdateOption()
|
||||
const queryClient = useQueryClient()
|
||||
const [confirmOpen, setConfirmOpen] = useState(false)
|
||||
|
||||
const resetMutation = useMutation({
|
||||
mutationFn: resetModelRatios,
|
||||
onSuccess: (data) => {
|
||||
if (data.success) {
|
||||
toast.success(t('Model ratios reset successfully'))
|
||||
queryClient.invalidateQueries({ queryKey: ['system-options'] })
|
||||
setConfirmOpen(false)
|
||||
} else {
|
||||
toast.error(data.message || t('Failed to reset model ratios'))
|
||||
}
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || t('Failed to reset model ratios'))
|
||||
},
|
||||
})
|
||||
|
||||
const modelNormalizedDefaults = useRef({
|
||||
ModelPrice: normalizeJsonString(modelDefaults.ModelPrice),
|
||||
ModelRatio: normalizeJsonString(modelDefaults.ModelRatio),
|
||||
CacheRatio: normalizeJsonString(modelDefaults.CacheRatio),
|
||||
CreateCacheRatio: normalizeJsonString(modelDefaults.CreateCacheRatio),
|
||||
CompletionRatio: normalizeJsonString(modelDefaults.CompletionRatio),
|
||||
ImageRatio: normalizeJsonString(modelDefaults.ImageRatio),
|
||||
AudioRatio: normalizeJsonString(modelDefaults.AudioRatio),
|
||||
AudioCompletionRatio: normalizeJsonString(
|
||||
modelDefaults.AudioCompletionRatio
|
||||
),
|
||||
ExposeRatioEnabled: modelDefaults.ExposeRatioEnabled,
|
||||
BillingMode: normalizeJsonString(modelDefaults.BillingMode),
|
||||
BillingExpr: normalizeJsonString(modelDefaults.BillingExpr),
|
||||
})
|
||||
|
||||
const groupNormalizedDefaults = useRef({
|
||||
GroupRatio: normalizeJsonString(groupDefaults.GroupRatio),
|
||||
TopupGroupRatio: normalizeJsonString(groupDefaults.TopupGroupRatio),
|
||||
UserUsableGroups: normalizeJsonString(groupDefaults.UserUsableGroups),
|
||||
GroupGroupRatio: normalizeJsonString(groupDefaults.GroupGroupRatio),
|
||||
AutoGroups: normalizeJsonString(groupDefaults.AutoGroups),
|
||||
DefaultUseAutoGroup: groupDefaults.DefaultUseAutoGroup,
|
||||
GroupSpecialUsableGroup: normalizeJsonString(
|
||||
groupDefaults.GroupSpecialUsableGroup
|
||||
),
|
||||
})
|
||||
|
||||
const modelForm = useForm<ModelFormValues>({
|
||||
resolver: zodResolver(modelSchema),
|
||||
mode: 'onChange',
|
||||
defaultValues: {
|
||||
...modelDefaults,
|
||||
ModelPrice: formatJsonForTextarea(modelDefaults.ModelPrice),
|
||||
ModelRatio: formatJsonForTextarea(modelDefaults.ModelRatio),
|
||||
CacheRatio: formatJsonForTextarea(modelDefaults.CacheRatio),
|
||||
CreateCacheRatio: formatJsonForTextarea(modelDefaults.CreateCacheRatio),
|
||||
CompletionRatio: formatJsonForTextarea(modelDefaults.CompletionRatio),
|
||||
ImageRatio: formatJsonForTextarea(modelDefaults.ImageRatio),
|
||||
AudioRatio: formatJsonForTextarea(modelDefaults.AudioRatio),
|
||||
AudioCompletionRatio: formatJsonForTextarea(
|
||||
modelDefaults.AudioCompletionRatio
|
||||
),
|
||||
BillingMode: formatJsonForTextarea(modelDefaults.BillingMode),
|
||||
BillingExpr: formatJsonForTextarea(modelDefaults.BillingExpr),
|
||||
},
|
||||
})
|
||||
|
||||
const groupForm = useForm<GroupFormValues>({
|
||||
resolver: zodResolver(groupSchema),
|
||||
mode: 'onChange',
|
||||
defaultValues: {
|
||||
...groupDefaults,
|
||||
GroupRatio: formatJsonForTextarea(groupDefaults.GroupRatio),
|
||||
TopupGroupRatio: formatJsonForTextarea(groupDefaults.TopupGroupRatio),
|
||||
UserUsableGroups: formatJsonForTextarea(groupDefaults.UserUsableGroups),
|
||||
GroupGroupRatio: formatJsonForTextarea(groupDefaults.GroupGroupRatio),
|
||||
AutoGroups: formatJsonForTextarea(groupDefaults.AutoGroups),
|
||||
GroupSpecialUsableGroup: formatJsonForTextarea(
|
||||
groupDefaults.GroupSpecialUsableGroup
|
||||
),
|
||||
},
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
modelNormalizedDefaults.current = {
|
||||
ModelPrice: normalizeJsonString(modelDefaults.ModelPrice),
|
||||
ModelRatio: normalizeJsonString(modelDefaults.ModelRatio),
|
||||
CacheRatio: normalizeJsonString(modelDefaults.CacheRatio),
|
||||
CreateCacheRatio: normalizeJsonString(modelDefaults.CreateCacheRatio),
|
||||
CompletionRatio: normalizeJsonString(modelDefaults.CompletionRatio),
|
||||
ImageRatio: normalizeJsonString(modelDefaults.ImageRatio),
|
||||
AudioRatio: normalizeJsonString(modelDefaults.AudioRatio),
|
||||
AudioCompletionRatio: normalizeJsonString(
|
||||
modelDefaults.AudioCompletionRatio
|
||||
),
|
||||
ExposeRatioEnabled: modelDefaults.ExposeRatioEnabled,
|
||||
BillingMode: normalizeJsonString(modelDefaults.BillingMode),
|
||||
BillingExpr: normalizeJsonString(modelDefaults.BillingExpr),
|
||||
}
|
||||
|
||||
modelForm.reset({
|
||||
...modelDefaults,
|
||||
ModelPrice: formatJsonForTextarea(modelDefaults.ModelPrice),
|
||||
ModelRatio: formatJsonForTextarea(modelDefaults.ModelRatio),
|
||||
CacheRatio: formatJsonForTextarea(modelDefaults.CacheRatio),
|
||||
CreateCacheRatio: formatJsonForTextarea(modelDefaults.CreateCacheRatio),
|
||||
CompletionRatio: formatJsonForTextarea(modelDefaults.CompletionRatio),
|
||||
ImageRatio: formatJsonForTextarea(modelDefaults.ImageRatio),
|
||||
AudioRatio: formatJsonForTextarea(modelDefaults.AudioRatio),
|
||||
AudioCompletionRatio: formatJsonForTextarea(
|
||||
modelDefaults.AudioCompletionRatio
|
||||
),
|
||||
BillingMode: formatJsonForTextarea(modelDefaults.BillingMode),
|
||||
BillingExpr: formatJsonForTextarea(modelDefaults.BillingExpr),
|
||||
})
|
||||
}, [modelDefaults, modelForm])
|
||||
|
||||
useEffect(() => {
|
||||
groupNormalizedDefaults.current = {
|
||||
GroupRatio: normalizeJsonString(groupDefaults.GroupRatio),
|
||||
TopupGroupRatio: normalizeJsonString(groupDefaults.TopupGroupRatio),
|
||||
UserUsableGroups: normalizeJsonString(groupDefaults.UserUsableGroups),
|
||||
GroupGroupRatio: normalizeJsonString(groupDefaults.GroupGroupRatio),
|
||||
AutoGroups: normalizeJsonString(groupDefaults.AutoGroups),
|
||||
DefaultUseAutoGroup: groupDefaults.DefaultUseAutoGroup,
|
||||
GroupSpecialUsableGroup: normalizeJsonString(
|
||||
groupDefaults.GroupSpecialUsableGroup
|
||||
),
|
||||
}
|
||||
|
||||
groupForm.reset({
|
||||
...groupDefaults,
|
||||
GroupRatio: formatJsonForTextarea(groupDefaults.GroupRatio),
|
||||
TopupGroupRatio: formatJsonForTextarea(groupDefaults.TopupGroupRatio),
|
||||
UserUsableGroups: formatJsonForTextarea(groupDefaults.UserUsableGroups),
|
||||
GroupGroupRatio: formatJsonForTextarea(groupDefaults.GroupGroupRatio),
|
||||
AutoGroups: formatJsonForTextarea(groupDefaults.AutoGroups),
|
||||
GroupSpecialUsableGroup: formatJsonForTextarea(
|
||||
groupDefaults.GroupSpecialUsableGroup
|
||||
),
|
||||
})
|
||||
}, [groupDefaults, groupForm])
|
||||
|
||||
const saveModelRatios = useCallback(
|
||||
async (values: ModelFormValues) => {
|
||||
const normalized = {
|
||||
ModelPrice: normalizeJsonString(values.ModelPrice),
|
||||
ModelRatio: normalizeJsonString(values.ModelRatio),
|
||||
CacheRatio: normalizeJsonString(values.CacheRatio),
|
||||
CreateCacheRatio: normalizeJsonString(values.CreateCacheRatio),
|
||||
CompletionRatio: normalizeJsonString(values.CompletionRatio),
|
||||
ImageRatio: normalizeJsonString(values.ImageRatio),
|
||||
AudioRatio: normalizeJsonString(values.AudioRatio),
|
||||
AudioCompletionRatio: normalizeJsonString(values.AudioCompletionRatio),
|
||||
ExposeRatioEnabled: values.ExposeRatioEnabled,
|
||||
BillingMode: normalizeJsonString(values.BillingMode),
|
||||
BillingExpr: normalizeJsonString(values.BillingExpr),
|
||||
}
|
||||
|
||||
const apiKeyMap: Record<string, string> = {
|
||||
BillingMode: 'billing_setting.billing_mode',
|
||||
BillingExpr: 'billing_setting.billing_expr',
|
||||
}
|
||||
|
||||
const updates = (
|
||||
Object.keys(normalized) as Array<keyof ModelFormValues>
|
||||
).filter(
|
||||
(key) => normalized[key] !== modelNormalizedDefaults.current[key]
|
||||
)
|
||||
|
||||
for (const key of updates) {
|
||||
const apiKey = apiKeyMap[key as string] || (key as string)
|
||||
await updateOption.mutateAsync({ key: apiKey, value: normalized[key] })
|
||||
}
|
||||
},
|
||||
[updateOption]
|
||||
)
|
||||
|
||||
const saveGroupRatios = useCallback(
|
||||
async (values: GroupFormValues) => {
|
||||
const normalized = {
|
||||
GroupRatio: normalizeJsonString(values.GroupRatio),
|
||||
TopupGroupRatio: normalizeJsonString(values.TopupGroupRatio),
|
||||
UserUsableGroups: normalizeJsonString(values.UserUsableGroups),
|
||||
GroupGroupRatio: normalizeJsonString(values.GroupGroupRatio),
|
||||
AutoGroups: normalizeJsonString(values.AutoGroups),
|
||||
DefaultUseAutoGroup: values.DefaultUseAutoGroup,
|
||||
GroupSpecialUsableGroup: normalizeJsonString(
|
||||
values.GroupSpecialUsableGroup
|
||||
),
|
||||
}
|
||||
|
||||
// Map form field names to API keys (most are 1:1, except GroupSpecialUsableGroup)
|
||||
const apiKeyMap: Record<string, string> = {
|
||||
GroupSpecialUsableGroup:
|
||||
'group_ratio_setting.group_special_usable_group',
|
||||
}
|
||||
|
||||
const updates = (
|
||||
Object.keys(normalized) as Array<keyof typeof normalized>
|
||||
).filter(
|
||||
(key) => normalized[key] !== groupNormalizedDefaults.current[key]
|
||||
)
|
||||
|
||||
for (const key of updates) {
|
||||
const apiKey = apiKeyMap[key] || key
|
||||
await updateOption.mutateAsync({ key: apiKey, value: normalized[key] })
|
||||
}
|
||||
},
|
||||
[updateOption]
|
||||
)
|
||||
|
||||
const handleResetRatios = useCallback(() => {
|
||||
setConfirmOpen(true)
|
||||
}, [])
|
||||
|
||||
const { mutate: resetMutate } = resetMutation
|
||||
const handleConfirmReset = useCallback(() => {
|
||||
resetMutate()
|
||||
}, [resetMutate])
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
title={t('Pricing Ratios')}
|
||||
description={t(
|
||||
'Configure model, caching, and group ratios used for billing'
|
||||
)}
|
||||
>
|
||||
<Tabs defaultValue='models' className='space-y-6'>
|
||||
<TabsList className='grid w-full grid-cols-4'>
|
||||
<TabsTrigger value='models'>{t('Model ratios')}</TabsTrigger>
|
||||
<TabsTrigger value='groups'>{t('Group ratios')}</TabsTrigger>
|
||||
<TabsTrigger value='tool-prices'>{t('Tool prices')}</TabsTrigger>
|
||||
<TabsTrigger value='upstream-sync'>
|
||||
{t('Upstream price sync')}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value='models'>
|
||||
<ModelRatioForm
|
||||
form={modelForm}
|
||||
onSave={saveModelRatios}
|
||||
onReset={handleResetRatios}
|
||||
isSaving={updateOption.isPending}
|
||||
isResetting={resetMutation.isPending}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value='groups'>
|
||||
<GroupRatioForm
|
||||
form={groupForm}
|
||||
onSave={saveGroupRatios}
|
||||
isSaving={updateOption.isPending}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value='tool-prices'>
|
||||
<ToolPriceSettings defaultValue={toolPricesDefault} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value='upstream-sync'>
|
||||
<UpstreamRatioSync
|
||||
modelRatios={{
|
||||
ModelPrice: modelDefaults.ModelPrice,
|
||||
ModelRatio: modelDefaults.ModelRatio,
|
||||
CompletionRatio: modelDefaults.CompletionRatio,
|
||||
CacheRatio: modelDefaults.CacheRatio,
|
||||
CreateCacheRatio: modelDefaults.CreateCacheRatio,
|
||||
ImageRatio: modelDefaults.ImageRatio,
|
||||
AudioRatio: modelDefaults.AudioRatio,
|
||||
AudioCompletionRatio: modelDefaults.AudioCompletionRatio,
|
||||
'billing_setting.billing_mode': modelDefaults.BillingMode,
|
||||
'billing_setting.billing_expr': modelDefaults.BillingExpr,
|
||||
}}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<ConfirmDialog
|
||||
open={confirmOpen}
|
||||
onOpenChange={setConfirmOpen}
|
||||
title={t('Reset all model ratios?')}
|
||||
desc={t(
|
||||
'This will clear custom pricing ratios and revert to upstream defaults.'
|
||||
)}
|
||||
destructive
|
||||
isLoading={resetMutation.isPending}
|
||||
handleConfirm={handleConfirmReset}
|
||||
confirmText={t('Reset')}
|
||||
/>
|
||||
</SettingsSection>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import type { ModelSettings } from '../types'
|
||||
import { createSectionRegistry } from '../utils/section-registry'
|
||||
import { ClaudeSettingsCard } from './claude-settings-card'
|
||||
import { GeminiSettingsCard } from './gemini-settings-card'
|
||||
import { GlobalSettingsCard } from './global-settings-card'
|
||||
import { GrokSettingsCard } from './grok-settings-card'
|
||||
import { RatioSettingsCard } from './ratio-settings-card'
|
||||
|
||||
function formatJsonForEditor(value: string, fallback: string) {
|
||||
const raw = (value ?? '').toString().trim()
|
||||
if (!raw) return fallback
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(raw), null, 2)
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
const MODELS_SECTIONS = [
|
||||
{
|
||||
id: 'global',
|
||||
titleKey: 'Global Model Configuration',
|
||||
descriptionKey: 'Configure global model settings',
|
||||
build: (settings: ModelSettings) => (
|
||||
<GlobalSettingsCard
|
||||
defaultValues={{
|
||||
global: {
|
||||
pass_through_request_enabled:
|
||||
settings['global.pass_through_request_enabled'],
|
||||
thinking_model_blacklist: formatJsonForEditor(
|
||||
settings['global.thinking_model_blacklist'],
|
||||
'[]'
|
||||
),
|
||||
chat_completions_to_responses_policy: formatJsonForEditor(
|
||||
settings['global.chat_completions_to_responses_policy'],
|
||||
'{}'
|
||||
),
|
||||
},
|
||||
general_setting: {
|
||||
ping_interval_enabled:
|
||||
settings['general_setting.ping_interval_enabled'],
|
||||
ping_interval_seconds:
|
||||
settings['general_setting.ping_interval_seconds'],
|
||||
},
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'gemini',
|
||||
titleKey: 'Gemini',
|
||||
descriptionKey: 'Configure Gemini model settings',
|
||||
build: (settings: ModelSettings) => (
|
||||
<GeminiSettingsCard
|
||||
defaultValues={{
|
||||
gemini: {
|
||||
safety_settings: settings['gemini.safety_settings'],
|
||||
version_settings: settings['gemini.version_settings'],
|
||||
supported_imagine_models:
|
||||
settings['gemini.supported_imagine_models'],
|
||||
thinking_adapter_enabled:
|
||||
settings['gemini.thinking_adapter_enabled'],
|
||||
thinking_adapter_budget_tokens_percentage:
|
||||
settings['gemini.thinking_adapter_budget_tokens_percentage'],
|
||||
function_call_thought_signature_enabled:
|
||||
settings['gemini.function_call_thought_signature_enabled'],
|
||||
remove_function_response_id_enabled:
|
||||
settings['gemini.remove_function_response_id_enabled'],
|
||||
},
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'claude',
|
||||
titleKey: 'Claude',
|
||||
descriptionKey: 'Configure Claude model settings',
|
||||
build: (settings: ModelSettings) => (
|
||||
<ClaudeSettingsCard
|
||||
defaultValues={{
|
||||
claude: {
|
||||
model_headers_settings: settings['claude.model_headers_settings'],
|
||||
default_max_tokens: settings['claude.default_max_tokens'],
|
||||
thinking_adapter_enabled:
|
||||
settings['claude.thinking_adapter_enabled'],
|
||||
thinking_adapter_budget_tokens_percentage:
|
||||
settings['claude.thinking_adapter_budget_tokens_percentage'],
|
||||
},
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'grok',
|
||||
titleKey: 'Grok',
|
||||
descriptionKey: 'Configure xAI Grok model settings',
|
||||
build: (settings: ModelSettings) => (
|
||||
<GrokSettingsCard
|
||||
defaultValues={{
|
||||
'grok.violation_deduction_enabled':
|
||||
settings['grok.violation_deduction_enabled'] ?? true,
|
||||
'grok.violation_deduction_amount':
|
||||
settings['grok.violation_deduction_amount'] ?? 0.05,
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'ratio',
|
||||
titleKey: 'Pricing Ratios',
|
||||
descriptionKey: 'Configure model pricing and ratio settings',
|
||||
build: (settings: ModelSettings) => (
|
||||
<RatioSettingsCard
|
||||
modelDefaults={{
|
||||
ModelPrice: settings.ModelPrice,
|
||||
ModelRatio: settings.ModelRatio,
|
||||
CacheRatio: settings.CacheRatio,
|
||||
CreateCacheRatio: settings.CreateCacheRatio,
|
||||
CompletionRatio: settings.CompletionRatio,
|
||||
ImageRatio: settings.ImageRatio,
|
||||
AudioRatio: settings.AudioRatio,
|
||||
AudioCompletionRatio: settings.AudioCompletionRatio,
|
||||
ExposeRatioEnabled: settings.ExposeRatioEnabled,
|
||||
BillingMode: settings['billing_setting.billing_mode'],
|
||||
BillingExpr: settings['billing_setting.billing_expr'],
|
||||
}}
|
||||
toolPricesDefault={settings['tool_price_setting.prices']}
|
||||
groupDefaults={{
|
||||
TopupGroupRatio: settings.TopupGroupRatio,
|
||||
GroupRatio: settings.GroupRatio,
|
||||
UserUsableGroups: settings.UserUsableGroups,
|
||||
GroupGroupRatio: settings.GroupGroupRatio,
|
||||
AutoGroups: settings.AutoGroups,
|
||||
DefaultUseAutoGroup: settings.DefaultUseAutoGroup,
|
||||
GroupSpecialUsableGroup:
|
||||
settings['group_ratio_setting.group_special_usable_group'],
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
] as const
|
||||
|
||||
export type ModelSectionId = (typeof MODELS_SECTIONS)[number]['id']
|
||||
|
||||
const modelsRegistry = createSectionRegistry<ModelSectionId, ModelSettings>({
|
||||
sections: MODELS_SECTIONS,
|
||||
defaultSection: 'global',
|
||||
basePath: '/system-settings/models',
|
||||
urlStyle: 'path',
|
||||
})
|
||||
|
||||
export const MODELS_SECTION_IDS = modelsRegistry.sectionIds
|
||||
export const MODELS_DEFAULT_SECTION = modelsRegistry.defaultSection
|
||||
export const getModelsSectionNavItems = modelsRegistry.getSectionNavItems
|
||||
export const getModelsSectionContent = modelsRegistry.getSectionContent
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,337 @@
|
||||
import { memo, useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { Code2, Copy, Eye, Plus, Trash2 } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { useUpdateOption } from '../hooks/use-update-option'
|
||||
|
||||
const OPTION_KEY = 'tool_price_setting.prices'
|
||||
|
||||
const DEFAULT_PRICES: Record<string, number> = {
|
||||
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,
|
||||
}
|
||||
|
||||
type ToolPriceRow = {
|
||||
id: number
|
||||
key: string
|
||||
price: number
|
||||
}
|
||||
|
||||
function rowsToObject(rows: ToolPriceRow[]): Record<string, number> {
|
||||
const prices: Record<string, number> = {}
|
||||
for (const row of rows) {
|
||||
const k = row.key.trim()
|
||||
if (!k) continue
|
||||
prices[k] = Number(row.price) || 0
|
||||
}
|
||||
return prices
|
||||
}
|
||||
|
||||
function objectToRows(prices: Record<string, number>): ToolPriceRow[] {
|
||||
return Object.entries(prices).map(([key, price], index) => ({
|
||||
id: index + 1,
|
||||
key,
|
||||
price: Number(price) || 0,
|
||||
}))
|
||||
}
|
||||
|
||||
function parseInitialPrices(
|
||||
rawValue: string | undefined
|
||||
): Record<string, number> {
|
||||
if (!rawValue) return { ...DEFAULT_PRICES }
|
||||
try {
|
||||
const parsed = JSON.parse(rawValue) as unknown
|
||||
if (
|
||||
parsed &&
|
||||
typeof parsed === 'object' &&
|
||||
!Array.isArray(parsed) &&
|
||||
Object.keys(parsed as object).length > 0
|
||||
) {
|
||||
return parsed as Record<string, number>
|
||||
}
|
||||
} catch {
|
||||
// fall through to defaults
|
||||
}
|
||||
return { ...DEFAULT_PRICES }
|
||||
}
|
||||
|
||||
type ToolPriceSettingsProps = {
|
||||
defaultValue: string
|
||||
}
|
||||
|
||||
export const ToolPriceSettings = memo(function ToolPriceSettings({
|
||||
defaultValue,
|
||||
}: ToolPriceSettingsProps) {
|
||||
const { t } = useTranslation()
|
||||
const updateOption = useUpdateOption()
|
||||
const [editMode, setEditMode] = useState<'visual' | 'json'>('visual')
|
||||
const [rows, setRows] = useState<ToolPriceRow[]>([])
|
||||
const [jsonText, setJsonText] = useState('')
|
||||
const [jsonError, setJsonError] = useState('')
|
||||
const [nextRowId, setNextRowId] = useState(1)
|
||||
|
||||
useEffect(() => {
|
||||
const prices = parseInitialPrices(defaultValue)
|
||||
const initialRows = objectToRows(prices)
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setRows(initialRows)
|
||||
setJsonText(JSON.stringify(prices, null, 2))
|
||||
setJsonError('')
|
||||
setNextRowId(initialRows.length + 1)
|
||||
}, [defaultValue])
|
||||
|
||||
const currentPrices = useMemo(() => rowsToObject(rows), [rows])
|
||||
|
||||
const syncFromRows = useCallback((nextRows: ToolPriceRow[]) => {
|
||||
setRows(nextRows)
|
||||
setJsonText(JSON.stringify(rowsToObject(nextRows), null, 2))
|
||||
setJsonError('')
|
||||
}, [])
|
||||
|
||||
const handleJsonChange = useCallback(
|
||||
(text: string) => {
|
||||
setJsonText(text)
|
||||
try {
|
||||
const parsed = JSON.parse(text) as unknown
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
setJsonError(t('JSON must be an object'))
|
||||
return
|
||||
}
|
||||
const nextRows = objectToRows(parsed as Record<string, number>)
|
||||
setRows(nextRows)
|
||||
setNextRowId(nextRows.length + 1)
|
||||
setJsonError('')
|
||||
} catch (error) {
|
||||
setJsonError(error instanceof Error ? error.message : t('Invalid JSON'))
|
||||
}
|
||||
},
|
||||
[t]
|
||||
)
|
||||
|
||||
const updateRow = useCallback(
|
||||
(id: number, field: 'key' | 'price', value: string | number) => {
|
||||
syncFromRows(
|
||||
rows.map((r) => (r.id === id ? { ...r, [field]: value } : r))
|
||||
)
|
||||
},
|
||||
[rows, syncFromRows]
|
||||
)
|
||||
|
||||
const addRow = useCallback(() => {
|
||||
const newRow: ToolPriceRow = { id: nextRowId, key: '', price: 0 }
|
||||
setNextRowId((prev) => prev + 1)
|
||||
syncFromRows([...rows, newRow])
|
||||
}, [nextRowId, rows, syncFromRows])
|
||||
|
||||
const removeRow = useCallback(
|
||||
(id: number) => {
|
||||
syncFromRows(rows.filter((r) => r.id !== id))
|
||||
},
|
||||
[rows, syncFromRows]
|
||||
)
|
||||
|
||||
const resetToDefault = useCallback(() => {
|
||||
const initialRows = objectToRows(DEFAULT_PRICES)
|
||||
setRows(initialRows)
|
||||
setJsonText(JSON.stringify(DEFAULT_PRICES, null, 2))
|
||||
setJsonError('')
|
||||
setNextRowId(initialRows.length + 1)
|
||||
}, [])
|
||||
|
||||
const handleCopyJson = useCallback(async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(jsonText)
|
||||
toast.success(t('Copied to clipboard'))
|
||||
} catch {
|
||||
toast.error(t('Failed to copy'))
|
||||
}
|
||||
}, [jsonText, t])
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (editMode === 'json' && jsonError) {
|
||||
toast.error(t('Please fix JSON errors before saving'))
|
||||
return
|
||||
}
|
||||
await updateOption.mutateAsync({
|
||||
key: OPTION_KEY,
|
||||
value: JSON.stringify(currentPrices),
|
||||
})
|
||||
}, [currentPrices, editMode, jsonError, t, updateOption])
|
||||
|
||||
const toggleEditMode = useCallback(() => {
|
||||
setEditMode((prev) => (prev === 'visual' ? 'json' : 'visual'))
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className='space-y-4'>
|
||||
<Alert>
|
||||
<AlertDescription className='space-y-1 text-sm'>
|
||||
<div>
|
||||
{t(
|
||||
'Configure per-tool unit prices ($/1K calls). Per-request models do not incur additional tool fees.'
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<span className='font-medium'>{t('Format')}:</span>{' '}
|
||||
<code className='bg-muted rounded px-1 py-0.5 text-xs'>
|
||||
web_search_preview
|
||||
</code>{' '}
|
||||
{t('is the default price; ')}
|
||||
<code className='bg-muted rounded px-1 py-0.5 text-xs'>
|
||||
web_search_preview:gpt-4o*
|
||||
</code>{' '}
|
||||
{t('overrides for matching model prefix.')}
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className='flex flex-wrap items-center justify-between gap-2'>
|
||||
<div className='flex flex-wrap items-center gap-2'>
|
||||
{editMode === 'visual' ? (
|
||||
<>
|
||||
<Button variant='outline' size='sm' onClick={addRow}>
|
||||
<Plus className='mr-2 h-4 w-4' />
|
||||
{t('Add')}
|
||||
</Button>
|
||||
<Button variant='ghost' size='sm' onClick={resetToDefault}>
|
||||
{t('Restore defaults')}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button variant='ghost' size='sm' onClick={handleCopyJson}>
|
||||
<Copy className='mr-2 h-4 w-4' />
|
||||
{t('Copy')}
|
||||
</Button>
|
||||
<Button variant='ghost' size='sm' onClick={resetToDefault}>
|
||||
{t('Restore defaults')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<Button variant='outline' size='sm' onClick={toggleEditMode}>
|
||||
{editMode === 'visual' ? (
|
||||
<>
|
||||
<Code2 className='mr-2 h-4 w-4' />
|
||||
{t('Switch to JSON')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Eye className='mr-2 h-4 w-4' />
|
||||
{t('Switch to Visual')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{editMode === 'visual' ? (
|
||||
<div className='overflow-hidden rounded-md border'>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t('Tool identifier')}</TableHead>
|
||||
<TableHead className='w-[200px]'>
|
||||
{t('Price ($/1K calls)')}
|
||||
</TableHead>
|
||||
<TableHead className='w-[80px] text-right'>
|
||||
{t('Actions')}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={3}
|
||||
className='text-muted-foreground py-8 text-center'
|
||||
>
|
||||
{t('No tools configured')}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
rows.map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
<TableCell>
|
||||
<Input
|
||||
value={row.key}
|
||||
placeholder='web_search_preview:gpt-4o*'
|
||||
onChange={(e) =>
|
||||
updateRow(row.id, 'key', e.target.value)
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Input
|
||||
type='number'
|
||||
min={0}
|
||||
step={0.5}
|
||||
value={row.price}
|
||||
onChange={(e) =>
|
||||
updateRow(
|
||||
row.id,
|
||||
'price',
|
||||
Number(e.target.value) || 0
|
||||
)
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
onClick={() => removeRow(row.id)}
|
||||
aria-label={t('Delete')}
|
||||
>
|
||||
<Trash2 className='text-destructive h-4 w-4' />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
) : (
|
||||
<div className='space-y-2'>
|
||||
<Textarea
|
||||
value={jsonText}
|
||||
onChange={(e) => handleJsonChange(e.target.value)}
|
||||
className='font-mono text-sm'
|
||||
rows={12}
|
||||
spellCheck={false}
|
||||
/>
|
||||
{jsonError && <p className='text-destructive text-sm'>{jsonError}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='flex justify-end'>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={
|
||||
updateOption.isPending || (editMode === 'json' && !!jsonError)
|
||||
}
|
||||
>
|
||||
{t('Save tool prices')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user