🚀 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:
+7
@@ -0,0 +1,7 @@
|
||||
import { api } from '@/lib/api'
|
||||
import type { AboutResponse } from './types'
|
||||
|
||||
export async function getAboutContent() {
|
||||
const res = await api.get<AboutResponse>('/api/about')
|
||||
return res.data
|
||||
}
|
||||
+168
@@ -0,0 +1,168 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Construction } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Markdown } from '@/components/ui/markdown'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { PublicLayout } from '@/components/layout'
|
||||
import { getAboutContent } from './api'
|
||||
|
||||
function isValidUrl(value: string) {
|
||||
try {
|
||||
const url = new URL(value)
|
||||
return url.protocol === 'http:' || url.protocol === 'https:'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function isLikelyHtml(value: string) {
|
||||
return /<\/?[a-z][\s\S]*>/i.test(value)
|
||||
}
|
||||
|
||||
function EmptyAboutState() {
|
||||
const { t } = useTranslation()
|
||||
const currentYear = new Date().getFullYear()
|
||||
|
||||
return (
|
||||
<div className='flex min-h-[60vh] items-center justify-center p-8'>
|
||||
<div className='max-w-2xl space-y-6 text-center'>
|
||||
<div className='flex justify-center'>
|
||||
<Construction className='text-muted-foreground h-24 w-24' />
|
||||
</div>
|
||||
<div className='space-y-2'>
|
||||
<h2 className='text-2xl font-bold'>{t('No About Content Set')}</h2>
|
||||
<p className='text-muted-foreground'>
|
||||
{t(
|
||||
'The administrator has not configured any about content yet. You can set it in the settings page, supporting HTML or URL.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className='space-y-4 text-sm'>
|
||||
<p>
|
||||
{t('New API Project Repository:')}{' '}
|
||||
<a
|
||||
href='https://github.com/QuantumNous/new-api'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='text-primary hover:underline'
|
||||
>
|
||||
{t('https://github.com/QuantumNous/new-api')}
|
||||
</a>
|
||||
</p>
|
||||
<p className='text-muted-foreground'>
|
||||
<a
|
||||
href='https://github.com/QuantumNous/new-api'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='text-primary hover:underline'
|
||||
>
|
||||
{t('NewAPI')}
|
||||
</a>{' '}
|
||||
© {currentYear}{' '}
|
||||
<a
|
||||
href='https://github.com/QuantumNous'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='text-primary hover:underline'
|
||||
>
|
||||
{t('QuantumNous')}
|
||||
</a>{' '}
|
||||
{t('| Based on')}{' '}
|
||||
<a
|
||||
href='https://github.com/songquanpeng/one-api'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='text-primary hover:underline'
|
||||
>
|
||||
{t('One API')}
|
||||
</a>{' '}
|
||||
© 2023{' '}
|
||||
<a
|
||||
href='https://github.com/songquanpeng'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='text-primary hover:underline'
|
||||
>
|
||||
{t('JustSong')}
|
||||
</a>
|
||||
</p>
|
||||
<p className='text-muted-foreground'>
|
||||
{t('This project must be used in compliance with the')}{' '}
|
||||
<a
|
||||
href='https://github.com/QuantumNous/new-api/blob/main/LICENSE'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='text-primary hover:underline'
|
||||
>
|
||||
{t('AGPL v3.0 License')}
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function About() {
|
||||
const { t } = useTranslation()
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['about-content'],
|
||||
queryFn: getAboutContent,
|
||||
})
|
||||
|
||||
const rawContent = data?.data?.trim() ?? ''
|
||||
const hasContent = rawContent.length > 0
|
||||
const isUrl = hasContent && isValidUrl(rawContent)
|
||||
const isHtml = hasContent && !isUrl && isLikelyHtml(rawContent)
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<PublicLayout>
|
||||
<div className='mx-auto flex max-w-4xl flex-col gap-4 py-12'>
|
||||
<Skeleton className='h-8 w-[45%]' />
|
||||
<Skeleton className='h-4 w-full' />
|
||||
<Skeleton className='h-4 w-[90%]' />
|
||||
<Skeleton className='h-4 w-[80%]' />
|
||||
</div>
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
|
||||
if (!hasContent) {
|
||||
return (
|
||||
<PublicLayout>
|
||||
<EmptyAboutState />
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
|
||||
if (isUrl) {
|
||||
return (
|
||||
<PublicLayout showMainContainer={false}>
|
||||
<iframe
|
||||
src={rawContent}
|
||||
className='h-[calc(100vh-3.5rem)] w-full border-0'
|
||||
title={t('About')}
|
||||
/>
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<PublicLayout>
|
||||
<div className='mx-auto max-w-6xl px-4 py-8'>
|
||||
{isHtml ? (
|
||||
<div
|
||||
className='prose prose-neutral dark:prose-invert max-w-none'
|
||||
dangerouslySetInnerHTML={{ __html: rawContent }}
|
||||
/>
|
||||
) : (
|
||||
<Markdown className='prose-neutral dark:prose-invert max-w-none'>
|
||||
{rawContent}
|
||||
</Markdown>
|
||||
)}
|
||||
</div>
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
export type AboutResponse = {
|
||||
success: boolean
|
||||
message: string
|
||||
data?: string
|
||||
}
|
||||
Vendored
+116
@@ -0,0 +1,116 @@
|
||||
import { api } from '@/lib/api'
|
||||
import type {
|
||||
LoginPayload,
|
||||
LoginResponse,
|
||||
Login2FAResponse,
|
||||
TwoFAPayload,
|
||||
RegisterPayload,
|
||||
ApiResponse,
|
||||
} from './types'
|
||||
|
||||
// ============================================================================
|
||||
// Authentication APIs
|
||||
// ============================================================================
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Login & Logout
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
// User login with username and password
|
||||
export async function login(payload: LoginPayload) {
|
||||
const turnstile = payload.turnstile ?? ''
|
||||
const res = await api.post<LoginResponse>(
|
||||
`/api/user/login?turnstile=${turnstile}`,
|
||||
{
|
||||
username: payload.username,
|
||||
password: payload.password,
|
||||
}
|
||||
)
|
||||
return res.data
|
||||
}
|
||||
|
||||
// Two-factor authentication login
|
||||
export async function login2fa(payload: TwoFAPayload) {
|
||||
const res = await api.post<Login2FAResponse>('/api/user/login/2fa', payload)
|
||||
return res.data
|
||||
}
|
||||
|
||||
// User logout
|
||||
export async function logout(): Promise<ApiResponse> {
|
||||
const res = await api.get('/api/user/logout')
|
||||
return res.data
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Password Management
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
// Send password reset email
|
||||
export async function sendPasswordResetEmail(
|
||||
email: string,
|
||||
turnstile?: string
|
||||
): Promise<ApiResponse> {
|
||||
const res = await api.get('/api/reset_password', {
|
||||
params: { email, turnstile },
|
||||
})
|
||||
return res.data
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// OAuth
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
// Start GitHub OAuth flow
|
||||
export async function githubOAuthStart(clientId: string, state: string) {
|
||||
const url = `https://github.com/login/oauth/authorize?client_id=${clientId}&state=${state}&scope=user:email`
|
||||
window.open(url)
|
||||
}
|
||||
|
||||
// Get OAuth state for CSRF protection
|
||||
export async function getOAuthState(): Promise<string> {
|
||||
const aff =
|
||||
typeof window !== 'undefined' ? (localStorage.getItem('aff') ?? '') : ''
|
||||
const res = await api.get('/api/oauth/state', { params: { aff } })
|
||||
if (res.data?.success) return res.data.data
|
||||
return ''
|
||||
}
|
||||
|
||||
// WeChat login by authorization code
|
||||
export async function wechatLoginByCode(code: string): Promise<ApiResponse> {
|
||||
const res = await api.get('/api/oauth/wechat', { params: { code } })
|
||||
return res.data
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Registration
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
// User registration
|
||||
export async function register(payload: RegisterPayload): Promise<ApiResponse> {
|
||||
const res = await api.post(`/api/user/register`, payload, {
|
||||
params: { turnstile: payload.turnstile ?? '' },
|
||||
})
|
||||
return res.data
|
||||
}
|
||||
|
||||
// Send email verification code
|
||||
export async function sendEmailVerification(
|
||||
email: string,
|
||||
turnstile?: string
|
||||
): Promise<ApiResponse> {
|
||||
const res = await api.get('/api/verification', {
|
||||
params: { email, turnstile },
|
||||
})
|
||||
return res.data
|
||||
}
|
||||
|
||||
// Bind email to OAuth account
|
||||
export async function bindEmail(
|
||||
email: string,
|
||||
code: string
|
||||
): Promise<ApiResponse> {
|
||||
const res = await api.get('/api/oauth/email/bind', {
|
||||
params: { email, code },
|
||||
})
|
||||
return res.data
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useSystemConfig } from '@/hooks/use-system-config'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
|
||||
type AuthLayoutProps = {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export function AuthLayout({ children }: AuthLayoutProps) {
|
||||
const { t } = useTranslation()
|
||||
const { systemName, logo, loading } = useSystemConfig()
|
||||
|
||||
return (
|
||||
<div className='relative grid h-svh max-w-none'>
|
||||
<Link
|
||||
to='/'
|
||||
className='absolute top-4 left-4 z-10 flex items-center gap-2 transition-opacity hover:opacity-80 sm:top-8 sm:left-8'
|
||||
>
|
||||
<div className='relative h-8 w-8'>
|
||||
{loading ? (
|
||||
<Skeleton className='absolute inset-0 rounded-full' />
|
||||
) : (
|
||||
<img
|
||||
src={logo}
|
||||
alt={t('Logo')}
|
||||
className='h-8 w-8 rounded-full object-cover'
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{loading ? (
|
||||
<Skeleton className='h-6 w-24' />
|
||||
) : (
|
||||
<h1 className='text-xl font-medium'>{systemName}</h1>
|
||||
)}
|
||||
</Link>
|
||||
<div className='container flex items-center pt-16 sm:pt-0'>
|
||||
<div className='mx-auto flex w-full flex-col justify-center space-y-2 px-4 py-8 sm:w-[480px] sm:p-8'>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import type { SystemStatus } from '../types'
|
||||
|
||||
interface LegalConsentProps {
|
||||
status: SystemStatus | null
|
||||
checked: boolean
|
||||
onCheckedChange: (nextValue: boolean) => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function LegalConsent({
|
||||
status,
|
||||
checked,
|
||||
onCheckedChange,
|
||||
className,
|
||||
}: LegalConsentProps) {
|
||||
const { t } = useTranslation()
|
||||
const hasUserAgreement = Boolean(status?.user_agreement_enabled)
|
||||
const hasPrivacyPolicy = Boolean(status?.privacy_policy_enabled)
|
||||
|
||||
if (!hasUserAgreement && !hasPrivacyPolicy) {
|
||||
return null
|
||||
}
|
||||
|
||||
const handleChange = (value: boolean | 'indeterminate') => {
|
||||
onCheckedChange(value === true)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'border-border/60 bg-muted/40 flex items-start gap-3 rounded-md border p-3',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<Checkbox
|
||||
id='legal-consent'
|
||||
checked={checked}
|
||||
onCheckedChange={handleChange}
|
||||
className='mt-0.5'
|
||||
/>
|
||||
<Label
|
||||
htmlFor='legal-consent'
|
||||
className='text-muted-foreground items-start gap-1 text-left text-xs leading-5 font-normal'
|
||||
>
|
||||
<span>
|
||||
{t('I have read and agree to the')}{' '}
|
||||
{hasUserAgreement && (
|
||||
<a
|
||||
href='/user-agreement'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='text-primary hover:underline'
|
||||
>
|
||||
{t('User Agreement')}
|
||||
</a>
|
||||
)}
|
||||
{hasUserAgreement && hasPrivacyPolicy && ' and the '}
|
||||
{hasPrivacyPolicy && (
|
||||
<a
|
||||
href='/privacy-policy'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='text-primary hover:underline'
|
||||
>
|
||||
{t('Privacy Policy')}
|
||||
</a>
|
||||
)}
|
||||
.
|
||||
</span>
|
||||
</Label>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { useMemo } from 'react'
|
||||
import { Loader2, Send, Shield, UserRound, type LucideIcon } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { SiGithub, SiLinux, SiWechat } from 'react-icons/si'
|
||||
import { AuthLayout } from '../auth-layout'
|
||||
|
||||
type OAuthCallbackScreenProps = {
|
||||
provider: string
|
||||
mode: 'login' | 'bind'
|
||||
}
|
||||
|
||||
type ProviderMeta = {
|
||||
label: string
|
||||
Icon: LucideIcon | ((props: { className?: string }) => React.JSX.Element)
|
||||
}
|
||||
|
||||
const providerDictionary: Record<string, ProviderMeta> = {
|
||||
github: {
|
||||
label: 'GitHub',
|
||||
Icon: (props: { className?: string }) => (
|
||||
<SiGithub className={props.className} focusable='false' />
|
||||
),
|
||||
},
|
||||
oidc: { label: 'OIDC', Icon: Shield },
|
||||
linuxdo: {
|
||||
label: 'LinuxDO',
|
||||
Icon: (props: { className?: string }) => (
|
||||
<SiLinux className={props.className} focusable='false' />
|
||||
),
|
||||
},
|
||||
telegram: { label: 'Telegram', Icon: Send },
|
||||
wechat: {
|
||||
label: 'WeChat',
|
||||
Icon: (props: { className?: string }) => (
|
||||
<SiWechat className={props.className} focusable='false' />
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
export function OAuthCallbackScreen({
|
||||
provider,
|
||||
mode,
|
||||
}: OAuthCallbackScreenProps) {
|
||||
const { t } = useTranslation()
|
||||
const { label, Icon } = useMemo(() => {
|
||||
const normalized = provider?.toLowerCase() ?? ''
|
||||
return (
|
||||
providerDictionary[normalized] || {
|
||||
label: 'account',
|
||||
Icon: UserRound,
|
||||
}
|
||||
)
|
||||
}, [provider])
|
||||
|
||||
const providerLabel = t(label)
|
||||
const isBindMode = mode === 'bind'
|
||||
|
||||
const headline = isBindMode
|
||||
? t('Binding your {{provider}} account', { provider: providerLabel })
|
||||
: t('Signing you in with {{provider}}', { provider: providerLabel })
|
||||
|
||||
const description = isBindMode
|
||||
? t('Hang tight while we securely link this account to your profile.')
|
||||
: t('Hang tight while we finish connecting your account.')
|
||||
|
||||
const secondaryNote = isBindMode
|
||||
? t(
|
||||
'You can close this tab once the binding completes or a success message appears in the original window.'
|
||||
)
|
||||
: t(
|
||||
"You'll be redirected automatically. You can return to the previous page if nothing happens after a few seconds."
|
||||
)
|
||||
|
||||
return (
|
||||
<AuthLayout>
|
||||
<div className='w-full space-y-8'>
|
||||
<div className='flex flex-col items-center space-y-4 text-center'>
|
||||
<div className='bg-muted flex h-16 w-16 items-center justify-center rounded-full'>
|
||||
<Icon className='h-8 w-8' />
|
||||
</div>
|
||||
<div className='space-y-2'>
|
||||
<h2 className='text-center text-2xl font-semibold tracking-tight'>
|
||||
{headline}
|
||||
</h2>
|
||||
<p className='text-muted-foreground text-sm sm:text-base'>
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='space-y-4 text-center'>
|
||||
<div className='flex items-center justify-center gap-2 text-sm font-medium'>
|
||||
<Loader2 className='h-4 w-4 animate-spin' />
|
||||
<span>{t('Processing OAuth response...')}</span>
|
||||
</div>
|
||||
<p className='text-muted-foreground text-sm'>{secondaryNote}</p>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{t(
|
||||
'This may take a few moments while we validate the request and update your session.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</AuthLayout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
IconDiscord,
|
||||
IconGithub,
|
||||
IconLinuxDo,
|
||||
IconWeChat,
|
||||
} from '@/assets/brand-icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useOAuthLogin } from '../hooks/use-oauth-login'
|
||||
import type { SystemStatus } from '../types'
|
||||
|
||||
type OAuthProvidersProps = {
|
||||
status: SystemStatus | null
|
||||
disabled?: boolean
|
||||
className?: string
|
||||
onWeChatLogin?: () => void
|
||||
isWeChatLoading?: boolean
|
||||
}
|
||||
|
||||
type ProviderButton = {
|
||||
key: string
|
||||
label: string
|
||||
onClick: () => void
|
||||
icon?: ReactNode
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export function OAuthProviders({
|
||||
status,
|
||||
disabled = false,
|
||||
className,
|
||||
onWeChatLogin,
|
||||
isWeChatLoading = false,
|
||||
}: OAuthProvidersProps) {
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
isLoading,
|
||||
githubButtonText,
|
||||
githubButtonDisabled,
|
||||
handleGitHubLogin,
|
||||
handleDiscordLogin,
|
||||
handleOIDCLogin,
|
||||
handleLinuxDOLogin,
|
||||
handleTelegramLogin,
|
||||
handleCustomOAuthLogin,
|
||||
} = useOAuthLogin(status)
|
||||
|
||||
const providerButtons: ProviderButton[] = []
|
||||
|
||||
if (status?.wechat_login && onWeChatLogin) {
|
||||
providerButtons.push({
|
||||
key: 'wechat',
|
||||
label: t('Continue with WeChat'),
|
||||
onClick: onWeChatLogin,
|
||||
icon: <IconWeChat className='h-4 w-4' />,
|
||||
disabled: isWeChatLoading,
|
||||
})
|
||||
}
|
||||
|
||||
if (status?.github_oauth) {
|
||||
providerButtons.push({
|
||||
key: 'github',
|
||||
label: githubButtonText || t('Continue with GitHub'),
|
||||
onClick: handleGitHubLogin,
|
||||
icon: <IconGithub className='h-4 w-4' />,
|
||||
disabled: githubButtonDisabled,
|
||||
})
|
||||
}
|
||||
|
||||
if (status?.discord_oauth) {
|
||||
providerButtons.push({
|
||||
key: 'discord',
|
||||
label: t('Continue with Discord'),
|
||||
onClick: handleDiscordLogin,
|
||||
icon: <IconDiscord className='h-4 w-4' />,
|
||||
})
|
||||
}
|
||||
|
||||
if (status?.oidc_enabled) {
|
||||
providerButtons.push({
|
||||
key: 'oidc',
|
||||
label: t('Continue with OIDC'),
|
||||
onClick: handleOIDCLogin,
|
||||
})
|
||||
}
|
||||
|
||||
if (status?.linuxdo_oauth) {
|
||||
providerButtons.push({
|
||||
key: 'linuxdo',
|
||||
label: t('Continue with LinuxDO'),
|
||||
onClick: handleLinuxDOLogin,
|
||||
icon: <IconLinuxDo className='h-4 w-4' />,
|
||||
})
|
||||
}
|
||||
|
||||
if (status?.telegram_oauth) {
|
||||
providerButtons.push({
|
||||
key: 'telegram',
|
||||
label: t('Continue with Telegram'),
|
||||
onClick: handleTelegramLogin,
|
||||
})
|
||||
}
|
||||
|
||||
// Custom OAuth providers
|
||||
const customProviders = status?.custom_oauth_providers
|
||||
if (customProviders && customProviders.length > 0) {
|
||||
for (const provider of customProviders) {
|
||||
providerButtons.push({
|
||||
key: `custom-${provider.slug}`,
|
||||
label: t('Continue with {{name}}', { name: provider.name }),
|
||||
onClick: () => handleCustomOAuthLogin(provider),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (providerButtons.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-3', className)}>
|
||||
<div className='relative'>
|
||||
<div className='absolute inset-0 flex items-center'>
|
||||
<span className='w-full border-t' />
|
||||
</div>
|
||||
<div className='relative flex justify-center text-xs uppercase'>
|
||||
<span className='bg-background text-muted-foreground px-2'>
|
||||
{t('Or continue with')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col gap-2'>
|
||||
{providerButtons.map(
|
||||
({ key, label, onClick, icon, disabled: extraDisabled }) => (
|
||||
<Button
|
||||
key={key}
|
||||
variant='outline'
|
||||
type='button'
|
||||
disabled={disabled || isLoading || extraDisabled}
|
||||
onClick={onClick}
|
||||
className='h-11 w-full justify-center gap-2 rounded-lg'
|
||||
>
|
||||
{icon}
|
||||
{label}
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { SystemStatus } from '../types'
|
||||
|
||||
interface TermsFooterProps {
|
||||
variant?: 'sign-in' | 'sign-up'
|
||||
className?: string
|
||||
status?: SystemStatus | null
|
||||
}
|
||||
|
||||
export function TermsFooter({
|
||||
variant = 'sign-in',
|
||||
className,
|
||||
status,
|
||||
}: TermsFooterProps) {
|
||||
const { t } = useTranslation()
|
||||
const text =
|
||||
variant === 'sign-in'
|
||||
? 'By clicking sign in, you agree to our'
|
||||
: 'By creating an account, you agree to our'
|
||||
|
||||
const hasUserAgreement = Boolean(status?.user_agreement_enabled)
|
||||
const hasPrivacyPolicy = Boolean(status?.privacy_policy_enabled)
|
||||
|
||||
if (!hasUserAgreement && !hasPrivacyPolicy) {
|
||||
return null
|
||||
}
|
||||
|
||||
const agreementLink = {
|
||||
label: 'User Agreement',
|
||||
href: '/user-agreement',
|
||||
}
|
||||
const privacyLink = {
|
||||
label: 'Privacy Policy',
|
||||
href: '/privacy-policy',
|
||||
}
|
||||
|
||||
const activeLinks =
|
||||
hasUserAgreement || hasPrivacyPolicy
|
||||
? ([
|
||||
hasUserAgreement ? agreementLink : null,
|
||||
hasPrivacyPolicy ? privacyLink : null,
|
||||
].filter(Boolean) as Array<{ label: string; href: string }>)
|
||||
: [agreementLink, privacyLink]
|
||||
|
||||
const [firstLink, secondLink] = activeLinks
|
||||
|
||||
return (
|
||||
<p className={cn('text-muted-foreground text-center text-xs', className)}>
|
||||
{text}{' '}
|
||||
{firstLink && (
|
||||
<a
|
||||
href={firstLink.href}
|
||||
className='hover:text-primary underline underline-offset-4'
|
||||
>
|
||||
{firstLink.label}
|
||||
</a>
|
||||
)}
|
||||
{secondLink && (
|
||||
<>
|
||||
{' '}
|
||||
{t('and')}{' '}
|
||||
<a
|
||||
href={secondLink.href}
|
||||
className='hover:text-primary underline underline-offset-4'
|
||||
>
|
||||
{secondLink.label}
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
.
|
||||
</p>
|
||||
)
|
||||
}
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
// ============================================================================
|
||||
// Form Schemas
|
||||
// ============================================================================
|
||||
|
||||
export const loginFormSchema = z.object({
|
||||
username: z.string().min(1, 'Please enter your username or email'),
|
||||
password: z
|
||||
.string()
|
||||
.min(1, 'Please enter your password')
|
||||
.min(8, 'Password must be at least 8 characters long'),
|
||||
})
|
||||
|
||||
export const registerFormSchema = z
|
||||
.object({
|
||||
username: z.string().min(1, 'Please enter your username'),
|
||||
email: z.string().optional(),
|
||||
password: z
|
||||
.string()
|
||||
.min(1, 'Please enter your password')
|
||||
.min(8, 'Password must be at least 8 characters long')
|
||||
.max(20, 'Password must be at most 20 characters long'),
|
||||
confirmPassword: z.string().min(1, 'Please confirm your password'),
|
||||
})
|
||||
.refine((data) => data.password === data.confirmPassword, {
|
||||
message: "Passwords don't match.",
|
||||
path: ['confirmPassword'],
|
||||
})
|
||||
|
||||
export const forgotPasswordFormSchema = z.object({
|
||||
email: z.string().email({
|
||||
message: 'Please enter a valid email address',
|
||||
}),
|
||||
})
|
||||
|
||||
export const otpFormSchema = z.object({
|
||||
otp: z.string().min(1, 'Please enter a code.'),
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// Validation Constants
|
||||
// ============================================================================
|
||||
|
||||
export const PASSWORD_MIN_LENGTH = 8
|
||||
export const PASSWORD_MAX_LENGTH = 20
|
||||
export const OTP_LENGTH = 6
|
||||
export const BACKUP_CODE_LENGTH = 9 // XXXX-XXXX format
|
||||
export const BACKUP_CODE_REGEX = /^[A-Z0-9]{4}-[A-Z0-9]{4}$/i
|
||||
export const OTP_REGEX = /^\d{6}$/
|
||||
|
||||
// ============================================================================
|
||||
// Countdown Constants
|
||||
// ============================================================================
|
||||
|
||||
export const EMAIL_VERIFICATION_COUNTDOWN = 30 // seconds
|
||||
export const PASSWORD_RESET_COUNTDOWN = 30 // seconds
|
||||
|
||||
// ============================================================================
|
||||
// OAuth Constants
|
||||
// ============================================================================
|
||||
|
||||
export const OAUTH_BIND_STORAGE_KEY = 'oauth:binding:result'
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
import { useState } from 'react'
|
||||
import type { z } from 'zod'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { ArrowRight, Loader2 } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useCountdown } from '@/hooks/use-countdown'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Turnstile } from '@/components/turnstile'
|
||||
import { sendPasswordResetEmail } from '@/features/auth/api'
|
||||
import {
|
||||
forgotPasswordFormSchema,
|
||||
PASSWORD_RESET_COUNTDOWN,
|
||||
} from '@/features/auth/constants'
|
||||
import { useTurnstile } from '@/features/auth/hooks/use-turnstile'
|
||||
|
||||
export function ForgotPasswordForm({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLFormElement>) {
|
||||
const { t } = useTranslation()
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
const {
|
||||
isTurnstileEnabled,
|
||||
turnstileSiteKey,
|
||||
turnstileToken,
|
||||
setTurnstileToken,
|
||||
validateTurnstile,
|
||||
} = useTurnstile()
|
||||
const {
|
||||
secondsLeft,
|
||||
isActive,
|
||||
start: startCountdown,
|
||||
} = useCountdown({ initialSeconds: PASSWORD_RESET_COUNTDOWN })
|
||||
|
||||
const form = useForm<z.infer<typeof forgotPasswordFormSchema>>({
|
||||
resolver: zodResolver(forgotPasswordFormSchema),
|
||||
defaultValues: { email: '' },
|
||||
})
|
||||
|
||||
async function onSubmit(data: z.infer<typeof forgotPasswordFormSchema>) {
|
||||
if (!validateTurnstile()) return
|
||||
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const res = await sendPasswordResetEmail(data.email, turnstileToken)
|
||||
if (res?.success) {
|
||||
form.reset()
|
||||
startCountdown()
|
||||
toast.success(t('Reset email sent, please check your inbox'))
|
||||
}
|
||||
} catch (_error) {
|
||||
// Errors are handled by global interceptor
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className={cn('grid gap-2', className)}
|
||||
{...props}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='email'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder='name@example.com' {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button className='mt-2' disabled={isLoading || isActive}>
|
||||
{isActive ? `Resend (${secondsLeft}s)` : 'Send reset email'}
|
||||
{isLoading ? <Loader2 className='animate-spin' /> : <ArrowRight />}
|
||||
</Button>
|
||||
|
||||
{isTurnstileEnabled && (
|
||||
<div className='mt-2'>
|
||||
<Turnstile
|
||||
siteKey={turnstileSiteKey}
|
||||
onVerify={setTurnstileToken}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</Form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { AuthLayout } from '../auth-layout'
|
||||
import { ForgotPasswordForm } from './components/forgot-password-form'
|
||||
|
||||
export function ForgotPassword() {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<AuthLayout>
|
||||
<div className='w-full space-y-8'>
|
||||
<div className='space-y-3'>
|
||||
<h2 className='text-center text-2xl font-semibold tracking-tight sm:text-left'>
|
||||
{t('Forgot password')}
|
||||
</h2>
|
||||
<p className='text-muted-foreground text-left text-sm sm:text-base'>
|
||||
{t(
|
||||
'Enter your registered email and we will send you a link to reset your password.'
|
||||
)}
|
||||
</p>
|
||||
<p className='text-muted-foreground text-left text-sm sm:text-base'>
|
||||
{t("Don't have an account?")}{' '}
|
||||
<Link
|
||||
to='/sign-up'
|
||||
className='hover:text-primary font-medium underline underline-offset-4'
|
||||
>
|
||||
{t('Sign up')}
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ForgotPasswordForm className='space-y-0' />
|
||||
</div>
|
||||
</AuthLayout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import i18n from 'i18next'
|
||||
import { useAuthStore } from '@/stores/auth-store'
|
||||
import { getSelf } from '@/lib/api'
|
||||
import type { User } from '@/features/users/types'
|
||||
import { saveUserId } from '../lib/storage'
|
||||
|
||||
/**
|
||||
* Hook for handling authentication redirects and user data management
|
||||
*/
|
||||
export function useAuthRedirect() {
|
||||
const navigate = useNavigate()
|
||||
const { auth } = useAuthStore()
|
||||
|
||||
/**
|
||||
* Handle successful login
|
||||
* @param userData - Optional user data from login response
|
||||
* @param redirectTo - Redirect path after login
|
||||
*/
|
||||
const handleLoginSuccess = async (
|
||||
userData?: { id?: number } | null,
|
||||
redirectTo?: string
|
||||
) => {
|
||||
// Save user ID if available
|
||||
if (userData?.id) {
|
||||
saveUserId(userData.id)
|
||||
}
|
||||
|
||||
// Fetch and set user data
|
||||
try {
|
||||
const self = await getSelf()
|
||||
if (self?.success && self.data) {
|
||||
const user = self.data as User
|
||||
auth.setUser(user)
|
||||
|
||||
// Update user ID if not already set
|
||||
if (user.id) {
|
||||
saveUserId(user.id)
|
||||
}
|
||||
|
||||
// Restore saved language preference
|
||||
const savedLang = (user as Record<string, unknown>).language as
|
||||
| string
|
||||
| undefined
|
||||
if (savedLang && savedLang !== i18n.language) {
|
||||
i18n.changeLanguage(savedLang)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Failed to fetch user data:', error)
|
||||
}
|
||||
|
||||
// Navigate to target page
|
||||
const targetPath = redirectTo || '/dashboard'
|
||||
navigate({ to: targetPath, replace: true })
|
||||
}
|
||||
|
||||
/**
|
||||
* Redirect to 2FA page
|
||||
*/
|
||||
const redirectTo2FA = () => {
|
||||
navigate({ to: '/otp', replace: true })
|
||||
}
|
||||
|
||||
/**
|
||||
* Redirect to login page
|
||||
*/
|
||||
const redirectToLogin = () => {
|
||||
navigate({ to: '/sign-in', replace: true })
|
||||
}
|
||||
|
||||
/**
|
||||
* Redirect to register page
|
||||
*/
|
||||
const redirectToRegister = () => {
|
||||
navigate({ to: '/sign-up', replace: true })
|
||||
}
|
||||
|
||||
return {
|
||||
handleLoginSuccess,
|
||||
redirectTo2FA,
|
||||
redirectToLogin,
|
||||
redirectToRegister,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useState } from 'react'
|
||||
import i18next from 'i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { useCountdown } from '@/hooks/use-countdown'
|
||||
import { sendEmailVerification } from '../api'
|
||||
import { EMAIL_VERIFICATION_COUNTDOWN } from '../constants'
|
||||
|
||||
interface UseEmailVerificationOptions {
|
||||
turnstileToken?: string
|
||||
validateTurnstile?: () => boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for managing email verification code sending
|
||||
*/
|
||||
export function useEmailVerification(options?: UseEmailVerificationOptions) {
|
||||
const [isSending, setIsSending] = useState(false)
|
||||
const {
|
||||
secondsLeft,
|
||||
isActive,
|
||||
start: startCountdown,
|
||||
} = useCountdown({ initialSeconds: EMAIL_VERIFICATION_COUNTDOWN })
|
||||
|
||||
/**
|
||||
* Send verification code to email
|
||||
*/
|
||||
const sendCode = async (email: string) => {
|
||||
if (!email) {
|
||||
toast.error(i18next.t('Please enter your email first'))
|
||||
return false
|
||||
}
|
||||
|
||||
// Validate turnstile if validation function is provided
|
||||
if (options?.validateTurnstile && !options.validateTurnstile()) {
|
||||
return false
|
||||
}
|
||||
|
||||
setIsSending(true)
|
||||
try {
|
||||
const res = await sendEmailVerification(email, options?.turnstileToken)
|
||||
if (res?.success) {
|
||||
startCountdown()
|
||||
toast.success(i18next.t('Verification email sent'))
|
||||
return true
|
||||
}
|
||||
return false
|
||||
} catch (_error) {
|
||||
// Errors are handled by global interceptor
|
||||
return false
|
||||
} finally {
|
||||
setIsSending(false)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isSending,
|
||||
secondsLeft,
|
||||
isActive,
|
||||
sendCode,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import type { AxiosRequestConfig } from 'axios'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { useAuthStore } from '@/stores/auth-store'
|
||||
import { api } from '@/lib/api'
|
||||
import { getOAuthState } from '../api'
|
||||
import {
|
||||
buildGitHubOAuthUrl,
|
||||
buildDiscordOAuthUrl,
|
||||
buildOIDCOAuthUrl,
|
||||
buildLinuxDOOAuthUrl,
|
||||
} from '../lib/oauth'
|
||||
import type { SystemStatus, CustomOAuthProviderInfo } from '../types'
|
||||
|
||||
type LogoutRequestConfig = AxiosRequestConfig & {
|
||||
skipErrorHandler?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for managing OAuth login
|
||||
*/
|
||||
export function useOAuthLogin(status: SystemStatus | null) {
|
||||
const { t } = useTranslation()
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [githubButtonText, setGithubButtonText] = useState('')
|
||||
const [githubButtonDisabled, setGithubButtonDisabled] = useState(false)
|
||||
const githubTimeoutRef = useRef<NodeJS.Timeout | null>(null)
|
||||
const { auth } = useAuthStore()
|
||||
|
||||
useEffect(() => {
|
||||
setGithubButtonText(t('Continue with GitHub'))
|
||||
|
||||
return () => {
|
||||
if (githubTimeoutRef.current) {
|
||||
clearTimeout(githubTimeoutRef.current)
|
||||
}
|
||||
}
|
||||
}, [t])
|
||||
|
||||
const resetSession = async () => {
|
||||
try {
|
||||
auth.reset()
|
||||
} catch (_error) {
|
||||
// ignore store reset errors
|
||||
}
|
||||
try {
|
||||
await api.get('/api/user/logout', {
|
||||
skipErrorHandler: true,
|
||||
} as LogoutRequestConfig)
|
||||
} catch (_error) {
|
||||
// ignore logout errors
|
||||
}
|
||||
}
|
||||
|
||||
const handleGitHubLogin = async () => {
|
||||
if (!status?.github_client_id) return
|
||||
if (githubButtonDisabled) return
|
||||
|
||||
setIsLoading(true)
|
||||
setGithubButtonDisabled(true)
|
||||
setGithubButtonText(t('Redirecting to GitHub...'))
|
||||
|
||||
if (githubTimeoutRef.current) {
|
||||
clearTimeout(githubTimeoutRef.current)
|
||||
}
|
||||
|
||||
githubTimeoutRef.current = setTimeout(() => {
|
||||
setIsLoading(false)
|
||||
setGithubButtonText(
|
||||
t('Request timed out, please refresh and restart GitHub login')
|
||||
)
|
||||
setGithubButtonDisabled(true)
|
||||
}, 20000)
|
||||
|
||||
try {
|
||||
await resetSession()
|
||||
const state = await getOAuthState()
|
||||
if (!state) {
|
||||
toast.error(t('Failed to initialize OAuth'))
|
||||
if (githubTimeoutRef.current) {
|
||||
clearTimeout(githubTimeoutRef.current)
|
||||
}
|
||||
setIsLoading(false)
|
||||
setGithubButtonText(t('Continue with GitHub'))
|
||||
setGithubButtonDisabled(false)
|
||||
return
|
||||
}
|
||||
|
||||
const url = buildGitHubOAuthUrl(status.github_client_id, state)
|
||||
window.open(url, '_self')
|
||||
} catch (_error) {
|
||||
toast.error(t('Failed to start GitHub login'))
|
||||
if (githubTimeoutRef.current) {
|
||||
clearTimeout(githubTimeoutRef.current)
|
||||
}
|
||||
setIsLoading(false)
|
||||
setGithubButtonText(t('Continue with GitHub'))
|
||||
setGithubButtonDisabled(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDiscordLogin = async () => {
|
||||
if (!status?.discord_client_id) return
|
||||
|
||||
setIsLoading(true)
|
||||
try {
|
||||
await resetSession()
|
||||
const state = await getOAuthState()
|
||||
if (!state) {
|
||||
toast.error(t('Failed to initialize OAuth'))
|
||||
return
|
||||
}
|
||||
|
||||
const url = buildDiscordOAuthUrl(status.discord_client_id, state)
|
||||
window.open(url, '_self')
|
||||
} catch (_error) {
|
||||
toast.error(t('Failed to start Discord login'))
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleOIDCLogin = async () => {
|
||||
if (!status?.oidc_authorization_endpoint || !status?.oidc_client_id) return
|
||||
|
||||
setIsLoading(true)
|
||||
try {
|
||||
await resetSession()
|
||||
const state = await getOAuthState()
|
||||
if (!state) {
|
||||
toast.error(t('Failed to initialize OAuth'))
|
||||
return
|
||||
}
|
||||
|
||||
const url = buildOIDCOAuthUrl(
|
||||
status.oidc_authorization_endpoint,
|
||||
status.oidc_client_id,
|
||||
state
|
||||
)
|
||||
window.open(url, '_self')
|
||||
} catch (_error) {
|
||||
toast.error(t('Failed to start OIDC login'))
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleLinuxDOLogin = async () => {
|
||||
if (!status?.linuxdo_client_id) return
|
||||
|
||||
setIsLoading(true)
|
||||
try {
|
||||
await resetSession()
|
||||
const state = await getOAuthState()
|
||||
if (!state) {
|
||||
toast.error(t('Failed to initialize OAuth'))
|
||||
return
|
||||
}
|
||||
|
||||
const url = buildLinuxDOOAuthUrl(status.linuxdo_client_id, state)
|
||||
window.open(url, '_self')
|
||||
} catch (_error) {
|
||||
toast.error(t('Failed to start LinuxDO login'))
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleTelegramLogin = () => {
|
||||
toast.info(t('Telegram login requires widget integration; coming soon'))
|
||||
}
|
||||
|
||||
const handleCustomOAuthLogin = async (provider: CustomOAuthProviderInfo) => {
|
||||
if (!provider.authorization_endpoint || !provider.client_id) return
|
||||
|
||||
setIsLoading(true)
|
||||
try {
|
||||
await resetSession()
|
||||
const state = await getOAuthState()
|
||||
if (!state) {
|
||||
toast.error(t('Failed to initialize OAuth'))
|
||||
return
|
||||
}
|
||||
|
||||
const redirectUri = `${window.location.origin}/oauth/${provider.slug}`
|
||||
const url = new URL(provider.authorization_endpoint)
|
||||
url.searchParams.set('client_id', provider.client_id)
|
||||
url.searchParams.set('redirect_uri', redirectUri)
|
||||
url.searchParams.set('response_type', 'code')
|
||||
url.searchParams.set('state', state)
|
||||
if (provider.scopes) {
|
||||
url.searchParams.set('scope', provider.scopes)
|
||||
}
|
||||
|
||||
window.open(url.toString(), '_self')
|
||||
} catch (_error) {
|
||||
toast.error(
|
||||
t('Failed to start {{provider}} login', { provider: provider.name })
|
||||
)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isLoading,
|
||||
githubButtonText,
|
||||
githubButtonDisabled,
|
||||
handleGitHubLogin,
|
||||
handleDiscordLogin,
|
||||
handleOIDCLogin,
|
||||
handleLinuxDOLogin,
|
||||
handleTelegramLogin,
|
||||
handleCustomOAuthLogin,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { useState } from 'react'
|
||||
import i18next from 'i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { useStatus } from '@/hooks/use-status'
|
||||
|
||||
/**
|
||||
* Hook for managing Turnstile verification
|
||||
*/
|
||||
export function useTurnstile() {
|
||||
const { status } = useStatus()
|
||||
const [turnstileToken, setTurnstileToken] = useState('')
|
||||
|
||||
const isTurnstileEnabled = !!(
|
||||
status?.turnstile_check && status?.turnstile_site_key
|
||||
)
|
||||
const turnstileSiteKey = status?.turnstile_site_key || ''
|
||||
|
||||
/**
|
||||
* Validate if turnstile is ready when required
|
||||
*/
|
||||
const validateTurnstile = (): boolean => {
|
||||
if (isTurnstileEnabled && !turnstileToken) {
|
||||
toast.info(
|
||||
i18next.t('Please wait a moment, human check is initializing...')
|
||||
)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
return {
|
||||
isTurnstileEnabled,
|
||||
turnstileSiteKey,
|
||||
turnstileToken,
|
||||
setTurnstileToken,
|
||||
validateTurnstile,
|
||||
}
|
||||
}
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
// ============================================================================
|
||||
// API Functions
|
||||
// ============================================================================
|
||||
|
||||
export {
|
||||
login,
|
||||
login2fa,
|
||||
logout,
|
||||
register,
|
||||
sendPasswordResetEmail,
|
||||
sendEmailVerification,
|
||||
bindEmail,
|
||||
getOAuthState,
|
||||
githubOAuthStart,
|
||||
wechatLoginByCode,
|
||||
} from './api'
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
export type {
|
||||
LoginPayload,
|
||||
LoginResponse,
|
||||
Login2FAResponse,
|
||||
TwoFAPayload,
|
||||
RegisterPayload,
|
||||
PasswordResetPayload,
|
||||
EmailVerificationPayload,
|
||||
BindEmailPayload,
|
||||
ApiResponse,
|
||||
SystemStatus,
|
||||
OAuthProvider,
|
||||
AuthFormProps,
|
||||
} from './types'
|
||||
|
||||
// ============================================================================
|
||||
// Constants & Schemas
|
||||
// ============================================================================
|
||||
|
||||
export {
|
||||
loginFormSchema,
|
||||
registerFormSchema,
|
||||
forgotPasswordFormSchema,
|
||||
otpFormSchema,
|
||||
PASSWORD_MIN_LENGTH,
|
||||
PASSWORD_MAX_LENGTH,
|
||||
OTP_LENGTH,
|
||||
BACKUP_CODE_LENGTH,
|
||||
BACKUP_CODE_REGEX,
|
||||
OTP_REGEX,
|
||||
EMAIL_VERIFICATION_COUNTDOWN,
|
||||
PASSWORD_RESET_COUNTDOWN,
|
||||
} from './constants'
|
||||
|
||||
// ============================================================================
|
||||
// Utilities
|
||||
// ============================================================================
|
||||
|
||||
export {
|
||||
buildGitHubOAuthUrl,
|
||||
buildDiscordOAuthUrl,
|
||||
buildOIDCOAuthUrl,
|
||||
buildLinuxDOOAuthUrl,
|
||||
getAvailableOAuthProviders,
|
||||
hasOAuthProviders,
|
||||
} from './lib/oauth'
|
||||
|
||||
export {
|
||||
saveUserId,
|
||||
getUserId,
|
||||
removeUserId,
|
||||
getAffiliateCode,
|
||||
saveAffiliateCode,
|
||||
} from './lib/storage'
|
||||
|
||||
export {
|
||||
isValidOTP,
|
||||
isValidBackupCode,
|
||||
formatBackupCode,
|
||||
cleanBackupCode,
|
||||
isValidEmail,
|
||||
} from './lib/validation'
|
||||
|
||||
// ============================================================================
|
||||
// Hooks
|
||||
// ============================================================================
|
||||
|
||||
export { useTurnstile } from './hooks/use-turnstile'
|
||||
export { useOAuthLogin } from './hooks/use-oauth-login'
|
||||
export { useAuthRedirect } from './hooks/use-auth-redirect'
|
||||
export { useEmailVerification } from './hooks/use-email-verification'
|
||||
|
||||
// ============================================================================
|
||||
// Components
|
||||
// ============================================================================
|
||||
|
||||
export { AuthLayout } from './auth-layout'
|
||||
export { OAuthProviders } from './components/oauth-providers'
|
||||
export { TermsFooter } from './components/terms-footer'
|
||||
export { LegalConsent } from './components/legal-consent'
|
||||
export { SignIn } from './sign-in'
|
||||
export { SignUp } from './sign-up'
|
||||
export { ForgotPassword } from './forgot-password'
|
||||
export { Otp } from './otp'
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
import type { SystemStatus, OAuthProvider } from '../types'
|
||||
|
||||
export {
|
||||
buildGitHubOAuthUrl,
|
||||
buildDiscordOAuthUrl,
|
||||
buildOIDCOAuthUrl,
|
||||
buildLinuxDOOAuthUrl,
|
||||
} from '@/lib/oauth'
|
||||
|
||||
// ============================================================================
|
||||
// OAuth Providers Utilities
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get available OAuth providers from system status
|
||||
*/
|
||||
export function getAvailableOAuthProviders(
|
||||
status: SystemStatus | null
|
||||
): OAuthProvider[] {
|
||||
if (!status) return []
|
||||
|
||||
const providers: OAuthProvider[] = []
|
||||
|
||||
if (status.github_oauth) {
|
||||
providers.push({
|
||||
name: 'GitHub',
|
||||
type: 'github',
|
||||
enabled: true,
|
||||
clientId: status.github_client_id,
|
||||
})
|
||||
}
|
||||
|
||||
if (status.discord_oauth) {
|
||||
providers.push({
|
||||
name: 'Discord',
|
||||
type: 'discord',
|
||||
enabled: true,
|
||||
clientId: status.discord_client_id,
|
||||
})
|
||||
}
|
||||
|
||||
if (status.oidc_enabled) {
|
||||
providers.push({
|
||||
name: 'OIDC',
|
||||
type: 'oidc',
|
||||
enabled: true,
|
||||
clientId: status.oidc_client_id,
|
||||
authEndpoint: status.oidc_authorization_endpoint,
|
||||
})
|
||||
}
|
||||
|
||||
if (status.linuxdo_oauth) {
|
||||
providers.push({
|
||||
name: 'LinuxDO',
|
||||
type: 'linuxdo',
|
||||
enabled: true,
|
||||
clientId: status.linuxdo_client_id,
|
||||
})
|
||||
}
|
||||
|
||||
if (status.telegram_oauth) {
|
||||
providers.push({
|
||||
name: 'Telegram',
|
||||
type: 'telegram',
|
||||
enabled: true,
|
||||
})
|
||||
}
|
||||
|
||||
return providers
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if any OAuth provider is available
|
||||
*/
|
||||
export function hasOAuthProviders(status: SystemStatus | null): boolean {
|
||||
if (!status) return false
|
||||
return !!(
|
||||
status.github_oauth ||
|
||||
status.discord_oauth ||
|
||||
status.oidc_enabled ||
|
||||
status.linuxdo_oauth ||
|
||||
status.telegram_oauth ||
|
||||
status.wechat_login
|
||||
)
|
||||
}
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Utilities for managing authentication-related browser storage
|
||||
*/
|
||||
|
||||
// ============================================================================
|
||||
// LocalStorage Keys
|
||||
// ============================================================================
|
||||
|
||||
const STORAGE_KEYS = {
|
||||
USER_ID: 'uid',
|
||||
AFFILIATE: 'aff',
|
||||
STATUS: 'status',
|
||||
} as const
|
||||
|
||||
// ============================================================================
|
||||
// User ID Storage
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Save user ID to localStorage
|
||||
*/
|
||||
export function saveUserId(userId: number | string): void {
|
||||
if (typeof window === 'undefined') return
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEYS.USER_ID, String(userId))
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Failed to save user ID:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user ID from localStorage
|
||||
*/
|
||||
export function getUserId(): string | null {
|
||||
if (typeof window === 'undefined') return null
|
||||
try {
|
||||
return window.localStorage.getItem(STORAGE_KEYS.USER_ID)
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Failed to get user ID:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove user ID from localStorage
|
||||
*/
|
||||
export function removeUserId(): void {
|
||||
if (typeof window === 'undefined') return
|
||||
try {
|
||||
window.localStorage.removeItem(STORAGE_KEYS.USER_ID)
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Failed to remove user ID:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Affiliate Code Storage
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get affiliate code from localStorage
|
||||
*/
|
||||
export function getAffiliateCode(): string {
|
||||
if (typeof window === 'undefined') return ''
|
||||
try {
|
||||
return window.localStorage.getItem(STORAGE_KEYS.AFFILIATE) ?? ''
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Failed to get affiliate code:', error)
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save affiliate code to localStorage
|
||||
*/
|
||||
export function saveAffiliateCode(code: string): void {
|
||||
if (typeof window === 'undefined') return
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEYS.AFFILIATE, code)
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Failed to save affiliate code:', error)
|
||||
}
|
||||
}
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
import { BACKUP_CODE_REGEX, OTP_REGEX } from '../constants'
|
||||
|
||||
/**
|
||||
* Validation utilities for authentication forms
|
||||
*/
|
||||
|
||||
// ============================================================================
|
||||
// OTP Validation
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Validate OTP code (6 digits)
|
||||
*/
|
||||
export function isValidOTP(code: string): boolean {
|
||||
return OTP_REGEX.test(code)
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate backup code (XXXX-XXXX format)
|
||||
*/
|
||||
export function isValidBackupCode(code: string): boolean {
|
||||
return BACKUP_CODE_REGEX.test(code)
|
||||
}
|
||||
|
||||
/**
|
||||
* Format backup code with hyphen (XXXX-XXXX)
|
||||
*/
|
||||
export function formatBackupCode(value: string): string {
|
||||
// Remove all non-alphanumeric characters and convert to uppercase
|
||||
let cleaned = value.toUpperCase().replace(/[^A-Z0-9]/g, '')
|
||||
|
||||
// Limit to 8 characters
|
||||
if (cleaned.length > 8) {
|
||||
cleaned = cleaned.slice(0, 8)
|
||||
}
|
||||
|
||||
// Add hyphen after 4th character
|
||||
if (cleaned.length > 4) {
|
||||
return cleaned.slice(0, 4) + '-' + cleaned.slice(4)
|
||||
}
|
||||
|
||||
return cleaned
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove hyphens from backup code before sending to server
|
||||
*/
|
||||
export function cleanBackupCode(code: string): string {
|
||||
return code.replace(/-/g, '')
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Email Validation
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Basic email validation
|
||||
*/
|
||||
export function isValidEmail(email: string): boolean {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
return emailRegex.test(email)
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
import { useState } from 'react'
|
||||
import type { z } from 'zod'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { useAuthStore } from '@/stores/auth-store'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
FormDescription,
|
||||
} from '@/components/ui/form'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
InputOTP,
|
||||
InputOTPGroup,
|
||||
InputOTPSlot,
|
||||
InputOTPSeparator,
|
||||
} from '@/components/ui/input-otp'
|
||||
import { login2fa } from '@/features/auth/api'
|
||||
import {
|
||||
otpFormSchema,
|
||||
OTP_LENGTH,
|
||||
BACKUP_CODE_LENGTH,
|
||||
} from '@/features/auth/constants'
|
||||
import { useAuthRedirect } from '@/features/auth/hooks/use-auth-redirect'
|
||||
import { saveUserId } from '@/features/auth/lib/storage'
|
||||
import {
|
||||
isValidOTP,
|
||||
isValidBackupCode,
|
||||
formatBackupCode,
|
||||
cleanBackupCode,
|
||||
} from '@/features/auth/lib/validation'
|
||||
import type { User } from '@/features/users/types'
|
||||
|
||||
type OtpFormProps = React.HTMLAttributes<HTMLFormElement>
|
||||
|
||||
export function OtpForm({ className, ...props }: OtpFormProps) {
|
||||
const { t } = useTranslation()
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [useBackupCode, setUseBackupCode] = useState(false)
|
||||
|
||||
const { auth } = useAuthStore()
|
||||
const { redirectToLogin } = useAuthRedirect()
|
||||
|
||||
const form = useForm<z.infer<typeof otpFormSchema>>({
|
||||
resolver: zodResolver(otpFormSchema),
|
||||
defaultValues: { otp: '' },
|
||||
})
|
||||
|
||||
const otp = form.watch('otp')
|
||||
|
||||
async function onSubmit(data: z.infer<typeof otpFormSchema>) {
|
||||
// Validate based on mode
|
||||
if (useBackupCode) {
|
||||
if (!isValidBackupCode(data.otp)) {
|
||||
toast.error(t('Backup code must be in format XXXX-XXXX'))
|
||||
return
|
||||
}
|
||||
} else {
|
||||
if (!isValidOTP(data.otp)) {
|
||||
toast.error(t('Verification code must be 6 digits'))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
setIsLoading(true)
|
||||
try {
|
||||
// Remove all hyphens from backup code before sending to backend
|
||||
const code = useBackupCode ? cleanBackupCode(data.otp) : data.otp
|
||||
const res = await login2fa({ code })
|
||||
|
||||
if (!res.success) {
|
||||
toast.error(res.message || t('Invalid code'))
|
||||
return
|
||||
}
|
||||
|
||||
// Handle user data from 2FA login response
|
||||
const userData = res.data
|
||||
if (!userData) {
|
||||
throw new Error('No user data received from login')
|
||||
}
|
||||
|
||||
// Update auth store
|
||||
auth.setUser(userData as User)
|
||||
|
||||
// Store user ID in localStorage for compatibility
|
||||
if (userData.id) {
|
||||
saveUserId(userData.id)
|
||||
}
|
||||
|
||||
toast.success(t('Signed in'))
|
||||
redirectToLogin() // This will redirect to dashboard via the redirect logic
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('2FA verification error:', error)
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : t('Verification failed')
|
||||
toast.error(errorMessage)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
function handleToggleMode() {
|
||||
setUseBackupCode(!useBackupCode)
|
||||
form.setValue('otp', '')
|
||||
}
|
||||
|
||||
function handleBackToLogin() {
|
||||
redirectToLogin()
|
||||
}
|
||||
|
||||
const isFormValid = useBackupCode
|
||||
? otp.length >= BACKUP_CODE_LENGTH
|
||||
: otp.length >= OTP_LENGTH
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className={cn('grid gap-4', className)}
|
||||
{...props}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='otp'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{useBackupCode ? t('Backup Code') : t('Verification Code')}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
{useBackupCode ? (
|
||||
<Input
|
||||
placeholder={t('Enter backup code (e.g., CAWD-OQDV)')}
|
||||
{...field}
|
||||
maxLength={BACKUP_CODE_LENGTH}
|
||||
autoComplete='off'
|
||||
className='font-mono uppercase'
|
||||
onChange={(e) => {
|
||||
const formatted = formatBackupCode(e.target.value)
|
||||
field.onChange(formatted)
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<InputOTP
|
||||
maxLength={OTP_LENGTH}
|
||||
{...field}
|
||||
containerClassName='justify-between sm:[&>[data-slot="input-otp-group"]>div]:w-12'
|
||||
>
|
||||
<InputOTPGroup>
|
||||
<InputOTPSlot index={0} />
|
||||
<InputOTPSlot index={1} />
|
||||
</InputOTPGroup>
|
||||
<InputOTPSeparator />
|
||||
<InputOTPGroup>
|
||||
<InputOTPSlot index={2} />
|
||||
<InputOTPSlot index={3} />
|
||||
</InputOTPGroup>
|
||||
<InputOTPSeparator />
|
||||
<InputOTPGroup>
|
||||
<InputOTPSlot index={4} />
|
||||
<InputOTPSlot index={5} />
|
||||
</InputOTPGroup>
|
||||
</InputOTP>
|
||||
)}
|
||||
</FormControl>
|
||||
<FormDescription className='text-muted-foreground text-xs'>
|
||||
{useBackupCode
|
||||
? t('Each backup code can only be used once.')
|
||||
: t('Verification code updates every 30 seconds.')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button className='mt-2 w-full' disabled={!isFormValid || isLoading}>
|
||||
{isLoading ? <Loader2 className='h-4 w-4 animate-spin' /> : null}
|
||||
{t('Verify and Sign In')}
|
||||
</Button>
|
||||
|
||||
<div className='flex items-center justify-center gap-2 text-sm'>
|
||||
<Button
|
||||
type='button'
|
||||
variant='link'
|
||||
size='sm'
|
||||
className='text-primary h-auto p-0'
|
||||
onClick={handleToggleMode}
|
||||
>
|
||||
{useBackupCode ? t('Use authenticator code') : t('Use backup code')}
|
||||
</Button>
|
||||
<span className='text-muted-foreground'>·</span>
|
||||
<Button
|
||||
type='button'
|
||||
variant='link'
|
||||
size='sm'
|
||||
className='text-primary h-auto p-0'
|
||||
onClick={handleBackToLogin}
|
||||
>
|
||||
{t('Back to login')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
)
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { AuthLayout } from '../auth-layout'
|
||||
import { OtpForm } from './components/otp-form'
|
||||
|
||||
export function Otp() {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<AuthLayout>
|
||||
<div className='w-full space-y-8'>
|
||||
<div className='space-y-3'>
|
||||
<h2 className='text-center text-2xl font-semibold tracking-tight sm:text-left'>
|
||||
{t('Two-factor Authentication')}
|
||||
</h2>
|
||||
<p className='text-muted-foreground text-left text-sm sm:text-base'>
|
||||
{t('Please enter the authentication code.')}
|
||||
</p>
|
||||
<p className='text-muted-foreground text-left text-sm sm:text-base'>
|
||||
{t('Session expired?')}{' '}
|
||||
<Link
|
||||
to='/sign-in'
|
||||
className='hover:text-primary font-medium underline underline-offset-4'
|
||||
>
|
||||
{t('Re-login')}
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<OtpForm />
|
||||
</div>
|
||||
</AuthLayout>
|
||||
)
|
||||
}
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
import { api } from '@/lib/api'
|
||||
import type { ApiResponse, PasskeyOptionsPayload, PasskeyStatus } from './types'
|
||||
|
||||
export async function getPasskeyStatus(): Promise<ApiResponse<PasskeyStatus>> {
|
||||
const res = await api.get<ApiResponse<PasskeyStatus>>('/api/user/passkey')
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function beginPasskeyRegistration(): Promise<
|
||||
ApiResponse<PasskeyOptionsPayload>
|
||||
> {
|
||||
const res = await api.post<ApiResponse<PasskeyOptionsPayload>>(
|
||||
'/api/user/passkey/register/begin'
|
||||
)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function finishPasskeyRegistration(
|
||||
payload: Record<string, unknown>
|
||||
): Promise<ApiResponse> {
|
||||
const res = await api.post<ApiResponse>(
|
||||
'/api/user/passkey/register/finish',
|
||||
payload
|
||||
)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function deletePasskey(): Promise<ApiResponse> {
|
||||
const res = await api.delete<ApiResponse>('/api/user/passkey')
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function beginPasskeyLogin(): Promise<
|
||||
ApiResponse<PasskeyOptionsPayload>
|
||||
> {
|
||||
const res = await api.post<ApiResponse<PasskeyOptionsPayload>>(
|
||||
'/api/user/passkey/login/begin'
|
||||
)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function finishPasskeyLogin(
|
||||
payload: Record<string, unknown>
|
||||
): Promise<ApiResponse> {
|
||||
const res = await api.post<ApiResponse>(
|
||||
'/api/user/passkey/login/finish',
|
||||
payload
|
||||
)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function beginPasskeyVerification(): Promise<
|
||||
ApiResponse<PasskeyOptionsPayload>
|
||||
> {
|
||||
const res = await api.post<ApiResponse<PasskeyOptionsPayload>>(
|
||||
'/api/user/passkey/verify/begin'
|
||||
)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function finishPasskeyVerification(
|
||||
payload: Record<string, unknown>
|
||||
): Promise<ApiResponse> {
|
||||
const res = await api.post<ApiResponse>(
|
||||
'/api/user/passkey/verify/finish',
|
||||
payload
|
||||
)
|
||||
return res.data
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import i18next from 'i18next'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
buildRegistrationResult,
|
||||
createCredential,
|
||||
isPasskeySupported as detectPasskeySupport,
|
||||
prepareCredentialCreationOptions,
|
||||
} from '@/lib/passkey'
|
||||
import {
|
||||
beginPasskeyRegistration,
|
||||
deletePasskey,
|
||||
finishPasskeyRegistration,
|
||||
getPasskeyStatus,
|
||||
} from '../api'
|
||||
import type { PasskeyStatus } from '../types'
|
||||
|
||||
interface UsePasskeyManagementOptions {
|
||||
onStatusChange?: (status: PasskeyStatus | null) => void
|
||||
}
|
||||
|
||||
export function usePasskeyManagement(
|
||||
options: UsePasskeyManagementOptions = {}
|
||||
) {
|
||||
const { onStatusChange } = options
|
||||
|
||||
const [status, setStatus] = useState<PasskeyStatus | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [registering, setRegistering] = useState(false)
|
||||
const [removing, setRemoving] = useState(false)
|
||||
const [supported, setSupported] = useState(false)
|
||||
|
||||
const fetchStatus = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const res = await getPasskeyStatus()
|
||||
if (res.success) {
|
||||
setStatus(res.data ?? null)
|
||||
onStatusChange?.(res.data ?? null)
|
||||
} else {
|
||||
setStatus(null)
|
||||
toast.error(res.message || i18next.t('Failed to load Passkey status'))
|
||||
}
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('[Passkey] Failed to fetch status', error)
|
||||
toast.error(i18next.t('Failed to load Passkey status'))
|
||||
setStatus(null)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [onStatusChange])
|
||||
|
||||
useEffect(() => {
|
||||
fetchStatus()
|
||||
}, [fetchStatus])
|
||||
|
||||
useEffect(() => {
|
||||
detectPasskeySupport()
|
||||
.then(setSupported)
|
||||
.catch(() => setSupported(false))
|
||||
}, [])
|
||||
|
||||
const register = useCallback(async () => {
|
||||
if (!supported) {
|
||||
toast.error(i18next.t('This device does not support Passkey'))
|
||||
return false
|
||||
}
|
||||
if (!navigator?.credentials) {
|
||||
toast.error(i18next.t('Passkey is not supported in this environment'))
|
||||
return false
|
||||
}
|
||||
|
||||
setRegistering(true)
|
||||
try {
|
||||
const beginResponse = await beginPasskeyRegistration()
|
||||
if (!beginResponse.success) {
|
||||
toast.error(
|
||||
beginResponse.message ||
|
||||
i18next.t('Failed to start Passkey registration')
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
const publicKey = prepareCredentialCreationOptions(
|
||||
beginResponse.data?.options ?? beginResponse.data
|
||||
)
|
||||
|
||||
const credential = (await createCredential(
|
||||
publicKey
|
||||
)) as PublicKeyCredential | null
|
||||
if (!credential) {
|
||||
toast.error(i18next.t('Passkey registration was cancelled'))
|
||||
return false
|
||||
}
|
||||
|
||||
const attestation = buildRegistrationResult(credential)
|
||||
if (!attestation) {
|
||||
toast.error(i18next.t('Invalid Passkey registration response'))
|
||||
return false
|
||||
}
|
||||
|
||||
const finishResponse = await finishPasskeyRegistration(attestation)
|
||||
if (!finishResponse.success) {
|
||||
toast.error(
|
||||
finishResponse.message || i18next.t('Failed to register Passkey')
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
toast.success(i18next.t('Passkey registered successfully'))
|
||||
await fetchStatus()
|
||||
return true
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof DOMException && error.name === 'NotAllowedError') {
|
||||
toast.info(i18next.t('Passkey registration was cancelled'))
|
||||
return false
|
||||
}
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('[Passkey] Registration error', error)
|
||||
toast.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: i18next.t('Failed to register Passkey')
|
||||
)
|
||||
return false
|
||||
} finally {
|
||||
setRegistering(false)
|
||||
}
|
||||
}, [supported, fetchStatus])
|
||||
|
||||
const remove = useCallback(async () => {
|
||||
setRemoving(true)
|
||||
try {
|
||||
const res = await deletePasskey()
|
||||
if (!res.success) {
|
||||
toast.error(res.message || i18next.t('Failed to remove Passkey'))
|
||||
return false
|
||||
}
|
||||
|
||||
toast.success(i18next.t('Passkey removed successfully'))
|
||||
await fetchStatus()
|
||||
return true
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('[Passkey] Removal error', error)
|
||||
toast.error(i18next.t('Failed to remove Passkey'))
|
||||
return false
|
||||
} finally {
|
||||
setRemoving(false)
|
||||
}
|
||||
}, [fetchStatus])
|
||||
|
||||
const enabled = useMemo(() => Boolean(status?.enabled), [status])
|
||||
const lastUsed = useMemo(() => status?.last_used_at ?? null, [status])
|
||||
|
||||
return {
|
||||
status,
|
||||
loading,
|
||||
registering,
|
||||
removing,
|
||||
supported,
|
||||
enabled,
|
||||
lastUsed,
|
||||
fetchStatus,
|
||||
register,
|
||||
remove,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './api'
|
||||
export * from './types'
|
||||
export * from './hooks/use-passkey-management'
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
export interface ApiResponse<T = unknown> {
|
||||
success: boolean
|
||||
message?: string
|
||||
data?: T
|
||||
}
|
||||
|
||||
export interface PasskeyStatus {
|
||||
enabled: boolean
|
||||
last_used_at?: string | null
|
||||
backup_eligible?: boolean
|
||||
backup_state?: boolean
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface PasskeyOptionsPayload {
|
||||
options?: unknown
|
||||
publicKey?: unknown
|
||||
response?: unknown
|
||||
Response?: unknown
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { CheckIcon, CopyIcon } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { api } from '@/lib/api'
|
||||
import { copyToClipboard } from '@/lib/copy-to-clipboard'
|
||||
import { useCountdown } from '@/hooks/use-countdown'
|
||||
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 { AuthLayout } from '../auth-layout'
|
||||
|
||||
export type ResetPasswordSearchParams = {
|
||||
email?: string
|
||||
token?: string
|
||||
}
|
||||
|
||||
type ResetPasswordConfirmProps = ResetPasswordSearchParams
|
||||
|
||||
export function ResetPasswordConfirm({
|
||||
email,
|
||||
token,
|
||||
}: ResetPasswordConfirmProps) {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const [newPassword, setNewPassword] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [copied, setCopied] = useState(false)
|
||||
const {
|
||||
secondsLeft,
|
||||
isActive,
|
||||
start: startCountdown,
|
||||
} = useCountdown({ initialSeconds: 30 })
|
||||
|
||||
const isValidResetLink = Boolean(email && token)
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!isValidResetLink || !email || !token) {
|
||||
toast.error(t('Invalid reset link, please request a new password reset'))
|
||||
return
|
||||
}
|
||||
|
||||
startCountdown()
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await api.post('/api/user/reset', { email, token }, {
|
||||
skipBusinessError: true,
|
||||
} as Record<string, unknown>)
|
||||
|
||||
if (res?.data?.success) {
|
||||
const password = res.data.data
|
||||
setNewPassword(password)
|
||||
const copySuccess = await copyToClipboard(password)
|
||||
if (copySuccess) {
|
||||
toast.success(
|
||||
t('Password reset and copied to clipboard: {{password}}', {
|
||||
password,
|
||||
})
|
||||
)
|
||||
} else {
|
||||
toast.success(t('Password reset: {{password}}', { password }))
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Errors handled by global interceptor
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCopy() {
|
||||
if (!newPassword) return
|
||||
|
||||
const copySuccess = await copyToClipboard(newPassword)
|
||||
if (copySuccess) {
|
||||
setCopied(true)
|
||||
toast.success(
|
||||
t('Password copied to clipboard: {{password}}', {
|
||||
password: newPassword,
|
||||
})
|
||||
)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthLayout>
|
||||
<div className='w-full space-y-8'>
|
||||
<div className='space-y-2'>
|
||||
<h2 className='text-center text-2xl font-semibold tracking-tight sm:text-left'>
|
||||
{t('Reset password')}
|
||||
</h2>
|
||||
<p className='text-muted-foreground text-left text-sm sm:text-base'>
|
||||
{newPassword
|
||||
? 'Your password has been reset successfully'
|
||||
: 'Confirm the reset request to generate a new password.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className='space-y-4'>
|
||||
{!isValidResetLink && (
|
||||
<Alert variant='destructive'>
|
||||
<AlertDescription>
|
||||
{t('Invalid reset link, please request a new password reset.')}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='email'>{t('Email')}</Label>
|
||||
<Input
|
||||
id='email'
|
||||
type='email'
|
||||
value={email || ''}
|
||||
disabled
|
||||
placeholder={t('Waiting for email...')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{newPassword && (
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='password'>{t('New password')}</Label>
|
||||
<div className='flex gap-2'>
|
||||
<Input
|
||||
id='password'
|
||||
value={newPassword}
|
||||
disabled
|
||||
className='font-mono'
|
||||
/>
|
||||
<Button
|
||||
type='button'
|
||||
size='icon'
|
||||
variant='outline'
|
||||
onClick={handleCopy}
|
||||
>
|
||||
{copied ? (
|
||||
<CheckIcon className='h-4 w-4' />
|
||||
) : (
|
||||
<CopyIcon className='h-4 w-4' />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{t('Password has been copied to clipboard')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
className='w-full'
|
||||
onClick={
|
||||
newPassword
|
||||
? () => navigate({ to: '/sign-in', replace: true })
|
||||
: handleSubmit
|
||||
}
|
||||
disabled={
|
||||
newPassword ? false : loading || isActive || !isValidResetLink
|
||||
}
|
||||
>
|
||||
{newPassword
|
||||
? 'Return to login'
|
||||
: isActive
|
||||
? `Retry (${secondsLeft}s)`
|
||||
: 'Confirm reset password'}
|
||||
</Button>
|
||||
|
||||
{!newPassword && (
|
||||
<Button
|
||||
variant='link'
|
||||
className='w-full'
|
||||
onClick={() => navigate({ to: '/sign-in', replace: true })}
|
||||
>
|
||||
{t('Back to login')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</AuthLayout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { api, get2FAStatus } from '@/lib/api'
|
||||
import {
|
||||
buildAssertionResult,
|
||||
prepareCredentialRequestOptions,
|
||||
isPasskeySupported as detectPasskeySupport,
|
||||
} from '@/lib/passkey'
|
||||
import {
|
||||
beginPasskeyVerification,
|
||||
finishPasskeyVerification,
|
||||
getPasskeyStatus,
|
||||
} from '../passkey'
|
||||
import type { VerificationMethod, VerificationMethods } from './types'
|
||||
|
||||
/**
|
||||
* Fetch available verification methods for the current user.
|
||||
*/
|
||||
export async function checkVerificationMethods(): Promise<VerificationMethods> {
|
||||
try {
|
||||
const [twoFAResponse, passkeyResponse, passkeySupported] =
|
||||
await Promise.all([
|
||||
get2FAStatus(),
|
||||
getPasskeyStatus(),
|
||||
detectPasskeySupport(),
|
||||
])
|
||||
|
||||
const has2FA =
|
||||
Boolean(twoFAResponse?.success) && Boolean(twoFAResponse?.data?.enabled)
|
||||
const hasPasskey =
|
||||
Boolean(passkeyResponse?.success) &&
|
||||
Boolean(passkeyResponse?.data?.enabled)
|
||||
|
||||
return {
|
||||
has2FA,
|
||||
hasPasskey,
|
||||
passkeySupported,
|
||||
}
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('[Secure Verification] Failed to check methods', error)
|
||||
return {
|
||||
has2FA: false,
|
||||
hasPasskey: false,
|
||||
passkeySupported: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a verification flow based on the method type.
|
||||
*/
|
||||
export async function verify(
|
||||
method: VerificationMethod,
|
||||
code?: string
|
||||
): Promise<void> {
|
||||
switch (method) {
|
||||
case '2fa':
|
||||
return verifyTwoFA(code)
|
||||
case 'passkey':
|
||||
return verifyPasskey()
|
||||
default:
|
||||
throw new Error(`Unsupported verification method: ${method}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform 2FA verification flow.
|
||||
*/
|
||||
async function verifyTwoFA(code?: string | null): Promise<void> {
|
||||
const trimmed = code?.trim()
|
||||
if (!trimmed) {
|
||||
throw new Error('Please enter the verification code or backup code')
|
||||
}
|
||||
|
||||
const res = await api.post('/api/verify', {
|
||||
method: '2fa',
|
||||
code: trimmed,
|
||||
})
|
||||
|
||||
if (!res.data?.success) {
|
||||
throw new Error(res.data?.message || 'Verification failed')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform Passkey verification flow.
|
||||
*/
|
||||
async function verifyPasskey(): Promise<void> {
|
||||
if (typeof navigator === 'undefined' || !navigator.credentials) {
|
||||
throw new Error('Passkey verification is not supported in this environment')
|
||||
}
|
||||
|
||||
try {
|
||||
const beginResponse = await beginPasskeyVerification()
|
||||
if (!beginResponse.success) {
|
||||
throw new Error(beginResponse.message || 'Failed to start verification')
|
||||
}
|
||||
|
||||
const publicKey = prepareCredentialRequestOptions(
|
||||
beginResponse.data?.options ?? beginResponse.data
|
||||
)
|
||||
|
||||
const credential = (await navigator.credentials.get({
|
||||
publicKey,
|
||||
})) as PublicKeyCredential | null
|
||||
|
||||
if (!credential) {
|
||||
throw new Error('Passkey verification was cancelled')
|
||||
}
|
||||
|
||||
const assertion = buildAssertionResult(credential)
|
||||
if (!assertion) {
|
||||
throw new Error('Unable to build Passkey assertion')
|
||||
}
|
||||
|
||||
const finishResponse = await finishPasskeyVerification(assertion)
|
||||
if (!finishResponse.success) {
|
||||
throw new Error(finishResponse.message || 'Passkey verification failed')
|
||||
}
|
||||
|
||||
const verifyResponse = await api.post('/api/verify', {
|
||||
method: 'passkey',
|
||||
})
|
||||
|
||||
if (!verifyResponse.data?.success) {
|
||||
throw new Error(
|
||||
verifyResponse.data?.message || 'Failed to complete verification'
|
||||
)
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof DOMException && error.name === 'NotAllowedError') {
|
||||
throw new Error('Passkey verification was cancelled or timed out', {
|
||||
cause: error,
|
||||
})
|
||||
}
|
||||
if (error instanceof DOMException && error.name === 'InvalidStateError') {
|
||||
throw new Error(
|
||||
'Passkey verification is not available in the current state',
|
||||
{ cause: error }
|
||||
)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
Vendored
+194
@@ -0,0 +1,194 @@
|
||||
import { useMemo } from 'react'
|
||||
import { ShieldCheck, KeyRound, Loader2 } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import type {
|
||||
SecureVerificationState,
|
||||
VerificationMethod,
|
||||
VerificationMethods,
|
||||
} from '../types'
|
||||
|
||||
interface SecureVerificationDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
methods: VerificationMethods
|
||||
state: SecureVerificationState
|
||||
onVerify: (method: VerificationMethod, code?: string) => void | Promise<void>
|
||||
onCancel: () => void
|
||||
onCodeChange: (code: string) => void
|
||||
onMethodChange: (method: VerificationMethod) => void
|
||||
}
|
||||
|
||||
export function SecureVerificationDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
methods,
|
||||
state,
|
||||
onVerify,
|
||||
onCancel,
|
||||
onCodeChange,
|
||||
onMethodChange,
|
||||
}: SecureVerificationDialogProps) {
|
||||
const { t } = useTranslation()
|
||||
const availableTabs: VerificationMethod[] = useMemo(() => {
|
||||
const tabs: VerificationMethod[] = []
|
||||
if (methods.has2FA) tabs.push('2fa')
|
||||
if (methods.hasPasskey && methods.passkeySupported) tabs.push('passkey')
|
||||
return tabs
|
||||
}, [methods])
|
||||
|
||||
const activeMethod =
|
||||
state.method ?? (availableTabs.length > 0 ? availableTabs[0] : null)
|
||||
|
||||
const title =
|
||||
state.title ??
|
||||
(availableTabs.length
|
||||
? 'Additional verification required'
|
||||
: 'Verification unavailable')
|
||||
|
||||
const description =
|
||||
state.description ??
|
||||
(availableTabs.length
|
||||
? 'Confirm your identity before accessing this sensitive action.'
|
||||
: 'Enable Two-factor Authentication or Passkey in your profile settings to continue.')
|
||||
|
||||
const handleVerify = () => {
|
||||
if (!activeMethod) return
|
||||
const payload = activeMethod === '2fa' ? state.code : undefined
|
||||
onVerify(activeMethod, payload)
|
||||
}
|
||||
|
||||
const verifyDisabled =
|
||||
state.loading ||
|
||||
(activeMethod === '2fa' && (!state.code.trim() || state.code.length < 6))
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className='top-[8vh] max-w-[calc(100%-1.5rem)] translate-y-0 gap-0 overflow-hidden border-none p-0 shadow-xl sm:top-1/2 sm:max-w-md sm:translate-y-[-50%] sm:rounded-xl'
|
||||
showCloseButton={!state.loading}
|
||||
>
|
||||
<div className='bg-background flex max-h-[calc(100dvh-2rem)] flex-col'>
|
||||
<DialogHeader className='border-b px-6 py-5 text-left'>
|
||||
<DialogTitle className='flex items-center gap-2 text-lg font-semibold'>
|
||||
<ShieldCheck className='text-primary h-5 w-5' />
|
||||
{title}
|
||||
</DialogTitle>
|
||||
<DialogDescription className='text-left'>
|
||||
{description}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className='flex-1 overflow-y-auto px-6 py-5'>
|
||||
{availableTabs.length === 0 ? (
|
||||
<div className='grid place-items-center gap-4 text-center'>
|
||||
<div className='bg-muted flex h-16 w-16 items-center justify-center rounded-full'>
|
||||
<ShieldCheck className='text-muted-foreground h-8 w-8' />
|
||||
</div>
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
{t(
|
||||
'Enable Two-factor Authentication or Passkey in your profile to unlock sensitive operations.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<Tabs
|
||||
value={activeMethod ?? availableTabs[0]}
|
||||
onValueChange={(value) =>
|
||||
onMethodChange(value as VerificationMethod)
|
||||
}
|
||||
className='gap-4'
|
||||
>
|
||||
<TabsList>
|
||||
{methods.has2FA && (
|
||||
<TabsTrigger value='2fa'>
|
||||
{t('Authenticator code')}
|
||||
</TabsTrigger>
|
||||
)}
|
||||
{methods.hasPasskey && methods.passkeySupported && (
|
||||
<TabsTrigger value='passkey'>{t('Passkey')}</TabsTrigger>
|
||||
)}
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value='2fa' className='space-y-3'>
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
{t(
|
||||
'Enter the 6-digit Time-based One-Time Password or 8-character backup code from your authenticator app.'
|
||||
)}
|
||||
</p>
|
||||
<Input
|
||||
inputMode='numeric'
|
||||
maxLength={8}
|
||||
value={state.code}
|
||||
onChange={(event) => onCodeChange(event.target.value)}
|
||||
placeholder={t('Enter verification code')}
|
||||
disabled={state.loading}
|
||||
autoFocus={activeMethod === '2fa'}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' && !verifyDisabled) {
|
||||
event.preventDefault()
|
||||
handleVerify()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value='passkey' className='space-y-4'>
|
||||
<div className='bg-muted/50 flex items-center justify-center rounded-lg p-4'>
|
||||
<div className='text-muted-foreground flex items-center gap-3'>
|
||||
<KeyRound className='text-primary h-6 w-6' />
|
||||
<div className='text-left text-sm'>
|
||||
<p className='text-foreground font-medium'>
|
||||
{t('Use your Passkey')}
|
||||
</p>
|
||||
<p>
|
||||
{t(
|
||||
'We will prompt your device to confirm using biometrics or your hardware key.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{!methods.passkeySupported && (
|
||||
<p className='text-destructive text-sm'>
|
||||
{t('This device does not support Passkey verification.')}
|
||||
</p>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className='bg-muted/30 border-t px-6 py-4 sm:flex-row sm:justify-end'>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
disabled={state.loading}
|
||||
onClick={onCancel}
|
||||
>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
onClick={handleVerify}
|
||||
disabled={availableTabs.length === 0 || verifyDisabled}
|
||||
>
|
||||
{state.loading && <Loader2 className='h-4 w-4 animate-spin' />}
|
||||
{t('Verify')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
+220
@@ -0,0 +1,220 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import i18next from 'i18next'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
extractVerificationInfo,
|
||||
isVerificationRequiredError,
|
||||
} from '@/lib/secure-verification'
|
||||
import { checkVerificationMethods, verify } from '../api'
|
||||
import type {
|
||||
SecureVerificationState,
|
||||
StartVerificationOptions,
|
||||
UseSecureVerificationOptions,
|
||||
VerificationMethod,
|
||||
VerificationMethods,
|
||||
} from '../types'
|
||||
|
||||
type ApiCall = (() => Promise<unknown>) | null
|
||||
|
||||
interface InternalState extends SecureVerificationState {
|
||||
apiCall: ApiCall
|
||||
}
|
||||
|
||||
const defaultMethods: VerificationMethods = {
|
||||
has2FA: false,
|
||||
hasPasskey: false,
|
||||
passkeySupported: false,
|
||||
}
|
||||
|
||||
const initialState: InternalState = {
|
||||
method: null,
|
||||
loading: false,
|
||||
code: '',
|
||||
title: undefined,
|
||||
description: undefined,
|
||||
apiCall: null,
|
||||
}
|
||||
|
||||
export function useSecureVerification(
|
||||
options: UseSecureVerificationOptions = {}
|
||||
) {
|
||||
const { onSuccess, onError, successMessage, autoReset = true } = options
|
||||
|
||||
const [methods, setMethods] = useState<VerificationMethods>(defaultMethods)
|
||||
const [state, setState] = useState<InternalState>(initialState)
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
const fetchVerificationMethods = useCallback(async () => {
|
||||
const result = await checkVerificationMethods()
|
||||
setMethods(result)
|
||||
return result
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchVerificationMethods()
|
||||
}, [fetchVerificationMethods])
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setState(initialState)
|
||||
setOpen(false)
|
||||
}, [])
|
||||
|
||||
const startVerification = useCallback(
|
||||
async (
|
||||
apiCall: () => Promise<unknown>,
|
||||
config: StartVerificationOptions = {}
|
||||
) => {
|
||||
const { preferredMethod, title, description } = config
|
||||
const availableMethods = await fetchVerificationMethods()
|
||||
|
||||
if (!availableMethods.has2FA && !availableMethods.hasPasskey) {
|
||||
toast.error(
|
||||
i18next.t(
|
||||
'Please enable Two-factor Authentication or Passkey before proceeding'
|
||||
)
|
||||
)
|
||||
onError?.(
|
||||
new Error(
|
||||
'No verification methods available. Enable 2FA or Passkey to continue.'
|
||||
)
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
let defaultMethod: VerificationMethod | null = preferredMethod ?? null
|
||||
if (!defaultMethod) {
|
||||
if (availableMethods.hasPasskey && availableMethods.passkeySupported) {
|
||||
defaultMethod = 'passkey'
|
||||
} else if (availableMethods.has2FA) {
|
||||
defaultMethod = '2fa'
|
||||
}
|
||||
}
|
||||
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
apiCall,
|
||||
method: defaultMethod,
|
||||
title,
|
||||
description,
|
||||
}))
|
||||
setOpen(true)
|
||||
return true
|
||||
},
|
||||
[fetchVerificationMethods, onError]
|
||||
)
|
||||
|
||||
const executeVerification = useCallback(
|
||||
async (method?: VerificationMethod, code?: string) => {
|
||||
if (!state.apiCall) {
|
||||
toast.error(i18next.t('Verification is not configured properly'))
|
||||
return
|
||||
}
|
||||
|
||||
const actualMethod = method ?? state.method
|
||||
if (!actualMethod) {
|
||||
toast.error(i18next.t('Select a verification method first'))
|
||||
return
|
||||
}
|
||||
|
||||
setState((prev) => ({ ...prev, loading: true }))
|
||||
|
||||
try {
|
||||
await verify(actualMethod, code ?? state.code)
|
||||
const result = await state.apiCall()
|
||||
|
||||
if (successMessage) {
|
||||
toast.success(successMessage)
|
||||
}
|
||||
|
||||
onSuccess?.(result, actualMethod)
|
||||
|
||||
if (autoReset) {
|
||||
reset()
|
||||
}
|
||||
|
||||
return result
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: i18next.t('Verification failed')
|
||||
toast.error(message)
|
||||
onError?.(error)
|
||||
throw error
|
||||
} finally {
|
||||
setState((prev) => ({ ...prev, loading: false }))
|
||||
}
|
||||
},
|
||||
[state, successMessage, onSuccess, onError, autoReset, reset]
|
||||
)
|
||||
|
||||
const setCode = useCallback((code: string) => {
|
||||
setState((prev) => ({ ...prev, code }))
|
||||
}, [])
|
||||
|
||||
const switchMethod = useCallback((method: VerificationMethod) => {
|
||||
setState((prev) => ({ ...prev, method, code: '' }))
|
||||
}, [])
|
||||
|
||||
const cancel = useCallback(() => {
|
||||
reset()
|
||||
}, [reset])
|
||||
|
||||
const withVerification = useCallback(
|
||||
async (
|
||||
apiCall: () => Promise<unknown>,
|
||||
config: StartVerificationOptions = {}
|
||||
) => {
|
||||
try {
|
||||
return await apiCall()
|
||||
} catch (error) {
|
||||
if (isVerificationRequiredError(error)) {
|
||||
const info = extractVerificationInfo(error)
|
||||
toast.info(info.message)
|
||||
await startVerification(apiCall, config)
|
||||
return null
|
||||
}
|
||||
throw error
|
||||
}
|
||||
},
|
||||
[startVerification]
|
||||
)
|
||||
|
||||
const canUseMethod = useCallback(
|
||||
(method: VerificationMethod) => {
|
||||
if (method === '2fa') return methods.has2FA
|
||||
if (method === 'passkey') {
|
||||
return methods.hasPasskey && methods.passkeySupported
|
||||
}
|
||||
return false
|
||||
},
|
||||
[methods]
|
||||
)
|
||||
|
||||
const recommendedMethod = useMemo<VerificationMethod | null>(() => {
|
||||
if (methods.hasPasskey && methods.passkeySupported) return 'passkey'
|
||||
if (methods.has2FA) return '2fa'
|
||||
return null
|
||||
}, [methods])
|
||||
|
||||
return {
|
||||
open,
|
||||
setOpen,
|
||||
methods,
|
||||
state,
|
||||
startVerification,
|
||||
executeVerification,
|
||||
cancel,
|
||||
reset,
|
||||
setCode,
|
||||
switchMethod,
|
||||
withVerification,
|
||||
fetchVerificationMethods,
|
||||
canUseMethod,
|
||||
recommendedMethod,
|
||||
hasAnyMethod: methods.has2FA || methods.hasPasskey,
|
||||
isLoading: state.loading,
|
||||
currentMethod: state.method,
|
||||
code: state.code,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from './api'
|
||||
export * from './types'
|
||||
export * from './hooks/use-secure-verification'
|
||||
export * from './components/secure-verification-dialog'
|
||||
@@ -0,0 +1,28 @@
|
||||
export type VerificationMethod = '2fa' | 'passkey'
|
||||
|
||||
export interface VerificationMethods {
|
||||
has2FA: boolean
|
||||
hasPasskey: boolean
|
||||
passkeySupported: boolean
|
||||
}
|
||||
|
||||
export interface SecureVerificationState {
|
||||
method: VerificationMethod | null
|
||||
loading: boolean
|
||||
code: string
|
||||
title?: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
export interface UseSecureVerificationOptions {
|
||||
onSuccess?: (result: unknown, method: VerificationMethod) => void
|
||||
onError?: (error: unknown) => void
|
||||
successMessage?: string
|
||||
autoReset?: boolean
|
||||
}
|
||||
|
||||
export interface StartVerificationOptions {
|
||||
preferredMethod?: VerificationMethod
|
||||
title?: string
|
||||
description?: string
|
||||
}
|
||||
@@ -0,0 +1,435 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import type { z } from 'zod'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { Loader2, LogIn, KeyRound } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
buildAssertionResult,
|
||||
prepareCredentialRequestOptions,
|
||||
isPasskeySupported as detectPasskeySupport,
|
||||
} from '@/lib/passkey'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useStatus } from '@/hooks/use-status'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { PasswordInput } from '@/components/password-input'
|
||||
import { Turnstile } from '@/components/turnstile'
|
||||
import { login, wechatLoginByCode } from '@/features/auth/api'
|
||||
import { LegalConsent } from '@/features/auth/components/legal-consent'
|
||||
import { OAuthProviders } from '@/features/auth/components/oauth-providers'
|
||||
import { loginFormSchema } from '@/features/auth/constants'
|
||||
import { useAuthRedirect } from '@/features/auth/hooks/use-auth-redirect'
|
||||
import { useTurnstile } from '@/features/auth/hooks/use-turnstile'
|
||||
import { beginPasskeyLogin, finishPasskeyLogin } from '@/features/auth/passkey'
|
||||
import type { AuthFormProps } from '@/features/auth/types'
|
||||
|
||||
export function UserAuthForm({
|
||||
className,
|
||||
redirectTo,
|
||||
...props
|
||||
}: AuthFormProps) {
|
||||
const { t } = useTranslation()
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [wechatCode, setWeChatCode] = useState('')
|
||||
const [agreedToLegal, setAgreedToLegal] = useState(false)
|
||||
const [passkeySupported, setPasskeySupported] = useState(false)
|
||||
const [isPasskeyLoading, setIsPasskeyLoading] = useState(false)
|
||||
const [isWeChatDialogOpen, setIsWeChatDialogOpen] = useState(false)
|
||||
const [isWeChatSubmitting, setIsWeChatSubmitting] = useState(false)
|
||||
const legalConsentErrorMessage = t('Please agree to the legal terms first')
|
||||
const loginFailedMessage = t('Login failed')
|
||||
|
||||
const { status } = useStatus()
|
||||
const passkeyLoginEnabled = Boolean(
|
||||
status?.passkey_login ?? status?.data?.passkey_login
|
||||
)
|
||||
const {
|
||||
isTurnstileEnabled,
|
||||
turnstileSiteKey,
|
||||
turnstileToken,
|
||||
setTurnstileToken,
|
||||
validateTurnstile,
|
||||
} = useTurnstile()
|
||||
const { handleLoginSuccess, redirectTo2FA } = useAuthRedirect()
|
||||
|
||||
const hasUserAgreement = Boolean(status?.user_agreement_enabled)
|
||||
const hasPrivacyPolicy = Boolean(status?.privacy_policy_enabled)
|
||||
const requiresLegalConsent = hasUserAgreement || hasPrivacyPolicy
|
||||
const passkeyButtonDisabled =
|
||||
isPasskeyLoading ||
|
||||
!passkeySupported ||
|
||||
(requiresLegalConsent && !agreedToLegal)
|
||||
const hasWeChatLogin = Boolean(status?.wechat_login)
|
||||
|
||||
useEffect(() => {
|
||||
if (requiresLegalConsent) {
|
||||
setAgreedToLegal(false)
|
||||
} else {
|
||||
setAgreedToLegal(true)
|
||||
}
|
||||
}, [requiresLegalConsent])
|
||||
|
||||
useEffect(() => {
|
||||
detectPasskeySupport()
|
||||
.then(setPasskeySupported)
|
||||
.catch(() => setPasskeySupported(false))
|
||||
}, [])
|
||||
|
||||
const form = useForm<z.infer<typeof loginFormSchema>>({
|
||||
resolver: zodResolver(loginFormSchema),
|
||||
defaultValues: {
|
||||
username: '',
|
||||
password: '',
|
||||
},
|
||||
})
|
||||
|
||||
const wechatQrCodeUrl = useMemo(() => {
|
||||
return (
|
||||
status?.wechat_qrcode ||
|
||||
status?.wechat_qr_code ||
|
||||
status?.wechat_qrcode_image_url ||
|
||||
status?.wechat_qr_code_image_url ||
|
||||
status?.wechat_account_qrcode_image_url ||
|
||||
status?.WeChatAccountQRCodeImageURL ||
|
||||
status?.data?.wechat_qrcode ||
|
||||
status?.data?.WeChatAccountQRCodeImageURL ||
|
||||
''
|
||||
)
|
||||
}, [status])
|
||||
|
||||
async function onSubmit(data: z.infer<typeof loginFormSchema>) {
|
||||
if (requiresLegalConsent && !agreedToLegal) {
|
||||
toast.error(legalConsentErrorMessage)
|
||||
return
|
||||
}
|
||||
|
||||
if (!validateTurnstile()) return
|
||||
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const res = await login({
|
||||
username: data.username,
|
||||
password: data.password,
|
||||
turnstile: turnstileToken,
|
||||
})
|
||||
|
||||
if (res.success) {
|
||||
if (res.data?.require_2fa) {
|
||||
redirectTo2FA()
|
||||
return
|
||||
}
|
||||
|
||||
await handleLoginSuccess(res.data as { id?: number } | null, redirectTo)
|
||||
toast.success(t('Welcome back!'))
|
||||
}
|
||||
} catch (_error) {
|
||||
// Errors are handled by global interceptor
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleOpenWeChatDialog = () => {
|
||||
if (requiresLegalConsent && !agreedToLegal) {
|
||||
toast.error(legalConsentErrorMessage)
|
||||
return
|
||||
}
|
||||
|
||||
setIsWeChatDialogOpen(true)
|
||||
}
|
||||
|
||||
const handleWeChatDialogChange = (open: boolean) => {
|
||||
setIsWeChatDialogOpen(open)
|
||||
if (!open) {
|
||||
setWeChatCode('')
|
||||
setIsWeChatSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleWeChatLogin() {
|
||||
if (!wechatCode.trim()) {
|
||||
toast.error(t('Please enter the verification code'))
|
||||
return
|
||||
}
|
||||
|
||||
setIsWeChatSubmitting(true)
|
||||
try {
|
||||
const res = await wechatLoginByCode(wechatCode)
|
||||
if (res?.success) {
|
||||
await handleLoginSuccess(res.data as { id?: number } | null, redirectTo)
|
||||
toast.success(t('Signed in via WeChat'))
|
||||
handleWeChatDialogChange(false)
|
||||
} else {
|
||||
toast.error(res?.message || loginFailedMessage)
|
||||
}
|
||||
} catch (_error) {
|
||||
toast.error(loginFailedMessage)
|
||||
} finally {
|
||||
setIsWeChatSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePasskeyLogin() {
|
||||
if (requiresLegalConsent && !agreedToLegal) {
|
||||
toast.error(legalConsentErrorMessage)
|
||||
return
|
||||
}
|
||||
|
||||
if (!passkeySupported) {
|
||||
toast.error(t('Passkey is not supported on this device'))
|
||||
return
|
||||
}
|
||||
|
||||
if (!navigator?.credentials) {
|
||||
toast.error(t('Passkey is not available in this browser'))
|
||||
return
|
||||
}
|
||||
|
||||
setIsPasskeyLoading(true)
|
||||
try {
|
||||
const begin = await beginPasskeyLogin()
|
||||
if (!begin.success) {
|
||||
throw new Error(begin.message || t('Failed to start Passkey login'))
|
||||
}
|
||||
|
||||
const publicKey = prepareCredentialRequestOptions(
|
||||
begin.data?.options ?? begin.data
|
||||
)
|
||||
|
||||
const credential = (await navigator.credentials.get({
|
||||
publicKey,
|
||||
})) as PublicKeyCredential | null
|
||||
|
||||
if (!credential) {
|
||||
toast.info(t('Passkey login was cancelled'))
|
||||
return
|
||||
}
|
||||
|
||||
const assertion = buildAssertionResult(credential)
|
||||
if (!assertion) {
|
||||
throw new Error(t('Invalid Passkey response'))
|
||||
}
|
||||
|
||||
const finish = await finishPasskeyLogin(assertion)
|
||||
if (!finish.success) {
|
||||
throw new Error(finish.message || t('Failed to complete Passkey login'))
|
||||
}
|
||||
|
||||
if (!finish.data) {
|
||||
throw new Error(t('Missing user data from Passkey login response'))
|
||||
}
|
||||
|
||||
await handleLoginSuccess(
|
||||
finish.data as { id?: number } | null,
|
||||
redirectTo
|
||||
)
|
||||
toast.success(t('Signed in with Passkey'))
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof DOMException && error.name === 'NotAllowedError') {
|
||||
toast.info(t('Passkey login was cancelled or timed out'))
|
||||
} else if (error instanceof Error) {
|
||||
toast.error(error.message)
|
||||
} else {
|
||||
toast.error(t('Passkey login failed'))
|
||||
}
|
||||
} finally {
|
||||
setIsPasskeyLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className={cn('grid gap-4', className)}
|
||||
{...props}
|
||||
>
|
||||
{/* Username Field */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='username'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Username or Email')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t('Enter your username or email')}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Password Field */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='password'
|
||||
render={({ field }) => (
|
||||
<FormItem className='relative'>
|
||||
<FormLabel>{t('Password')}</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput placeholder={t('Enter password')} {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<Link
|
||||
to='/forgot-password'
|
||||
className='text-muted-foreground absolute end-0 -top-0.5 text-sm font-medium hover:opacity-75'
|
||||
>
|
||||
{t('Forgot password?')}
|
||||
</Link>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Submit Button */}
|
||||
<Button
|
||||
className='mt-2 w-full justify-center gap-2'
|
||||
disabled={isLoading || (requiresLegalConsent && !agreedToLegal)}
|
||||
>
|
||||
{isLoading ? <Loader2 className='animate-spin' /> : <LogIn />}
|
||||
{t('Sign in')}
|
||||
</Button>
|
||||
|
||||
{/* Turnstile */}
|
||||
{isTurnstileEnabled && (
|
||||
<div className='mt-2'>
|
||||
<Turnstile
|
||||
siteKey={turnstileSiteKey}
|
||||
onVerify={setTurnstileToken}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<LegalConsent
|
||||
status={status}
|
||||
checked={agreedToLegal}
|
||||
onCheckedChange={setAgreedToLegal}
|
||||
className='mt-1'
|
||||
/>
|
||||
|
||||
{passkeyLoginEnabled && (
|
||||
<div className='mt-2 space-y-1'>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
disabled={passkeyButtonDisabled}
|
||||
onClick={handlePasskeyLogin}
|
||||
className='h-11 w-full justify-center gap-2 rounded-lg'
|
||||
>
|
||||
{isPasskeyLoading ? (
|
||||
<Loader2 className='h-4 w-4 animate-spin' />
|
||||
) : (
|
||||
<KeyRound className='h-4 w-4' />
|
||||
)}
|
||||
{t('Sign in with Passkey')}
|
||||
</Button>
|
||||
{!passkeySupported && (
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{t('Passkey is not supported on this device.')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* OAuth Providers */}
|
||||
<OAuthProviders
|
||||
status={status}
|
||||
disabled={isLoading || (requiresLegalConsent && !agreedToLegal)}
|
||||
onWeChatLogin={hasWeChatLogin ? handleOpenWeChatDialog : undefined}
|
||||
isWeChatLoading={isWeChatSubmitting}
|
||||
/>
|
||||
</form>
|
||||
|
||||
{hasWeChatLogin && (
|
||||
<Dialog
|
||||
open={isWeChatDialogOpen}
|
||||
onOpenChange={handleWeChatDialogChange}
|
||||
>
|
||||
<DialogContent className='max-w-sm'>
|
||||
<DialogHeader className='text-left'>
|
||||
<DialogTitle>{t('WeChat sign in')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t(
|
||||
'Scan the QR code to follow the official account and reply with “验证码” to receive your verification code.'
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{wechatQrCodeUrl ? (
|
||||
<div className='flex justify-center'>
|
||||
<img
|
||||
src={wechatQrCodeUrl}
|
||||
alt={t('WeChat login QR code')}
|
||||
className='h-40 w-40 rounded-md border object-contain'
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
{t('QR code is not configured. Please contact support.')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className='grid gap-2'>
|
||||
<Label htmlFor='wechat-code'>{t('Verification code')}</Label>
|
||||
<Input
|
||||
id='wechat-code'
|
||||
placeholder={t('Enter the verification code')}
|
||||
value={wechatCode}
|
||||
onChange={(event) => setWeChatCode(event.target.value)}
|
||||
autoComplete='one-time-code'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
onClick={() => handleWeChatDialogChange(false)}
|
||||
disabled={isWeChatSubmitting}
|
||||
>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
onClick={handleWeChatLogin}
|
||||
disabled={
|
||||
isWeChatSubmitting ||
|
||||
!wechatCode.trim() ||
|
||||
(requiresLegalConsent && !agreedToLegal)
|
||||
}
|
||||
className='gap-2'
|
||||
>
|
||||
{isWeChatSubmitting ? (
|
||||
<Loader2 className='h-4 w-4 animate-spin' />
|
||||
) : null}
|
||||
{t('Confirm')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</Form>
|
||||
)
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
import { Link, useSearch } from '@tanstack/react-router'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useStatus } from '@/hooks/use-status'
|
||||
import { AuthLayout } from '../auth-layout'
|
||||
import { TermsFooter } from '../components/terms-footer'
|
||||
import { UserAuthForm } from './components/user-auth-form'
|
||||
|
||||
export function SignIn() {
|
||||
const { t } = useTranslation()
|
||||
const { redirect } = useSearch({ from: '/(auth)/sign-in' })
|
||||
const { status } = useStatus()
|
||||
|
||||
return (
|
||||
<AuthLayout>
|
||||
<div className='w-full space-y-8'>
|
||||
<div className='space-y-2'>
|
||||
<h2 className='text-center text-2xl font-semibold tracking-tight sm:text-left'>
|
||||
{t('Sign in')}
|
||||
</h2>
|
||||
{!status?.self_use_mode_enabled && (
|
||||
<p className='text-muted-foreground text-left text-sm sm:text-base'>
|
||||
{t("Don't have an account?")}{' '}
|
||||
<Link
|
||||
to='/sign-up'
|
||||
className='hover:text-primary font-medium underline underline-offset-4'
|
||||
>
|
||||
{t('Sign up')}
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<UserAuthForm redirectTo={redirect} />
|
||||
|
||||
<TermsFooter
|
||||
variant='sign-in'
|
||||
status={status}
|
||||
className='text-center'
|
||||
/>
|
||||
</div>
|
||||
</AuthLayout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,412 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import type { z } from 'zod'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useStatus } from '@/hooks/use-status'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { PasswordInput } from '@/components/password-input'
|
||||
import { Turnstile } from '@/components/turnstile'
|
||||
import { register, wechatLoginByCode } from '@/features/auth/api'
|
||||
import { LegalConsent } from '@/features/auth/components/legal-consent'
|
||||
import { OAuthProviders } from '@/features/auth/components/oauth-providers'
|
||||
import { registerFormSchema } from '@/features/auth/constants'
|
||||
import { useAuthRedirect } from '@/features/auth/hooks/use-auth-redirect'
|
||||
import { useEmailVerification } from '@/features/auth/hooks/use-email-verification'
|
||||
import { useTurnstile } from '@/features/auth/hooks/use-turnstile'
|
||||
import { getAffiliateCode } from '@/features/auth/lib/storage'
|
||||
|
||||
export function SignUpForm({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLFormElement>) {
|
||||
const { t } = useTranslation()
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [verificationCode, setVerificationCode] = useState('')
|
||||
const [agreedToLegal, setAgreedToLegal] = useState(false)
|
||||
const [wechatCode, setWeChatCode] = useState('')
|
||||
const [isWeChatDialogOpen, setIsWeChatDialogOpen] = useState(false)
|
||||
const [isWeChatSubmitting, setIsWeChatSubmitting] = useState(false)
|
||||
const legalConsentErrorMessage = t('Please agree to the legal terms first')
|
||||
|
||||
const { status } = useStatus()
|
||||
const {
|
||||
isTurnstileEnabled,
|
||||
turnstileSiteKey,
|
||||
turnstileToken,
|
||||
setTurnstileToken,
|
||||
validateTurnstile,
|
||||
} = useTurnstile()
|
||||
const { redirectToLogin, handleLoginSuccess } = useAuthRedirect()
|
||||
const {
|
||||
isSending: isSendingCode,
|
||||
secondsLeft,
|
||||
isActive,
|
||||
sendCode,
|
||||
} = useEmailVerification({
|
||||
turnstileToken,
|
||||
validateTurnstile,
|
||||
})
|
||||
|
||||
const form = useForm<z.infer<typeof registerFormSchema>>({
|
||||
resolver: zodResolver(registerFormSchema),
|
||||
defaultValues: {
|
||||
username: '',
|
||||
email: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
},
|
||||
})
|
||||
|
||||
const emailValue = form.watch('email')
|
||||
const emailVerificationRequired = !!status?.email_verification
|
||||
const hasUserAgreement = Boolean(status?.user_agreement_enabled)
|
||||
const hasPrivacyPolicy = Boolean(status?.privacy_policy_enabled)
|
||||
const requiresLegalConsent = hasUserAgreement || hasPrivacyPolicy
|
||||
const oauthRegisterEnabled =
|
||||
status?.oauth_register_enabled ??
|
||||
status?.data?.oauth_register_enabled ??
|
||||
true
|
||||
const hasWeChatLogin = Boolean(status?.wechat_login)
|
||||
|
||||
const wechatQrCodeUrl = useMemo(() => {
|
||||
return (
|
||||
status?.wechat_qrcode ||
|
||||
status?.wechat_qr_code ||
|
||||
status?.wechat_qrcode_image_url ||
|
||||
status?.wechat_qr_code_image_url ||
|
||||
status?.wechat_account_qrcode_image_url ||
|
||||
status?.WeChatAccountQRCodeImageURL ||
|
||||
status?.data?.wechat_qrcode ||
|
||||
status?.data?.WeChatAccountQRCodeImageURL ||
|
||||
''
|
||||
)
|
||||
}, [status])
|
||||
|
||||
useEffect(() => {
|
||||
if (requiresLegalConsent) {
|
||||
setAgreedToLegal(false)
|
||||
} else {
|
||||
setAgreedToLegal(true)
|
||||
}
|
||||
}, [requiresLegalConsent])
|
||||
|
||||
async function onSubmit(data: z.infer<typeof registerFormSchema>) {
|
||||
if (requiresLegalConsent && !agreedToLegal) {
|
||||
toast.error(legalConsentErrorMessage)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate email verification if required
|
||||
if (emailVerificationRequired) {
|
||||
if (!data.email) {
|
||||
toast.error(t('Please enter your email'))
|
||||
return
|
||||
}
|
||||
if (!verificationCode) {
|
||||
toast.error(t('Please enter the verification code'))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const res = await register({
|
||||
username: data.username,
|
||||
password: data.password,
|
||||
email: data.email || undefined,
|
||||
verification_code: verificationCode || undefined,
|
||||
aff: getAffiliateCode(),
|
||||
turnstile: turnstileToken,
|
||||
})
|
||||
|
||||
if (res?.success) {
|
||||
toast.success(t('Account created! Please sign in'))
|
||||
redirectToLogin()
|
||||
}
|
||||
} catch (_error) {
|
||||
// Errors are handled by global interceptor
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSendVerificationCode() {
|
||||
await sendCode(emailValue || '')
|
||||
}
|
||||
|
||||
const handleOpenWeChatDialog = () => {
|
||||
if (requiresLegalConsent && !agreedToLegal) {
|
||||
toast.error(legalConsentErrorMessage)
|
||||
return
|
||||
}
|
||||
|
||||
setIsWeChatDialogOpen(true)
|
||||
}
|
||||
|
||||
const handleWeChatDialogChange = (open: boolean) => {
|
||||
setIsWeChatDialogOpen(open)
|
||||
if (!open) {
|
||||
setWeChatCode('')
|
||||
setIsWeChatSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleWeChatLogin() {
|
||||
if (!wechatCode.trim()) {
|
||||
toast.error(t('Please enter the verification code'))
|
||||
return
|
||||
}
|
||||
|
||||
setIsWeChatSubmitting(true)
|
||||
try {
|
||||
const res = await wechatLoginByCode(wechatCode)
|
||||
if (res?.success) {
|
||||
await handleLoginSuccess(res.data as { id?: number } | null)
|
||||
toast.success(t('Signed in via WeChat'))
|
||||
handleWeChatDialogChange(false)
|
||||
} else {
|
||||
toast.error(res?.message || t('Login failed'))
|
||||
}
|
||||
} catch (_error) {
|
||||
toast.error(t('Login failed'))
|
||||
} finally {
|
||||
setIsWeChatSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className={cn('grid gap-4', className)}
|
||||
{...props}
|
||||
>
|
||||
{/* Username Field */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='username'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Username')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder={t('Enter your username')} {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Password Field */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='password'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Password')}</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput
|
||||
placeholder={t('Enter password (8-20 characters)')}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Confirm Password Field */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='confirmPassword'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Confirm password')}</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput placeholder={t('Confirm password')} {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Email Verification Section */}
|
||||
{emailVerificationRequired && (
|
||||
<>
|
||||
{/* Email Field */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='email'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t('Email (required for verification)')}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t('name@example.com')}
|
||||
type='email'
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Verification Code Field */}
|
||||
<div className='flex items-end gap-2'>
|
||||
<div className='flex-1'>
|
||||
<Input
|
||||
placeholder={t('Verification code')}
|
||||
value={verificationCode}
|
||||
onChange={(e) => setVerificationCode(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant='outline'
|
||||
type='button'
|
||||
disabled={isLoading || isSendingCode || isActive || !emailValue}
|
||||
onClick={handleSendVerificationCode}
|
||||
>
|
||||
{isActive ? (
|
||||
t('Resend ({{seconds}}s)', { seconds: secondsLeft })
|
||||
) : isSendingCode ? (
|
||||
<Loader2 className='h-4 w-4 animate-spin' />
|
||||
) : (
|
||||
t('Send code')
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Turnstile */}
|
||||
{isTurnstileEnabled && (
|
||||
<div className='mt-2'>
|
||||
<Turnstile
|
||||
siteKey={turnstileSiteKey}
|
||||
onVerify={setTurnstileToken}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<LegalConsent
|
||||
status={status}
|
||||
checked={agreedToLegal}
|
||||
onCheckedChange={setAgreedToLegal}
|
||||
className='mt-1'
|
||||
/>
|
||||
|
||||
{/* Submit Button */}
|
||||
<Button
|
||||
className='mt-2 w-full justify-center gap-2'
|
||||
disabled={isLoading || (requiresLegalConsent && !agreedToLegal)}
|
||||
>
|
||||
{isLoading ? <Loader2 className='h-4 w-4 animate-spin' /> : null}
|
||||
{t('Create account')}
|
||||
</Button>
|
||||
|
||||
{oauthRegisterEnabled && (
|
||||
<OAuthProviders
|
||||
status={status}
|
||||
disabled={isLoading || (requiresLegalConsent && !agreedToLegal)}
|
||||
onWeChatLogin={hasWeChatLogin ? handleOpenWeChatDialog : undefined}
|
||||
isWeChatLoading={isWeChatSubmitting}
|
||||
className='pt-2'
|
||||
/>
|
||||
)}
|
||||
</form>
|
||||
|
||||
{hasWeChatLogin && (
|
||||
<Dialog
|
||||
open={isWeChatDialogOpen}
|
||||
onOpenChange={handleWeChatDialogChange}
|
||||
>
|
||||
<DialogContent className='max-w-sm'>
|
||||
<DialogHeader className='text-left'>
|
||||
<DialogTitle>{t('WeChat sign in')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t(
|
||||
'Scan the QR code to follow the official account and reply with “验证码” to receive your verification code.'
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{wechatQrCodeUrl ? (
|
||||
<div className='flex justify-center'>
|
||||
<img
|
||||
src={wechatQrCodeUrl}
|
||||
alt={t('WeChat login QR code')}
|
||||
className='h-40 w-40 rounded-md border object-contain'
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
{t('QR code is not configured. Please contact support.')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className='grid gap-2'>
|
||||
<Label htmlFor='wechat-code'>{t('Verification code')}</Label>
|
||||
<Input
|
||||
id='wechat-code'
|
||||
placeholder={t('Enter the verification code')}
|
||||
value={wechatCode}
|
||||
onChange={(event) => setWeChatCode(event.target.value)}
|
||||
autoComplete='one-time-code'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
onClick={() => handleWeChatDialogChange(false)}
|
||||
disabled={isWeChatSubmitting}
|
||||
>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
onClick={handleWeChatLogin}
|
||||
disabled={
|
||||
isWeChatSubmitting ||
|
||||
!wechatCode.trim() ||
|
||||
(requiresLegalConsent && !agreedToLegal)
|
||||
}
|
||||
className='gap-2'
|
||||
>
|
||||
{isWeChatSubmitting ? (
|
||||
<Loader2 className='h-4 w-4 animate-spin' />
|
||||
) : null}
|
||||
{t('Confirm')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</Form>
|
||||
)
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useStatus } from '@/hooks/use-status'
|
||||
import { AuthLayout } from '../auth-layout'
|
||||
import { TermsFooter } from '../components/terms-footer'
|
||||
import { SignUpForm } from './components/sign-up-form'
|
||||
|
||||
export function SignUp() {
|
||||
const { t } = useTranslation()
|
||||
const { status } = useStatus()
|
||||
|
||||
return (
|
||||
<AuthLayout>
|
||||
<div className='w-full space-y-8'>
|
||||
<div className='space-y-2'>
|
||||
<h2 className='text-center text-2xl font-semibold tracking-tight sm:text-left'>
|
||||
{t('Create an account')}
|
||||
</h2>
|
||||
<p className='text-muted-foreground text-left text-sm sm:text-base'>
|
||||
{t('Already have an account?')}{' '}
|
||||
<Link
|
||||
to='/sign-in'
|
||||
className='hover:text-primary font-medium underline underline-offset-4'
|
||||
>
|
||||
{t('Sign in')}
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<SignUpForm />
|
||||
|
||||
<TermsFooter
|
||||
variant='sign-up'
|
||||
status={status}
|
||||
className='text-center'
|
||||
/>
|
||||
</div>
|
||||
</AuthLayout>
|
||||
)
|
||||
}
|
||||
+186
@@ -0,0 +1,186 @@
|
||||
import type { User } from '@/features/users/types'
|
||||
|
||||
// ============================================================================
|
||||
// API Payloads
|
||||
// ============================================================================
|
||||
|
||||
export interface LoginPayload {
|
||||
username: string
|
||||
password: string
|
||||
turnstile?: string
|
||||
}
|
||||
|
||||
export interface TwoFAPayload {
|
||||
code: string
|
||||
}
|
||||
|
||||
export interface RegisterPayload {
|
||||
username: string
|
||||
password: string
|
||||
email?: string
|
||||
verification_code?: string
|
||||
aff?: string
|
||||
turnstile?: string
|
||||
}
|
||||
|
||||
export interface PasswordResetPayload {
|
||||
email: string
|
||||
turnstile?: string
|
||||
}
|
||||
|
||||
export interface EmailVerificationPayload {
|
||||
email: string
|
||||
turnstile?: string
|
||||
}
|
||||
|
||||
export interface BindEmailPayload {
|
||||
email: string
|
||||
code: string
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// API Responses
|
||||
// ============================================================================
|
||||
|
||||
export interface LoginResponse {
|
||||
success: boolean
|
||||
message: string
|
||||
data?: {
|
||||
require_2fa?: boolean
|
||||
id?: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface Login2FAResponse {
|
||||
success: boolean
|
||||
message: string
|
||||
data?: User
|
||||
}
|
||||
|
||||
export interface ApiResponse {
|
||||
success: boolean
|
||||
message: string
|
||||
data?: unknown
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// System Status
|
||||
// ============================================================================
|
||||
|
||||
export interface SystemStatus {
|
||||
success?: boolean
|
||||
message?: string
|
||||
data?: {
|
||||
version?: string
|
||||
system_name?: string
|
||||
logo?: string
|
||||
github_oauth?: boolean
|
||||
github_client_id?: string
|
||||
discord_oauth?: boolean
|
||||
discord_client_id?: string
|
||||
oidc_enabled?: boolean
|
||||
oidc_authorization_endpoint?: string
|
||||
oidc_client_id?: string
|
||||
linuxdo_oauth?: boolean
|
||||
linuxdo_client_id?: string
|
||||
telegram_oauth?: boolean
|
||||
passkey_login?: boolean
|
||||
wechat_login?: boolean
|
||||
wechat_qrcode?: string
|
||||
wechat_qr_code?: string
|
||||
wechat_qrcode_image_url?: string
|
||||
wechat_qr_code_image_url?: string
|
||||
wechat_account_qrcode_image_url?: string
|
||||
WeChatAccountQRCodeImageURL?: string
|
||||
turnstile_check?: boolean
|
||||
turnstile_site_key?: string
|
||||
email_verification?: boolean
|
||||
self_use_mode_enabled?: boolean
|
||||
display_in_currency?: boolean
|
||||
display_token_stat_enabled?: boolean
|
||||
quota_per_unit?: number
|
||||
quota_display_type?: string
|
||||
usd_exchange_rate?: number
|
||||
custom_currency_symbol?: string
|
||||
custom_currency_exchange_rate?: number
|
||||
demo_site_enabled?: boolean
|
||||
user_agreement_enabled?: boolean
|
||||
privacy_policy_enabled?: boolean
|
||||
oauth_register_enabled?: boolean
|
||||
register_enabled?: boolean
|
||||
password_register_enabled?: boolean
|
||||
custom_oauth_providers?: CustomOAuthProviderInfo[]
|
||||
[key: string]: unknown
|
||||
}
|
||||
// Allow direct access to common properties
|
||||
version?: string
|
||||
system_name?: string
|
||||
logo?: string
|
||||
github_oauth?: boolean
|
||||
github_client_id?: string
|
||||
discord_oauth?: boolean
|
||||
discord_client_id?: string
|
||||
oidc_enabled?: boolean
|
||||
oidc_authorization_endpoint?: string
|
||||
oidc_client_id?: string
|
||||
linuxdo_oauth?: boolean
|
||||
linuxdo_client_id?: string
|
||||
telegram_oauth?: boolean
|
||||
passkey_login?: boolean
|
||||
wechat_login?: boolean
|
||||
wechat_qrcode?: string
|
||||
wechat_qr_code?: string
|
||||
wechat_qrcode_image_url?: string
|
||||
wechat_qr_code_image_url?: string
|
||||
wechat_account_qrcode_image_url?: string
|
||||
WeChatAccountQRCodeImageURL?: string
|
||||
turnstile_check?: boolean
|
||||
turnstile_site_key?: string
|
||||
email_verification?: boolean
|
||||
self_use_mode_enabled?: boolean
|
||||
display_in_currency?: boolean
|
||||
display_token_stat_enabled?: boolean
|
||||
quota_per_unit?: number
|
||||
quota_display_type?: string
|
||||
usd_exchange_rate?: number
|
||||
custom_currency_symbol?: string
|
||||
custom_currency_exchange_rate?: number
|
||||
demo_site_enabled?: boolean
|
||||
user_agreement_enabled?: boolean
|
||||
privacy_policy_enabled?: boolean
|
||||
oauth_register_enabled?: boolean
|
||||
register_enabled?: boolean
|
||||
password_register_enabled?: boolean
|
||||
custom_oauth_providers?: CustomOAuthProviderInfo[]
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// OAuth
|
||||
// ============================================================================
|
||||
|
||||
export interface OAuthProvider {
|
||||
name: string
|
||||
type: 'github' | 'discord' | 'oidc' | 'linuxdo' | 'telegram' | 'wechat'
|
||||
enabled: boolean
|
||||
clientId?: string
|
||||
authEndpoint?: string
|
||||
}
|
||||
|
||||
export interface CustomOAuthProviderInfo {
|
||||
id: number
|
||||
name: string
|
||||
slug: string
|
||||
icon: string
|
||||
client_id: string
|
||||
authorization_endpoint: string
|
||||
scopes: string
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Form Props
|
||||
// ============================================================================
|
||||
|
||||
export interface AuthFormProps extends React.HTMLAttributes<HTMLFormElement> {
|
||||
redirectTo?: string
|
||||
}
|
||||
+548
@@ -0,0 +1,548 @@
|
||||
import type { AxiosRequestConfig } from 'axios'
|
||||
import { api } from '@/lib/api'
|
||||
import { getGroups as getUserGroups } from '@/features/users/api'
|
||||
import type {
|
||||
AddChannelRequest,
|
||||
BatchDeleteParams,
|
||||
BatchSetTagParams,
|
||||
Channel,
|
||||
ChannelBalanceResponse,
|
||||
ChannelTestResponse,
|
||||
CopyChannelParams,
|
||||
CopyChannelResponse,
|
||||
FetchModelsResponse,
|
||||
GetChannelResponse,
|
||||
GetChannelsParams,
|
||||
GetChannelsResponse,
|
||||
MultiKeyManageParams,
|
||||
MultiKeyStatusResponse,
|
||||
SearchChannelsParams,
|
||||
SearchChannelsResponse,
|
||||
TagOperationParams,
|
||||
} from './types'
|
||||
|
||||
// Extended API config types
|
||||
interface ExtendedApiConfig extends AxiosRequestConfig {
|
||||
skipBusinessError?: boolean
|
||||
disableDuplicate?: boolean
|
||||
}
|
||||
|
||||
export type CodexOAuthStartResponse = {
|
||||
success: boolean
|
||||
message?: string
|
||||
data?: {
|
||||
authorize_url?: string
|
||||
}
|
||||
}
|
||||
|
||||
export type CodexOAuthCompleteResponse = {
|
||||
success: boolean
|
||||
message?: string
|
||||
data?: {
|
||||
key?: string
|
||||
account_id?: string
|
||||
email?: string
|
||||
expires_at?: string
|
||||
last_refresh?: string
|
||||
}
|
||||
}
|
||||
|
||||
export type CodexUsageResponse = {
|
||||
success: boolean
|
||||
message?: string
|
||||
upstream_status?: number
|
||||
data?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export type CodexCredentialRefreshResponse = {
|
||||
success: boolean
|
||||
message?: string
|
||||
data?: {
|
||||
expires_at?: string
|
||||
last_refresh?: string
|
||||
account_id?: string
|
||||
email?: string
|
||||
channel_id?: number
|
||||
channel_type?: number
|
||||
channel_name?: string
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Base Channel CRUD Operations
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get paginated list of channels
|
||||
*/
|
||||
export async function getChannels(
|
||||
params: GetChannelsParams = {}
|
||||
): Promise<GetChannelsResponse> {
|
||||
const res = await api.get('/api/channel', { params })
|
||||
return res.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Search channels with filters
|
||||
*/
|
||||
export async function searchChannels(
|
||||
params: SearchChannelsParams
|
||||
): Promise<SearchChannelsResponse> {
|
||||
const res = await api.get('/api/channel/search', { params })
|
||||
return res.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Get single channel by ID
|
||||
*/
|
||||
export async function getChannel(id: number): Promise<GetChannelResponse> {
|
||||
const res = await api.get(`/api/channel/${id}`)
|
||||
return res.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Create new channel(s)
|
||||
* Supports single, batch, and multi-key modes
|
||||
*/
|
||||
export async function createChannel(
|
||||
data: AddChannelRequest
|
||||
): Promise<{ success: boolean; message?: string }> {
|
||||
const res = await api.post('/api/channel', data)
|
||||
return res.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Update existing channel
|
||||
*/
|
||||
export async function updateChannel(
|
||||
id: number,
|
||||
data: Partial<Channel>
|
||||
): Promise<{ success: boolean; message?: string; data?: Channel }> {
|
||||
const res = await api.put('/api/channel/', { id, ...data })
|
||||
return res.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete single channel
|
||||
*/
|
||||
export async function deleteChannel(
|
||||
id: number
|
||||
): Promise<{ success: boolean; message?: string }> {
|
||||
const res = await api.delete(`/api/channel/${id}`)
|
||||
return res.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch delete channels
|
||||
*/
|
||||
export async function batchDeleteChannels(
|
||||
data: BatchDeleteParams
|
||||
): Promise<{ success: boolean; message?: string; data?: number }> {
|
||||
const res = await api.post('/api/channel/batch', data)
|
||||
return res.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch set tag for channels
|
||||
*/
|
||||
export async function batchSetChannelTag(
|
||||
data: BatchSetTagParams
|
||||
): Promise<{ success: boolean; message?: string; data?: number }> {
|
||||
const res = await api.post('/api/channel/batch/tag', data)
|
||||
return res.data
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Channel Operations
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Test channel connectivity
|
||||
*/
|
||||
export async function testChannel(
|
||||
id: number,
|
||||
params?: { model?: string; endpoint_type?: string; stream?: boolean }
|
||||
): Promise<ChannelTestResponse> {
|
||||
const res = await api.get(`/api/channel/test/${id}`, { params })
|
||||
return res.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Update channel balance
|
||||
*/
|
||||
export async function updateChannelBalance(
|
||||
id: number
|
||||
): Promise<ChannelBalanceResponse> {
|
||||
const res = await api.get(`/api/channel/update_balance/${id}`)
|
||||
return res.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch available models from upstream provider
|
||||
*/
|
||||
export async function fetchUpstreamModels(
|
||||
id: number
|
||||
): Promise<FetchModelsResponse> {
|
||||
const res = await api.get(`/api/channel/fetch_models/${id}`)
|
||||
return res.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy/clone a channel
|
||||
*/
|
||||
export async function copyChannel(
|
||||
id: number,
|
||||
params: CopyChannelParams = {}
|
||||
): Promise<CopyChannelResponse> {
|
||||
const res = await api.post(`/api/channel/copy/${id}`, null, { params })
|
||||
return res.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Fix channel abilities
|
||||
*/
|
||||
export async function fixChannelAbilities(): Promise<{
|
||||
success: boolean
|
||||
message?: string
|
||||
data?: { success: number; fails: number }
|
||||
}> {
|
||||
const res = await api.post('/api/channel/fix')
|
||||
return res.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all disabled channels
|
||||
*/
|
||||
export async function deleteDisabledChannels(): Promise<{
|
||||
success: boolean
|
||||
message?: string
|
||||
data?: number
|
||||
}> {
|
||||
const res = await api.delete('/api/channel/disabled')
|
||||
return res.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Get channel key (requires 2FA verification)
|
||||
*/
|
||||
export async function getChannelKey(
|
||||
id: number,
|
||||
code?: string
|
||||
): Promise<{ success: boolean; message?: string; data?: { key: string } }> {
|
||||
const payload = code ? { code } : undefined
|
||||
const res = await api.post(`/api/channel/${id}/key`, payload)
|
||||
return res.data
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Codex Channel Operations
|
||||
// ============================================================================
|
||||
|
||||
export async function startCodexOAuth(): Promise<CodexOAuthStartResponse> {
|
||||
const config: ExtendedApiConfig = { skipBusinessError: true }
|
||||
const res = await api.post('/api/channel/codex/oauth/start', {}, config)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function completeCodexOAuth(
|
||||
input: string
|
||||
): Promise<CodexOAuthCompleteResponse> {
|
||||
const config: ExtendedApiConfig = { skipBusinessError: true }
|
||||
const res = await api.post(
|
||||
'/api/channel/codex/oauth/complete',
|
||||
{ input },
|
||||
config
|
||||
)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function refreshCodexCredential(
|
||||
channelId: number
|
||||
): Promise<CodexCredentialRefreshResponse> {
|
||||
const config: ExtendedApiConfig = { skipBusinessError: true }
|
||||
const res = await api.post(
|
||||
`/api/channel/${channelId}/codex/refresh`,
|
||||
{},
|
||||
config
|
||||
)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function getCodexUsage(
|
||||
channelId: number
|
||||
): Promise<CodexUsageResponse> {
|
||||
const config: ExtendedApiConfig = {
|
||||
skipBusinessError: true,
|
||||
disableDuplicate: true,
|
||||
}
|
||||
const res = await api.get(`/api/channel/${channelId}/codex/usage`, config)
|
||||
return res.data
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Multi-Key Management
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Manage multi-key channel operations
|
||||
*/
|
||||
export async function manageMultiKeys(
|
||||
params: MultiKeyManageParams
|
||||
): Promise<MultiKeyStatusResponse | { success: boolean; message?: string }> {
|
||||
const res = await api.post('/api/channel/multi_key/manage', params)
|
||||
return res.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Get key status for multi-key channel
|
||||
*/
|
||||
export async function getMultiKeyStatus(
|
||||
channelId: number,
|
||||
page = 1,
|
||||
pageSize = 50,
|
||||
status?: number
|
||||
): Promise<MultiKeyStatusResponse> {
|
||||
return manageMultiKeys({
|
||||
channel_id: channelId,
|
||||
action: 'get_key_status',
|
||||
page,
|
||||
page_size: pageSize,
|
||||
status,
|
||||
}) as Promise<MultiKeyStatusResponse>
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable a specific key in multi-key channel
|
||||
*/
|
||||
export async function enableMultiKey(
|
||||
channelId: number,
|
||||
keyIndex: number
|
||||
): Promise<{ success: boolean; message?: string }> {
|
||||
return manageMultiKeys({
|
||||
channel_id: channelId,
|
||||
action: 'enable_key',
|
||||
key_index: keyIndex,
|
||||
}) as Promise<{ success: boolean; message?: string }>
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable a specific key in multi-key channel
|
||||
*/
|
||||
export async function disableMultiKey(
|
||||
channelId: number,
|
||||
keyIndex: number
|
||||
): Promise<{ success: boolean; message?: string }> {
|
||||
return manageMultiKeys({
|
||||
channel_id: channelId,
|
||||
action: 'disable_key',
|
||||
key_index: keyIndex,
|
||||
}) as Promise<{ success: boolean; message?: string }>
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a specific key in multi-key channel
|
||||
*/
|
||||
export async function deleteMultiKey(
|
||||
channelId: number,
|
||||
keyIndex: number
|
||||
): Promise<{ success: boolean; message?: string }> {
|
||||
return manageMultiKeys({
|
||||
channel_id: channelId,
|
||||
action: 'delete_key',
|
||||
key_index: keyIndex,
|
||||
}) as Promise<{ success: boolean; message?: string }>
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable all keys in multi-key channel
|
||||
*/
|
||||
export async function enableAllMultiKeys(
|
||||
channelId: number
|
||||
): Promise<{ success: boolean; message?: string }> {
|
||||
return manageMultiKeys({
|
||||
channel_id: channelId,
|
||||
action: 'enable_all_keys',
|
||||
}) as Promise<{ success: boolean; message?: string }>
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable all keys in multi-key channel
|
||||
*/
|
||||
export async function disableAllMultiKeys(
|
||||
channelId: number
|
||||
): Promise<{ success: boolean; message?: string }> {
|
||||
return manageMultiKeys({
|
||||
channel_id: channelId,
|
||||
action: 'disable_all_keys',
|
||||
}) as Promise<{ success: boolean; message?: string }>
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all disabled keys in multi-key channel
|
||||
*/
|
||||
export async function deleteDisabledMultiKeys(
|
||||
channelId: number
|
||||
): Promise<{ success: boolean; message?: string; data?: number }> {
|
||||
return manageMultiKeys({
|
||||
channel_id: channelId,
|
||||
action: 'delete_disabled_keys',
|
||||
}) as Promise<{ success: boolean; message?: string; data?: number }>
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tag Operations
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Enable all channels with a specific tag
|
||||
*/
|
||||
export async function enableTagChannels(
|
||||
tag: string
|
||||
): Promise<{ success: boolean; message?: string }> {
|
||||
const res = await api.post('/api/channel/tag/enabled', { tag })
|
||||
return res.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable all channels with a specific tag
|
||||
*/
|
||||
export async function disableTagChannels(
|
||||
tag: string
|
||||
): Promise<{ success: boolean; message?: string }> {
|
||||
const res = await api.post('/api/channel/tag/disabled', { tag })
|
||||
return res.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Edit all channels with a specific tag
|
||||
*/
|
||||
export async function editTagChannels(
|
||||
params: TagOperationParams
|
||||
): Promise<{ success: boolean; message?: string }> {
|
||||
const res = await api.put('/api/channel/tag', params)
|
||||
return res.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Get models for a specific tag
|
||||
*/
|
||||
export async function getTagModels(
|
||||
tag: string
|
||||
): Promise<{ success: boolean; message?: string; data?: string }> {
|
||||
const res = await api.get('/api/channel/tag/models', { params: { tag } })
|
||||
return res.data
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Utility Functions
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Fetch models from a custom endpoint (for testing before creating channel)
|
||||
*/
|
||||
export async function fetchModels(data: {
|
||||
base_url: string
|
||||
type: number
|
||||
key: string
|
||||
}): Promise<FetchModelsResponse> {
|
||||
const res = await api.post('/api/channel/fetch_models', data)
|
||||
return res.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an Ollama model from a channel
|
||||
*/
|
||||
export async function deleteOllamaModel(params: {
|
||||
channel_id: number
|
||||
model_name: string
|
||||
}): Promise<{ success: boolean; message?: string }> {
|
||||
const res = await api.delete('/api/channel/ollama/delete', { data: params })
|
||||
return res.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Test all enabled channels
|
||||
*/
|
||||
export async function testAllChannels(): Promise<{
|
||||
success: boolean
|
||||
message?: string
|
||||
}> {
|
||||
const res = await api.get('/api/channel/test')
|
||||
return res.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Update balance for all enabled channels
|
||||
*/
|
||||
export async function updateAllChannelsBalance(): Promise<{
|
||||
success: boolean
|
||||
message?: string
|
||||
}> {
|
||||
const res = await api.get('/api/channel/update_balance')
|
||||
return res.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all available models
|
||||
*/
|
||||
export async function getAllModels(): Promise<{
|
||||
success: boolean
|
||||
message?: string
|
||||
data?: Array<{ id: string; [key: string]: unknown }>
|
||||
}> {
|
||||
const res = await api.get('/api/channel/models')
|
||||
return res.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all enabled models
|
||||
*/
|
||||
export async function getEnabledModels(): Promise<{
|
||||
success: boolean
|
||||
message?: string
|
||||
data?: string[]
|
||||
}> {
|
||||
const res = await api.get('/api/channel/models_enabled')
|
||||
return res.data
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Ollama Utilities
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Check Ollama version for a given channel
|
||||
*/
|
||||
export async function getOllamaVersion(
|
||||
channelId: number
|
||||
): Promise<{ success: boolean; message?: string; data?: { version: string } }> {
|
||||
const res = await api.get(`/api/channel/ollama/version/${channelId}`)
|
||||
return res.data
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Group Management
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get all available groups (re-exported from users API for convenience)
|
||||
*/
|
||||
export const getGroups = getUserGroups
|
||||
|
||||
// ============================================================================
|
||||
// Prefill Groups (Model Groups)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get prefill groups for quick model selection
|
||||
*/
|
||||
export async function getPrefillGroups(
|
||||
type: 'model' | 'group' = 'model'
|
||||
): Promise<{
|
||||
success: boolean
|
||||
message?: string
|
||||
data?: Array<{ id: number; name: string; items: string | string[] }>
|
||||
}> {
|
||||
const res = await api.get('/api/prefill_group', { params: { type } })
|
||||
return res.data
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,85 @@
|
||||
import { useChannels } from './channels-provider'
|
||||
import { BalanceQueryDialog } from './dialogs/balance-query-dialog'
|
||||
import { ChannelTestDialog } from './dialogs/channel-test-dialog'
|
||||
import { CopyChannelDialog } from './dialogs/copy-channel-dialog'
|
||||
import { EditTagDialog } from './dialogs/edit-tag-dialog'
|
||||
import { FetchModelsDialog } from './dialogs/fetch-models-dialog'
|
||||
import { MultiKeyManageDialog } from './dialogs/multi-key-manage-dialog'
|
||||
import { OllamaModelsDialog } from './dialogs/ollama-models-dialog'
|
||||
import { TagBatchEditDialog } from './dialogs/tag-batch-edit-dialog'
|
||||
import { UpstreamUpdateDialog } from './dialogs/upstream-update-dialog'
|
||||
import { ChannelMutateDrawer } from './drawers/channel-mutate-drawer'
|
||||
|
||||
export function ChannelsDialogs() {
|
||||
const { open, setOpen, currentRow, upstream } = useChannels()
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Channel Create/Update Drawer */}
|
||||
<ChannelMutateDrawer
|
||||
open={open === 'create-channel' || open === 'update-channel'}
|
||||
onOpenChange={(v) => !v && setOpen(null)}
|
||||
currentRow={open === 'update-channel' ? currentRow : null}
|
||||
/>
|
||||
|
||||
{/* Test Channel Dialog */}
|
||||
<ChannelTestDialog
|
||||
open={open === 'test-channel'}
|
||||
onOpenChange={(v) => !v && setOpen(null)}
|
||||
/>
|
||||
|
||||
{/* Balance Query Dialog */}
|
||||
<BalanceQueryDialog
|
||||
open={open === 'balance-query'}
|
||||
onOpenChange={(v) => !v && setOpen(null)}
|
||||
/>
|
||||
|
||||
{/* Fetch Models Dialog */}
|
||||
<FetchModelsDialog
|
||||
open={open === 'fetch-models'}
|
||||
onOpenChange={(v) => !v && setOpen(null)}
|
||||
/>
|
||||
|
||||
{/* Ollama Models Dialog */}
|
||||
<OllamaModelsDialog
|
||||
open={open === 'ollama-models'}
|
||||
onOpenChange={(v) => !v && setOpen(null)}
|
||||
/>
|
||||
|
||||
{/* Copy Channel Dialog */}
|
||||
<CopyChannelDialog
|
||||
open={open === 'copy-channel'}
|
||||
onOpenChange={(v) => !v && setOpen(null)}
|
||||
/>
|
||||
|
||||
{/* Multi-Key Management Dialog */}
|
||||
<MultiKeyManageDialog
|
||||
open={open === 'multi-key-manage'}
|
||||
onOpenChange={(v) => !v && setOpen(null)}
|
||||
/>
|
||||
|
||||
{/* Tag Batch Edit Dialog */}
|
||||
<TagBatchEditDialog
|
||||
open={open === 'tag-batch-edit'}
|
||||
onOpenChange={(v) => !v && setOpen(null)}
|
||||
/>
|
||||
|
||||
{/* Edit Tag Dialog */}
|
||||
<EditTagDialog
|
||||
open={open === 'edit-tag'}
|
||||
onOpenChange={(v) => !v && setOpen(null)}
|
||||
/>
|
||||
|
||||
{/* Upstream Model Update Dialog */}
|
||||
<UpstreamUpdateDialog
|
||||
open={upstream.showModal}
|
||||
addModels={upstream.addModels}
|
||||
removeModels={upstream.removeModels}
|
||||
preferredTab={upstream.preferredTab}
|
||||
confirmLoading={upstream.applyLoading}
|
||||
onConfirm={upstream.applyUpdates}
|
||||
onCancel={upstream.closeModal}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import { useState } from 'react'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
Plus,
|
||||
MoreHorizontal,
|
||||
Settings2,
|
||||
Trash2,
|
||||
Tags,
|
||||
TestTube,
|
||||
DollarSign,
|
||||
SortAsc,
|
||||
RefreshCw,
|
||||
ArrowUpFromLine,
|
||||
} from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import {
|
||||
handleDeleteAllDisabled,
|
||||
handleFixAbilities,
|
||||
handleTestAllChannels,
|
||||
handleUpdateAllBalances,
|
||||
} from '../lib'
|
||||
import { useChannels } from './channels-provider'
|
||||
|
||||
export function ChannelsPrimaryButtons() {
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
setOpen,
|
||||
enableTagMode,
|
||||
setEnableTagMode,
|
||||
idSort,
|
||||
setIdSort,
|
||||
upstream,
|
||||
} = useChannels()
|
||||
const queryClient = useQueryClient()
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
||||
|
||||
const handleTagModeToggle = (checked: boolean) => {
|
||||
localStorage.setItem('enable-tag-mode', String(checked))
|
||||
setEnableTagMode(checked)
|
||||
}
|
||||
|
||||
const handleIdSortToggle = (checked: boolean) => {
|
||||
localStorage.setItem('channels-id-sort', String(checked))
|
||||
setIdSort(checked)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='flex items-center gap-2'>
|
||||
{/* Desktop: Toggle switches visible */}
|
||||
<div className='hidden items-center gap-2 rounded-md border px-3 py-1.5 sm:flex'>
|
||||
<Tags className='text-muted-foreground h-4 w-4' />
|
||||
<Label htmlFor='tag-mode' className='cursor-pointer text-sm'>
|
||||
{t('Tag Mode')}
|
||||
</Label>
|
||||
<Switch
|
||||
id='tag-mode'
|
||||
checked={enableTagMode}
|
||||
onCheckedChange={handleTagModeToggle}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='hidden items-center gap-2 rounded-md border px-3 py-1.5 sm:flex'>
|
||||
<SortAsc className='text-muted-foreground h-4 w-4' />
|
||||
<Label htmlFor='id-sort' className='cursor-pointer text-sm'>
|
||||
{t('Sort by ID')}
|
||||
</Label>
|
||||
<Switch
|
||||
id='id-sort'
|
||||
checked={idSort}
|
||||
onCheckedChange={handleIdSortToggle}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Create Channel */}
|
||||
<Button onClick={() => setOpen('create-channel')} size='sm'>
|
||||
<Plus className='h-4 w-4' />
|
||||
<span className='max-sm:hidden'>{t('Create Channel')}</span>
|
||||
<span className='sm:hidden'>{t('Create')}</span>
|
||||
</Button>
|
||||
|
||||
{/* More Actions */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant='outline' size='sm'>
|
||||
<MoreHorizontal className='h-4 w-4' />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end' className='w-56'>
|
||||
{/* Mobile-only: toggle switches */}
|
||||
<DropdownMenuCheckboxItem
|
||||
className='sm:hidden'
|
||||
checked={enableTagMode}
|
||||
onCheckedChange={handleTagModeToggle}
|
||||
>
|
||||
<Tags className='mr-2 h-4 w-4' />
|
||||
{t('Tag Mode')}
|
||||
</DropdownMenuCheckboxItem>
|
||||
|
||||
<DropdownMenuCheckboxItem
|
||||
className='sm:hidden'
|
||||
checked={idSort}
|
||||
onCheckedChange={handleIdSortToggle}
|
||||
>
|
||||
<SortAsc className='mr-2 h-4 w-4' />
|
||||
{t('Sort by ID')}
|
||||
</DropdownMenuCheckboxItem>
|
||||
|
||||
<DropdownMenuSeparator className='sm:hidden' />
|
||||
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
handleTestAllChannels(queryClient)
|
||||
}}
|
||||
>
|
||||
{t('Test All Channels')}
|
||||
<DropdownMenuShortcut>
|
||||
<TestTube className='h-4 w-4' />
|
||||
</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
handleUpdateAllBalances(queryClient)
|
||||
}}
|
||||
>
|
||||
{t('Update All Balances')}
|
||||
<DropdownMenuShortcut>
|
||||
<DollarSign className='h-4 w-4' />
|
||||
</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuItem
|
||||
onClick={() => upstream.detectAllUpdates()}
|
||||
disabled={upstream.detectAllLoading}
|
||||
>
|
||||
{t('Detect All Upstream Updates')}
|
||||
<DropdownMenuShortcut>
|
||||
<RefreshCw className='h-4 w-4' />
|
||||
</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
onClick={() => upstream.applyAllUpdates()}
|
||||
disabled={upstream.applyAllLoading}
|
||||
>
|
||||
{t('Apply All Upstream Updates')}
|
||||
<DropdownMenuShortcut>
|
||||
<ArrowUpFromLine className='h-4 w-4' />
|
||||
</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
handleFixAbilities(queryClient, (_result) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('Fix abilities result:', _result)
|
||||
})
|
||||
}}
|
||||
>
|
||||
{t('Fix Abilities')}
|
||||
<DropdownMenuShortcut>
|
||||
<Settings2 className='h-4 w-4' />
|
||||
</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuItem
|
||||
onSelect={(e) => {
|
||||
e.preventDefault()
|
||||
setShowDeleteDialog(true)
|
||||
}}
|
||||
className='text-destructive focus:text-destructive'
|
||||
>
|
||||
{t('Delete All Disabled')}
|
||||
<DropdownMenuShortcut>
|
||||
<Trash2 className='h-4 w-4' />
|
||||
</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={showDeleteDialog}
|
||||
onOpenChange={setShowDeleteDialog}
|
||||
title={t('Delete All Disabled Channels?')}
|
||||
desc='This will permanently delete all manually and automatically disabled channels. This action cannot be undone.'
|
||||
destructive
|
||||
handleConfirm={() => {
|
||||
handleDeleteAllDisabled(queryClient, (_count) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`Deleted ${_count} channels`)
|
||||
})
|
||||
setShowDeleteDialog(false)
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
import React, { createContext, useContext, useState, useCallback } from 'react'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useChannelUpstreamUpdates } from '../hooks/use-channel-upstream-updates'
|
||||
import { channelsQueryKeys } from '../lib'
|
||||
import type { Channel } from '../types'
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
type DialogType =
|
||||
| 'create-channel'
|
||||
| 'update-channel'
|
||||
| 'test-channel'
|
||||
| 'balance-query'
|
||||
| 'fetch-models'
|
||||
| 'ollama-models'
|
||||
| 'multi-key-manage'
|
||||
| 'tag-batch-edit'
|
||||
| 'edit-tag'
|
||||
| 'copy-channel'
|
||||
| null
|
||||
|
||||
type UpstreamUpdateState = ReturnType<typeof useChannelUpstreamUpdates>
|
||||
|
||||
type ChannelsContextType = {
|
||||
open: DialogType
|
||||
setOpen: (open: DialogType) => void
|
||||
currentRow: Channel | null
|
||||
setCurrentRow: (row: Channel | null) => void
|
||||
currentTag: string | null
|
||||
setCurrentTag: (tag: string | null) => void
|
||||
enableTagMode: boolean
|
||||
setEnableTagMode: (enabled: boolean) => void
|
||||
idSort: boolean
|
||||
setIdSort: (enabled: boolean) => void
|
||||
upstream: UpstreamUpdateState
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Context
|
||||
// ============================================================================
|
||||
|
||||
const ChannelsContext = createContext<ChannelsContextType | undefined>(
|
||||
undefined
|
||||
)
|
||||
|
||||
// ============================================================================
|
||||
// Provider
|
||||
// ============================================================================
|
||||
|
||||
export function ChannelsProvider({ children }: { children: React.ReactNode }) {
|
||||
const [open, setOpen] = useState<DialogType>(null)
|
||||
const [currentRow, setCurrentRow] = useState<Channel | null>(null)
|
||||
const [currentTag, setCurrentTag] = useState<string | null>(null)
|
||||
const [enableTagMode, setEnableTagMode] = useState(() => {
|
||||
return localStorage.getItem('enable-tag-mode') === 'true'
|
||||
})
|
||||
const [idSort, setIdSort] = useState(() => {
|
||||
return localStorage.getItem('channels-id-sort') === 'true'
|
||||
})
|
||||
|
||||
const queryClient = useQueryClient()
|
||||
const refreshChannels = useCallback(async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: channelsQueryKeys.all })
|
||||
}, [queryClient])
|
||||
const upstream = useChannelUpstreamUpdates(refreshChannels)
|
||||
|
||||
return (
|
||||
<ChannelsContext.Provider
|
||||
value={{
|
||||
open,
|
||||
setOpen,
|
||||
currentRow,
|
||||
setCurrentRow,
|
||||
currentTag,
|
||||
setCurrentTag,
|
||||
enableTagMode,
|
||||
setEnableTagMode,
|
||||
idSort,
|
||||
setIdSort,
|
||||
upstream,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ChannelsContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Hook
|
||||
// ============================================================================
|
||||
|
||||
export function useChannels() {
|
||||
const context = useContext(ChannelsContext)
|
||||
if (!context) {
|
||||
throw new Error('useChannels must be used within ChannelsProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
@@ -0,0 +1,391 @@
|
||||
import { useState, useMemo, useEffect } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { getRouteApi } from '@tanstack/react-router'
|
||||
import {
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
useReactTable,
|
||||
getExpandedRowModel,
|
||||
type SortingState,
|
||||
type VisibilityState,
|
||||
type ExpandedState,
|
||||
type Row,
|
||||
} from '@tanstack/react-table'
|
||||
import { useDebounce, useMediaQuery } from '@/hooks'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useTableUrlState } from '@/hooks/use-table-url-state'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import {
|
||||
DataTableToolbar,
|
||||
TableSkeleton,
|
||||
TableEmpty,
|
||||
MobileCardList,
|
||||
} from '@/components/data-table'
|
||||
import { DataTablePagination } from '@/components/data-table/pagination'
|
||||
import { PageFooterPortal } from '@/components/layout'
|
||||
import { getChannels, searchChannels, getGroups } from '../api'
|
||||
import {
|
||||
DEFAULT_PAGE_SIZE,
|
||||
CHANNEL_STATUS_OPTIONS,
|
||||
CHANNEL_TYPE_OPTIONS,
|
||||
} from '../constants'
|
||||
import {
|
||||
channelsQueryKeys,
|
||||
aggregateChannelsByTag,
|
||||
isTagAggregateRow,
|
||||
} from '../lib'
|
||||
import type { Channel } from '../types'
|
||||
import { useChannelsColumns } from './channels-columns'
|
||||
import { useChannels } from './channels-provider'
|
||||
import { DataTableBulkActions } from './data-table-bulk-actions'
|
||||
|
||||
const route = getRouteApi('/_authenticated/channels/')
|
||||
|
||||
export function ChannelsTable() {
|
||||
const { t } = useTranslation()
|
||||
const { enableTagMode, idSort } = useChannels()
|
||||
const isMobile = useMediaQuery('(max-width: 640px)')
|
||||
|
||||
// Table state
|
||||
const [sorting, setSorting] = useState<SortingState>([])
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({
|
||||
models: false,
|
||||
tag: false,
|
||||
})
|
||||
const [rowSelection, setRowSelection] = useState({})
|
||||
const [expanded, setExpanded] = useState<ExpandedState>({})
|
||||
|
||||
// URL state management
|
||||
const {
|
||||
globalFilter,
|
||||
onGlobalFilterChange,
|
||||
columnFilters,
|
||||
onColumnFiltersChange,
|
||||
pagination,
|
||||
onPaginationChange,
|
||||
ensurePageInRange,
|
||||
} = useTableUrlState({
|
||||
search: route.useSearch(),
|
||||
navigate: route.useNavigate(),
|
||||
pagination: { defaultPage: 1, defaultPageSize: DEFAULT_PAGE_SIZE },
|
||||
globalFilter: { enabled: true, key: 'filter' },
|
||||
columnFilters: [
|
||||
{ columnId: 'status', searchKey: 'status', type: 'array' },
|
||||
{ columnId: 'type', searchKey: 'type', type: 'array' },
|
||||
{ columnId: 'group', searchKey: 'group', type: 'array' },
|
||||
{ columnId: 'model', searchKey: 'model', type: 'string' },
|
||||
],
|
||||
})
|
||||
|
||||
// Extract filters from column filters
|
||||
const statusFilter =
|
||||
(columnFilters.find((f) => f.id === 'status')?.value as string[]) || []
|
||||
const typeFilter =
|
||||
(columnFilters.find((f) => f.id === 'type')?.value as string[]) || []
|
||||
const groupFilter =
|
||||
(columnFilters.find((f) => f.id === 'group')?.value as string[]) || []
|
||||
const modelFilterFromUrl =
|
||||
(columnFilters.find((f) => f.id === 'model')?.value as string) || ''
|
||||
|
||||
// Local state for immediate input feedback
|
||||
const [modelFilterInput, setModelFilterInput] = useState(modelFilterFromUrl)
|
||||
const debouncedModelFilter = useDebounce(modelFilterInput, 500)
|
||||
|
||||
// Sync local input with URL when URL changes (e.g., from back/forward navigation)
|
||||
useEffect(() => {
|
||||
setModelFilterInput(modelFilterFromUrl)
|
||||
}, [modelFilterFromUrl])
|
||||
|
||||
// Update URL when debounced value changes
|
||||
useEffect(() => {
|
||||
if (debouncedModelFilter !== modelFilterFromUrl) {
|
||||
onColumnFiltersChange((prev) => {
|
||||
const filtered = prev.filter((f) => f.id !== 'model')
|
||||
return debouncedModelFilter
|
||||
? [...filtered, { id: 'model', value: debouncedModelFilter }]
|
||||
: filtered
|
||||
})
|
||||
}
|
||||
}, [debouncedModelFilter, modelFilterFromUrl, onColumnFiltersChange])
|
||||
|
||||
const modelFilter = modelFilterFromUrl
|
||||
|
||||
// Determine whether to use search or regular list API
|
||||
const shouldSearch = Boolean(globalFilter?.trim() || modelFilter.trim())
|
||||
|
||||
// Fetch groups for filter
|
||||
const { data: groupsData } = useQuery({
|
||||
queryKey: ['groups'],
|
||||
queryFn: getGroups,
|
||||
})
|
||||
|
||||
const groupOptions = useMemo(
|
||||
() =>
|
||||
(groupsData?.data || []).map((g) => ({
|
||||
label: g,
|
||||
value: g,
|
||||
})),
|
||||
[groupsData]
|
||||
)
|
||||
|
||||
// Fetch channels data
|
||||
// eslint-disable-next-line @tanstack/query/exhaustive-deps
|
||||
const { data, isLoading, isFetching } = useQuery({
|
||||
queryKey: channelsQueryKeys.list({
|
||||
keyword: globalFilter,
|
||||
model: modelFilter,
|
||||
group:
|
||||
groupFilter.length > 0 && !groupFilter.includes('all')
|
||||
? groupFilter[0]
|
||||
: undefined,
|
||||
status:
|
||||
statusFilter.length > 0 && !statusFilter.includes('all')
|
||||
? statusFilter[0]
|
||||
: undefined,
|
||||
type:
|
||||
typeFilter.length > 0 && !typeFilter.includes('all')
|
||||
? Number(typeFilter[0])
|
||||
: undefined,
|
||||
tag_mode: enableTagMode,
|
||||
id_sort: idSort,
|
||||
p: pagination.pageIndex + 1,
|
||||
page_size: pagination.pageSize,
|
||||
}),
|
||||
queryFn: async () => {
|
||||
if (shouldSearch) {
|
||||
return searchChannels({
|
||||
keyword: globalFilter,
|
||||
model: modelFilter,
|
||||
group:
|
||||
groupFilter.length > 0 && !groupFilter.includes('all')
|
||||
? groupFilter[0]
|
||||
: undefined,
|
||||
status:
|
||||
statusFilter.length > 0 && !statusFilter.includes('all')
|
||||
? statusFilter[0]
|
||||
: undefined,
|
||||
type:
|
||||
typeFilter.length > 0 && !typeFilter.includes('all')
|
||||
? Number(typeFilter[0])
|
||||
: undefined,
|
||||
tag_mode: enableTagMode,
|
||||
id_sort: idSort,
|
||||
p: pagination.pageIndex + 1,
|
||||
page_size: pagination.pageSize,
|
||||
})
|
||||
} else {
|
||||
return getChannels({
|
||||
group:
|
||||
groupFilter.length > 0 && !groupFilter.includes('all')
|
||||
? groupFilter[0]
|
||||
: undefined,
|
||||
status:
|
||||
statusFilter.length > 0 && !statusFilter.includes('all')
|
||||
? statusFilter[0]
|
||||
: undefined,
|
||||
type:
|
||||
typeFilter.length > 0 && !typeFilter.includes('all')
|
||||
? Number(typeFilter[0])
|
||||
: undefined,
|
||||
tag_mode: enableTagMode,
|
||||
id_sort: idSort,
|
||||
p: pagination.pageIndex + 1,
|
||||
page_size: pagination.pageSize,
|
||||
})
|
||||
}
|
||||
},
|
||||
placeholderData: (previousData) => previousData,
|
||||
})
|
||||
|
||||
// Apply tag aggregation if tag mode is enabled
|
||||
const channels = useMemo(() => {
|
||||
const rawChannels = data?.data?.items || []
|
||||
|
||||
if (enableTagMode && rawChannels.length > 0) {
|
||||
return aggregateChannelsByTag(rawChannels)
|
||||
}
|
||||
|
||||
return rawChannels
|
||||
}, [data, enableTagMode])
|
||||
|
||||
const totalCount = data?.data?.total || 0
|
||||
const typeCounts = data?.data?.type_counts
|
||||
|
||||
// Columns configuration
|
||||
const columns = useChannelsColumns()
|
||||
|
||||
// React Table instance
|
||||
const table = useReactTable({
|
||||
data: channels,
|
||||
columns,
|
||||
pageCount: Math.ceil(totalCount / pagination.pageSize),
|
||||
state: {
|
||||
sorting,
|
||||
columnFilters,
|
||||
columnVisibility,
|
||||
rowSelection,
|
||||
pagination,
|
||||
expanded,
|
||||
globalFilter,
|
||||
},
|
||||
enableRowSelection: (row: Row<Channel>) => !isTagAggregateRow(row.original),
|
||||
onRowSelectionChange: setRowSelection,
|
||||
onSortingChange: setSorting,
|
||||
onColumnFiltersChange,
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
onPaginationChange,
|
||||
onExpandedChange: setExpanded,
|
||||
onGlobalFilterChange,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getExpandedRowModel: getExpandedRowModel(),
|
||||
getSubRows: (row: Channel & { children?: Channel[] }) => row.children,
|
||||
manualPagination: true,
|
||||
manualSorting: true,
|
||||
manualFiltering: true,
|
||||
})
|
||||
|
||||
// Ensure page is in range when total count changes
|
||||
const pageCount = table.getPageCount()
|
||||
useEffect(() => {
|
||||
ensurePageInRange(pageCount)
|
||||
}, [pageCount, ensurePageInRange])
|
||||
|
||||
// Prepare filter options (option.label are i18n keys; faceted-filter uses t(option.label))
|
||||
const typeFilterOptions = [
|
||||
{
|
||||
label: `${t('All Types')}${typeCounts?.all ? ` (${typeCounts.all})` : ''}`,
|
||||
value: 'all',
|
||||
},
|
||||
...CHANNEL_TYPE_OPTIONS.map((option) => ({
|
||||
label: `${t(option.label)}${typeCounts?.[option.value] ? ` (${typeCounts[option.value]})` : ''}`,
|
||||
value: String(option.value),
|
||||
})),
|
||||
]
|
||||
|
||||
const groupFilterOptions = [
|
||||
{ label: t('All Groups'), value: 'all' },
|
||||
...groupOptions,
|
||||
]
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='space-y-4'>
|
||||
<DataTableToolbar
|
||||
table={table}
|
||||
searchPlaceholder={t('Filter by name, ID, or key...')}
|
||||
additionalSearch={
|
||||
<Input
|
||||
placeholder={t('Filter by model...')}
|
||||
value={modelFilterInput}
|
||||
onChange={(e) => setModelFilterInput(e.target.value)}
|
||||
className='h-8 w-full sm:w-[150px] lg:w-[200px]'
|
||||
/>
|
||||
}
|
||||
filters={[
|
||||
{
|
||||
columnId: 'status',
|
||||
title: t('Status'),
|
||||
options: [...CHANNEL_STATUS_OPTIONS],
|
||||
singleSelect: true,
|
||||
},
|
||||
{
|
||||
columnId: 'type',
|
||||
title: t('Type'),
|
||||
options: typeFilterOptions,
|
||||
singleSelect: true,
|
||||
},
|
||||
{
|
||||
columnId: 'group',
|
||||
title: t('Group'),
|
||||
options: groupFilterOptions,
|
||||
singleSelect: true,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{isMobile ? (
|
||||
<MobileCardList
|
||||
table={table}
|
||||
isLoading={isLoading}
|
||||
emptyTitle='No Channels Found'
|
||||
emptyDescription='No channels available. Create your first channel to get started.'
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
'overflow-hidden rounded-md border transition-opacity duration-150',
|
||||
isFetching && !isLoading && 'pointer-events-none opacity-50'
|
||||
)}
|
||||
>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHead
|
||||
key={header.id}
|
||||
style={{ width: header.getSize() }}
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
<TableSkeleton table={table} keyPrefix='channel-skeleton' />
|
||||
) : table.getRowModel().rows.length === 0 ? (
|
||||
<TableEmpty
|
||||
colSpan={columns.length}
|
||||
title={t('No Channels Found')}
|
||||
description={t(
|
||||
'No channels available. Create your first channel to get started.'
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
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>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<DataTableBulkActions table={table} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<PageFooterPortal>
|
||||
<DataTablePagination table={table} />
|
||||
</PageFooterPortal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
import { useState } from 'react'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { type Table } from '@tanstack/react-table'
|
||||
import { Power, PowerOff, Tag, Trash2 } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
import { DataTableBulkActions as BulkActionsToolbar } from '@/components/data-table'
|
||||
import {
|
||||
handleBatchDelete,
|
||||
handleBatchDisable,
|
||||
handleBatchEnable,
|
||||
handleBatchSetTag,
|
||||
} from '../lib'
|
||||
import type { Channel } from '../types'
|
||||
|
||||
interface DataTableBulkActionsProps<TData> {
|
||||
table: Table<TData>
|
||||
}
|
||||
|
||||
export function DataTableBulkActions<TData>({
|
||||
table,
|
||||
}: DataTableBulkActionsProps<TData>) {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const [showTagDialog, setShowTagDialog] = useState(false)
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||
const [tagValue, setTagValue] = useState('')
|
||||
|
||||
const selectedRows = table.getFilteredSelectedRowModel().rows
|
||||
const selectedIds = selectedRows.reduce<number[]>((ids, row) => {
|
||||
const id = (row.original as Channel).id
|
||||
|
||||
if (typeof id === 'number') {
|
||||
ids.push(id)
|
||||
}
|
||||
|
||||
return ids
|
||||
}, [])
|
||||
|
||||
const handleClearSelection = () => {
|
||||
table.resetRowSelection()
|
||||
}
|
||||
|
||||
const handleEnableAll = () => {
|
||||
handleBatchEnable(selectedIds, queryClient, handleClearSelection)
|
||||
}
|
||||
|
||||
const handleDisableAll = () => {
|
||||
handleBatchDisable(selectedIds, queryClient, handleClearSelection)
|
||||
}
|
||||
|
||||
const handleDeleteAll = () => {
|
||||
handleBatchDelete(selectedIds, queryClient, () => {
|
||||
setShowDeleteConfirm(false)
|
||||
handleClearSelection()
|
||||
})
|
||||
}
|
||||
|
||||
const handleSetTag = () => {
|
||||
handleBatchSetTag(selectedIds, tagValue || null, queryClient, () => {
|
||||
setShowTagDialog(false)
|
||||
setTagValue('')
|
||||
handleClearSelection()
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<BulkActionsToolbar table={table} entityName='channel'>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='icon'
|
||||
onClick={handleEnableAll}
|
||||
className='size-8'
|
||||
aria-label={t('Enable selected channels')}
|
||||
title={t('Enable selected channels')}
|
||||
>
|
||||
<Power />
|
||||
<span className='sr-only'>{t('Enable selected channels')}</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{t('Enable selected channels')}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='icon'
|
||||
onClick={handleDisableAll}
|
||||
className='size-8'
|
||||
aria-label={t('Disable selected channels')}
|
||||
title={t('Disable selected channels')}
|
||||
>
|
||||
<PowerOff />
|
||||
<span className='sr-only'>{t('Disable selected channels')}</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{t('Disable selected channels')}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='icon'
|
||||
onClick={() => setShowTagDialog(true)}
|
||||
className='size-8'
|
||||
aria-label={t('Set tag for selected channels')}
|
||||
title={t('Set tag for selected channels')}
|
||||
>
|
||||
<Tag />
|
||||
<span className='sr-only'>
|
||||
{t('Set tag for selected channels')}
|
||||
</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{t('Set tag for selected channels')}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant='destructive'
|
||||
size='icon'
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
className='size-8'
|
||||
aria-label={t('Delete selected channels')}
|
||||
title={t('Delete selected channels')}
|
||||
>
|
||||
<Trash2 />
|
||||
<span className='sr-only'>{t('Delete selected channels')}</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{t('Delete selected channels')}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</BulkActionsToolbar>
|
||||
|
||||
{/* Set Tag Dialog */}
|
||||
<Dialog open={showTagDialog} onOpenChange={setShowTagDialog}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('Set Tag')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('Set a tag for')} {selectedIds.length}{' '}
|
||||
{t('selected channel(s). Leave empty to remove tag.')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className='grid gap-4 py-4'>
|
||||
<div className='grid gap-2'>
|
||||
<Label htmlFor='tag'>{t('Tag')}</Label>
|
||||
<Input
|
||||
id='tag'
|
||||
placeholder={t('Enter tag name (optional)')}
|
||||
value={tagValue}
|
||||
onChange={(e) => setTagValue(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant='outline'
|
||||
onClick={() => {
|
||||
setShowTagDialog(false)
|
||||
setTagValue('')
|
||||
}}
|
||||
>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleSetTag}>{t('Set Tag')}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<Dialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('Delete Channels?')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('Are you sure you want to delete')} {selectedIds.length}{' '}
|
||||
{t('channel(s)? This action cannot be undone.')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant='outline'
|
||||
onClick={() => setShowDeleteConfirm(false)}
|
||||
>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
<Button variant='destructive' onClick={handleDeleteAll}>
|
||||
{t('Delete')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
import { useState } from 'react'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { type Row } from '@tanstack/react-table'
|
||||
import {
|
||||
MoreHorizontal,
|
||||
Boxes,
|
||||
Pencil,
|
||||
TestTube,
|
||||
DollarSign,
|
||||
Download,
|
||||
Copy,
|
||||
Power,
|
||||
PowerOff,
|
||||
Key,
|
||||
Trash2,
|
||||
RefreshCw,
|
||||
} from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import { MODEL_FETCHABLE_TYPES } from '../constants'
|
||||
import {
|
||||
handleDeleteChannel,
|
||||
handleToggleChannelStatus,
|
||||
isChannelEnabled,
|
||||
isMultiKeyChannel,
|
||||
} from '../lib'
|
||||
import { parseUpstreamUpdateMeta } from '../lib/upstream-update-utils'
|
||||
import type { Channel } from '../types'
|
||||
import { useChannels } from './channels-provider'
|
||||
|
||||
interface DataTableRowActionsProps {
|
||||
row: Row<Channel>
|
||||
}
|
||||
|
||||
export function DataTableRowActions({ row }: DataTableRowActionsProps) {
|
||||
const { t } = useTranslation()
|
||||
const channel = row.original
|
||||
const { setOpen, setCurrentRow, upstream } = useChannels()
|
||||
const queryClient = useQueryClient()
|
||||
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false)
|
||||
|
||||
const isEnabled = isChannelEnabled(channel)
|
||||
const isMultiKey = isMultiKeyChannel(channel)
|
||||
|
||||
const handleEdit = () => {
|
||||
setCurrentRow(channel)
|
||||
setOpen('update-channel')
|
||||
}
|
||||
|
||||
const handleTest = () => {
|
||||
setCurrentRow(channel)
|
||||
setOpen('test-channel')
|
||||
}
|
||||
|
||||
const handleQueryBalance = () => {
|
||||
setCurrentRow(channel)
|
||||
setOpen('balance-query')
|
||||
}
|
||||
|
||||
const handleFetchModels = () => {
|
||||
setCurrentRow(channel)
|
||||
setOpen('fetch-models')
|
||||
}
|
||||
|
||||
const handleManageOllamaModels = () => {
|
||||
setCurrentRow(channel)
|
||||
setOpen('ollama-models')
|
||||
}
|
||||
|
||||
const handleCopy = () => {
|
||||
setCurrentRow(channel)
|
||||
setOpen('copy-channel')
|
||||
}
|
||||
|
||||
const handleManageKeys = () => {
|
||||
setCurrentRow(channel)
|
||||
setOpen('multi-key-manage')
|
||||
}
|
||||
|
||||
const handleToggleStatus = () => {
|
||||
handleToggleChannelStatus(channel.id, channel.status, queryClient)
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
className='data-[state=open]:bg-muted flex h-8 w-8 p-0'
|
||||
>
|
||||
<MoreHorizontal className='h-4 w-4' />
|
||||
<span className='sr-only'>{t('Open menu')}</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end' className='w-48'>
|
||||
{/* Edit */}
|
||||
<DropdownMenuItem onClick={handleEdit}>
|
||||
{t('Edit')}
|
||||
<DropdownMenuShortcut>
|
||||
<Pencil size={16} />
|
||||
</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
|
||||
{/* Test Connection */}
|
||||
<DropdownMenuItem onClick={handleTest}>
|
||||
{t('Test Connection')}
|
||||
<DropdownMenuShortcut>
|
||||
<TestTube size={16} />
|
||||
</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
|
||||
{/* Query Balance */}
|
||||
<DropdownMenuItem onClick={handleQueryBalance}>
|
||||
{t('Query Balance')}
|
||||
<DropdownMenuShortcut>
|
||||
<DollarSign size={16} />
|
||||
</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
|
||||
{/* Fetch Models */}
|
||||
<DropdownMenuItem onClick={handleFetchModels}>
|
||||
{t('Fetch Models')}
|
||||
<DropdownMenuShortcut>
|
||||
<Download size={16} />
|
||||
</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
|
||||
{/* Detect Upstream Updates (only for fetchable channel types) */}
|
||||
{MODEL_FETCHABLE_TYPES.has(channel.type) && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
const meta = parseUpstreamUpdateMeta(channel.settings)
|
||||
if (
|
||||
meta.pendingAddModels.length > 0 ||
|
||||
meta.pendingRemoveModels.length > 0
|
||||
) {
|
||||
upstream.openModal(
|
||||
channel,
|
||||
meta.pendingAddModels,
|
||||
meta.pendingRemoveModels,
|
||||
meta.pendingAddModels.length > 0 ? 'add' : 'remove'
|
||||
)
|
||||
} else {
|
||||
upstream.detectChannelUpdates(channel)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t('Upstream Updates')}
|
||||
<DropdownMenuShortcut>
|
||||
<RefreshCw size={16} />
|
||||
</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
{/* Ollama Models (only for Ollama channels) */}
|
||||
{channel.type === 4 && (
|
||||
<DropdownMenuItem onClick={handleManageOllamaModels}>
|
||||
{t('Manage Ollama Models')}
|
||||
<DropdownMenuShortcut>
|
||||
<Boxes size={16} />
|
||||
</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
{/* Copy Channel */}
|
||||
<DropdownMenuItem onClick={handleCopy}>
|
||||
{t('Copy Channel')}
|
||||
<DropdownMenuShortcut>
|
||||
<Copy size={16} />
|
||||
</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
|
||||
{/* Manage Keys (only for multi-key channels) */}
|
||||
{isMultiKey && (
|
||||
<DropdownMenuItem onClick={handleManageKeys}>
|
||||
{t('Manage Keys')}
|
||||
<DropdownMenuShortcut>
|
||||
<Key size={16} />
|
||||
</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
{/* Enable/Disable */}
|
||||
<DropdownMenuItem onClick={handleToggleStatus}>
|
||||
{isEnabled ? (
|
||||
<>
|
||||
{t('Disable')}
|
||||
<DropdownMenuShortcut>
|
||||
<PowerOff size={16} />
|
||||
</DropdownMenuShortcut>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{t('Enable')}
|
||||
<DropdownMenuShortcut>
|
||||
<Power size={16} />
|
||||
</DropdownMenuShortcut>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
{/* Delete */}
|
||||
<DropdownMenuItem
|
||||
onSelect={(e) => {
|
||||
e.preventDefault()
|
||||
setDeleteConfirmOpen(true)
|
||||
}}
|
||||
className='text-destructive focus:text-destructive'
|
||||
>
|
||||
{t('Delete')}
|
||||
<DropdownMenuShortcut>
|
||||
<Trash2 size={16} />
|
||||
</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
|
||||
<ConfirmDialog
|
||||
open={deleteConfirmOpen}
|
||||
onOpenChange={setDeleteConfirmOpen}
|
||||
title={t('Delete Channel')}
|
||||
desc={`Are you sure you want to delete "${channel.name}"? This action cannot be undone.`}
|
||||
confirmText='Delete'
|
||||
destructive
|
||||
handleConfirm={() => {
|
||||
handleDeleteChannel(channel.id, queryClient)
|
||||
setDeleteConfirmOpen(false)
|
||||
}}
|
||||
/>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { type Row } from '@tanstack/react-table'
|
||||
import { MoreHorizontal, Power, PowerOff, Pencil, Edit } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { handleEnableTagChannels, handleDisableTagChannels } from '../lib'
|
||||
import type { Channel } from '../types'
|
||||
import { useChannels } from './channels-provider'
|
||||
|
||||
interface DataTableTagRowActionsProps {
|
||||
row: Row<Channel & { tag?: string }>
|
||||
}
|
||||
|
||||
export function DataTableTagRowActions({ row }: DataTableTagRowActionsProps) {
|
||||
const { t } = useTranslation()
|
||||
const tag = row.original.tag
|
||||
const { setOpen, setCurrentTag } = useChannels()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
if (!tag) return null
|
||||
|
||||
const handleEnableAll = () => {
|
||||
handleEnableTagChannels(tag, queryClient)
|
||||
}
|
||||
|
||||
const handleDisableAll = () => {
|
||||
handleDisableTagChannels(tag, queryClient)
|
||||
}
|
||||
|
||||
const handleBatchEdit = () => {
|
||||
setCurrentTag(tag)
|
||||
setOpen('tag-batch-edit')
|
||||
}
|
||||
|
||||
const handleEditTag = () => {
|
||||
setCurrentTag(tag)
|
||||
setOpen('edit-tag')
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
className='data-[state=open]:bg-muted flex h-8 w-8 p-0'
|
||||
>
|
||||
<MoreHorizontal className='h-4 w-4' />
|
||||
<span className='sr-only'>{t('Open menu')}</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end' className='w-48'>
|
||||
{/* Edit Tag */}
|
||||
<DropdownMenuItem onClick={handleEditTag}>
|
||||
{t('Edit Tag')}
|
||||
<DropdownMenuShortcut>
|
||||
<Edit size={16} />
|
||||
</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
|
||||
{/* Batch Edit */}
|
||||
<DropdownMenuItem onClick={handleBatchEdit}>
|
||||
{t('Batch Edit')}
|
||||
<DropdownMenuShortcut>
|
||||
<Pencil size={16} />
|
||||
</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
{/* Enable All */}
|
||||
<DropdownMenuItem onClick={handleEnableAll}>
|
||||
{t('Enable All')}
|
||||
<DropdownMenuShortcut>
|
||||
<Power size={16} />
|
||||
</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
|
||||
{/* Disable All */}
|
||||
<DropdownMenuItem onClick={handleDisableAll}>
|
||||
{t('Disable All')}
|
||||
<DropdownMenuShortcut>
|
||||
<PowerOff size={16} />
|
||||
</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
+195
@@ -0,0 +1,195 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { Loader2, RefreshCw, DollarSign } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { formatCurrencyFromUSD } from '@/lib/currency'
|
||||
import { formatTimestampToDate } from '@/lib/format'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { getCodexUsage, updateChannelBalance } from '../../api'
|
||||
import { channelsQueryKeys } from '../../lib'
|
||||
import { useChannels } from '../channels-provider'
|
||||
import {
|
||||
CodexUsageDialog,
|
||||
type CodexUsageDialogData,
|
||||
} from './codex-usage-dialog'
|
||||
|
||||
type BalanceQueryDialogProps = {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
export function BalanceQueryDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: BalanceQueryDialogProps) {
|
||||
const { t } = useTranslation()
|
||||
const { currentRow, setCurrentRow } = useChannels()
|
||||
const queryClient = useQueryClient()
|
||||
const [isQuerying, setIsQuerying] = useState(false)
|
||||
const [balance, setBalance] = useState<number | null>(null)
|
||||
const [balanceUpdatedTime, setBalanceUpdatedTime] = useState<number | null>(
|
||||
null
|
||||
)
|
||||
const [codexUsageResponse, setCodexUsageResponse] =
|
||||
useState<CodexUsageDialogData | null>(null)
|
||||
|
||||
const isCodex = currentRow?.type === 57
|
||||
|
||||
const handleQueryCodexUsage = async () => {
|
||||
const row = currentRow
|
||||
if (!row) return
|
||||
setIsQuerying(true)
|
||||
try {
|
||||
const res = await getCodexUsage(row.id)
|
||||
if (!res.success) {
|
||||
throw new Error(res.message || t('Failed to fetch usage'))
|
||||
}
|
||||
setCodexUsageResponse(res)
|
||||
} catch (error: unknown) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : t('Failed to fetch usage')
|
||||
)
|
||||
} finally {
|
||||
setIsQuerying(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!isCodex) return
|
||||
if (!open) return
|
||||
handleQueryCodexUsage()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, isCodex])
|
||||
|
||||
if (!currentRow) return null
|
||||
|
||||
const handleQueryBalance = async () => {
|
||||
setIsQuerying(true)
|
||||
try {
|
||||
const response = await updateChannelBalance(currentRow.id)
|
||||
if (response.success && response.balance !== undefined) {
|
||||
const newBalance = response.balance
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
|
||||
setBalance(newBalance)
|
||||
setBalanceUpdatedTime(now)
|
||||
toast.success(t('Balance updated successfully'))
|
||||
|
||||
// Update currentRow immediately with new balance and timestamp
|
||||
setCurrentRow({
|
||||
...currentRow,
|
||||
balance: newBalance,
|
||||
balance_updated_time: now,
|
||||
})
|
||||
|
||||
// Invalidate queries to refresh the table
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: channelsQueryKeys.lists(),
|
||||
})
|
||||
} else {
|
||||
toast.error(response.message || t('Failed to query balance'))
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : t('Failed to query balance')
|
||||
)
|
||||
} finally {
|
||||
setIsQuerying(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
setBalance(null)
|
||||
setBalanceUpdatedTime(null)
|
||||
setCodexUsageResponse(null)
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
const formatBalance = (bal: number) =>
|
||||
formatCurrencyFromUSD(bal, {
|
||||
digitsLarge: 2,
|
||||
digitsSmall: 4,
|
||||
abbreviate: false,
|
||||
})
|
||||
|
||||
const formatDate = (timestamp: number) => {
|
||||
if (!timestamp) return 'Never'
|
||||
return formatTimestampToDate(timestamp)
|
||||
}
|
||||
|
||||
if (isCodex) {
|
||||
return (
|
||||
<CodexUsageDialog
|
||||
open={open}
|
||||
onOpenChange={(v) => {
|
||||
if (!v) handleClose()
|
||||
}}
|
||||
channelName={currentRow.name}
|
||||
channelId={currentRow.id}
|
||||
response={codexUsageResponse}
|
||||
onRefresh={handleQueryCodexUsage}
|
||||
isRefreshing={isQuerying}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('Query Balance')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('Update balance for:')} <strong>{currentRow.name}</strong>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className='space-y-4 py-4'>
|
||||
{/* Current Balance Display */}
|
||||
<div className='bg-muted/50 rounded-lg border p-4'>
|
||||
<div className='text-muted-foreground mb-2 flex items-center gap-2 text-sm'>
|
||||
<DollarSign className='h-4 w-4' />
|
||||
<span>{t('Current Balance')}</span>
|
||||
</div>
|
||||
<div className='text-2xl font-bold'>
|
||||
{balance !== null
|
||||
? formatBalance(balance)
|
||||
: formatBalance(currentRow.balance)}
|
||||
</div>
|
||||
<div className='text-muted-foreground mt-2 text-xs'>
|
||||
{t('Last updated:')}{' '}
|
||||
{formatDate(
|
||||
balanceUpdatedTime ?? currentRow.balance_updated_time
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Balance Update Button */}
|
||||
<Button
|
||||
className='w-full'
|
||||
onClick={handleQueryBalance}
|
||||
disabled={isQuerying}
|
||||
>
|
||||
{isQuerying && <Loader2 className='mr-2 h-4 w-4 animate-spin' />}
|
||||
{!isQuerying && <RefreshCw className='mr-2 h-4 w-4' />}
|
||||
{isQuerying ? t('Querying...') : t('Update Balance')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant='outline' onClick={handleClose} disabled={isQuerying}>
|
||||
{t('Close')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
+611
@@ -0,0 +1,611 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import {
|
||||
type ColumnDef,
|
||||
type RowSelectionState,
|
||||
type Table as TanStackTable,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getPaginationRowModel,
|
||||
useReactTable,
|
||||
} from '@tanstack/react-table'
|
||||
import { Loader2, Settings } 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 { Label } from '@/components/ui/label'
|
||||
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 {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
import { DataTableBulkActions as BulkActionsToolbar } from '@/components/data-table'
|
||||
import { DataTablePagination } from '@/components/data-table/pagination'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { formatResponseTime, handleTestChannel } from '../../lib'
|
||||
import { useChannels } from '../channels-provider'
|
||||
|
||||
type ChannelTestDialogProps = {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
type ModelRow = {
|
||||
model: string
|
||||
}
|
||||
|
||||
type TestStatus = 'idle' | 'testing' | 'success' | 'error'
|
||||
|
||||
type TestResult = {
|
||||
status: TestStatus
|
||||
responseTime?: number
|
||||
error?: string
|
||||
errorCode?: string
|
||||
}
|
||||
|
||||
const endpointTypeOptions: Array<{ value: string; label: string }> = [
|
||||
{ value: 'auto', label: 'Auto detect (default)' },
|
||||
{ value: 'openai', label: 'OpenAI (/v1/chat/completions)' },
|
||||
{ value: 'openai-response', label: 'OpenAI Responses (/v1/responses)' },
|
||||
{
|
||||
value: 'openai-response-compact',
|
||||
label: 'OpenAI Response Compaction (/v1/responses/compact)',
|
||||
},
|
||||
{ value: 'anthropic', label: 'Anthropic (/v1/messages)' },
|
||||
{
|
||||
value: 'gemini',
|
||||
label: 'Gemini (/v1beta/models/{model}:generateContent)',
|
||||
},
|
||||
{ value: 'jina-rerank', label: 'Jina Rerank (/v1/rerank)' },
|
||||
{
|
||||
value: 'image-generation',
|
||||
label: 'Image Generation (/v1/images/generations)',
|
||||
},
|
||||
{ value: 'embeddings', label: 'Embeddings (/v1/embeddings)' },
|
||||
]
|
||||
|
||||
const STREAM_INCOMPATIBLE_ENDPOINTS = new Set([
|
||||
'embeddings',
|
||||
'image-generation',
|
||||
'jina-rerank',
|
||||
'openai-response-compact',
|
||||
])
|
||||
|
||||
export function ChannelTestDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: ChannelTestDialogProps) {
|
||||
const { t } = useTranslation()
|
||||
const { currentRow } = useChannels()
|
||||
const [endpointType, setEndpointType] = useState('auto')
|
||||
const [isStreamTest, setIsStreamTest] = useState(false)
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [testResults, setTestResults] = useState<Record<string, TestResult>>({})
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
|
||||
const [testingModels, setTestingModels] = useState<Set<string>>(
|
||||
() => new Set()
|
||||
)
|
||||
const [isBatchTesting, setIsBatchTesting] = useState(false)
|
||||
const [pagination, setPagination] = useState({
|
||||
pageIndex: 0,
|
||||
pageSize: 10,
|
||||
})
|
||||
|
||||
const resetState = useCallback(() => {
|
||||
setEndpointType('auto')
|
||||
setIsStreamTest(false)
|
||||
setSearchTerm('')
|
||||
setTestResults({})
|
||||
setRowSelection({})
|
||||
setTestingModels(() => new Set())
|
||||
setIsBatchTesting(false)
|
||||
setPagination({ pageIndex: 0, pageSize: 10 })
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (open && currentRow) {
|
||||
resetState()
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, currentRow?.id, resetState])
|
||||
|
||||
const streamDisabled = STREAM_INCOMPATIBLE_ENDPOINTS.has(endpointType)
|
||||
|
||||
useEffect(() => {
|
||||
if (streamDisabled) {
|
||||
setIsStreamTest(false)
|
||||
}
|
||||
}, [streamDisabled])
|
||||
|
||||
const modelsValue = currentRow?.models ?? ''
|
||||
const defaultTestModel = currentRow?.test_model?.trim()
|
||||
|
||||
const models = useMemo(() => {
|
||||
if (!modelsValue) return []
|
||||
return modelsValue
|
||||
.split(',')
|
||||
.map((model) => model.trim())
|
||||
.filter(Boolean)
|
||||
}, [modelsValue])
|
||||
|
||||
const filteredModels = useMemo(() => {
|
||||
if (!searchTerm) return models
|
||||
const keyword = searchTerm.toLowerCase()
|
||||
return models.filter((model) => model.toLowerCase().includes(keyword))
|
||||
}, [models, searchTerm])
|
||||
|
||||
useEffect(() => {
|
||||
setPagination((prev) => ({ ...prev, pageIndex: 0 }))
|
||||
}, [searchTerm, modelsValue])
|
||||
|
||||
const tableData = useMemo<ModelRow[]>(
|
||||
() => filteredModels.map((model) => ({ model })),
|
||||
[filteredModels]
|
||||
)
|
||||
|
||||
const markModelTesting = useCallback((key: string, isTesting: boolean) => {
|
||||
setTestingModels((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (isTesting) {
|
||||
next.add(key)
|
||||
} else {
|
||||
next.delete(key)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const updateTestResult = useCallback((key: string, result: TestResult) => {
|
||||
setTestResults((prev) => ({
|
||||
...prev,
|
||||
[key]: result,
|
||||
}))
|
||||
}, [])
|
||||
|
||||
const testSingleModel = useCallback(
|
||||
async (model: string) => {
|
||||
if (!currentRow) return
|
||||
|
||||
markModelTesting(model, true)
|
||||
updateTestResult(model, { status: 'testing' })
|
||||
|
||||
try {
|
||||
await handleTestChannel(
|
||||
currentRow.id,
|
||||
{
|
||||
testModel: model,
|
||||
endpointType: endpointType === 'auto' ? undefined : endpointType,
|
||||
stream: isStreamTest || undefined,
|
||||
},
|
||||
(success, responseTime, error, errorCode) => {
|
||||
updateTestResult(model, {
|
||||
status: success ? 'success' : 'error',
|
||||
responseTime,
|
||||
error,
|
||||
errorCode,
|
||||
})
|
||||
}
|
||||
)
|
||||
} catch (error: unknown) {
|
||||
updateTestResult(model, {
|
||||
status: 'error',
|
||||
error: error instanceof Error ? error.message : 'Test failed',
|
||||
})
|
||||
} finally {
|
||||
markModelTesting(model, false)
|
||||
}
|
||||
},
|
||||
[currentRow, endpointType, isStreamTest, markModelTesting, updateTestResult]
|
||||
)
|
||||
|
||||
const handleBatchTest = useCallback(
|
||||
async (modelsToTest: string[]) => {
|
||||
if (!modelsToTest.length) return
|
||||
|
||||
setIsBatchTesting(true)
|
||||
try {
|
||||
await Promise.allSettled(
|
||||
modelsToTest.map((modelName) => testSingleModel(modelName))
|
||||
)
|
||||
} finally {
|
||||
setIsBatchTesting(false)
|
||||
setRowSelection({})
|
||||
}
|
||||
},
|
||||
[testSingleModel]
|
||||
)
|
||||
|
||||
const handleClose = () => {
|
||||
resetState()
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
const isAnyTesting = testingModels.size > 0 || isBatchTesting
|
||||
|
||||
const columns = useMemo<ColumnDef<ModelRow>[]>(
|
||||
() => [
|
||||
{
|
||||
id: 'select',
|
||||
header: ({ table }) => (
|
||||
<Checkbox
|
||||
checked={
|
||||
table.getIsAllPageRowsSelected() ||
|
||||
(table.getIsSomePageRowsSelected() && 'indeterminate')
|
||||
}
|
||||
onCheckedChange={(value) =>
|
||||
table.toggleAllPageRowsSelected(!!value)
|
||||
}
|
||||
aria-label='Select all models'
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<Checkbox
|
||||
checked={row.getIsSelected()}
|
||||
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||||
aria-label={`Select model ${row.original.model}`}
|
||||
/>
|
||||
),
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
size: 40,
|
||||
},
|
||||
{
|
||||
accessorKey: 'model',
|
||||
header: 'Model',
|
||||
cell: ({ row }) => {
|
||||
const model = row.original.model
|
||||
const isDefault = defaultTestModel === model
|
||||
|
||||
return (
|
||||
<div className='flex items-center gap-2'>
|
||||
<span className='font-medium'>{model}</span>
|
||||
{isDefault && (
|
||||
<StatusBadge
|
||||
label='Default'
|
||||
variant='info'
|
||||
size='sm'
|
||||
copyable={false}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'status',
|
||||
header: 'Status',
|
||||
cell: ({ row }) => {
|
||||
const model = row.original.model
|
||||
const result = testResults[model]
|
||||
|
||||
if (!result || result.status === 'idle') {
|
||||
return (
|
||||
<StatusBadge
|
||||
label={t('Not tested')}
|
||||
variant='neutral'
|
||||
copyable={false}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (result.status === 'testing') {
|
||||
return (
|
||||
<div className='text-muted-foreground flex items-center gap-2 text-sm'>
|
||||
<Loader2 className='h-4 w-4 animate-spin' />
|
||||
Testing...
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (result.status === 'success') {
|
||||
return (
|
||||
<div className='flex flex-col gap-1 text-xs'>
|
||||
<StatusBadge
|
||||
label='Success'
|
||||
variant='success'
|
||||
copyable={false}
|
||||
/>
|
||||
{typeof result.responseTime === 'number' && (
|
||||
<span className='text-muted-foreground'>
|
||||
{formatResponseTime(result.responseTime, t)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-1 text-xs'>
|
||||
<StatusBadge label='Failed' variant='danger' copyable={false} />
|
||||
{result.error && (
|
||||
<span className='text-muted-foreground break-all'>
|
||||
{result.error}
|
||||
</span>
|
||||
)}
|
||||
{result.errorCode === 'model_price_error' && (
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
className='w-fit'
|
||||
onClick={() =>
|
||||
window.open('/console/setting?tab=ratio', '_blank')
|
||||
}
|
||||
>
|
||||
<Settings className='mr-1 h-3 w-3' />
|
||||
{t('Go to Settings')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
enableSorting: false,
|
||||
size: 220,
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: 'Actions',
|
||||
cell: ({ row }) => {
|
||||
const model = row.original.model
|
||||
const isTestingModel = testingModels.has(model)
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() => testSingleModel(model)}
|
||||
disabled={isTestingModel || isBatchTesting}
|
||||
>
|
||||
{isTestingModel && (
|
||||
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
|
||||
)}
|
||||
Test
|
||||
</Button>
|
||||
)
|
||||
},
|
||||
enableSorting: false,
|
||||
size: 120,
|
||||
},
|
||||
],
|
||||
[
|
||||
defaultTestModel,
|
||||
isBatchTesting,
|
||||
t,
|
||||
testResults,
|
||||
testingModels,
|
||||
testSingleModel,
|
||||
]
|
||||
)
|
||||
|
||||
const table = useReactTable({
|
||||
data: tableData,
|
||||
columns,
|
||||
state: {
|
||||
rowSelection,
|
||||
pagination,
|
||||
},
|
||||
enableRowSelection: true,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
onRowSelectionChange: setRowSelection,
|
||||
onPaginationChange: setPagination,
|
||||
})
|
||||
|
||||
if (!currentRow) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent className='max-h-[90vh] overflow-hidden sm:max-w-3xl'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('Test Channel Connection')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('Test connectivity for:')} <strong>{currentRow.name}</strong>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className='max-h-[78vh] space-y-4 overflow-y-auto py-4 pr-1'>
|
||||
<div className='grid gap-4 md:grid-cols-2'>
|
||||
<div className='grid gap-2'>
|
||||
<Label htmlFor='endpoint-type'>{t('Endpoint Type')}</Label>
|
||||
<Select value={endpointType} onValueChange={setEndpointType}>
|
||||
<SelectTrigger id='endpoint-type'>
|
||||
<SelectValue placeholder={t('Auto detect (default)')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{endpointTypeOptions.map((option) => {
|
||||
const itemValue = option.value
|
||||
return (
|
||||
<SelectItem key={itemValue} value={itemValue}>
|
||||
{t(option.label)}
|
||||
</SelectItem>
|
||||
)
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{t(
|
||||
'Override the endpoint used for testing. Leave empty to auto detect.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className='grid gap-2'>
|
||||
<Label htmlFor='stream-toggle'>{t('Stream Mode')}</Label>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Switch
|
||||
id='stream-toggle'
|
||||
checked={isStreamTest}
|
||||
onCheckedChange={setIsStreamTest}
|
||||
disabled={streamDisabled}
|
||||
/>
|
||||
<span className='text-sm'>
|
||||
{isStreamTest ? t('Enabled') : t('Disabled')}
|
||||
</span>
|
||||
</div>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{t('Enable streaming mode for the test request.')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='space-y-3 max-sm:has-[div[role="toolbar"]]:pb-16'>
|
||||
<div className='flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between'>
|
||||
<div>
|
||||
<p className='text-sm font-medium'>{t('Channel models')}</p>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{t('Select models to run batch tests.')}
|
||||
</p>
|
||||
</div>
|
||||
<Input
|
||||
placeholder={t('Filter models...')}
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className='sm:w-64'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='space-y-3'>
|
||||
<div className='overflow-hidden rounded-md border' role='region'>
|
||||
<div className='max-h-[360px] overflow-y-auto'>
|
||||
<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' : undefined
|
||||
}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext()
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={table.getVisibleLeafColumns().length}
|
||||
className='text-muted-foreground h-16 text-center text-sm'
|
||||
>
|
||||
{models.length
|
||||
? 'No models matched your search.'
|
||||
: 'This channel has no configured models.'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DataTablePagination table={table} />
|
||||
</div>
|
||||
|
||||
<TestModelsBulkActions
|
||||
table={table}
|
||||
disabled={isAnyTesting}
|
||||
onTestSelected={handleBatchTest}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant='outline' onClick={handleClose}>
|
||||
{t('Close')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function TestModelsBulkActions({
|
||||
table,
|
||||
disabled,
|
||||
onTestSelected,
|
||||
}: {
|
||||
table: TanStackTable<ModelRow>
|
||||
disabled?: boolean
|
||||
onTestSelected: (models: string[]) => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const selectedRows = table.getFilteredSelectedRowModel().rows
|
||||
const selectedModels = selectedRows.map((row) => row.original.model)
|
||||
|
||||
const buttonLabel =
|
||||
selectedModels.length > 0
|
||||
? `Test ${selectedModels.length} selected`
|
||||
: 'Test selected models'
|
||||
|
||||
return (
|
||||
<BulkActionsToolbar table={table} entityName='model'>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size='sm'
|
||||
onClick={() => onTestSelected(selectedModels)}
|
||||
disabled={disabled || selectedModels.length === 0}
|
||||
>
|
||||
{disabled ? (
|
||||
<>
|
||||
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
|
||||
{t('Testing...')}
|
||||
</>
|
||||
) : (
|
||||
buttonLabel
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{t('Run tests for the selected models')}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</BulkActionsToolbar>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { ExternalLink, Copy, Check, Loader2 } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { tryPrettyJson } from '@/lib/utils'
|
||||
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { completeCodexOAuth, startCodexOAuth } from '../../api'
|
||||
|
||||
type CodexOAuthDialogProps = {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onKeyGenerated: (key: string) => void
|
||||
}
|
||||
|
||||
export function CodexOAuthDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onKeyGenerated,
|
||||
}: CodexOAuthDialogProps) {
|
||||
const { t } = useTranslation()
|
||||
const { copiedText, copyToClipboard } = useCopyToClipboard({ notify: false })
|
||||
|
||||
const [state, setState] = useState({
|
||||
authorizeUrl: '',
|
||||
callbackUrl: '',
|
||||
isStarting: false,
|
||||
isCompleting: false,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setState({
|
||||
authorizeUrl: '',
|
||||
callbackUrl: '',
|
||||
isStarting: false,
|
||||
isCompleting: false,
|
||||
})
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const canCopyAuthorizeUrl = Boolean(state.authorizeUrl && !state.isStarting)
|
||||
const canComplete = useMemo(
|
||||
() => Boolean(state.callbackUrl.trim()) && !state.isCompleting,
|
||||
[state.callbackUrl, state.isCompleting]
|
||||
)
|
||||
|
||||
const handleStart = async () => {
|
||||
setState((prev) => ({ ...prev, isStarting: true }))
|
||||
try {
|
||||
const res = await startCodexOAuth()
|
||||
if (!res.success) {
|
||||
throw new Error(res.message || 'Failed to start OAuth')
|
||||
}
|
||||
|
||||
const url = res.data?.authorize_url || ''
|
||||
if (!url) {
|
||||
throw new Error('Missing authorize_url in response')
|
||||
}
|
||||
|
||||
setState((prev) => ({ ...prev, authorizeUrl: url }))
|
||||
try {
|
||||
window.open(url, '_blank', 'noopener,noreferrer')
|
||||
toast.success(t('Opened authorization page'))
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('Failed to open authorization page:', error)
|
||||
toast.warning(t('Please manually copy and open the authorization link'))
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : t('OAuth start failed')
|
||||
)
|
||||
} finally {
|
||||
setState((prev) => ({ ...prev, isStarting: false }))
|
||||
}
|
||||
}
|
||||
|
||||
const handleComplete = async () => {
|
||||
if (!state.callbackUrl.trim()) return
|
||||
setState((prev) => ({ ...prev, isCompleting: true }))
|
||||
try {
|
||||
const res = await completeCodexOAuth(state.callbackUrl.trim())
|
||||
if (!res.success) {
|
||||
throw new Error(res.message || 'OAuth failed')
|
||||
}
|
||||
|
||||
const rawKey = res.data?.key || ''
|
||||
if (!rawKey) {
|
||||
throw new Error('Missing key in response')
|
||||
}
|
||||
|
||||
onKeyGenerated(tryPrettyJson(rawKey))
|
||||
toast.success(t('Credential generated'))
|
||||
onOpenChange(false)
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : t('OAuth failed'))
|
||||
} finally {
|
||||
setState((prev) => ({ ...prev, isCompleting: false }))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className='sm:max-w-2xl'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('Codex Authorization')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t(
|
||||
'Generate a Codex OAuth credential and paste it into the channel key field.'
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className='space-y-4'>
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
{t(
|
||||
'1) Click "Open authorization page" and complete login. 2) Your browser may redirect to localhost (it is OK if the page does not load). 3) Copy the full URL from the address bar and paste it below. 4) Click "Generate credential".'
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
<Button onClick={handleStart} disabled={state.isStarting}>
|
||||
{state.isStarting ? (
|
||||
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
|
||||
) : (
|
||||
<ExternalLink className='mr-2 h-4 w-4' />
|
||||
)}
|
||||
{t('Open authorization page')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
disabled={!canCopyAuthorizeUrl}
|
||||
onClick={async () => {
|
||||
if (!state.authorizeUrl) return
|
||||
await copyToClipboard(state.authorizeUrl)
|
||||
}}
|
||||
aria-label={t('Copy authorization link')}
|
||||
title={t('Copy authorization link')}
|
||||
>
|
||||
{copiedText === state.authorizeUrl ? (
|
||||
<Check className='mr-2 h-4 w-4 text-green-600' />
|
||||
) : (
|
||||
<Copy className='mr-2 h-4 w-4' />
|
||||
)}
|
||||
{t('Copy authorization link')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<div className='text-sm font-medium'>{t('Callback URL')}</div>
|
||||
<Input
|
||||
value={state.callbackUrl}
|
||||
onChange={(e) =>
|
||||
setState((prev) => ({ ...prev, callbackUrl: e.target.value }))
|
||||
}
|
||||
placeholder={t(
|
||||
'Paste the full callback URL (includes code & state)'
|
||||
)}
|
||||
autoComplete='off'
|
||||
spellCheck={false}
|
||||
/>
|
||||
<div className='text-muted-foreground text-xs'>
|
||||
{t(
|
||||
'Tip: The generated key is a JSON credential including access_token / refresh_token / account_id.'
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={state.isStarting || state.isCompleting}
|
||||
>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleComplete} disabled={!canComplete}>
|
||||
{state.isCompleting && (
|
||||
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
|
||||
)}
|
||||
{state.isCompleting ? t('Generating...') : t('Generate credential')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,581 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import {
|
||||
Copy,
|
||||
Check,
|
||||
RefreshCw,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
User,
|
||||
Mail,
|
||||
Hash,
|
||||
} from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import dayjs from '@/lib/dayjs'
|
||||
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { StatusBadge, type StatusBadgeProps } from '@/components/status-badge'
|
||||
|
||||
type CodexRateLimitWindow = {
|
||||
used_percent?: number
|
||||
reset_at?: number
|
||||
reset_after_seconds?: number
|
||||
limit_window_seconds?: number
|
||||
}
|
||||
|
||||
type CodexRateLimit = {
|
||||
plan_type?: string
|
||||
allowed?: boolean
|
||||
limit_reached?: boolean
|
||||
primary_window?: CodexRateLimitWindow
|
||||
secondary_window?: CodexRateLimitWindow
|
||||
}
|
||||
|
||||
type CodexAdditionalRateLimit = {
|
||||
limit_name?: string
|
||||
metered_feature?: string
|
||||
rate_limit?: CodexRateLimit
|
||||
primary_window?: CodexRateLimitWindow
|
||||
secondary_window?: CodexRateLimitWindow
|
||||
plan_type?: string
|
||||
}
|
||||
|
||||
type CodexUsagePayload = {
|
||||
plan_type?: string
|
||||
user_id?: string
|
||||
email?: string
|
||||
account_id?: string
|
||||
rate_limit?: CodexRateLimit
|
||||
additional_rate_limits?: CodexAdditionalRateLimit[]
|
||||
}
|
||||
|
||||
export type CodexUsageDialogData = {
|
||||
success: boolean
|
||||
message?: string
|
||||
upstream_status?: number
|
||||
data?: Record<string, unknown>
|
||||
}
|
||||
|
||||
type CodexUsageDialogProps = {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
channelName?: string
|
||||
channelId?: number
|
||||
response: CodexUsageDialogData | null
|
||||
onRefresh?: () => void
|
||||
isRefreshing?: boolean
|
||||
}
|
||||
|
||||
function clampPercent(value: unknown): number {
|
||||
const v = Number(value)
|
||||
return Number.isFinite(v) ? Math.max(0, Math.min(100, v)) : 0
|
||||
}
|
||||
|
||||
function formatUnixSeconds(unixSeconds: unknown): string {
|
||||
const v = Number(unixSeconds)
|
||||
if (!Number.isFinite(v) || v <= 0) return '-'
|
||||
try {
|
||||
return dayjs(v * 1000).format('YYYY-MM-DD HH:mm:ss')
|
||||
} catch {
|
||||
return String(unixSeconds)
|
||||
}
|
||||
}
|
||||
|
||||
function formatDurationSeconds(
|
||||
seconds: unknown,
|
||||
t: (key: string) => string
|
||||
): string {
|
||||
const s = Number(seconds)
|
||||
if (!Number.isFinite(s) || s <= 0) return '-'
|
||||
|
||||
const total = Math.floor(s)
|
||||
const hours = Math.floor(total / 3600)
|
||||
const minutes = Math.floor((total % 3600) / 60)
|
||||
const secs = total % 60
|
||||
|
||||
if (hours > 0) return `${hours}${t('h')} ${minutes}${t('m')}`
|
||||
if (minutes > 0) return `${minutes}${t('m')} ${secs}${t('s')}`
|
||||
return `${secs}${t('s')}`
|
||||
}
|
||||
|
||||
function normalizePlanType(value: unknown): string {
|
||||
if (value == null) return ''
|
||||
return String(value).trim().toLowerCase()
|
||||
}
|
||||
|
||||
function classifyWindowByDuration(
|
||||
windowData?: CodexRateLimitWindow | null
|
||||
): 'weekly' | 'fiveHour' | null {
|
||||
const seconds = Number(windowData?.limit_window_seconds)
|
||||
if (!Number.isFinite(seconds) || seconds <= 0) return null
|
||||
return seconds >= 24 * 60 * 60 ? 'weekly' : 'fiveHour'
|
||||
}
|
||||
|
||||
type RateLimitSource = {
|
||||
plan_type?: string
|
||||
rate_limit?: CodexRateLimit
|
||||
}
|
||||
|
||||
function resolveRateLimitWindows(data: RateLimitSource | null): {
|
||||
fiveHourWindow: CodexRateLimitWindow | null
|
||||
weeklyWindow: CodexRateLimitWindow | null
|
||||
} {
|
||||
const rateLimit = data?.rate_limit ?? {}
|
||||
const primary = rateLimit?.primary_window ?? null
|
||||
const secondary = rateLimit?.secondary_window ?? null
|
||||
const windows = [primary, secondary].filter(Boolean) as CodexRateLimitWindow[]
|
||||
const planType = normalizePlanType(data?.plan_type ?? rateLimit?.plan_type)
|
||||
|
||||
let fiveHourWindow: CodexRateLimitWindow | null = null
|
||||
let weeklyWindow: CodexRateLimitWindow | null = null
|
||||
|
||||
for (const w of windows) {
|
||||
const bucket = classifyWindowByDuration(w)
|
||||
if (bucket === 'fiveHour' && !fiveHourWindow) {
|
||||
fiveHourWindow = w
|
||||
continue
|
||||
}
|
||||
if (bucket === 'weekly' && !weeklyWindow) {
|
||||
weeklyWindow = w
|
||||
}
|
||||
}
|
||||
|
||||
if (planType === 'free') {
|
||||
if (!weeklyWindow) weeklyWindow = primary ?? secondary ?? null
|
||||
return { fiveHourWindow: null, weeklyWindow }
|
||||
}
|
||||
|
||||
if (!fiveHourWindow && !weeklyWindow) {
|
||||
return { fiveHourWindow: primary, weeklyWindow: secondary }
|
||||
}
|
||||
|
||||
if (!fiveHourWindow) {
|
||||
fiveHourWindow = windows.find((w) => w !== weeklyWindow) ?? null
|
||||
}
|
||||
if (!weeklyWindow) {
|
||||
weeklyWindow = windows.find((w) => w !== fiveHourWindow) ?? null
|
||||
}
|
||||
|
||||
return { fiveHourWindow, weeklyWindow }
|
||||
}
|
||||
|
||||
const PLAN_TYPE_BADGE: Record<
|
||||
string,
|
||||
{ label: string; variant: StatusBadgeProps['variant'] }
|
||||
> = {
|
||||
enterprise: { label: 'Enterprise', variant: 'success' },
|
||||
team: { label: 'Team', variant: 'info' },
|
||||
pro: { label: 'Pro', variant: 'blue' },
|
||||
plus: { label: 'Plus', variant: 'purple' },
|
||||
free: { label: 'Free', variant: 'warning' },
|
||||
}
|
||||
|
||||
function getAccountTypeBadge(
|
||||
value: unknown,
|
||||
t: (key: string) => string
|
||||
): { label: string; variant: StatusBadgeProps['variant'] } {
|
||||
const normalized = normalizePlanType(value)
|
||||
return (
|
||||
PLAN_TYPE_BADGE[normalized] ?? {
|
||||
label: String(value || '') || t('Unknown'),
|
||||
variant: 'neutral' as const,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
function windowLabel(windowData?: CodexRateLimitWindow | null) {
|
||||
const percent = clampPercent(windowData?.used_percent)
|
||||
const variant: StatusBadgeProps['variant'] =
|
||||
percent >= 95 ? 'danger' : percent >= 80 ? 'warning' : 'info'
|
||||
return { percent, variant }
|
||||
}
|
||||
|
||||
type RateLimitWindowProps = {
|
||||
title: string
|
||||
window?: CodexRateLimitWindow | null
|
||||
}
|
||||
|
||||
function RateLimitWindow(props: RateLimitWindowProps) {
|
||||
const { t } = useTranslation()
|
||||
const hasData =
|
||||
!!props.window &&
|
||||
typeof props.window === 'object' &&
|
||||
Object.keys(props.window).length > 0
|
||||
const { percent, variant } = windowLabel(props.window)
|
||||
|
||||
return (
|
||||
<div className='rounded-lg border p-4'>
|
||||
<div className='flex items-center justify-between gap-2'>
|
||||
<div className='text-sm font-medium'>{props.title}</div>
|
||||
<StatusBadge label={`${percent}%`} variant={variant} copyable={false} />
|
||||
</div>
|
||||
<div className='mt-3'>
|
||||
<Progress
|
||||
value={percent}
|
||||
aria-label={`${props.title} usage: ${percent}%`}
|
||||
/>
|
||||
</div>
|
||||
{hasData ? (
|
||||
<div className='text-muted-foreground mt-2 space-y-1 text-xs'>
|
||||
<div>
|
||||
{t('Reset at:')} {formatUnixSeconds(props.window?.reset_at)}
|
||||
</div>
|
||||
<div>
|
||||
{t('Resets in:')}{' '}
|
||||
{formatDurationSeconds(props.window?.reset_after_seconds, t)}
|
||||
</div>
|
||||
<div>
|
||||
{t('Window:')}{' '}
|
||||
{formatDurationSeconds(props.window?.limit_window_seconds, t)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className='text-muted-foreground mt-2 text-xs'>-</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type RateLimitGroupSectionProps = {
|
||||
title: string
|
||||
description?: string
|
||||
source: RateLimitSource | null
|
||||
meteredFeature?: string
|
||||
}
|
||||
|
||||
function RateLimitGroupSection(props: RateLimitGroupSectionProps) {
|
||||
const { t } = useTranslation()
|
||||
const { fiveHourWindow, weeklyWindow } = resolveRateLimitWindows(props.source)
|
||||
|
||||
return (
|
||||
<section className='space-y-3'>
|
||||
<div className='space-y-1'>
|
||||
<div className='text-sm font-semibold'>{props.title}</div>
|
||||
{(props.description || props.meteredFeature) && (
|
||||
<div className='text-muted-foreground flex flex-wrap items-center gap-2 text-xs'>
|
||||
{props.description && <span>{props.description}</span>}
|
||||
{props.meteredFeature && (
|
||||
<span className='bg-muted/60 inline-flex max-w-full items-center gap-2 rounded-full px-2 py-0.5'>
|
||||
<span className='text-[11px]'>metered_feature</span>
|
||||
<span className='min-w-0 font-mono text-xs break-all'>
|
||||
{props.meteredFeature}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className='grid grid-cols-1 gap-4 md:grid-cols-2'>
|
||||
<RateLimitWindow title={t('5-Hour Window')} window={fiveHourWindow} />
|
||||
<RateLimitWindow title={t('Weekly Window')} window={weeklyWindow} />
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function CopyableField(props: {
|
||||
icon: React.ReactNode
|
||||
label: string
|
||||
value?: string | null
|
||||
mono?: boolean
|
||||
}) {
|
||||
const { copyToClipboard, copiedText } = useCopyToClipboard({ notify: false })
|
||||
const text = props.value?.trim() || ''
|
||||
const hasCopied = copiedText === text
|
||||
|
||||
return (
|
||||
<div className='flex items-center justify-between gap-2 py-1'>
|
||||
<div className='flex min-w-0 items-center gap-2'>
|
||||
<span className='text-muted-foreground flex-shrink-0'>
|
||||
{props.icon}
|
||||
</span>
|
||||
<span className='text-muted-foreground flex-shrink-0 text-xs'>
|
||||
{props.label}
|
||||
</span>
|
||||
<span
|
||||
className={`min-w-0 truncate text-xs ${props.mono ? 'font-mono' : ''}`}
|
||||
>
|
||||
{text || '-'}
|
||||
</span>
|
||||
</div>
|
||||
{text && (
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='h-6 w-6 flex-shrink-0 p-0'
|
||||
onClick={() => copyToClipboard(text)}
|
||||
>
|
||||
{hasCopied ? (
|
||||
<Check className='h-3 w-3 text-green-600' />
|
||||
) : (
|
||||
<Copy className='h-3 w-3' />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function CodexUsageDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
channelName,
|
||||
channelId,
|
||||
response,
|
||||
onRefresh,
|
||||
isRefreshing,
|
||||
}: CodexUsageDialogProps) {
|
||||
const { t } = useTranslation()
|
||||
const { copiedText, copyToClipboard } = useCopyToClipboard({ notify: false })
|
||||
const [showRawJson, setShowRawJson] = useState(false)
|
||||
|
||||
const payload: CodexUsagePayload | null = useMemo(() => {
|
||||
const raw = response?.data
|
||||
if (!raw || typeof raw !== 'object') return null
|
||||
return raw as CodexUsagePayload
|
||||
}, [response?.data])
|
||||
|
||||
const rateLimit = payload?.rate_limit
|
||||
const accountType = payload?.plan_type ?? rateLimit?.plan_type
|
||||
const accountBadge = getAccountTypeBadge(accountType, t)
|
||||
const additionalRateLimits = (payload?.additional_rate_limits ?? []).filter(
|
||||
(item) => item && Object.keys(item).length > 0
|
||||
)
|
||||
|
||||
const statusBadge = (() => {
|
||||
if (!rateLimit || Object.keys(rateLimit).length === 0) {
|
||||
return (
|
||||
<StatusBadge label={t('Pending')} variant='neutral' copyable={false} />
|
||||
)
|
||||
}
|
||||
if (rateLimit.allowed && !rateLimit.limit_reached) {
|
||||
return (
|
||||
<StatusBadge
|
||||
label={t('Available')}
|
||||
variant='success'
|
||||
copyable={false}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<StatusBadge label={t('Limited')} variant='danger' copyable={false} />
|
||||
)
|
||||
})()
|
||||
|
||||
const errorMessage =
|
||||
response?.success === false
|
||||
? response?.message?.trim() || t('Failed to fetch usage')
|
||||
: ''
|
||||
|
||||
const rawJsonText = useMemo(() => {
|
||||
if (!response) return ''
|
||||
try {
|
||||
return JSON.stringify(
|
||||
{
|
||||
success: response.success,
|
||||
message: response.message,
|
||||
upstream_status: response.upstream_status,
|
||||
data: response.data,
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
} catch {
|
||||
return String(response?.data ?? '')
|
||||
}
|
||||
}, [response])
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className='sm:max-w-3xl'>
|
||||
<DialogHeader>
|
||||
<DialogTitle className='flex items-center gap-2'>
|
||||
{t('Codex Account & Usage')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('Channel:')} <strong>{channelName || '-'}</strong>{' '}
|
||||
{channelId ? `(#${channelId})` : ''}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className='space-y-4'>
|
||||
{errorMessage && (
|
||||
<div className='rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700 dark:border-red-800 dark:bg-red-950/30 dark:text-red-400'>
|
||||
{errorMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Account summary */}
|
||||
<div className='rounded-lg border p-4'>
|
||||
<div className='flex flex-wrap items-center justify-between gap-2'>
|
||||
<div className='flex flex-wrap items-center gap-2'>
|
||||
<StatusBadge
|
||||
label={accountBadge.label}
|
||||
variant={accountBadge.variant}
|
||||
copyable={false}
|
||||
/>
|
||||
{statusBadge}
|
||||
{typeof response?.upstream_status === 'number' && (
|
||||
<StatusBadge
|
||||
label={`${t('Status:')} ${response.upstream_status}`}
|
||||
variant='neutral'
|
||||
copyable={false}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{onRefresh && (
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={onRefresh}
|
||||
disabled={Boolean(isRefreshing)}
|
||||
>
|
||||
<RefreshCw className='mr-1.5 h-3.5 w-3.5' />
|
||||
{t('Refresh')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Account identity info */}
|
||||
<div className='bg-muted/30 mt-3 rounded-md px-3 py-2'>
|
||||
<CopyableField
|
||||
icon={<User className='h-3.5 w-3.5' />}
|
||||
label='User ID'
|
||||
value={payload?.user_id}
|
||||
mono
|
||||
/>
|
||||
<CopyableField
|
||||
icon={<Mail className='h-3.5 w-3.5' />}
|
||||
label={t('Email')}
|
||||
value={payload?.email}
|
||||
/>
|
||||
<CopyableField
|
||||
icon={<Hash className='h-3.5 w-3.5' />}
|
||||
label='Account ID'
|
||||
value={payload?.account_id}
|
||||
mono
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Rate limit windows */}
|
||||
<div className='space-y-5'>
|
||||
<div>
|
||||
<div className='mb-1 text-sm font-medium'>
|
||||
{t('Rate Limit Windows')}
|
||||
</div>
|
||||
<p className='text-muted-foreground mb-3 text-xs'>
|
||||
{t(
|
||||
'Tracks current account base limits and additional metered usage on Codex upstream.'
|
||||
)}
|
||||
</p>
|
||||
<RateLimitGroupSection
|
||||
title={t('Base Limits')}
|
||||
description={t('Base rate limit windows for this account.')}
|
||||
source={payload}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{additionalRateLimits.length > 0 && (
|
||||
<div className='space-y-4 border-t pt-4'>
|
||||
<div>
|
||||
<div className='text-sm font-medium'>
|
||||
{t('Additional Limits')}
|
||||
</div>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{t(
|
||||
'Per-feature metered windows split by model or capability.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className='space-y-4'>
|
||||
{additionalRateLimits.map((item, index) => {
|
||||
const limitName =
|
||||
item.limit_name ||
|
||||
item.metered_feature ||
|
||||
`${t('Additional Limit')} ${index + 1}`
|
||||
return (
|
||||
<div
|
||||
key={`${limitName}-${item.metered_feature ?? ''}-${index}`}
|
||||
className={index > 0 ? 'border-t pt-4' : ''}
|
||||
>
|
||||
<RateLimitGroupSection
|
||||
title={limitName}
|
||||
description={t('Additional metered capability')}
|
||||
source={item}
|
||||
meteredFeature={item.metered_feature}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Raw JSON collapsible */}
|
||||
<div className='rounded-lg border'>
|
||||
<button
|
||||
type='button'
|
||||
className='hover:bg-muted/40 flex w-full items-center justify-between gap-2 p-3 transition-colors'
|
||||
onClick={() => setShowRawJson((v) => !v)}
|
||||
>
|
||||
<div className='text-sm font-medium'>{t('Raw JSON')}</div>
|
||||
{showRawJson ? (
|
||||
<ChevronUp className='text-muted-foreground h-4 w-4' />
|
||||
) : (
|
||||
<ChevronDown className='text-muted-foreground h-4 w-4' />
|
||||
)}
|
||||
</button>
|
||||
{showRawJson && (
|
||||
<>
|
||||
<div className='flex justify-end border-t px-3 py-2'>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() => copyToClipboard(rawJsonText)}
|
||||
disabled={!rawJsonText}
|
||||
>
|
||||
{copiedText === rawJsonText ? (
|
||||
<Check className='mr-1.5 h-3.5 w-3.5 text-green-600' />
|
||||
) : (
|
||||
<Copy className='mr-1.5 h-3.5 w-3.5' />
|
||||
)}
|
||||
{t('Copy')}
|
||||
</Button>
|
||||
</div>
|
||||
<ScrollArea className='max-h-[50vh]'>
|
||||
<pre className='bg-muted/30 m-0 p-3 text-xs break-words whitespace-pre-wrap'>
|
||||
{rawJsonText || '-'}
|
||||
</pre>
|
||||
</ScrollArea>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
{t('Close')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
import { useState } from 'react'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { Loader2 } 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 { Label } from '@/components/ui/label'
|
||||
import { handleCopyChannel } from '../../lib'
|
||||
import { useChannels } from '../channels-provider'
|
||||
|
||||
type CopyChannelDialogProps = {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
export function CopyChannelDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: CopyChannelDialogProps) {
|
||||
const { t } = useTranslation()
|
||||
const { currentRow } = useChannels()
|
||||
const queryClient = useQueryClient()
|
||||
const [suffix, setSuffix] = useState('_copy')
|
||||
const [resetBalance, setResetBalance] = useState(true)
|
||||
const [isCopying, setIsCopying] = useState(false)
|
||||
|
||||
if (!currentRow) return null
|
||||
|
||||
const handleCopy = async () => {
|
||||
setIsCopying(true)
|
||||
|
||||
await handleCopyChannel(
|
||||
currentRow.id,
|
||||
{
|
||||
suffix,
|
||||
reset_balance: resetBalance,
|
||||
},
|
||||
queryClient,
|
||||
() => {
|
||||
onOpenChange(false)
|
||||
setSuffix('_copy')
|
||||
setResetBalance(true)
|
||||
}
|
||||
)
|
||||
|
||||
setIsCopying(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('Copy Channel')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('Create a copy of:')} <strong>{currentRow.name}</strong>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className='space-y-4 py-4'>
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='suffix'>{t('Name Suffix')}</Label>
|
||||
<Input
|
||||
id='suffix'
|
||||
placeholder={t('_copy')}
|
||||
value={suffix}
|
||||
onChange={(e) => setSuffix(e.target.value)}
|
||||
disabled={isCopying}
|
||||
/>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{t('New name will be:')} {currentRow.name}
|
||||
{suffix}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className='flex items-center space-x-2'>
|
||||
<Checkbox
|
||||
id='reset-balance'
|
||||
checked={resetBalance}
|
||||
onCheckedChange={(checked) => setResetBalance(!!checked)}
|
||||
disabled={isCopying}
|
||||
/>
|
||||
<Label htmlFor='reset-balance' className='text-sm font-normal'>
|
||||
{t('Reset balance and used quota')}
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant='outline'
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isCopying}
|
||||
>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleCopy} disabled={isCopying}>
|
||||
{isCopying && <Loader2 className='mr-2 h-4 w-4 animate-spin' />}
|
||||
{isCopying ? 'Copying...' : 'Copy Channel'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,420 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import {
|
||||
editTagChannels,
|
||||
getTagModels,
|
||||
getAllModels,
|
||||
getGroups,
|
||||
} from '../../api'
|
||||
import { channelsQueryKeys } from '../../lib'
|
||||
import type { TagOperationParams } from '../../types'
|
||||
import { useChannels } from '../channels-provider'
|
||||
|
||||
type EditTagDialogProps = {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
export function EditTagDialog({ open, onOpenChange }: EditTagDialogProps) {
|
||||
const { t } = useTranslation()
|
||||
const { currentTag } = useChannels()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
// Form state
|
||||
const [newTag, setNewTag] = useState('')
|
||||
const [selectedModels, setSelectedModels] = useState<string[]>([])
|
||||
const [customModel, setCustomModel] = useState('')
|
||||
const [modelMapping, setModelMapping] = useState('')
|
||||
const [selectedGroups, setSelectedGroups] = useState<string[]>([])
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
// Fetch tag models
|
||||
const { data: tagModelsData, isLoading: isLoadingTagModels } = useQuery({
|
||||
queryKey: ['tag-models', currentTag],
|
||||
queryFn: () => (currentTag ? getTagModels(currentTag) : null),
|
||||
enabled: open && !!currentTag,
|
||||
})
|
||||
|
||||
// Fetch all available models
|
||||
const { data: allModelsData } = useQuery({
|
||||
queryKey: ['all-models'],
|
||||
queryFn: getAllModels,
|
||||
enabled: open,
|
||||
})
|
||||
|
||||
// Fetch groups
|
||||
const { data: groupsData } = useQuery({
|
||||
queryKey: ['groups'],
|
||||
queryFn: getGroups,
|
||||
enabled: open,
|
||||
})
|
||||
|
||||
const availableModels =
|
||||
allModelsData?.data?.map((m) => m.id).filter(Boolean) || []
|
||||
const availableGroups = groupsData?.data || []
|
||||
|
||||
// Initialize form when tag changes
|
||||
useEffect(() => {
|
||||
if (open && currentTag) {
|
||||
setNewTag(currentTag)
|
||||
setModelMapping('')
|
||||
setSelectedGroups([])
|
||||
setCustomModel('')
|
||||
|
||||
// Load tag models
|
||||
if (tagModelsData?.data) {
|
||||
const models = tagModelsData.data.split(',').filter(Boolean)
|
||||
setSelectedModels(models)
|
||||
} else {
|
||||
setSelectedModels([])
|
||||
}
|
||||
}
|
||||
}, [open, currentTag, tagModelsData])
|
||||
|
||||
const handleAddCustomModel = () => {
|
||||
if (!customModel.trim()) return
|
||||
|
||||
const modelsToAdd = customModel
|
||||
.split(',')
|
||||
.map((m) => m.trim())
|
||||
.filter(Boolean)
|
||||
.filter((m) => !selectedModels.includes(m))
|
||||
|
||||
if (modelsToAdd.length > 0) {
|
||||
setSelectedModels([...selectedModels, ...modelsToAdd])
|
||||
toast.success(
|
||||
t('Added {{count}} model(s)', { count: modelsToAdd.length })
|
||||
)
|
||||
setCustomModel('')
|
||||
} else {
|
||||
toast.info(t('No new models to add'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemoveModel = (model: string) => {
|
||||
setSelectedModels(selectedModels.filter((m) => m !== model))
|
||||
}
|
||||
|
||||
const handleToggleGroup = (group: string) => {
|
||||
setSelectedGroups((prev) =>
|
||||
prev.includes(group) ? prev.filter((g) => g !== group) : [...prev, group]
|
||||
)
|
||||
}
|
||||
|
||||
const validateForm = () => {
|
||||
// Validate model mapping if provided
|
||||
if (modelMapping.trim()) {
|
||||
try {
|
||||
JSON.parse(modelMapping)
|
||||
} catch {
|
||||
toast.error(t('Model mapping must be valid JSON'))
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!currentTag) return
|
||||
if (!validateForm()) return
|
||||
|
||||
// Check if anything changed
|
||||
const hasChanges =
|
||||
newTag !== currentTag ||
|
||||
modelMapping.trim() ||
|
||||
selectedModels.length > 0 ||
|
||||
selectedGroups.length > 0
|
||||
|
||||
if (!hasChanges) {
|
||||
toast.warning(t('No changes to save'))
|
||||
return
|
||||
}
|
||||
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
const params: Record<string, string | null> = { tag: currentTag }
|
||||
|
||||
if (newTag && newTag !== currentTag) {
|
||||
params.new_tag = newTag || null
|
||||
}
|
||||
|
||||
if (modelMapping.trim()) {
|
||||
params.model_mapping = modelMapping
|
||||
}
|
||||
|
||||
if (selectedModels.length > 0) {
|
||||
params.models = selectedModels.join(',')
|
||||
}
|
||||
|
||||
if (selectedGroups.length > 0) {
|
||||
params.groups = selectedGroups.join(',')
|
||||
}
|
||||
|
||||
const response = await editTagChannels(
|
||||
params as unknown as TagOperationParams
|
||||
)
|
||||
|
||||
if (response.success) {
|
||||
toast.success(t('Tag updated successfully'))
|
||||
queryClient.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
|
||||
onOpenChange(false)
|
||||
} else {
|
||||
toast.error(response.message || t('Failed to update tag'))
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : t('Failed to update tag')
|
||||
)
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
if (!currentTag) return null
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent className='max-h-[90vh] max-w-2xl'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t('Edit Tag:')} {currentTag}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t(
|
||||
'Batch edit all channels with this tag. Leave fields empty to keep current values.'
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<ScrollArea className='max-h-[60vh] pr-4'>
|
||||
<div className='space-y-6'>
|
||||
{/* Tag Name */}
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='new-tag'>
|
||||
{t('Tag Name')}
|
||||
<span className='text-muted-foreground ml-2 text-xs'>
|
||||
{t('(Leave empty to dissolve tag)')}
|
||||
</span>
|
||||
</Label>
|
||||
<Input
|
||||
id='new-tag'
|
||||
value={newTag}
|
||||
onChange={(e) => setNewTag(e.target.value)}
|
||||
placeholder={t('Enter new tag name or leave empty')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Models */}
|
||||
<div className='space-y-2'>
|
||||
<Label>
|
||||
{t('Models')}
|
||||
<span className='text-muted-foreground ml-2 text-xs'>
|
||||
{t("(Override all channels' models)")}
|
||||
</span>
|
||||
</Label>
|
||||
|
||||
{isLoadingTagModels ? (
|
||||
<div className='flex items-center gap-2 py-4'>
|
||||
<Loader2 className='h-4 w-4 animate-spin' />
|
||||
<span className='text-muted-foreground text-sm'>
|
||||
{t('Loading current models...')}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className='flex min-h-[60px] flex-wrap gap-2 rounded-md border p-3'>
|
||||
{selectedModels.length > 0 ? (
|
||||
selectedModels.map((model) => (
|
||||
<StatusBadge
|
||||
key={model}
|
||||
variant='neutral'
|
||||
className='cursor-pointer transition-opacity hover:opacity-70'
|
||||
copyable={false}
|
||||
onClick={() => handleRemoveModel(model)}
|
||||
>
|
||||
{model} ×
|
||||
</StatusBadge>
|
||||
))
|
||||
) : (
|
||||
<span className='text-muted-foreground text-sm'>
|
||||
{t('No models selected')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='flex gap-2'>
|
||||
<Select
|
||||
onValueChange={(value) => {
|
||||
if (!selectedModels.includes(value)) {
|
||||
setSelectedModels([...selectedModels, value])
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className='flex-1'>
|
||||
<SelectValue
|
||||
placeholder={t('Add from available models...')}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<ScrollArea className='h-60'>
|
||||
{availableModels.map((model) => (
|
||||
<SelectItem key={model} value={model}>
|
||||
{model}
|
||||
</SelectItem>
|
||||
))}
|
||||
</ScrollArea>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className='flex gap-2'>
|
||||
<Input
|
||||
placeholder={t('Custom model (comma-separated)')}
|
||||
value={customModel}
|
||||
onChange={(e) => setCustomModel(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
handleAddCustomModel()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type='button'
|
||||
variant='secondary'
|
||||
onClick={handleAddCustomModel}
|
||||
>
|
||||
{t('Add')}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Model Mapping */}
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='model-mapping'>
|
||||
{t('Model Mapping (JSON)')}
|
||||
<span className='text-muted-foreground ml-2 text-xs'>
|
||||
{t('(Optional: redirect model names)')}
|
||||
</span>
|
||||
</Label>
|
||||
<Textarea
|
||||
id='model-mapping'
|
||||
value={modelMapping}
|
||||
onChange={(e) => setModelMapping(e.target.value)}
|
||||
placeholder={'{\n "gpt-3.5-turbo": "gpt-3.5-turbo-0125"\n}'}
|
||||
rows={4}
|
||||
className='font-mono text-sm'
|
||||
/>
|
||||
<div className='flex gap-2'>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() =>
|
||||
setModelMapping(
|
||||
JSON.stringify(
|
||||
{ 'gpt-3.5-turbo': 'gpt-3.5-turbo-0125' },
|
||||
null,
|
||||
2
|
||||
)
|
||||
)
|
||||
}
|
||||
>
|
||||
{t('Example')}
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() => setModelMapping(JSON.stringify({}, null, 2))}
|
||||
>
|
||||
{t('Clear Mapping')}
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() => setModelMapping('')}
|
||||
>
|
||||
{t('No Change')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Groups */}
|
||||
<div className='space-y-2'>
|
||||
<Label>
|
||||
{t('Groups')}
|
||||
<span className='text-muted-foreground ml-2 text-xs'>
|
||||
{t("(Override all channels' groups)")}
|
||||
</span>
|
||||
</Label>
|
||||
<div className='flex min-h-[60px] flex-wrap gap-2 rounded-md border p-3'>
|
||||
{availableGroups.map((group) => (
|
||||
<StatusBadge
|
||||
key={group}
|
||||
variant={
|
||||
selectedGroups.includes(group) ? 'info' : 'neutral'
|
||||
}
|
||||
className='cursor-pointer transition-opacity hover:opacity-70'
|
||||
copyable={false}
|
||||
onClick={() => handleToggleGroup(group)}
|
||||
>
|
||||
{group}
|
||||
</StatusBadge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant='outline' onClick={handleClose}>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={isSubmitting}>
|
||||
{isSubmitting && <Loader2 className='mr-2 h-4 w-4 animate-spin' />}
|
||||
{t('Save Changes')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
+473
@@ -0,0 +1,473 @@
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { Loader2, Search, Info, ChevronDown } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
import { fetchUpstreamModels, updateChannel } from '../../api'
|
||||
import {
|
||||
channelsQueryKeys,
|
||||
categorizeModelsWithRedirect,
|
||||
normalizeModelName,
|
||||
parseModelsString,
|
||||
} from '../../lib'
|
||||
import { useChannels } from '../channels-provider'
|
||||
|
||||
function normalizeModelNameList(models: readonly string[]): string[] {
|
||||
return Array.from(
|
||||
new Set(models.map((m) => normalizeModelName(m)).filter(Boolean))
|
||||
)
|
||||
}
|
||||
|
||||
type FetchModelsDialogProps = {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onModelsSelected?: (models: string[]) => void
|
||||
redirectModels?: string[]
|
||||
redirectSourceModels?: string[]
|
||||
}
|
||||
|
||||
export function FetchModelsDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onModelsSelected,
|
||||
redirectModels = [],
|
||||
redirectSourceModels = [],
|
||||
}: FetchModelsDialogProps) {
|
||||
const { t } = useTranslation()
|
||||
const { currentRow } = useChannels()
|
||||
const queryClient = useQueryClient()
|
||||
const [isFetching, setIsFetching] = useState(false)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [fetchedModels, setFetchedModels] = useState<string[]>([])
|
||||
const [selectedModels, setSelectedModels] = useState<string[]>([])
|
||||
const [searchKeyword, setSearchKeyword] = useState('')
|
||||
|
||||
// Parse existing models
|
||||
const existingModels = useMemo(
|
||||
() => parseModelsString(currentRow?.models || ''),
|
||||
[currentRow?.models]
|
||||
)
|
||||
|
||||
// Categorize models with redirect models
|
||||
const modelCategories = useMemo(
|
||||
() => categorizeModelsWithRedirect(existingModels, redirectModels),
|
||||
[existingModels, redirectModels]
|
||||
)
|
||||
|
||||
const { classificationSet, redirectOnlySet } = modelCategories
|
||||
|
||||
const fetchedModelSet = useMemo(
|
||||
() => new Set(normalizeModelNameList(fetchedModels)),
|
||||
[fetchedModels]
|
||||
)
|
||||
|
||||
// Source keys in model_mapping are aliases, not real upstream IDs, so we
|
||||
// must skip them when computing "removed upstream" entries to avoid false
|
||||
// positives.
|
||||
const redirectSourceKeysSet = useMemo(
|
||||
() => new Set(normalizeModelNameList(redirectSourceModels)),
|
||||
[redirectSourceModels]
|
||||
)
|
||||
|
||||
const removedModels = useMemo(() => {
|
||||
const kw = searchKeyword.toLowerCase().trim()
|
||||
return normalizeModelNameList(selectedModels).filter((model) => {
|
||||
if (fetchedModelSet.has(model)) return false
|
||||
if (redirectSourceKeysSet.has(model)) return false
|
||||
if (!kw) return true
|
||||
return model.toLowerCase().includes(kw)
|
||||
})
|
||||
}, [fetchedModelSet, redirectSourceKeysSet, searchKeyword, selectedModels])
|
||||
|
||||
useEffect(() => {
|
||||
if (open && currentRow) {
|
||||
handleFetchModels()
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, currentRow?.id])
|
||||
|
||||
const handleFetchModels = async () => {
|
||||
if (!currentRow) return
|
||||
|
||||
setIsFetching(true)
|
||||
try {
|
||||
const response = await fetchUpstreamModels(currentRow.id)
|
||||
if (response.success) {
|
||||
const list = Array.isArray(response.data) ? response.data : []
|
||||
setFetchedModels(list)
|
||||
setSelectedModels(existingModels)
|
||||
toast.success(t('Fetched {{count}} models', { count: list.length }))
|
||||
} else {
|
||||
toast.error(response.message || t('Failed to fetch models'))
|
||||
setFetchedModels([])
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : t('Failed to fetch models')
|
||||
)
|
||||
setFetchedModels([])
|
||||
} finally {
|
||||
setIsFetching(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!currentRow) return
|
||||
|
||||
// If onModelsSelected callback is provided, use it (form filling mode)
|
||||
if (onModelsSelected) {
|
||||
onModelsSelected(selectedModels)
|
||||
toast.success(t('Models filled to form'))
|
||||
onOpenChange(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Otherwise, directly save to API (standalone mode)
|
||||
setIsSaving(true)
|
||||
try {
|
||||
const modelsString = selectedModels.join(',')
|
||||
const response = await updateChannel(currentRow.id, {
|
||||
models: modelsString,
|
||||
})
|
||||
if (response.success) {
|
||||
toast.success(t('Models updated successfully'))
|
||||
queryClient.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
|
||||
onOpenChange(false)
|
||||
} else {
|
||||
toast.error(response.message || t('Failed to update models'))
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : t('Failed to update models')
|
||||
)
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
setFetchedModels([])
|
||||
setSelectedModels([])
|
||||
setSearchKeyword('')
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
// Categorize models by common prefixes
|
||||
const categorizeModels = (models: string[]) => {
|
||||
const categories: Record<string, string[]> = {}
|
||||
|
||||
models.forEach((model) => {
|
||||
let category = 'Other'
|
||||
|
||||
// Determine category based on model name
|
||||
if (
|
||||
model.toLowerCase().includes('gpt') ||
|
||||
model.toLowerCase().includes('o1') ||
|
||||
model.toLowerCase().includes('o3')
|
||||
) {
|
||||
category = 'OpenAI'
|
||||
} else if (model.toLowerCase().includes('claude')) {
|
||||
category = 'Anthropic'
|
||||
} else if (model.toLowerCase().includes('gemini')) {
|
||||
category = 'Gemini'
|
||||
} else if (model.toLowerCase().includes('qwen')) {
|
||||
category = 'Qwen'
|
||||
} else if (model.toLowerCase().includes('deepseek')) {
|
||||
category = 'DeepSeek'
|
||||
} else if (model.toLowerCase().includes('glm')) {
|
||||
category = 'Zhipu'
|
||||
} else if (model.toLowerCase().includes('llama')) {
|
||||
category = 'Meta'
|
||||
} else if (model.toLowerCase().includes('mistral')) {
|
||||
category = 'Mistral'
|
||||
}
|
||||
|
||||
if (!categories[category]) {
|
||||
categories[category] = []
|
||||
}
|
||||
categories[category].push(model)
|
||||
})
|
||||
|
||||
return categories
|
||||
}
|
||||
|
||||
// Filter models by search
|
||||
const filteredModels = useMemo(() => {
|
||||
if (!searchKeyword) return fetchedModels
|
||||
return fetchedModels.filter((model) =>
|
||||
model.toLowerCase().includes(searchKeyword.toLowerCase())
|
||||
)
|
||||
}, [fetchedModels, searchKeyword])
|
||||
|
||||
// Helper to check if a model is considered "existing" (in selected or redirect)
|
||||
const isExistingModel = (model: string) =>
|
||||
classificationSet.has(normalizeModelName(model))
|
||||
|
||||
// Separate new and existing models
|
||||
const newModels = filteredModels.filter((m) => !isExistingModel(m))
|
||||
const existingFilteredModels = filteredModels.filter((m) =>
|
||||
isExistingModel(m)
|
||||
)
|
||||
|
||||
const newModelsByCategory = categorizeModels(newModels)
|
||||
const existingModelsByCategory = categorizeModels(existingFilteredModels)
|
||||
|
||||
// 厂商分类按 a-z 排序,Other 放最后,便于查找
|
||||
const getSortedCategoryEntries = (
|
||||
categories: Record<string, string[]>
|
||||
): [string, string[]][] =>
|
||||
Object.entries(categories).sort(([a], [b]) => {
|
||||
if (a === 'Other') return 1
|
||||
if (b === 'Other') return -1
|
||||
return a.localeCompare(b, undefined, { sensitivity: 'base' })
|
||||
})
|
||||
|
||||
const toggleModel = (model: string) => {
|
||||
setSelectedModels((prev) =>
|
||||
prev.includes(model) ? prev.filter((m) => m !== model) : [...prev, model]
|
||||
)
|
||||
}
|
||||
|
||||
const toggleCategory = (categoryModels: string[], isChecked: boolean) => {
|
||||
setSelectedModels((prev) => {
|
||||
if (isChecked) {
|
||||
const newSelected = [...prev]
|
||||
categoryModels.forEach((model) => {
|
||||
if (!newSelected.includes(model)) {
|
||||
newSelected.push(model)
|
||||
}
|
||||
})
|
||||
return newSelected
|
||||
} else {
|
||||
return prev.filter((m) => !categoryModels.includes(m))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const isCategorySelected = (categoryModels: string[]) => {
|
||||
return categoryModels.every((m) => selectedModels.includes(m))
|
||||
}
|
||||
|
||||
const renderModelCategory = (
|
||||
categoryName: string,
|
||||
categoryModels: string[]
|
||||
) => {
|
||||
const allSelected = isCategorySelected(categoryModels)
|
||||
|
||||
return (
|
||||
<Collapsible key={categoryName} defaultOpen>
|
||||
<CollapsibleTrigger className='hover:bg-muted/50 flex w-full items-center justify-between rounded-lg border p-3'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<ChevronDown className='h-4 w-4' />
|
||||
<span className='font-medium'>
|
||||
{categoryName} ({categoryModels.length})
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex items-center gap-2'>
|
||||
<span className='text-muted-foreground text-sm'>
|
||||
{categoryModels.filter((m) => selectedModels.includes(m)).length}{' '}
|
||||
/ {categoryModels.length} selected
|
||||
</span>
|
||||
<Checkbox
|
||||
checked={allSelected}
|
||||
onCheckedChange={(checked) =>
|
||||
toggleCategory(categoryModels, !!checked)
|
||||
}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</div>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className='px-4 py-2'>
|
||||
<div className='grid grid-cols-2 gap-2'>
|
||||
{categoryModels.map((model) => (
|
||||
<div key={model} className='flex items-center space-x-2'>
|
||||
<Checkbox
|
||||
id={model}
|
||||
checked={selectedModels.includes(model)}
|
||||
onCheckedChange={() => toggleModel(model)}
|
||||
/>
|
||||
<Label
|
||||
htmlFor={model}
|
||||
className='flex cursor-pointer items-center gap-1.5 text-sm font-normal'
|
||||
>
|
||||
<span>{model}</span>
|
||||
{redirectOnlySet.has(normalizeModelName(model)) && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Info className='h-3.5 w-3.5 text-amber-500' />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{t('From model redirect, not yet added to models list')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent className='max-w-3xl'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('Fetch Models')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('Fetch available models for:')}{' '}
|
||||
<strong>{currentRow?.name}</strong>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{!currentRow ? (
|
||||
<div className='text-muted-foreground py-8 text-center'>
|
||||
{t('No channel selected')}
|
||||
</div>
|
||||
) : isFetching ? (
|
||||
<div className='flex items-center justify-center py-12'>
|
||||
<Loader2 className='text-muted-foreground h-8 w-8 animate-spin' />
|
||||
</div>
|
||||
) : fetchedModels.length === 0 && removedModels.length === 0 ? (
|
||||
<div className='text-muted-foreground py-8 text-center'>
|
||||
<p>{t('No models fetched yet.')}</p>
|
||||
<Button
|
||||
className='mt-4'
|
||||
onClick={handleFetchModels}
|
||||
disabled={isFetching}
|
||||
>
|
||||
{t('Fetch Models')}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className='space-y-4'>
|
||||
{/* Search Bar */}
|
||||
<div className='relative'>
|
||||
<Search className='text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2' />
|
||||
<Input
|
||||
placeholder={t('Search models...')}
|
||||
value={searchKeyword}
|
||||
onChange={(e) => setSearchKeyword(e.target.value)}
|
||||
className='pl-9'
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Tabs for New vs Existing vs Removed */}
|
||||
<Tabs
|
||||
key={`${currentRow?.id}-${fetchedModels.length}-${removedModels.length}`}
|
||||
defaultValue={
|
||||
newModels.length > 0
|
||||
? 'new'
|
||||
: removedModels.length > 0
|
||||
? 'removed'
|
||||
: 'existing'
|
||||
}
|
||||
>
|
||||
<TabsList
|
||||
className={`grid w-full ${removedModels.length > 0 ? 'grid-cols-3' : 'grid-cols-2'}`}
|
||||
>
|
||||
<TabsTrigger value='new' disabled={newModels.length === 0}>
|
||||
{t('New Models ({{count}})', { count: newModels.length })}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value='existing'
|
||||
disabled={existingFilteredModels.length === 0}
|
||||
>
|
||||
{t('Existing Models ({{count}})', {
|
||||
count: existingFilteredModels.length,
|
||||
})}
|
||||
</TabsTrigger>
|
||||
{removedModels.length > 0 && (
|
||||
<TabsTrigger value='removed'>
|
||||
{t('Removed Models ({{count}})', {
|
||||
count: removedModels.length,
|
||||
})}
|
||||
</TabsTrigger>
|
||||
)}
|
||||
</TabsList>
|
||||
|
||||
<TabsContent
|
||||
value='new'
|
||||
className='max-h-96 space-y-2 overflow-y-auto'
|
||||
>
|
||||
{getSortedCategoryEntries(newModelsByCategory).map(
|
||||
([category, models]) =>
|
||||
renderModelCategory(category, models)
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent
|
||||
value='existing'
|
||||
className='max-h-96 space-y-2 overflow-y-auto'
|
||||
>
|
||||
{getSortedCategoryEntries(existingModelsByCategory).map(
|
||||
([category, models]) =>
|
||||
renderModelCategory(category, models)
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{removedModels.length > 0 && (
|
||||
<TabsContent
|
||||
value='removed'
|
||||
className='max-h-96 space-y-2 overflow-y-auto'
|
||||
>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{t(
|
||||
'These models are still in your selection but were not returned by the upstream listing. Entries that are only model_mapping source aliases are omitted. Toggle to adjust before saving.'
|
||||
)}
|
||||
</p>
|
||||
{renderModelCategory(t('Removed'), removedModels)}
|
||||
</TabsContent>
|
||||
)}
|
||||
</Tabs>
|
||||
|
||||
{/* Selection Summary */}
|
||||
<div className='bg-muted/50 rounded-lg border p-3 text-sm'>
|
||||
{t('{{n}} model(s) selected', { n: selectedModels.length })}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant='outline'
|
||||
onClick={handleClose}
|
||||
disabled={isSaving}
|
||||
>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={isSaving}>
|
||||
{isSaving && <Loader2 className='mr-2 h-4 w-4 animate-spin' />}
|
||||
{isSaving ? t('Saving...') : t('Save Models')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
Vendored
+84
@@ -0,0 +1,84 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
export type MissingModelsAction = 'cancel' | 'submit' | 'add'
|
||||
|
||||
type MissingModelsConfirmationDialogProps = {
|
||||
open: boolean
|
||||
missingModels: string[]
|
||||
onConfirm: (action: MissingModelsAction) => void
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirmation dialog shown when models in model_mapping are missing from the models list
|
||||
* Provides three options:
|
||||
* 1. Cancel - Go back to edit
|
||||
* 2. Submit - Submit anyway without adding missing models
|
||||
* 3. Add - Automatically add missing models and submit
|
||||
*/
|
||||
export function MissingModelsConfirmationDialog({
|
||||
open,
|
||||
missingModels,
|
||||
onConfirm,
|
||||
onOpenChange,
|
||||
}: MissingModelsConfirmationDialogProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const handleOpenChange = (newOpen: boolean) => {
|
||||
if (!newOpen) {
|
||||
onConfirm('cancel')
|
||||
}
|
||||
onOpenChange?.(newOpen)
|
||||
}
|
||||
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={handleOpenChange}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t('Models not in list, may fail to invoke')}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription asChild>
|
||||
<div className='space-y-3 text-sm'>
|
||||
<div>
|
||||
{t(
|
||||
'The following models in the model redirect have not been added to the "Models" list and may fail during invocation due to missing available models:'
|
||||
)}
|
||||
</div>
|
||||
<div className='rounded-md bg-red-50 p-2 font-mono text-xs break-all text-red-600 dark:bg-red-950/50 dark:text-red-400'>
|
||||
{missingModels.join(', ')}
|
||||
</div>
|
||||
<div>
|
||||
{t(
|
||||
'You can manually add them in "Custom Model Names", click "Fill" and then submit, or use the operations below to handle automatically.'
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter className='flex-col gap-2 sm:flex-row'>
|
||||
<AlertDialogCancel onClick={() => onConfirm('cancel')}>
|
||||
{t('Go back and edit')}
|
||||
</AlertDialogCancel>
|
||||
<Button variant='secondary' onClick={() => onConfirm('submit')}>
|
||||
{t('Submit directly')}
|
||||
</Button>
|
||||
<AlertDialogAction onClick={() => onConfirm('add')}>
|
||||
{t('Add and submit')}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)
|
||||
}
|
||||
+423
@@ -0,0 +1,423 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { Loader2, RefreshCw, Trash2, Power, PowerOff } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import {
|
||||
getMultiKeyStatus,
|
||||
enableMultiKey,
|
||||
disableMultiKey,
|
||||
deleteMultiKey,
|
||||
enableAllMultiKeys,
|
||||
disableAllMultiKeys,
|
||||
deleteDisabledMultiKeys,
|
||||
} from '../../api'
|
||||
import { MULTI_KEY_FILTER_OPTIONS } from '../../constants'
|
||||
import {
|
||||
channelsQueryKeys,
|
||||
formatTimestamp,
|
||||
getMultiKeyStatusConfig,
|
||||
getMultiKeyConfirmMessage,
|
||||
isDestructiveAction,
|
||||
} from '../../lib'
|
||||
import type { KeyStatus, MultiKeyConfirmAction } from '../../types'
|
||||
import { useChannels } from '../channels-provider'
|
||||
import { StatisticsCard } from './multi-key-statistics-card'
|
||||
import { MultiKeyTableRowActions } from './multi-key-table-row-actions'
|
||||
|
||||
type MultiKeyManageDialogProps = {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
export function MultiKeyManageDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: MultiKeyManageDialogProps) {
|
||||
const { t } = useTranslation()
|
||||
const { currentRow } = useChannels()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
// Data state
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [keys, setKeys] = useState<KeyStatus[]>([])
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(10)
|
||||
const [total, setTotal] = useState(0)
|
||||
const [totalPages, setTotalPages] = useState(0)
|
||||
const [enabledCount, setEnabledCount] = useState(0)
|
||||
const [manualDisabledCount, setManualDisabledCount] = useState(0)
|
||||
const [autoDisabledCount, setAutoDisabledCount] = useState(0)
|
||||
|
||||
// UI state
|
||||
const [statusFilter, setStatusFilter] = useState<number | null>(null)
|
||||
const [confirmAction, setConfirmAction] =
|
||||
useState<MultiKeyConfirmAction | null>(null)
|
||||
const [isPerformingAction, setIsPerformingAction] = useState(false)
|
||||
|
||||
// Reset and load data when dialog opens
|
||||
useEffect(() => {
|
||||
if (open && currentRow) {
|
||||
setCurrentPage(1)
|
||||
setStatusFilter(null)
|
||||
loadKeyStatus(1, pageSize, null)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, currentRow?.id])
|
||||
|
||||
const loadKeyStatus = async (
|
||||
page: number = currentPage,
|
||||
size: number = pageSize,
|
||||
status: number | null = statusFilter
|
||||
) => {
|
||||
if (!currentRow) return
|
||||
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const response = await getMultiKeyStatus(
|
||||
currentRow.id,
|
||||
page,
|
||||
size,
|
||||
status === null ? undefined : status
|
||||
)
|
||||
|
||||
if (response.success && response.data) {
|
||||
setKeys(response.data.keys || [])
|
||||
setTotal(response.data.total || 0)
|
||||
setCurrentPage(response.data.page || 1)
|
||||
setPageSize(response.data.page_size || 10)
|
||||
setTotalPages(response.data.total_pages || 0)
|
||||
setEnabledCount(response.data.enabled_count || 0)
|
||||
setManualDisabledCount(response.data.manual_disabled_count || 0)
|
||||
setAutoDisabledCount(response.data.auto_disabled_count || 0)
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : 'Failed to load key status'
|
||||
)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleStatusFilterChange = (value: string) => {
|
||||
const newFilter = value === 'all' ? null : parseInt(value)
|
||||
setStatusFilter(newFilter)
|
||||
setCurrentPage(1)
|
||||
loadKeyStatus(1, pageSize, newFilter)
|
||||
}
|
||||
|
||||
const handlePageChange = (newPage: number) => {
|
||||
setCurrentPage(newPage)
|
||||
loadKeyStatus(newPage, pageSize)
|
||||
}
|
||||
|
||||
const performAction = async () => {
|
||||
if (!confirmAction || !currentRow) return
|
||||
|
||||
setIsPerformingAction(true)
|
||||
try {
|
||||
const { type, keyIndex } = confirmAction
|
||||
let response
|
||||
|
||||
// Execute the appropriate action
|
||||
if (type === 'enable' && keyIndex !== undefined) {
|
||||
response = await enableMultiKey(currentRow.id, keyIndex)
|
||||
} else if (type === 'disable' && keyIndex !== undefined) {
|
||||
response = await disableMultiKey(currentRow.id, keyIndex)
|
||||
} else if (type === 'delete' && keyIndex !== undefined) {
|
||||
response = await deleteMultiKey(currentRow.id, keyIndex)
|
||||
} else if (type === 'enable-all') {
|
||||
response = await enableAllMultiKeys(currentRow.id)
|
||||
} else if (type === 'disable-all') {
|
||||
response = await disableAllMultiKeys(currentRow.id)
|
||||
} else if (type === 'delete-disabled') {
|
||||
response = await deleteDisabledMultiKeys(currentRow.id)
|
||||
}
|
||||
|
||||
if (response?.success) {
|
||||
toast.success(response.message || 'Operation successful')
|
||||
queryClient.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
|
||||
|
||||
// Reload data - reset to page 1 for bulk actions
|
||||
const isBulkAction = type.includes('all') || type === 'delete-disabled'
|
||||
if (isBulkAction) {
|
||||
setCurrentPage(1)
|
||||
loadKeyStatus(1, pageSize)
|
||||
} else {
|
||||
loadKeyStatus(currentPage, pageSize)
|
||||
}
|
||||
} else {
|
||||
toast.error(response?.message || 'Operation failed')
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
toast.error(error instanceof Error ? error.message : 'Operation failed')
|
||||
} finally {
|
||||
setIsPerformingAction(false)
|
||||
setConfirmAction(null)
|
||||
}
|
||||
}
|
||||
|
||||
const renderStatusBadge = (status: number) => {
|
||||
const config = getMultiKeyStatusConfig(status)
|
||||
return (
|
||||
<StatusBadge
|
||||
label={t(config.label)}
|
||||
variant={config.variant}
|
||||
showDot
|
||||
copyable={false}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const formatKeyTimestamp = (timestamp?: number) => {
|
||||
if (!timestamp) return '-'
|
||||
return formatTimestamp(timestamp)
|
||||
}
|
||||
|
||||
if (!currentRow) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className='flex max-h-[90vh] max-w-5xl flex-col'>
|
||||
<DialogHeader>
|
||||
<DialogTitle className='flex items-center gap-2'>
|
||||
{t('Multi-Key Management')}
|
||||
<StatusBadge
|
||||
label={currentRow.name}
|
||||
variant='neutral'
|
||||
copyable={false}
|
||||
/>
|
||||
{currentRow.channel_info?.multi_key_mode && (
|
||||
<StatusBadge
|
||||
label={
|
||||
currentRow.channel_info.multi_key_mode === 'random'
|
||||
? t('Random')
|
||||
: t('Polling')
|
||||
}
|
||||
variant='neutral'
|
||||
copyable={false}
|
||||
/>
|
||||
)}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('Manage multi-key status and configuration for this channel')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className='flex min-h-0 flex-1 flex-col space-y-4 overflow-hidden'>
|
||||
{/* Statistics */}
|
||||
<div className='grid shrink-0 grid-cols-3 gap-3'>
|
||||
<StatisticsCard
|
||||
label={t('Enabled')}
|
||||
count={enabledCount}
|
||||
total={total}
|
||||
/>
|
||||
<StatisticsCard
|
||||
label={t('Manual Disabled')}
|
||||
count={manualDisabledCount}
|
||||
total={total}
|
||||
/>
|
||||
<StatisticsCard
|
||||
label={t('Auto Disabled')}
|
||||
count={autoDisabledCount}
|
||||
total={total}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Separator className='shrink-0' />
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className='flex shrink-0 items-center justify-between'>
|
||||
<Select
|
||||
value={statusFilter === null ? 'all' : statusFilter.toString()}
|
||||
onValueChange={handleStatusFilterChange}
|
||||
>
|
||||
<SelectTrigger className='w-40'>
|
||||
<SelectValue placeholder={t('All Status')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{MULTI_KEY_FILTER_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{t(option.label)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<div className='flex items-center gap-2'>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() => loadKeyStatus()}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<RefreshCw className='h-4 w-4' />
|
||||
</Button>
|
||||
|
||||
{manualDisabledCount + autoDisabledCount > 0 && (
|
||||
<Button
|
||||
variant='default'
|
||||
size='sm'
|
||||
onClick={() => setConfirmAction({ type: 'enable-all' })}
|
||||
>
|
||||
<Power className='mr-2 h-4 w-4' />
|
||||
{t('Enable All')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{enabledCount > 0 && (
|
||||
<Button
|
||||
variant='destructive'
|
||||
size='sm'
|
||||
onClick={() => setConfirmAction({ type: 'disable-all' })}
|
||||
>
|
||||
<PowerOff className='mr-2 h-4 w-4' />
|
||||
{t('Disable All')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{autoDisabledCount > 0 && (
|
||||
<Button
|
||||
variant='destructive'
|
||||
size='sm'
|
||||
onClick={() =>
|
||||
setConfirmAction({ type: 'delete-disabled' })
|
||||
}
|
||||
>
|
||||
<Trash2 className='mr-2 h-4 w-4' />
|
||||
{t('Delete Auto-Disabled')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className='min-h-0 flex-1 overflow-auto rounded-md border'>
|
||||
{isLoading ? (
|
||||
<div className='flex items-center justify-center py-12'>
|
||||
<Loader2 className='text-muted-foreground h-8 w-8 animate-spin' />
|
||||
</div>
|
||||
) : keys.length === 0 ? (
|
||||
<div className='text-muted-foreground py-12 text-center'>
|
||||
{t('No keys found')}
|
||||
</div>
|
||||
) : (
|
||||
<div className='min-w-[800px]'>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className='w-20'>{t('Index')}</TableHead>
|
||||
<TableHead className='w-32'>{t('Status')}</TableHead>
|
||||
<TableHead className='min-w-[200px]'>
|
||||
{t('Disabled Reason')}
|
||||
</TableHead>
|
||||
<TableHead className='w-44'>
|
||||
{t('Disabled Time')}
|
||||
</TableHead>
|
||||
<TableHead className='w-44 text-right'>
|
||||
{t('Actions')}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{keys.map((key) => (
|
||||
<TableRow key={key.index}>
|
||||
<TableCell className='font-mono text-sm'>
|
||||
#{key.index + 1}
|
||||
</TableCell>
|
||||
<TableCell>{renderStatusBadge(key.status)}</TableCell>
|
||||
<TableCell className='max-w-xs truncate text-sm'>
|
||||
{key.reason || '-'}
|
||||
</TableCell>
|
||||
<TableCell className='text-muted-foreground text-sm'>
|
||||
{formatKeyTimestamp(key.disabled_time)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<MultiKeyTableRowActions
|
||||
keyIndex={key.index}
|
||||
status={key.status}
|
||||
onAction={setConfirmAction}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className='flex shrink-0 items-center justify-between'>
|
||||
<div className='text-muted-foreground text-sm'>
|
||||
{t('Page {{current}} of {{total}}', {
|
||||
current: currentPage,
|
||||
total: totalPages,
|
||||
})}
|
||||
</div>
|
||||
<div className='flex gap-2'>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() => handlePageChange(currentPage - 1)}
|
||||
disabled={currentPage === 1 || isLoading}
|
||||
>
|
||||
{t('Previous')}
|
||||
</Button>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() => handlePageChange(currentPage + 1)}
|
||||
disabled={currentPage >= totalPages || isLoading}
|
||||
>
|
||||
{t('Next')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Confirmation Dialog */}
|
||||
<ConfirmDialog
|
||||
open={confirmAction !== null}
|
||||
onOpenChange={(open) => !open && setConfirmAction(null)}
|
||||
title={t('Confirm Action')}
|
||||
desc={t(getMultiKeyConfirmMessage(confirmAction))}
|
||||
destructive={isDestructiveAction(confirmAction)}
|
||||
isLoading={isPerformingAction}
|
||||
handleConfirm={performAction}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
type StatisticsCardProps = {
|
||||
label: string
|
||||
count: number
|
||||
total: number
|
||||
}
|
||||
|
||||
export function StatisticsCard({ label, count, total }: StatisticsCardProps) {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div className='rounded-md border p-3'>
|
||||
<div className='text-muted-foreground mb-1 text-xs font-medium'>
|
||||
{label}
|
||||
</div>
|
||||
<div className='flex items-baseline gap-2'>
|
||||
<span className='text-foreground text-2xl font-semibold'>{count}</span>
|
||||
<span className='text-muted-foreground text-sm'>
|
||||
{t('of')} {total}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import type { MultiKeyConfirmAction } from '../../types'
|
||||
|
||||
type MultiKeyTableRowActionsProps = {
|
||||
keyIndex: number
|
||||
status: number
|
||||
onAction: (action: MultiKeyConfirmAction) => void
|
||||
}
|
||||
|
||||
export function MultiKeyTableRowActions({
|
||||
keyIndex,
|
||||
status,
|
||||
onAction,
|
||||
}: MultiKeyTableRowActionsProps) {
|
||||
const { t } = useTranslation()
|
||||
const isEnabled = status === 1
|
||||
|
||||
return (
|
||||
<div className='flex justify-end gap-2'>
|
||||
{isEnabled ? (
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() => onAction({ type: 'disable', keyIndex })}
|
||||
>
|
||||
{t('Disable')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() => onAction({ type: 'enable', keyIndex })}
|
||||
>
|
||||
{t('Enable')}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant='destructive'
|
||||
size='sm'
|
||||
onClick={() => onAction({ type: 'delete', keyIndex })}
|
||||
>
|
||||
{t('Delete')}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+588
@@ -0,0 +1,588 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { Loader2, RefreshCw, Trash2, Download, Search } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { getCommonHeaders } from '@/lib/api'
|
||||
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 { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import {
|
||||
deleteOllamaModel,
|
||||
fetchModels as fetchModelsFromEndpoint,
|
||||
fetchUpstreamModels,
|
||||
updateChannel,
|
||||
} from '../../api'
|
||||
import { channelsQueryKeys, parseModelsString } from '../../lib'
|
||||
import {
|
||||
formatBytes,
|
||||
normalizeOllamaModels,
|
||||
resolveOllamaBaseUrl,
|
||||
type OllamaModel,
|
||||
type PullProgress,
|
||||
} from '../../lib/ollama-utils'
|
||||
import { useChannels } from '../channels-provider'
|
||||
|
||||
const CHANNEL_TYPE_OLLAMA = 4
|
||||
|
||||
export function OllamaModelsDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const { currentRow } = useChannels()
|
||||
|
||||
const isOllamaChannel = currentRow?.type === CHANNEL_TYPE_OLLAMA
|
||||
const channelId = currentRow?.id
|
||||
|
||||
const [isFetching, setIsFetching] = useState(false)
|
||||
const [models, setModels] = useState<OllamaModel[]>([])
|
||||
const [selected, setSelected] = useState<string[]>([])
|
||||
const [search, setSearch] = useState('')
|
||||
|
||||
const [pullName, setPullName] = useState('')
|
||||
const [isPulling, setIsPulling] = useState(false)
|
||||
const [pullProgress, setPullProgress] = useState<PullProgress | null>(null)
|
||||
const pullAbortRef = useRef<AbortController | null>(null)
|
||||
|
||||
const [deleteOpen, setDeleteOpen] = useState(false)
|
||||
const [deleteTarget, setDeleteTarget] = useState<string | null>(null)
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
|
||||
const filteredModels = useMemo(() => {
|
||||
if (!search.trim()) return models
|
||||
const keyword = search.trim().toLowerCase()
|
||||
return models.filter((m) => m.id.toLowerCase().includes(keyword))
|
||||
}, [models, search])
|
||||
|
||||
const existingModels = useMemo(
|
||||
() => parseModelsString(currentRow?.models ?? ''),
|
||||
[currentRow?.models]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setModels([])
|
||||
setSelected([])
|
||||
setSearch('')
|
||||
setPullName('')
|
||||
setIsPulling(false)
|
||||
setPullProgress(null)
|
||||
pullAbortRef.current?.abort()
|
||||
pullAbortRef.current = null
|
||||
return
|
||||
}
|
||||
|
||||
if (open && isOllamaChannel && channelId) {
|
||||
void fetchOllamaModels()
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, isOllamaChannel, channelId])
|
||||
|
||||
const fetchOllamaModels = useCallback(async () => {
|
||||
if (!channelId) return
|
||||
setIsFetching(true)
|
||||
try {
|
||||
let normalized: OllamaModel[] = []
|
||||
let lastErr = ''
|
||||
|
||||
// 1) Prefer live fetch for Ollama if base_url is set (more accurate / supports unsaved changes)
|
||||
const baseUrl = resolveOllamaBaseUrl(currentRow ?? null)
|
||||
if (isOllamaChannel && baseUrl) {
|
||||
try {
|
||||
const payloadLive = await fetchModelsFromEndpoint({
|
||||
base_url: baseUrl,
|
||||
type: CHANNEL_TYPE_OLLAMA,
|
||||
key: typeof currentRow?.key === 'string' ? currentRow.key : '',
|
||||
})
|
||||
if (payloadLive?.success) {
|
||||
normalized = normalizeOllamaModels(payloadLive.data)
|
||||
} else if (payloadLive?.message) {
|
||||
lastErr = String(payloadLive.message)
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
lastErr = err instanceof Error ? err.message : ''
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Fallback to server-side fetch by channelId
|
||||
if (!normalized.length) {
|
||||
const payload = await fetchUpstreamModels(Number(channelId))
|
||||
if (payload?.success) {
|
||||
normalized = normalizeOllamaModels(payload.data)
|
||||
lastErr = ''
|
||||
} else {
|
||||
lastErr = String(payload?.message || '')
|
||||
}
|
||||
}
|
||||
|
||||
if (!normalized.length && lastErr) {
|
||||
toast.error(lastErr || t('Failed to fetch models'))
|
||||
}
|
||||
|
||||
setModels(normalized)
|
||||
setSelected((prev) => {
|
||||
if (!prev.length) return normalized.map((m) => m.id)
|
||||
const stillAvailable = prev.filter((id) =>
|
||||
normalized.some((m) => m.id === id)
|
||||
)
|
||||
return stillAvailable.length
|
||||
? stillAvailable
|
||||
: normalized.map((m) => m.id)
|
||||
})
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : undefined
|
||||
toast.error(msg || t('Failed to fetch models'))
|
||||
setModels([])
|
||||
} finally {
|
||||
setIsFetching(false)
|
||||
}
|
||||
}, [channelId, currentRow, isOllamaChannel, t])
|
||||
|
||||
const toggleSelected = (modelId: string, checked: boolean) => {
|
||||
setSelected((prev) => {
|
||||
if (checked) return prev.includes(modelId) ? prev : [...prev, modelId]
|
||||
return prev.filter((id) => id !== modelId)
|
||||
})
|
||||
}
|
||||
|
||||
const selectAllFiltered = () => {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev)
|
||||
filteredModels.forEach((m) => next.add(m.id))
|
||||
return Array.from(next)
|
||||
})
|
||||
}
|
||||
|
||||
const clearSelection = () => setSelected([])
|
||||
|
||||
const applySelection = async (mode: 'append' | 'replace') => {
|
||||
if (!currentRow) return
|
||||
if (!selected.length) {
|
||||
toast.info(t('No models selected'))
|
||||
return
|
||||
}
|
||||
|
||||
const next =
|
||||
mode === 'replace'
|
||||
? Array.from(new Set(selected))
|
||||
: Array.from(new Set([...existingModels, ...selected]))
|
||||
|
||||
const res = await updateChannel(currentRow.id, { models: next.join(',') })
|
||||
if (res.success) {
|
||||
toast.success(
|
||||
mode === 'replace'
|
||||
? t('Models updated successfully')
|
||||
: t('Models appended successfully')
|
||||
)
|
||||
queryClient.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
|
||||
}
|
||||
}
|
||||
|
||||
const pullModel = async () => {
|
||||
if (!channelId) return
|
||||
if (!pullName.trim()) {
|
||||
toast.error(t('Please enter model name'))
|
||||
return
|
||||
}
|
||||
|
||||
if (!resolveOllamaBaseUrl(currentRow)) {
|
||||
toast.error(t('Please set Ollama API Base URL first'))
|
||||
return
|
||||
}
|
||||
|
||||
pullAbortRef.current?.abort()
|
||||
const controller = new AbortController()
|
||||
pullAbortRef.current = controller
|
||||
|
||||
setIsPulling(true)
|
||||
setPullProgress({ status: 'starting', completed: 0, total: 0 })
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/channel/ollama/pull/stream', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
...getCommonHeaders(),
|
||||
Accept: 'text/event-stream',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
channel_id: channelId,
|
||||
model_name: pullName.trim(),
|
||||
}),
|
||||
signal: controller.signal,
|
||||
})
|
||||
|
||||
if (!response.ok || !response.body) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const reader = response.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ''
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
const lines = buffer.split('\n')
|
||||
buffer = lines.pop() || ''
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith('data: ')) continue
|
||||
const eventData = line.slice(6)
|
||||
if (!eventData) continue
|
||||
|
||||
if (eventData === '[DONE]') {
|
||||
setIsPulling(false)
|
||||
setPullProgress(null)
|
||||
pullAbortRef.current = null
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const data = JSON.parse(eventData)
|
||||
if (data?.status) {
|
||||
setPullProgress(data)
|
||||
} else if (data?.error) {
|
||||
toast.error(String(data.error))
|
||||
setIsPulling(false)
|
||||
setPullProgress(null)
|
||||
pullAbortRef.current = null
|
||||
return
|
||||
} else if (data?.message) {
|
||||
toast.success(String(data.message))
|
||||
setPullName('')
|
||||
setIsPulling(false)
|
||||
setPullProgress(null)
|
||||
pullAbortRef.current = null
|
||||
await fetchOllamaModels()
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: channelsQueryKeys.lists(),
|
||||
})
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// ignore malformed events
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setIsPulling(false)
|
||||
setPullProgress(null)
|
||||
pullAbortRef.current = null
|
||||
await fetchOllamaModels()
|
||||
queryClient.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
|
||||
} catch (err: unknown) {
|
||||
const isAbort =
|
||||
typeof err === 'object' &&
|
||||
err !== null &&
|
||||
'name' in err &&
|
||||
(err as { name?: unknown }).name === 'AbortError'
|
||||
if (!isAbort) {
|
||||
const msg = err instanceof Error ? err.message : ''
|
||||
toast.error(t('Model pull failed: {{msg}}', { msg }))
|
||||
}
|
||||
setIsPulling(false)
|
||||
setPullProgress(null)
|
||||
pullAbortRef.current = null
|
||||
}
|
||||
}
|
||||
|
||||
const deleteModel = async (modelName: string) => {
|
||||
if (!channelId) return
|
||||
try {
|
||||
setIsDeleting(true)
|
||||
const payload = await deleteOllamaModel({
|
||||
channel_id: Number(channelId),
|
||||
model_name: modelName,
|
||||
})
|
||||
if (payload?.success) {
|
||||
toast.success(t('Model deleted'))
|
||||
await fetchOllamaModels()
|
||||
queryClient.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
|
||||
setDeleteOpen(false)
|
||||
setDeleteTarget(null)
|
||||
} else {
|
||||
toast.error(payload?.message || t('Failed to delete model'))
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : undefined
|
||||
toast.error(msg || t('Failed to delete model'))
|
||||
} finally {
|
||||
setIsDeleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const close = () => {
|
||||
pullAbortRef.current?.abort()
|
||||
pullAbortRef.current = null
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={close}>
|
||||
<DialogContent className='max-h-[90vh] overflow-hidden sm:max-w-3xl'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('Ollama Models')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('Manage local models for:')} <strong>{currentRow?.name}</strong>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{!isOllamaChannel ? (
|
||||
<div className='text-muted-foreground py-8 text-center'>
|
||||
{t('This channel is not an Ollama channel.')}
|
||||
</div>
|
||||
) : (
|
||||
<div className='max-h-[78vh] space-y-4 overflow-y-auto py-2 pr-1'>
|
||||
<div className='flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between'>
|
||||
<div className='flex-1 space-y-2'>
|
||||
<Label htmlFor='ollama-pull'>{t('Pull model')}</Label>
|
||||
<div className='flex gap-2'>
|
||||
<Input
|
||||
id='ollama-pull'
|
||||
placeholder={t('e.g. llama3.1:8b')}
|
||||
value={pullName}
|
||||
onChange={(e) => setPullName(e.target.value)}
|
||||
disabled={!channelId || isPulling}
|
||||
/>
|
||||
<Button
|
||||
onClick={() => void pullModel()}
|
||||
disabled={!channelId || isPulling}
|
||||
>
|
||||
{isPulling ? (
|
||||
<>
|
||||
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
|
||||
{t('Pulling...')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download className='mr-2 h-4 w-4' />
|
||||
{t('Pull')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
{pullProgress && (
|
||||
<div className='space-y-2'>
|
||||
<div className='text-muted-foreground text-xs'>
|
||||
{t('Status:')} {String(pullProgress.status || '-')}
|
||||
</div>
|
||||
<Progress
|
||||
value={
|
||||
typeof pullProgress.completed === 'number' &&
|
||||
typeof pullProgress.total === 'number' &&
|
||||
pullProgress.total > 0
|
||||
? Math.min(
|
||||
100,
|
||||
Math.round(
|
||||
(pullProgress.completed / pullProgress.total) *
|
||||
100
|
||||
)
|
||||
)
|
||||
: 0
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='flex gap-2'>
|
||||
<Button
|
||||
variant='outline'
|
||||
onClick={() => void fetchOllamaModels()}
|
||||
disabled={!channelId || isFetching}
|
||||
>
|
||||
{isFetching ? (
|
||||
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
|
||||
) : (
|
||||
<RefreshCw className='mr-2 h-4 w-4' />
|
||||
)}
|
||||
{t('Refresh')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className='space-y-3'>
|
||||
<div className='flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between'>
|
||||
<div>
|
||||
<p className='text-sm font-medium'>{t('Local models')}</p>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{t('Select models and apply to channel models list.')}
|
||||
</p>
|
||||
</div>
|
||||
<div className='relative sm:w-72'>
|
||||
<Search className='text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2' />
|
||||
<Input
|
||||
placeholder={t('Search models...')}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className='pl-9'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
<Button variant='outline' size='sm' onClick={selectAllFiltered}>
|
||||
{t('Select all (filtered)')}
|
||||
</Button>
|
||||
<Button variant='outline' size='sm' onClick={clearSelection}>
|
||||
{t('Clear selection')}
|
||||
</Button>
|
||||
<Button
|
||||
size='sm'
|
||||
onClick={() => void applySelection('append')}
|
||||
disabled={!selected.length}
|
||||
>
|
||||
{t('Append to channel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant='secondary'
|
||||
size='sm'
|
||||
onClick={() => void applySelection('replace')}
|
||||
disabled={!selected.length}
|
||||
>
|
||||
{t('Replace channel models')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className='overflow-hidden rounded-md border'>
|
||||
<div className='max-h-[420px] overflow-y-auto'>
|
||||
{filteredModels.length === 0 ? (
|
||||
<div className='text-muted-foreground p-6 text-center text-sm'>
|
||||
{t('No models found.')}
|
||||
</div>
|
||||
) : (
|
||||
<div className='divide-y'>
|
||||
{filteredModels.map((m) => {
|
||||
const checked = selected.includes(m.id)
|
||||
return (
|
||||
<div
|
||||
key={m.id}
|
||||
className='flex items-center justify-between gap-3 p-3'
|
||||
>
|
||||
<div className='flex min-w-0 items-start gap-3'>
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
onCheckedChange={(v) =>
|
||||
toggleSelected(m.id, !!v)
|
||||
}
|
||||
aria-label={`Select model ${m.id}`}
|
||||
/>
|
||||
<div className='min-w-0'>
|
||||
<div className='truncate font-mono text-sm'>
|
||||
{m.id}
|
||||
</div>
|
||||
<div className='text-muted-foreground flex flex-wrap gap-x-3 gap-y-1 text-xs'>
|
||||
<span>
|
||||
{t('Size:')} {formatBytes(m.size)}
|
||||
</span>
|
||||
{m.digest && (
|
||||
<span className='truncate'>
|
||||
{t('Digest:')} {String(m.digest)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='text-destructive hover:text-destructive'
|
||||
onClick={() => {
|
||||
setDeleteTarget(m.id)
|
||||
setDeleteOpen(true)
|
||||
}}
|
||||
disabled={!channelId}
|
||||
>
|
||||
<Trash2 className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant='outline' onClick={close}>
|
||||
{t('Close')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
<AlertDialog
|
||||
open={deleteOpen}
|
||||
onOpenChange={(v) => {
|
||||
setDeleteOpen(v)
|
||||
if (!v) setDeleteTarget(null)
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t('Confirm delete')}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t('Delete model "{{name}}"? This cannot be undone.', {
|
||||
name: deleteTarget || '',
|
||||
})}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isDeleting}>
|
||||
{t('Cancel')}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className='bg-destructive text-destructive-foreground hover:bg-destructive/90'
|
||||
disabled={isDeleting || !deleteTarget}
|
||||
onClick={() => {
|
||||
if (!deleteTarget) return
|
||||
void deleteModel(deleteTarget)
|
||||
}}
|
||||
>
|
||||
{isDeleting ? (
|
||||
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
|
||||
) : null}
|
||||
{t('Delete')}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
+150
@@ -0,0 +1,150 @@
|
||||
import { useState } from 'react'
|
||||
import { AlertTriangle } 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 { Label } from '@/components/ui/label'
|
||||
|
||||
interface StatusCodeRiskDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
detailItems: string[]
|
||||
onConfirm: () => void
|
||||
}
|
||||
|
||||
const CHECKLIST_KEYS = [
|
||||
'High-risk status code retry risk check 1',
|
||||
'High-risk status code retry risk check 2',
|
||||
'High-risk status code retry risk check 3',
|
||||
'High-risk status code retry risk check 4',
|
||||
] as const
|
||||
|
||||
export function StatusCodeRiskDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
detailItems,
|
||||
onConfirm,
|
||||
}: StatusCodeRiskDialogProps) {
|
||||
const { t } = useTranslation()
|
||||
const [checkedItems, setCheckedItems] = useState<Set<number>>(new Set())
|
||||
const [confirmText, setConfirmText] = useState('')
|
||||
|
||||
const requiredText = t('High-risk status code retry confirmation text')
|
||||
const allChecked = checkedItems.size === CHECKLIST_KEYS.length
|
||||
const textMatches = confirmText.trim() === requiredText.trim()
|
||||
const canConfirm = allChecked && textMatches
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (!canConfirm) return
|
||||
setCheckedItems(new Set())
|
||||
setConfirmText('')
|
||||
onConfirm()
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
setCheckedItems(new Set())
|
||||
setConfirmText('')
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
const toggleCheck = (idx: number) => {
|
||||
setCheckedItems((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(idx)) next.delete(idx)
|
||||
else next.add(idx)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className='max-w-lg'>
|
||||
<DialogHeader>
|
||||
<DialogTitle className='text-destructive flex items-center gap-2'>
|
||||
<AlertTriangle className='h-5 w-5' />
|
||||
{t('High-risk operation confirmation')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('High-risk status code retry risk disclaimer')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className='space-y-4'>
|
||||
{detailItems.length > 0 && (
|
||||
<div className='border-destructive/30 bg-destructive/5 rounded-lg border p-3'>
|
||||
<p className='mb-2 text-sm font-medium'>
|
||||
{t('Detected high-risk status code redirect rules')}
|
||||
</p>
|
||||
<ul className='list-inside list-disc text-sm'>
|
||||
{detailItems.map((item) => (
|
||||
<li key={item} className='font-mono text-xs'>
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='space-y-2'>
|
||||
{CHECKLIST_KEYS.map((key, idx) => (
|
||||
<div key={key} className='flex items-start gap-2'>
|
||||
<Checkbox
|
||||
id={`risk-check-${idx}`}
|
||||
checked={checkedItems.has(idx)}
|
||||
onCheckedChange={() => toggleCheck(idx)}
|
||||
/>
|
||||
<Label
|
||||
htmlFor={`risk-check-${idx}`}
|
||||
className='text-sm leading-tight'
|
||||
>
|
||||
{t(key)}
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className='space-y-1.5'>
|
||||
<Label className='text-sm'>
|
||||
{t('Action confirmation')}:{' '}
|
||||
<code className='bg-muted rounded px-1 text-xs'>
|
||||
{requiredText}
|
||||
</code>
|
||||
</Label>
|
||||
<Input
|
||||
value={confirmText}
|
||||
onChange={(e) => setConfirmText(e.target.value)}
|
||||
placeholder={t('High-risk status code retry input placeholder')}
|
||||
/>
|
||||
{confirmText && !textMatches && (
|
||||
<p className='text-destructive text-xs'>
|
||||
{t('High-risk status code retry input mismatch')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant='outline' onClick={handleCancel}>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant='destructive'
|
||||
disabled={!canConfirm}
|
||||
onClick={handleConfirm}
|
||||
>
|
||||
{t('I confirm enabling high-risk retry')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
+286
@@ -0,0 +1,286 @@
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Loader2, AlertCircle } 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,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { MultiSelect } from '@/components/multi-select'
|
||||
import {
|
||||
getTagModels,
|
||||
editTagChannels,
|
||||
getAllModels,
|
||||
getGroups,
|
||||
} from '../../api'
|
||||
import { channelsQueryKeys } from '../../lib'
|
||||
import type { TagOperationParams } from '../../types'
|
||||
import { useChannels } from '../channels-provider'
|
||||
import { ModelMappingEditor } from '../model-mapping-editor'
|
||||
|
||||
type TagBatchEditDialogProps = {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
export function TagBatchEditDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: TagBatchEditDialogProps) {
|
||||
const { t } = useTranslation()
|
||||
const { currentTag } = useChannels()
|
||||
const queryClient = useQueryClient()
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
|
||||
// Form fields
|
||||
const [newTag, setNewTag] = useState('')
|
||||
const [models, setModels] = useState('')
|
||||
const [modelMapping, setModelMapping] = useState('')
|
||||
const [groups, setGroups] = useState<string[]>([])
|
||||
|
||||
// Fetch available groups
|
||||
const { data: groupsData, isLoading: isLoadingGroups } = useQuery({
|
||||
queryKey: ['groups'],
|
||||
queryFn: getGroups,
|
||||
})
|
||||
|
||||
// Transform groups to multi-select options
|
||||
const groupOptions = useMemo(() => {
|
||||
if (!groupsData?.data) return []
|
||||
const allGroups = new Set([...groupsData.data, ...groups])
|
||||
return Array.from(allGroups).map((group) => ({
|
||||
value: group,
|
||||
label: group,
|
||||
}))
|
||||
}, [groupsData, groups])
|
||||
|
||||
useEffect(() => {
|
||||
if (open && currentTag) {
|
||||
loadTagData()
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, currentTag])
|
||||
|
||||
const loadTagData = async () => {
|
||||
if (!currentTag) return
|
||||
|
||||
setIsLoading(true)
|
||||
try {
|
||||
// Fetch current tag models
|
||||
const tagModelsResponse = await getTagModels(currentTag)
|
||||
if (tagModelsResponse.success && tagModelsResponse.data) {
|
||||
setModels(tagModelsResponse.data)
|
||||
}
|
||||
|
||||
// Fetch all available models (for future use if needed)
|
||||
const allModelsResponse = await getAllModels()
|
||||
if (allModelsResponse.success && allModelsResponse.data) {
|
||||
// Available models could be used for autocomplete in the future
|
||||
}
|
||||
|
||||
// Initialize new tag with current tag name
|
||||
setNewTag(currentTag)
|
||||
} catch (_error: unknown) {
|
||||
toast.error(
|
||||
_error instanceof Error ? _error.message : t('Failed to load tag data')
|
||||
)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!currentTag) return
|
||||
|
||||
// Validate model mapping JSON if provided
|
||||
if (modelMapping.trim()) {
|
||||
try {
|
||||
JSON.parse(modelMapping)
|
||||
} catch (_error) {
|
||||
toast.error(t('Model mapping must be valid JSON'))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
setIsSaving(true)
|
||||
try {
|
||||
const params: Record<string, string | undefined> = {
|
||||
tag: currentTag,
|
||||
}
|
||||
|
||||
if (newTag !== currentTag) {
|
||||
params.new_tag = newTag || undefined
|
||||
}
|
||||
|
||||
if (models.trim()) {
|
||||
params.models = models
|
||||
}
|
||||
|
||||
if (modelMapping.trim()) {
|
||||
params.model_mapping = modelMapping
|
||||
}
|
||||
|
||||
if (groups.length > 0) {
|
||||
params.groups = groups.join(',')
|
||||
}
|
||||
|
||||
// Check if there are any changes
|
||||
if (Object.keys(params).length === 1) {
|
||||
toast.warning(t('No changes made'))
|
||||
return
|
||||
}
|
||||
|
||||
const response = await editTagChannels(
|
||||
params as unknown as TagOperationParams
|
||||
)
|
||||
if (response.success) {
|
||||
toast.success(t('Tag updated successfully'))
|
||||
queryClient.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
|
||||
handleClose()
|
||||
} else {
|
||||
toast.error(response.message || t('Failed to update tag'))
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : t('Failed to update tag')
|
||||
)
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
setNewTag('')
|
||||
setModels('')
|
||||
setModelMapping('')
|
||||
setGroups([])
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
if (!currentTag) return null
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent className='max-h-[90vh] max-w-2xl overflow-y-auto'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('Batch Edit by Tag')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('Edit all channels with tag:')} <strong>{currentTag}</strong>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{isLoading ? (
|
||||
<div className='flex items-center justify-center py-12'>
|
||||
<Loader2 className='text-muted-foreground h-8 w-8 animate-spin' />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className='space-y-4 py-4'>
|
||||
<Alert>
|
||||
<AlertCircle className='h-4 w-4' />
|
||||
<AlertDescription>
|
||||
{t(
|
||||
'All edits are overwrite operations. Leave fields empty to keep current values unchanged.'
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
{/* Tag Name */}
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='new-tag'>{t('Tag Name')}</Label>
|
||||
<Input
|
||||
id='new-tag'
|
||||
placeholder={t(
|
||||
'Enter new tag name (leave empty to disband tag)'
|
||||
)}
|
||||
value={newTag}
|
||||
onChange={(e) => setNewTag(e.target.value)}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{t('Leave empty to disband the tag')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Models */}
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='models'>{t('Models')}</Label>
|
||||
<Textarea
|
||||
id='models'
|
||||
placeholder={t(
|
||||
'Comma-separated model names (leave empty to keep current)'
|
||||
)}
|
||||
value={models}
|
||||
onChange={(e) => setModels(e.target.value)}
|
||||
disabled={isSaving}
|
||||
rows={3}
|
||||
/>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{t(
|
||||
'Current models for the longest channel in this tag. May not include all models from all channels.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Model Mapping */}
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='model-mapping'>{t('Model Mapping')}</Label>
|
||||
<ModelMappingEditor
|
||||
value={modelMapping}
|
||||
onChange={setModelMapping}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Groups */}
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='groups'>{t('Groups')}</Label>
|
||||
{isLoadingGroups ? (
|
||||
<Skeleton className='h-10 w-full' />
|
||||
) : (
|
||||
<MultiSelect
|
||||
options={groupOptions}
|
||||
selected={groups}
|
||||
onChange={setGroups}
|
||||
placeholder={t(
|
||||
'Select groups (leave empty to keep current)'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{t('User groups that can access channels with this tag')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant='outline'
|
||||
onClick={handleClose}
|
||||
disabled={isSaving}
|
||||
>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={isSaving}>
|
||||
{isSaving && <Loader2 className='mr-2 h-4 w-4 animate-spin' />}
|
||||
{isSaving ? t('Saving...') : t('Save Changes')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
+289
@@ -0,0 +1,289 @@
|
||||
import { useState, useMemo } from 'react'
|
||||
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,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
|
||||
interface UpstreamUpdateDialogProps {
|
||||
open: boolean
|
||||
addModels: string[]
|
||||
removeModels: string[]
|
||||
preferredTab: 'add' | 'remove'
|
||||
confirmLoading: boolean
|
||||
onConfirm: (data: { addModels: string[]; removeModels: string[] }) => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
export function UpstreamUpdateDialog(props: UpstreamUpdateDialogProps) {
|
||||
const { t } = useTranslation()
|
||||
const [activeTab, setActiveTab] = useState(props.preferredTab)
|
||||
const [searchAdd, setSearchAdd] = useState('')
|
||||
const [searchRemove, setSearchRemove] = useState('')
|
||||
const [selectedAdd, setSelectedAdd] = useState<Set<string>>(
|
||||
() => new Set(props.addModels)
|
||||
)
|
||||
const [selectedRemove, setSelectedRemove] = useState<Set<string>>(
|
||||
() => new Set(props.removeModels)
|
||||
)
|
||||
const [partialConfirmOpen, setPartialConfirmOpen] = useState(false)
|
||||
|
||||
const filteredAdd = useMemo(
|
||||
() =>
|
||||
props.addModels.filter((m) =>
|
||||
m.toLowerCase().includes(searchAdd.toLowerCase())
|
||||
),
|
||||
[props.addModels, searchAdd]
|
||||
)
|
||||
|
||||
const filteredRemove = useMemo(
|
||||
() =>
|
||||
props.removeModels.filter((m) =>
|
||||
m.toLowerCase().includes(searchRemove.toLowerCase())
|
||||
),
|
||||
[props.removeModels, searchRemove]
|
||||
)
|
||||
|
||||
const toggleModel = (
|
||||
model: string,
|
||||
set: Set<string>,
|
||||
setter: (s: Set<string>) => void
|
||||
) => {
|
||||
const next = new Set(set)
|
||||
if (next.has(model)) next.delete(model)
|
||||
else next.add(model)
|
||||
setter(next)
|
||||
}
|
||||
|
||||
const toggleAllVisible = (
|
||||
models: string[],
|
||||
set: Set<string>,
|
||||
setter: (s: Set<string>) => void
|
||||
) => {
|
||||
const allSelected = models.every((m) => set.has(m))
|
||||
const next = new Set(set)
|
||||
if (allSelected) {
|
||||
models.forEach((m) => next.delete(m))
|
||||
} else {
|
||||
models.forEach((m) => next.add(m))
|
||||
}
|
||||
setter(next)
|
||||
}
|
||||
|
||||
const handleConfirm = () => {
|
||||
const hasAdd = props.addModels.length > 0
|
||||
const hasRemove = props.removeModels.length > 0
|
||||
const selectedAddArr = Array.from(selectedAdd)
|
||||
const selectedRemoveArr = Array.from(selectedRemove)
|
||||
const anyAdd = selectedAddArr.length > 0
|
||||
const anyRemove = selectedRemoveArr.length > 0
|
||||
|
||||
if (hasAdd && hasRemove && (!anyAdd || !anyRemove)) {
|
||||
setPartialConfirmOpen(true)
|
||||
return
|
||||
}
|
||||
|
||||
props.onConfirm({
|
||||
addModels: selectedAddArr,
|
||||
removeModels: selectedRemoveArr,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={props.open} onOpenChange={(v) => !v && props.onCancel()}>
|
||||
<DialogContent className='sm:max-w-lg'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('Upstream Model Updates')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
{t(
|
||||
'Select models to process. Unselected "add" models will be ignored.'
|
||||
)}
|
||||
</p>
|
||||
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={(v) => setActiveTab(v as 'add' | 'remove')}
|
||||
>
|
||||
<TabsList className='grid w-full grid-cols-2'>
|
||||
<TabsTrigger value='add' className='gap-1'>
|
||||
{t('Add Models')}
|
||||
<StatusBadge
|
||||
variant='neutral'
|
||||
className='ml-1'
|
||||
copyable={false}
|
||||
>
|
||||
{selectedAdd.size}/{props.addModels.length}
|
||||
</StatusBadge>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value='remove' className='gap-1'>
|
||||
{t('Remove Models')}
|
||||
<StatusBadge
|
||||
variant='neutral'
|
||||
className='ml-1'
|
||||
copyable={false}
|
||||
>
|
||||
{selectedRemove.size}/{props.removeModels.length}
|
||||
</StatusBadge>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value='add' className='space-y-3'>
|
||||
<div className='relative'>
|
||||
<Search className='text-muted-foreground absolute top-2.5 left-2.5 h-4 w-4' />
|
||||
<Input
|
||||
placeholder={t('Search models...')}
|
||||
className='pl-8'
|
||||
value={searchAdd}
|
||||
onChange={(e) => setSearchAdd(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{filteredAdd.length > 0 && (
|
||||
<div className='flex items-center gap-2'>
|
||||
<Checkbox
|
||||
checked={filteredAdd.every((m) => selectedAdd.has(m))}
|
||||
onCheckedChange={() =>
|
||||
toggleAllVisible(filteredAdd, selectedAdd, setSelectedAdd)
|
||||
}
|
||||
/>
|
||||
<span className='text-muted-foreground text-xs'>
|
||||
{t('Select All Visible')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<ScrollArea className='h-[280px] rounded-md border p-2'>
|
||||
{filteredAdd.length > 0 ? (
|
||||
<div className='space-y-1'>
|
||||
{filteredAdd.map((model) => (
|
||||
<label
|
||||
key={model}
|
||||
className='hover:bg-accent flex cursor-pointer items-center gap-2 rounded px-2 py-1.5'
|
||||
>
|
||||
<Checkbox
|
||||
checked={selectedAdd.has(model)}
|
||||
onCheckedChange={() =>
|
||||
toggleModel(model, selectedAdd, setSelectedAdd)
|
||||
}
|
||||
/>
|
||||
<span className='truncate text-sm'>{model}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className='text-muted-foreground py-8 text-center text-sm'>
|
||||
{props.addModels.length === 0
|
||||
? t('No models to add')
|
||||
: t('No matching results')}
|
||||
</p>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value='remove' className='space-y-3'>
|
||||
<div className='relative'>
|
||||
<Search className='text-muted-foreground absolute top-2.5 left-2.5 h-4 w-4' />
|
||||
<Input
|
||||
placeholder={t('Search models...')}
|
||||
className='pl-8'
|
||||
value={searchRemove}
|
||||
onChange={(e) => setSearchRemove(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{filteredRemove.length > 0 && (
|
||||
<div className='flex items-center gap-2'>
|
||||
<Checkbox
|
||||
checked={filteredRemove.every((m) => selectedRemove.has(m))}
|
||||
onCheckedChange={() =>
|
||||
toggleAllVisible(
|
||||
filteredRemove,
|
||||
selectedRemove,
|
||||
setSelectedRemove
|
||||
)
|
||||
}
|
||||
/>
|
||||
<span className='text-muted-foreground text-xs'>
|
||||
{t('Select All Visible')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<ScrollArea className='h-[280px] rounded-md border p-2'>
|
||||
{filteredRemove.length > 0 ? (
|
||||
<div className='space-y-1'>
|
||||
{filteredRemove.map((model) => (
|
||||
<label
|
||||
key={model}
|
||||
className='hover:bg-accent flex cursor-pointer items-center gap-2 rounded px-2 py-1.5'
|
||||
>
|
||||
<Checkbox
|
||||
checked={selectedRemove.has(model)}
|
||||
onCheckedChange={() =>
|
||||
toggleModel(
|
||||
model,
|
||||
selectedRemove,
|
||||
setSelectedRemove
|
||||
)
|
||||
}
|
||||
/>
|
||||
<span className='truncate text-sm'>{model}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className='text-muted-foreground py-8 text-center text-sm'>
|
||||
{props.removeModels.length === 0
|
||||
? t('No models to remove')
|
||||
: t('No matching results')}
|
||||
</p>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant='outline' onClick={props.onCancel}>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleConfirm}
|
||||
disabled={
|
||||
props.confirmLoading ||
|
||||
(selectedAdd.size === 0 && selectedRemove.size === 0)
|
||||
}
|
||||
>
|
||||
{t('Confirm')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<ConfirmDialog
|
||||
open={partialConfirmOpen}
|
||||
onOpenChange={setPartialConfirmOpen}
|
||||
title={t('Partial Submission')}
|
||||
desc={t(
|
||||
'There are both add and remove models pending, but you only selected one type. Confirm submitting only the selected items?'
|
||||
)}
|
||||
handleConfirm={() => {
|
||||
setPartialConfirmOpen(false)
|
||||
props.onConfirm({
|
||||
addModels: Array.from(selectedAdd),
|
||||
removeModels: Array.from(selectedRemove),
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
+3016
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,244 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Code, Table, Plus, Trash2 } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
|
||||
type ModelMappingEditorProps = {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
type MappingRow = {
|
||||
id: string
|
||||
from: string
|
||||
to: string
|
||||
}
|
||||
|
||||
export function ModelMappingEditor({
|
||||
value,
|
||||
onChange,
|
||||
disabled = false,
|
||||
}: ModelMappingEditorProps) {
|
||||
const { t } = useTranslation()
|
||||
const [mode, setMode] = useState<'visual' | 'json'>('visual')
|
||||
const [rows, setRows] = useState<MappingRow[]>([])
|
||||
const [jsonValue, setJsonValue] = useState(value)
|
||||
|
||||
const parseJsonToRows = (json: string) => {
|
||||
try {
|
||||
if (!json.trim()) {
|
||||
setRows([])
|
||||
return
|
||||
}
|
||||
const parsed = JSON.parse(json)
|
||||
const newRows: MappingRow[] = Object.entries(parsed).map(
|
||||
([from, to], index) => ({
|
||||
id: `${Date.now()}-${index}`,
|
||||
from,
|
||||
to: String(to),
|
||||
})
|
||||
)
|
||||
setRows(newRows)
|
||||
} catch (_error) {
|
||||
// Invalid JSON, keep current rows
|
||||
}
|
||||
}
|
||||
|
||||
// Parse JSON to rows when value changes externally
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setJsonValue(value)
|
||||
parseJsonToRows(value)
|
||||
}, [value])
|
||||
|
||||
const convertRowsToJson = (updatedRows: MappingRow[]): string => {
|
||||
if (updatedRows.length === 0) {
|
||||
return ''
|
||||
}
|
||||
const obj: Record<string, string> = {}
|
||||
updatedRows.forEach((row) => {
|
||||
if (row.from.trim()) {
|
||||
obj[row.from.trim()] = row.to.trim()
|
||||
}
|
||||
})
|
||||
return JSON.stringify(obj, null, 2)
|
||||
}
|
||||
|
||||
const handleAddRow = () => {
|
||||
const newRow: MappingRow = {
|
||||
id: `${Date.now()}`,
|
||||
from: '',
|
||||
to: '',
|
||||
}
|
||||
const updatedRows = [...rows, newRow]
|
||||
setRows(updatedRows)
|
||||
}
|
||||
|
||||
const handleDeleteRow = (id: string) => {
|
||||
const updatedRows = rows.filter((row) => row.id !== id)
|
||||
setRows(updatedRows)
|
||||
const json = convertRowsToJson(updatedRows)
|
||||
setJsonValue(json)
|
||||
onChange(json)
|
||||
}
|
||||
|
||||
const handleRowChange = (
|
||||
id: string,
|
||||
field: 'from' | 'to',
|
||||
newValue: string
|
||||
) => {
|
||||
const updatedRows = rows.map((row) =>
|
||||
row.id === id ? { ...row, [field]: newValue } : row
|
||||
)
|
||||
setRows(updatedRows)
|
||||
const json = convertRowsToJson(updatedRows)
|
||||
setJsonValue(json)
|
||||
onChange(json)
|
||||
}
|
||||
|
||||
const handleJsonChange = (newJson: string) => {
|
||||
setJsonValue(newJson)
|
||||
onChange(newJson)
|
||||
parseJsonToRows(newJson)
|
||||
}
|
||||
|
||||
const handleFillTemplate = () => {
|
||||
const template = JSON.stringify(
|
||||
{ 'gpt-3.5-turbo': 'gpt-3.5-turbo-0125' },
|
||||
null,
|
||||
2
|
||||
)
|
||||
setJsonValue(template)
|
||||
onChange(template)
|
||||
parseJsonToRows(template)
|
||||
}
|
||||
|
||||
const toggleMode = () => {
|
||||
if (mode === 'visual') {
|
||||
// Switching to JSON mode: sync rows to JSON
|
||||
const json = convertRowsToJson(rows)
|
||||
setJsonValue(json)
|
||||
onChange(json)
|
||||
setMode('json')
|
||||
} else {
|
||||
// Switching to visual mode: sync JSON to rows
|
||||
parseJsonToRows(jsonValue)
|
||||
setMode('visual')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='space-y-2'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex gap-2'>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={toggleMode}
|
||||
disabled={disabled}
|
||||
>
|
||||
{mode === 'visual' ? (
|
||||
<>
|
||||
<Code className='mr-2 h-4 w-4' />
|
||||
{t('JSON Mode')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Table className='mr-2 h-4 w-4' />
|
||||
{t('Visual Mode')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
variant='link'
|
||||
size='sm'
|
||||
className='h-auto p-0'
|
||||
onClick={handleFillTemplate}
|
||||
disabled={disabled}
|
||||
>
|
||||
{t('Fill Template')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{mode === 'visual' ? (
|
||||
<div className='space-y-2'>
|
||||
{rows.length > 0 ? (
|
||||
<div className='space-y-2'>
|
||||
<div className='grid grid-cols-[1fr_1fr_auto] gap-2 text-sm font-medium'>
|
||||
<div>{t('Original Model')}</div>
|
||||
<div>{t('Replacement Model')}</div>
|
||||
<div className='w-10'></div>
|
||||
</div>
|
||||
{rows.map((row) => (
|
||||
<div
|
||||
key={row.id}
|
||||
className='grid grid-cols-[1fr_1fr_auto] gap-2'
|
||||
>
|
||||
<Input
|
||||
value={row.from}
|
||||
onChange={(e) =>
|
||||
handleRowChange(row.id, 'from', e.target.value)
|
||||
}
|
||||
placeholder='gpt-3.5-turbo'
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Input
|
||||
value={row.to}
|
||||
onChange={(e) =>
|
||||
handleRowChange(row.id, 'to', e.target.value)
|
||||
}
|
||||
placeholder='gpt-3.5-turbo-0125'
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
onClick={() => handleDeleteRow(row.id)}
|
||||
disabled={disabled}
|
||||
className='h-10 w-10'
|
||||
>
|
||||
<Trash2 className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className='text-muted-foreground flex h-24 items-center justify-center rounded-md border border-dashed text-sm'>
|
||||
{t(
|
||||
'No model mappings configured. Click "Add Mapping" to get started.'
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={handleAddRow}
|
||||
disabled={disabled}
|
||||
className='w-full'
|
||||
>
|
||||
<Plus className='mr-2 h-4 w-4' />
|
||||
{t('Add Mapping')}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Textarea
|
||||
value={jsonValue}
|
||||
onChange={(e) => handleJsonChange(e.target.value)}
|
||||
placeholder={t('{"original-model": "replacement-model"}')}
|
||||
disabled={disabled}
|
||||
rows={8}
|
||||
className={cn('font-mono text-sm')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { Minus, Plus } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Label } from '@/components/ui/label'
|
||||
|
||||
interface NumericSpinnerInputProps {
|
||||
value: number | null | undefined
|
||||
onChange: (value: number) => void
|
||||
min?: number
|
||||
max?: number
|
||||
step?: number
|
||||
disabled?: boolean
|
||||
className?: string
|
||||
label?: string
|
||||
}
|
||||
|
||||
export function NumericSpinnerInput({
|
||||
value,
|
||||
onChange,
|
||||
min = 0,
|
||||
max,
|
||||
step = 1,
|
||||
disabled = false,
|
||||
className,
|
||||
label,
|
||||
}: NumericSpinnerInputProps) {
|
||||
const [localValue, setLocalValue] = useState(String(value ?? 0))
|
||||
const [editing, setEditing] = useState(false)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!editing) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setLocalValue(String(value ?? 0))
|
||||
}
|
||||
}, [value, editing])
|
||||
|
||||
const clamp = (v: number) => {
|
||||
let result = v
|
||||
if (min !== undefined) result = Math.max(min, result)
|
||||
if (max !== undefined) result = Math.min(max, result)
|
||||
return result
|
||||
}
|
||||
|
||||
const handleIncrement = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
if (disabled) return
|
||||
const next = clamp((Number(localValue) || 0) + step)
|
||||
setLocalValue(String(next))
|
||||
onChange(next)
|
||||
}
|
||||
|
||||
const handleDecrement = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
if (disabled) return
|
||||
const next = clamp((Number(localValue) || 0) - step)
|
||||
setLocalValue(String(next))
|
||||
onChange(next)
|
||||
}
|
||||
|
||||
const handleStartEdit = () => {
|
||||
if (disabled) return
|
||||
setEditing(true)
|
||||
requestAnimationFrame(() => inputRef.current?.select())
|
||||
}
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const raw = e.target.value
|
||||
if (raw === '' || raw === '-') {
|
||||
setLocalValue(raw)
|
||||
return
|
||||
}
|
||||
if (!/^-?\d+$/.test(raw)) return
|
||||
setLocalValue(raw)
|
||||
}
|
||||
|
||||
const commitValue = () => {
|
||||
setEditing(false)
|
||||
const num = Number(localValue)
|
||||
if (isNaN(num) || localValue === '' || localValue === '-') {
|
||||
setLocalValue(String(value ?? 0))
|
||||
return
|
||||
}
|
||||
const clamped = clamp(num)
|
||||
setLocalValue(String(clamped))
|
||||
if (clamped !== (value ?? 0)) {
|
||||
onChange(clamped)
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
commitValue()
|
||||
} else if (e.key === 'Escape') {
|
||||
setEditing(false)
|
||||
setLocalValue(String(value ?? 0))
|
||||
}
|
||||
}
|
||||
|
||||
const atMin = min !== undefined && Number(localValue) <= min
|
||||
const atMax = max !== undefined && Number(localValue) >= max
|
||||
|
||||
return (
|
||||
<div className={cn('inline-flex items-center', className)}>
|
||||
{label && (
|
||||
<Label className='text-muted-foreground mr-1.5 text-xs'>{label}</Label>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
'group/spinner inline-flex h-7 items-center gap-0 rounded-md transition-colors',
|
||||
!disabled && 'hover:bg-muted/60',
|
||||
editing && 'bg-muted/60 ring-primary/30 ring-1'
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type='button'
|
||||
tabIndex={-1}
|
||||
aria-label='Decrement'
|
||||
onClick={handleDecrement}
|
||||
disabled={disabled || atMin}
|
||||
className={cn(
|
||||
'text-muted-foreground/0 group-hover/spinner:text-muted-foreground flex h-7 w-6 shrink-0 items-center justify-center rounded-l-md transition-colors',
|
||||
!disabled &&
|
||||
!atMin &&
|
||||
'group-hover/spinner:hover:text-foreground group-hover/spinner:hover:bg-muted',
|
||||
(disabled || atMin) && 'group-hover/spinner:opacity-30'
|
||||
)}
|
||||
>
|
||||
<Minus className='size-3' />
|
||||
</button>
|
||||
|
||||
{editing ? (
|
||||
<input
|
||||
ref={inputRef}
|
||||
type='text'
|
||||
value={localValue}
|
||||
onChange={handleInputChange}
|
||||
onBlur={commitValue}
|
||||
onKeyDown={handleKeyDown}
|
||||
className='h-7 w-10 bg-transparent text-center font-mono text-sm outline-none'
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type='button'
|
||||
onClick={handleStartEdit}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'h-7 min-w-8 cursor-text px-1 text-center font-mono text-sm tabular-nums',
|
||||
disabled && 'cursor-default opacity-50'
|
||||
)}
|
||||
>
|
||||
{localValue}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
type='button'
|
||||
tabIndex={-1}
|
||||
aria-label='Increment'
|
||||
onClick={handleIncrement}
|
||||
disabled={disabled || atMax}
|
||||
className={cn(
|
||||
'text-muted-foreground/0 group-hover/spinner:text-muted-foreground flex h-7 w-6 shrink-0 items-center justify-center rounded-r-md transition-colors',
|
||||
!disabled &&
|
||||
!atMax &&
|
||||
'group-hover/spinner:hover:text-foreground group-hover/spinner:hover:bg-muted',
|
||||
(disabled || atMax) && 'group-hover/spinner:opacity-30'
|
||||
)}
|
||||
>
|
||||
<Plus className='size-3' />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+368
@@ -0,0 +1,368 @@
|
||||
// ============================================================================
|
||||
// Channel Types (from constant/channel.go)
|
||||
// All label/name values are i18n keys; use t(value) when displaying.
|
||||
// ============================================================================
|
||||
|
||||
export const CHANNEL_TYPES = {
|
||||
0: 'Unknown',
|
||||
1: 'OpenAI',
|
||||
2: 'Midjourney',
|
||||
3: 'Azure',
|
||||
4: 'Ollama',
|
||||
5: 'MidjourneyPlus',
|
||||
6: 'OpenAIMax',
|
||||
7: 'OhMyGPT',
|
||||
8: 'Custom',
|
||||
9: 'AILS',
|
||||
10: 'AI Proxy',
|
||||
11: 'PaLM',
|
||||
12: 'API2GPT',
|
||||
13: 'AIGC2D',
|
||||
14: 'Anthropic',
|
||||
15: 'Baidu',
|
||||
16: 'Zhipu',
|
||||
17: 'Ali',
|
||||
18: 'Xunfei',
|
||||
19: '360',
|
||||
20: 'OpenRouter',
|
||||
21: 'AI Proxy Library',
|
||||
22: 'FastGPT',
|
||||
23: 'Tencent',
|
||||
24: 'Gemini',
|
||||
25: 'Moonshot',
|
||||
26: 'Zhipu V4',
|
||||
27: 'Perplexity',
|
||||
31: 'LingYiWanWu',
|
||||
33: 'AWS',
|
||||
34: 'Cohere',
|
||||
35: 'MiniMax',
|
||||
36: 'SunoAPI',
|
||||
37: 'Dify',
|
||||
38: 'Jina',
|
||||
39: 'Cloudflare',
|
||||
40: 'SiliconFlow',
|
||||
41: 'Vertex AI',
|
||||
42: 'Mistral',
|
||||
43: 'DeepSeek',
|
||||
44: 'MokaAI',
|
||||
45: 'VolcEngine',
|
||||
46: 'Baidu V2',
|
||||
47: 'Xinference',
|
||||
48: 'xAI',
|
||||
49: 'Coze',
|
||||
50: 'Kling',
|
||||
51: 'Jimeng',
|
||||
52: 'Vidu',
|
||||
53: 'Submodel',
|
||||
54: 'DoubaoVideo',
|
||||
55: 'Sora',
|
||||
56: 'Replicate',
|
||||
57: 'Codex',
|
||||
} as const
|
||||
|
||||
export const CHANNEL_TYPE_OPTIONS = Object.entries(CHANNEL_TYPES)
|
||||
.filter(([value]) => {
|
||||
const num = Number(value)
|
||||
return num !== 0 // Exclude Unknown
|
||||
})
|
||||
.map(([value, label]) => ({
|
||||
value: Number(value),
|
||||
label,
|
||||
}))
|
||||
|
||||
// ============================================================================
|
||||
// Channel Status (label values are i18n keys; use t(config.label) in components)
|
||||
// ============================================================================
|
||||
|
||||
export const CHANNEL_STATUS = {
|
||||
UNKNOWN: 0,
|
||||
ENABLED: 1,
|
||||
MANUAL_DISABLED: 2,
|
||||
AUTO_DISABLED: 3,
|
||||
} as const
|
||||
|
||||
export const CHANNEL_STATUS_LABELS = {
|
||||
[CHANNEL_STATUS.UNKNOWN]: 'Unknown',
|
||||
[CHANNEL_STATUS.ENABLED]: 'Enabled',
|
||||
[CHANNEL_STATUS.MANUAL_DISABLED]: 'Disabled',
|
||||
[CHANNEL_STATUS.AUTO_DISABLED]: 'Auto Disabled',
|
||||
} as const
|
||||
|
||||
export const CHANNEL_STATUS_OPTIONS = [
|
||||
{ value: 'all', label: 'All Status' },
|
||||
{ value: 'enabled', label: 'Enabled' },
|
||||
{ value: 'disabled', label: 'Disabled' },
|
||||
] as const
|
||||
|
||||
export const CHANNEL_STATUS_CONFIG = {
|
||||
[CHANNEL_STATUS.UNKNOWN]: {
|
||||
variant: 'neutral' as const,
|
||||
label: 'Unknown',
|
||||
showDot: true,
|
||||
},
|
||||
[CHANNEL_STATUS.ENABLED]: {
|
||||
variant: 'success' as const,
|
||||
label: 'Enabled',
|
||||
showDot: true,
|
||||
},
|
||||
[CHANNEL_STATUS.MANUAL_DISABLED]: {
|
||||
variant: 'neutral' as const,
|
||||
label: 'Disabled',
|
||||
showDot: true,
|
||||
},
|
||||
[CHANNEL_STATUS.AUTO_DISABLED]: {
|
||||
variant: 'danger' as const,
|
||||
label: 'Auto Disabled',
|
||||
showDot: true,
|
||||
},
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Multi-Key Status
|
||||
// ============================================================================
|
||||
|
||||
export const MULTI_KEY_STATUS = {
|
||||
ENABLED: 1,
|
||||
MANUAL_DISABLED: 2,
|
||||
AUTO_DISABLED: 3,
|
||||
} as const
|
||||
|
||||
export const MULTI_KEY_STATUS_LABELS = {
|
||||
[MULTI_KEY_STATUS.ENABLED]: 'Enabled',
|
||||
[MULTI_KEY_STATUS.MANUAL_DISABLED]: 'Manual Disabled',
|
||||
[MULTI_KEY_STATUS.AUTO_DISABLED]: 'Auto Disabled',
|
||||
} as const
|
||||
|
||||
export const MULTI_KEY_STATUS_CONFIG = {
|
||||
[MULTI_KEY_STATUS.ENABLED]: {
|
||||
variant: 'success' as const,
|
||||
label: 'Enabled',
|
||||
},
|
||||
[MULTI_KEY_STATUS.MANUAL_DISABLED]: {
|
||||
variant: 'neutral' as const,
|
||||
label: 'Manual Disabled',
|
||||
},
|
||||
[MULTI_KEY_STATUS.AUTO_DISABLED]: {
|
||||
variant: 'danger' as const,
|
||||
label: 'Auto Disabled',
|
||||
},
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Multi-Key Modes
|
||||
// ============================================================================
|
||||
|
||||
export const MULTI_KEY_MODES = [
|
||||
{ value: 'random', label: 'Random' },
|
||||
{ value: 'polling', label: 'Polling' },
|
||||
] as const
|
||||
|
||||
export const ADD_MODE_OPTIONS = [
|
||||
{ value: 'single', label: 'Single Key' },
|
||||
{ value: 'batch', label: 'Batch Add (one key per line)' },
|
||||
{
|
||||
value: 'multi_to_single',
|
||||
label: 'Multi-Key Mode (multiple keys, one channel)',
|
||||
},
|
||||
] as const
|
||||
|
||||
// ============================================================================
|
||||
// Multi-Key Management
|
||||
// ============================================================================
|
||||
|
||||
export const MULTI_KEY_FILTER_OPTIONS = [
|
||||
{ value: 'all', label: 'All Status' },
|
||||
{ value: '1', label: 'Enabled' },
|
||||
{ value: '2', label: 'Manual Disabled' },
|
||||
{ value: '3', label: 'Auto Disabled' },
|
||||
] as const
|
||||
|
||||
export const MULTI_KEY_CONFIRM_MESSAGES = {
|
||||
DELETE:
|
||||
'Are you sure you want to delete this key? This action cannot be undone.',
|
||||
ENABLE: 'Enable this key?',
|
||||
DISABLE: 'Disable this key?',
|
||||
ENABLE_ALL: 'Are you sure you want to enable all keys?',
|
||||
DISABLE_ALL: 'Are you sure you want to disable all enabled keys?',
|
||||
DELETE_DISABLED:
|
||||
'Are you sure you want to delete all auto-disabled keys? This action cannot be undone.',
|
||||
} as const
|
||||
|
||||
// ============================================================================
|
||||
// Auto Ban Options
|
||||
// ============================================================================
|
||||
|
||||
export const AUTO_BAN_OPTIONS = [
|
||||
{ value: 1, label: 'Enabled' },
|
||||
{ value: 0, label: 'Disabled' },
|
||||
] as const
|
||||
|
||||
// ============================================================================
|
||||
// Error / Success Messages (i18n keys: use t(ERROR_MESSAGES.xxx) when displaying)
|
||||
// ============================================================================
|
||||
|
||||
export const ERROR_MESSAGES = {
|
||||
REQUIRED_NAME: 'Channel name is required',
|
||||
REQUIRED_TYPE: 'Channel type is required',
|
||||
REQUIRED_KEY: 'API key is required',
|
||||
REQUIRED_MODELS: 'Models are required',
|
||||
REQUIRED_GROUP: 'Group is required',
|
||||
INVALID_JSON: 'Invalid JSON format',
|
||||
INVALID_MODEL_MAPPING: 'Invalid model mapping format',
|
||||
CREATE_FAILED: 'Failed to create channel',
|
||||
UPDATE_FAILED: 'Failed to update channel',
|
||||
DELETE_FAILED: 'Failed to delete channel',
|
||||
TEST_FAILED: 'Failed to test channel',
|
||||
BALANCE_QUERY_FAILED: 'Failed to query balance',
|
||||
FETCH_MODELS_FAILED: 'Failed to fetch models',
|
||||
} as const
|
||||
|
||||
export const SUCCESS_MESSAGES = {
|
||||
CREATED: 'Channel created successfully',
|
||||
UPDATED: 'Channel updated successfully',
|
||||
DELETED: 'Channel deleted successfully',
|
||||
ENABLED: 'Channel enabled successfully',
|
||||
DISABLED: 'Channel disabled successfully',
|
||||
TESTED: 'Channel test completed',
|
||||
BALANCE_QUERIED: 'Balance queried successfully',
|
||||
MODELS_FETCHED: 'Models fetched successfully',
|
||||
COPIED: 'Channel copied successfully',
|
||||
TAG_SET: 'Tag set successfully',
|
||||
BATCH_DELETED: 'Channels deleted successfully',
|
||||
} as const
|
||||
|
||||
// ============================================================================
|
||||
// Default Values
|
||||
// ============================================================================
|
||||
|
||||
export const DEFAULT_PAGE_SIZE = 20
|
||||
|
||||
export const DEFAULT_CHANNEL_VALUES = {
|
||||
name: '',
|
||||
type: 0,
|
||||
base_url: '',
|
||||
key: '',
|
||||
models: '',
|
||||
group: 'default',
|
||||
status: CHANNEL_STATUS.ENABLED,
|
||||
priority: 0,
|
||||
weight: 0,
|
||||
auto_ban: 1,
|
||||
remark: '',
|
||||
} as const
|
||||
|
||||
// ============================================================================
|
||||
// Table Configuration
|
||||
// ============================================================================
|
||||
|
||||
export const CHANNELS_TABLE_PAGE_SIZE_OPTIONS = [10, 20, 50, 100]
|
||||
|
||||
// ============================================================================
|
||||
// Sort Options (label values are i18n keys)
|
||||
// ============================================================================
|
||||
|
||||
export const SORT_OPTIONS = [
|
||||
{ value: 'priority', label: 'Priority (Default)' },
|
||||
{ value: 'id', label: 'ID' },
|
||||
{ value: 'name', label: 'Name' },
|
||||
{ value: 'balance', label: 'Balance' },
|
||||
{ value: 'response_time', label: 'Response Time' },
|
||||
] as const
|
||||
|
||||
// ============================================================================
|
||||
// Balance Display
|
||||
// ============================================================================
|
||||
|
||||
export const BALANCE_THRESHOLDS = {
|
||||
LOW: 1,
|
||||
MEDIUM: 10,
|
||||
HIGH: 100,
|
||||
} as const
|
||||
|
||||
// ============================================================================
|
||||
// Response Time Thresholds (in ms)
|
||||
// ============================================================================
|
||||
|
||||
export const RESPONSE_TIME_THRESHOLDS = {
|
||||
EXCELLENT: 500,
|
||||
GOOD: 1000,
|
||||
FAIR: 2000,
|
||||
POOR: 5000,
|
||||
} as const
|
||||
|
||||
export const RESPONSE_TIME_CONFIG = {
|
||||
EXCELLENT: { variant: 'success' as const, label: 'Excellent' },
|
||||
GOOD: { variant: 'info' as const, label: 'Good' },
|
||||
FAIR: { variant: 'warning' as const, label: 'Fair' },
|
||||
POOR: { variant: 'danger' as const, label: 'Poor' },
|
||||
UNKNOWN: { variant: 'neutral' as const, label: 'Not tested' },
|
||||
} as const
|
||||
|
||||
// ============================================================================
|
||||
// Field Hints and Placeholders (i18n keys; use t() when displaying)
|
||||
// ============================================================================
|
||||
|
||||
export const FIELD_PLACEHOLDERS = {
|
||||
NAME: 'e.g., OpenAI GPT-4 Production',
|
||||
BASE_URL: 'Leave empty to use default',
|
||||
KEY: 'API Key (one per line for batch mode)',
|
||||
MODELS: 'Comma-separated model names, e.g., gpt-4,gpt-3.5-turbo',
|
||||
GROUP: 'Please Select user groups that can access this channel.',
|
||||
MODEL_MAPPING: '{"request_model": "actual_model"}',
|
||||
TEST_MODEL: 'Model to use for testing',
|
||||
TAG: 'Optional tag for grouping channels',
|
||||
REMARK: 'Optional notes about this channel',
|
||||
PARAM_OVERRIDE: '{"temperature": 0.7}',
|
||||
HEADER_OVERRIDE: '{"X-Custom-Header": "value"}',
|
||||
STATUS_CODE_MAPPING: '{"400": "500"}',
|
||||
} as const
|
||||
|
||||
export const FIELD_DESCRIPTIONS = {
|
||||
NAME: 'Friendly name to identify this channel',
|
||||
TYPE: 'Provider type (OpenAI, Anthropic, etc.)',
|
||||
BASE_URL: 'Custom API base URL. Leave empty to use provider default.',
|
||||
KEY: 'API key from the provider',
|
||||
MODELS:
|
||||
'List of models supported by this channel. Use comma to separate multiple models.',
|
||||
GROUP: 'User groups that can access this channel. ',
|
||||
MODEL_MAPPING:
|
||||
'Map request model names to actual provider model names (JSON format)',
|
||||
PRIORITY: 'Higher priority channels are selected first',
|
||||
WEIGHT: 'Used for load balancing. Higher weight = more requests',
|
||||
TEST_MODEL: 'Model to use when testing channel connectivity',
|
||||
AUTO_BAN: 'Automatically disable channel on repeated failures',
|
||||
STATUS_CODE_MAPPING: 'Map response status codes (JSON format)',
|
||||
TAG: 'Group channels by tag for batch operations',
|
||||
REMARK: 'Internal notes (not shown to users)',
|
||||
SETTING: 'Channel-specific settings (JSON format)',
|
||||
PARAM_OVERRIDE: 'Override request parameters (JSON format)',
|
||||
HEADER_OVERRIDE: 'Override request headers (JSON format)',
|
||||
MULTI_KEY_MODE: 'How to select keys: random or sequential polling',
|
||||
BATCH_ADD: 'Create multiple channels from multiple keys',
|
||||
OPENAI_ORG: 'OpenAI Organization ID (optional)',
|
||||
} as const
|
||||
|
||||
// ============================================================================
|
||||
// Channel Type Specific Configurations
|
||||
// ============================================================================
|
||||
|
||||
export const MODEL_FETCHABLE_TYPES = new Set([
|
||||
1, 4, 14, 17, 20, 23, 24, 25, 26, 31, 34, 35, 40, 42, 43, 47, 48,
|
||||
])
|
||||
|
||||
export const TYPE_TO_KEY_PROMPT: Record<number, string> = {
|
||||
15: 'Format: APIKey|SecretKey',
|
||||
18: 'Format: APPID|APISecret|APIKey',
|
||||
22: 'Format: APIKey-AppId, e.g., fastgpt-0sp2gtvfdgyi4k30jwlgwf1i-64f335d84283f05518e9e041',
|
||||
23: 'Format: AppId|SecretId|SecretKey',
|
||||
33: 'Format: Ak|Sk|Region',
|
||||
50: 'Format: AccessKey|SecretKey (or just ApiKey if upstream is New API)',
|
||||
51: 'Format: Access Key ID|Secret Access Key',
|
||||
57: 'Paste Codex OAuth JSON credential (access_token / refresh_token / account_id)',
|
||||
}
|
||||
|
||||
export const CHANNEL_TYPE_WARNINGS: Record<number, string> = {
|
||||
3: 'For channels added after May 10, 2025, no need to remove "." from model names during deployment',
|
||||
8: 'If connecting to upstream One API or New API relay projects, use OpenAI type instead unless you know what you are doing',
|
||||
37: 'Dify channels only support chatflow and agent, and agent does not support images',
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
import { useRef, useState, useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { api } from '@/lib/api'
|
||||
import { normalizeModelList } from '../lib/upstream-update-utils'
|
||||
|
||||
function getManualIgnoredModelCount(settings: unknown): number {
|
||||
let parsed: Record<string, unknown> | null = null
|
||||
if (settings && typeof settings === 'object')
|
||||
parsed = settings as Record<string, unknown>
|
||||
else if (typeof settings === 'string') {
|
||||
try {
|
||||
parsed = JSON.parse(settings)
|
||||
} catch {
|
||||
parsed = null
|
||||
}
|
||||
}
|
||||
if (!parsed) return 0
|
||||
return normalizeModelList(
|
||||
(parsed.upstream_model_update_ignored_models as unknown[]) || []
|
||||
).length
|
||||
}
|
||||
|
||||
export function useChannelUpstreamUpdates(refresh: () => Promise<void>) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const [channel, setChannel] = useState<{
|
||||
id: number
|
||||
[key: string]: unknown
|
||||
} | null>(null)
|
||||
const [addModels, setAddModels] = useState<string[]>([])
|
||||
const [removeModels, setRemoveModels] = useState<string[]>([])
|
||||
const [preferredTab, setPreferredTab] = useState<'add' | 'remove'>('add')
|
||||
const [applyLoading, setApplyLoading] = useState(false)
|
||||
const [detectAllLoading, setDetectAllLoading] = useState(false)
|
||||
const [applyAllLoading, setApplyAllLoading] = useState(false)
|
||||
|
||||
const applyRef = useRef(false)
|
||||
const detectRef = useRef(false)
|
||||
const detectAllRef = useRef(false)
|
||||
const applyAllRef = useRef(false)
|
||||
|
||||
const openModal = useCallback(
|
||||
(
|
||||
record: { id: number; [key: string]: unknown } | null,
|
||||
pendingAdd: string[] = [],
|
||||
pendingRemove: string[] = [],
|
||||
tab: 'add' | 'remove' = 'add'
|
||||
) => {
|
||||
const normAdd = normalizeModelList(pendingAdd)
|
||||
const normRemove = normalizeModelList(pendingRemove)
|
||||
if (!record?.id || (normAdd.length === 0 && normRemove.length === 0)) {
|
||||
toast.info(t('No processable upstream model updates for this channel'))
|
||||
return
|
||||
}
|
||||
setChannel(record)
|
||||
setAddModels(normAdd)
|
||||
setRemoveModels(normRemove)
|
||||
setPreferredTab(tab)
|
||||
setShowModal(true)
|
||||
},
|
||||
[t]
|
||||
)
|
||||
|
||||
const closeModal = useCallback(() => {
|
||||
setShowModal(false)
|
||||
setChannel(null)
|
||||
setAddModels([])
|
||||
setRemoveModels([])
|
||||
setPreferredTab('add')
|
||||
}, [])
|
||||
|
||||
const applyUpdates = useCallback(
|
||||
async ({
|
||||
addModels: selectedAdd = [],
|
||||
removeModels: selectedRemove = [],
|
||||
}: {
|
||||
addModels?: string[]
|
||||
removeModels?: string[]
|
||||
} = {}) => {
|
||||
if (applyRef.current) return
|
||||
if (!channel?.id) {
|
||||
closeModal()
|
||||
return
|
||||
}
|
||||
applyRef.current = true
|
||||
setApplyLoading(true)
|
||||
try {
|
||||
const normSelectedAdd = normalizeModelList(selectedAdd)
|
||||
const selectedAddSet = new Set(normSelectedAdd)
|
||||
const ignoreModels = addModels.filter((m) => !selectedAddSet.has(m))
|
||||
|
||||
const res = await api.post(
|
||||
'/api/channel/upstream_updates/apply',
|
||||
{
|
||||
id: channel.id,
|
||||
add_models: normSelectedAdd,
|
||||
ignore_models: ignoreModels,
|
||||
remove_models: normalizeModelList(selectedRemove),
|
||||
},
|
||||
{ skipErrorHandler: true } as Record<string, unknown>
|
||||
)
|
||||
const { success, message, data } = res.data || {}
|
||||
if (!success) {
|
||||
toast.error(message || t('Operation failed'))
|
||||
return
|
||||
}
|
||||
|
||||
toast.success(
|
||||
t(
|
||||
'Upstream model updates applied: {{added}} added, {{removed}} removed, {{ignored}} ignored this time, {{totalIgnored}} total ignored models',
|
||||
{
|
||||
added: data?.added_models?.length || 0,
|
||||
removed: data?.removed_models?.length || 0,
|
||||
ignored: normalizeModelList(ignoreModels).length,
|
||||
totalIgnored: getManualIgnoredModelCount(data?.settings),
|
||||
}
|
||||
)
|
||||
)
|
||||
closeModal()
|
||||
await refresh()
|
||||
} catch (e: unknown) {
|
||||
const err = e as {
|
||||
response?: { data?: { message?: string } }
|
||||
message?: string
|
||||
}
|
||||
toast.error(
|
||||
err?.response?.data?.message || err?.message || t('Operation failed')
|
||||
)
|
||||
} finally {
|
||||
applyRef.current = false
|
||||
setApplyLoading(false)
|
||||
}
|
||||
},
|
||||
[channel, addModels, closeModal, refresh, t]
|
||||
)
|
||||
|
||||
const applyAllUpdates = useCallback(async () => {
|
||||
if (applyAllRef.current) return
|
||||
applyAllRef.current = true
|
||||
setApplyAllLoading(true)
|
||||
try {
|
||||
const res = await api.post(
|
||||
'/api/channel/upstream_updates/apply_all',
|
||||
{},
|
||||
{ skipErrorHandler: true } as Record<string, unknown>
|
||||
)
|
||||
const { success, message, data } = res.data || {}
|
||||
if (!success) {
|
||||
toast.error(message || t('Batch processing failed'))
|
||||
return
|
||||
}
|
||||
|
||||
toast.success(
|
||||
t(
|
||||
'Batch upstream model updates applied: {{channels}} channels, {{added}} added, {{removed}} removed, {{fails}} failed',
|
||||
{
|
||||
channels: data?.processed_channels || 0,
|
||||
added: data?.added_models || 0,
|
||||
removed: data?.removed_models || 0,
|
||||
fails: (data?.failed_channel_ids || []).length,
|
||||
}
|
||||
)
|
||||
)
|
||||
await refresh()
|
||||
} catch (e: unknown) {
|
||||
const err = e as {
|
||||
response?: { data?: { message?: string } }
|
||||
message?: string
|
||||
}
|
||||
toast.error(
|
||||
err?.response?.data?.message ||
|
||||
err?.message ||
|
||||
t('Batch processing failed')
|
||||
)
|
||||
} finally {
|
||||
applyAllRef.current = false
|
||||
setApplyAllLoading(false)
|
||||
}
|
||||
}, [refresh, t])
|
||||
|
||||
const detectChannelUpdates = useCallback(
|
||||
async (ch: { id: number; [key: string]: unknown } | null) => {
|
||||
if (detectRef.current || !ch?.id) return
|
||||
detectRef.current = true
|
||||
try {
|
||||
const res = await api.post(
|
||||
'/api/channel/upstream_updates/detect',
|
||||
{ id: ch.id },
|
||||
{ skipErrorHandler: true } as Record<string, unknown>
|
||||
)
|
||||
const { success, message, data } = res.data || {}
|
||||
if (!success) {
|
||||
toast.error(message || t('Detection failed'))
|
||||
return
|
||||
}
|
||||
|
||||
toast.success(
|
||||
t('Detection complete: {{add}} to add, {{remove}} to remove', {
|
||||
add: data?.add_models?.length || 0,
|
||||
remove: data?.remove_models?.length || 0,
|
||||
})
|
||||
)
|
||||
await refresh()
|
||||
} catch (e: unknown) {
|
||||
const err = e as {
|
||||
response?: { data?: { message?: string } }
|
||||
message?: string
|
||||
}
|
||||
toast.error(
|
||||
err?.response?.data?.message || err?.message || t('Detection failed')
|
||||
)
|
||||
} finally {
|
||||
detectRef.current = false
|
||||
}
|
||||
},
|
||||
[refresh, t]
|
||||
)
|
||||
|
||||
const detectAllUpdates = useCallback(async () => {
|
||||
if (detectAllRef.current) return
|
||||
detectAllRef.current = true
|
||||
setDetectAllLoading(true)
|
||||
try {
|
||||
const res = await api.post(
|
||||
'/api/channel/upstream_updates/detect_all',
|
||||
{},
|
||||
{ skipErrorHandler: true } as Record<string, unknown>
|
||||
)
|
||||
const { success, message, data } = res.data || {}
|
||||
if (!success) {
|
||||
toast.error(message || t('Batch detection failed'))
|
||||
return
|
||||
}
|
||||
|
||||
toast.success(
|
||||
t(
|
||||
'Batch detection complete: {{channels}} channels, {{add}} to add, {{remove}} to remove, {{fails}} failed',
|
||||
{
|
||||
channels: data?.processed_channels || 0,
|
||||
add: data?.detected_add_models || 0,
|
||||
remove: data?.detected_remove_models || 0,
|
||||
fails: (data?.failed_channel_ids || []).length,
|
||||
}
|
||||
)
|
||||
)
|
||||
await refresh()
|
||||
} catch (e: unknown) {
|
||||
const err = e as {
|
||||
response?: { data?: { message?: string } }
|
||||
message?: string
|
||||
}
|
||||
toast.error(
|
||||
err?.response?.data?.message ||
|
||||
err?.message ||
|
||||
t('Batch detection failed')
|
||||
)
|
||||
} finally {
|
||||
detectAllRef.current = false
|
||||
setDetectAllLoading(false)
|
||||
}
|
||||
}, [refresh, t])
|
||||
|
||||
return {
|
||||
showModal,
|
||||
channel,
|
||||
addModels,
|
||||
removeModels,
|
||||
preferredTab,
|
||||
applyLoading,
|
||||
detectAllLoading,
|
||||
applyAllLoading,
|
||||
openModal,
|
||||
closeModal,
|
||||
applyUpdates,
|
||||
applyAllUpdates,
|
||||
detectChannelUpdates,
|
||||
detectAllUpdates,
|
||||
}
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { SectionPageLayout } from '@/components/layout'
|
||||
import { ChannelsDialogs } from './components/channels-dialogs'
|
||||
import { ChannelsPrimaryButtons } from './components/channels-primary-buttons'
|
||||
import { ChannelsProvider } from './components/channels-provider'
|
||||
import { ChannelsTable } from './components/channels-table'
|
||||
|
||||
export function Channels() {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<ChannelsProvider>
|
||||
<SectionPageLayout>
|
||||
<SectionPageLayout.Title>{t('Channels')}</SectionPageLayout.Title>
|
||||
<SectionPageLayout.Description>
|
||||
{t('Manage API channels and provider configurations')}
|
||||
</SectionPageLayout.Description>
|
||||
<SectionPageLayout.Actions>
|
||||
<ChannelsPrimaryButtons />
|
||||
</SectionPageLayout.Actions>
|
||||
<SectionPageLayout.Content>
|
||||
<ChannelsTable />
|
||||
</SectionPageLayout.Content>
|
||||
</SectionPageLayout>
|
||||
|
||||
<ChannelsDialogs />
|
||||
</ChannelsProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,577 @@
|
||||
import type { QueryClient } from '@tanstack/react-query'
|
||||
import i18next from 'i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { formatCurrencyFromUSD } from '@/lib/currency'
|
||||
import {
|
||||
copyChannel,
|
||||
deleteChannel,
|
||||
testChannel,
|
||||
updateChannel,
|
||||
batchDeleteChannels,
|
||||
batchSetChannelTag,
|
||||
enableTagChannels,
|
||||
disableTagChannels,
|
||||
deleteDisabledChannels,
|
||||
fixChannelAbilities,
|
||||
editTagChannels,
|
||||
testAllChannels,
|
||||
updateAllChannelsBalance,
|
||||
updateChannelBalance,
|
||||
} from '../api'
|
||||
import { CHANNEL_STATUS, ERROR_MESSAGES, SUCCESS_MESSAGES } from '../constants'
|
||||
import type { CopyChannelParams } from '../types'
|
||||
|
||||
// ============================================================================
|
||||
// Query Keys
|
||||
// ============================================================================
|
||||
|
||||
export const channelsQueryKeys = {
|
||||
all: ['channels'] as const,
|
||||
lists: () => [...channelsQueryKeys.all, 'list'] as const,
|
||||
list: (params: Record<string, unknown>) =>
|
||||
[...channelsQueryKeys.lists(), params] as const,
|
||||
details: () => [...channelsQueryKeys.all, 'detail'] as const,
|
||||
detail: (id: number) => [...channelsQueryKeys.details(), id] as const,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Single Channel Actions
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Enable a channel
|
||||
*/
|
||||
export async function handleEnableChannel(
|
||||
id: number,
|
||||
queryClient?: QueryClient,
|
||||
onSuccess?: () => void
|
||||
): Promise<void> {
|
||||
try {
|
||||
const response = await updateChannel(id, { status: CHANNEL_STATUS.ENABLED })
|
||||
if (response.success) {
|
||||
toast.success(i18next.t(SUCCESS_MESSAGES.ENABLED))
|
||||
queryClient?.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
|
||||
onSuccess?.()
|
||||
}
|
||||
} catch (_error) {
|
||||
toast.error(i18next.t(ERROR_MESSAGES.UPDATE_FAILED))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable a channel
|
||||
*/
|
||||
export async function handleDisableChannel(
|
||||
id: number,
|
||||
queryClient?: QueryClient,
|
||||
onSuccess?: () => void
|
||||
): Promise<void> {
|
||||
try {
|
||||
const response = await updateChannel(id, {
|
||||
status: CHANNEL_STATUS.MANUAL_DISABLED,
|
||||
})
|
||||
if (response.success) {
|
||||
toast.success(i18next.t(SUCCESS_MESSAGES.DISABLED))
|
||||
queryClient?.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
|
||||
onSuccess?.()
|
||||
}
|
||||
} catch (_error) {
|
||||
toast.error(i18next.t(ERROR_MESSAGES.UPDATE_FAILED))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle channel status (enable/disable)
|
||||
*/
|
||||
export async function handleToggleChannelStatus(
|
||||
id: number,
|
||||
currentStatus: number,
|
||||
queryClient?: QueryClient,
|
||||
onSuccess?: () => void
|
||||
): Promise<void> {
|
||||
if (currentStatus === CHANNEL_STATUS.ENABLED) {
|
||||
await handleDisableChannel(id, queryClient, onSuccess)
|
||||
} else {
|
||||
await handleEnableChannel(id, queryClient, onSuccess)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a channel
|
||||
*/
|
||||
export async function handleDeleteChannel(
|
||||
id: number,
|
||||
queryClient?: QueryClient,
|
||||
onSuccess?: () => void
|
||||
): Promise<void> {
|
||||
try {
|
||||
const response = await deleteChannel(id)
|
||||
if (response.success) {
|
||||
toast.success(i18next.t(SUCCESS_MESSAGES.DELETED))
|
||||
queryClient?.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
|
||||
onSuccess?.()
|
||||
}
|
||||
} catch (_error) {
|
||||
toast.error(i18next.t(ERROR_MESSAGES.DELETE_FAILED))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a specific channel field (e.g., priority, weight)
|
||||
*/
|
||||
export async function handleUpdateChannelField(
|
||||
id: number,
|
||||
fieldName: string,
|
||||
value: number,
|
||||
queryClient?: QueryClient,
|
||||
onSuccess?: () => void
|
||||
): Promise<void> {
|
||||
try {
|
||||
const response = await updateChannel(id, { [fieldName]: value })
|
||||
if (response.success) {
|
||||
// Show success toast with field name
|
||||
const fieldLabel =
|
||||
fieldName.charAt(0).toUpperCase() + fieldName.slice(1).toLowerCase()
|
||||
toast.success(
|
||||
i18next.t('{{field}} updated to {{value}}', {
|
||||
field: fieldLabel,
|
||||
value,
|
||||
})
|
||||
)
|
||||
queryClient?.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
|
||||
onSuccess?.()
|
||||
} else {
|
||||
toast.error(response.message || i18next.t(ERROR_MESSAGES.UPDATE_FAILED))
|
||||
}
|
||||
} catch (_error) {
|
||||
toast.error(i18next.t(ERROR_MESSAGES.UPDATE_FAILED))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a specific field for all channels with a tag
|
||||
*/
|
||||
export async function handleUpdateTagField(
|
||||
tag: string,
|
||||
fieldName: 'priority' | 'weight',
|
||||
value: number,
|
||||
queryClient?: QueryClient,
|
||||
onSuccess?: () => void
|
||||
): Promise<void> {
|
||||
try {
|
||||
const params = { tag, [fieldName]: value }
|
||||
const response = await editTagChannels(params)
|
||||
if (response.success) {
|
||||
// Show success toast with field name
|
||||
const fieldLabel =
|
||||
fieldName.charAt(0).toUpperCase() + fieldName.slice(1).toLowerCase()
|
||||
toast.success(
|
||||
i18next.t('{{field}} updated to {{value}} for tag: {{tag}}', {
|
||||
field: fieldLabel,
|
||||
value,
|
||||
tag,
|
||||
})
|
||||
)
|
||||
queryClient?.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
|
||||
onSuccess?.()
|
||||
} else {
|
||||
toast.error(response.message || i18next.t(ERROR_MESSAGES.UPDATE_FAILED))
|
||||
}
|
||||
} catch (_error) {
|
||||
toast.error(i18next.t(ERROR_MESSAGES.UPDATE_FAILED))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test channel connectivity
|
||||
*/
|
||||
export async function handleTestChannel(
|
||||
id: number,
|
||||
options?: { testModel?: string; endpointType?: string; stream?: boolean },
|
||||
onTestComplete?: (
|
||||
success: boolean,
|
||||
responseTime?: number,
|
||||
error?: string,
|
||||
errorCode?: string
|
||||
) => void
|
||||
): Promise<void> {
|
||||
const payload =
|
||||
options && (options.testModel || options.endpointType || options.stream)
|
||||
? {
|
||||
...(options.testModel ? { model: options.testModel } : {}),
|
||||
...(options.endpointType
|
||||
? { endpoint_type: options.endpointType }
|
||||
: {}),
|
||||
...(options.stream ? { stream: true } : {}),
|
||||
}
|
||||
: undefined
|
||||
|
||||
try {
|
||||
const response = await testChannel(id, payload)
|
||||
if (response.success) {
|
||||
toast.success(i18next.t(SUCCESS_MESSAGES.TESTED))
|
||||
onTestComplete?.(true, response.data?.response_time)
|
||||
} else {
|
||||
toast.error(response.message || i18next.t(ERROR_MESSAGES.TEST_FAILED))
|
||||
onTestComplete?.(false, undefined, response.message, response.error_code)
|
||||
}
|
||||
} catch (_error: unknown) {
|
||||
const err = _error as { response?: { data?: { message?: string } } }
|
||||
const errorMsg =
|
||||
err?.response?.data?.message || i18next.t(ERROR_MESSAGES.TEST_FAILED)
|
||||
toast.error(errorMsg)
|
||||
onTestComplete?.(false, undefined, errorMsg)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy a channel
|
||||
*/
|
||||
export async function handleCopyChannel(
|
||||
id: number,
|
||||
params: CopyChannelParams,
|
||||
queryClient?: QueryClient,
|
||||
onSuccess?: (newId: number) => void
|
||||
): Promise<void> {
|
||||
try {
|
||||
const response = await copyChannel(id, params)
|
||||
if (response.success && response.data?.id) {
|
||||
toast.success(i18next.t(SUCCESS_MESSAGES.COPIED))
|
||||
queryClient?.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
|
||||
onSuccess?.(response.data.id)
|
||||
}
|
||||
} catch (_error) {
|
||||
toast.error(i18next.t('Failed to copy channel'))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update channel balance
|
||||
*/
|
||||
export async function handleUpdateChannelBalance(
|
||||
id: number,
|
||||
queryClient?: QueryClient,
|
||||
onSuccess?: (balance: number) => void
|
||||
): Promise<void> {
|
||||
try {
|
||||
const response = await updateChannelBalance(id)
|
||||
if (response.success && response.balance !== undefined) {
|
||||
const balance = response.balance
|
||||
toast.success(
|
||||
i18next.t('Balance updated: {{balance}}', {
|
||||
balance: formatCurrencyFromUSD(balance, {
|
||||
digitsLarge: 2,
|
||||
digitsSmall: 4,
|
||||
abbreviate: false,
|
||||
}),
|
||||
})
|
||||
)
|
||||
queryClient?.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
|
||||
onSuccess?.(balance)
|
||||
} else {
|
||||
toast.error(response.message || i18next.t('Failed to update balance'))
|
||||
}
|
||||
} catch (_error: unknown) {
|
||||
toast.error(
|
||||
_error instanceof Error
|
||||
? _error.message
|
||||
: i18next.t('Failed to update balance')
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Batch Actions
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Batch delete channels
|
||||
*/
|
||||
export async function handleBatchDelete(
|
||||
ids: number[],
|
||||
queryClient?: QueryClient,
|
||||
onSuccess?: (deletedCount: number) => void
|
||||
): Promise<void> {
|
||||
if (ids.length === 0) {
|
||||
toast.error(i18next.t('No channels selected'))
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await batchDeleteChannels({ ids })
|
||||
if (response.success) {
|
||||
toast.success(
|
||||
i18next.t('{{count}} channel(s) deleted', {
|
||||
count: response.data || ids.length,
|
||||
})
|
||||
)
|
||||
queryClient?.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
|
||||
onSuccess?.(response.data || ids.length)
|
||||
}
|
||||
} catch (_error) {
|
||||
toast.error(i18next.t(ERROR_MESSAGES.DELETE_FAILED))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch enable channels
|
||||
*/
|
||||
export async function handleBatchEnable(
|
||||
ids: number[],
|
||||
queryClient?: QueryClient,
|
||||
onSuccess?: () => void
|
||||
): Promise<void> {
|
||||
if (ids.length === 0) {
|
||||
toast.error(i18next.t('No channels selected'))
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// Update each channel individually
|
||||
const promises = ids.map((id) =>
|
||||
updateChannel(id, { status: CHANNEL_STATUS.ENABLED })
|
||||
)
|
||||
const results = await Promise.allSettled(promises)
|
||||
|
||||
const successCount = results.filter((r) => r.status === 'fulfilled').length
|
||||
const failCount = results.filter((r) => r.status === 'rejected').length
|
||||
|
||||
if (successCount > 0) {
|
||||
toast.success(
|
||||
i18next.t('{{count}} channel(s) enabled', { count: successCount })
|
||||
)
|
||||
queryClient?.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
|
||||
onSuccess?.()
|
||||
}
|
||||
|
||||
if (failCount > 0) {
|
||||
toast.error(
|
||||
i18next.t('{{count}} channel(s) failed to enable', { count: failCount })
|
||||
)
|
||||
}
|
||||
} catch (_error) {
|
||||
toast.error(i18next.t('Failed to enable channels'))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch disable channels
|
||||
*/
|
||||
export async function handleBatchDisable(
|
||||
ids: number[],
|
||||
queryClient?: QueryClient,
|
||||
onSuccess?: () => void
|
||||
): Promise<void> {
|
||||
if (ids.length === 0) {
|
||||
toast.error(i18next.t('No channels selected'))
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// Update each channel individually
|
||||
const promises = ids.map((id) =>
|
||||
updateChannel(id, { status: CHANNEL_STATUS.MANUAL_DISABLED })
|
||||
)
|
||||
const results = await Promise.allSettled(promises)
|
||||
|
||||
const successCount = results.filter((r) => r.status === 'fulfilled').length
|
||||
const failCount = results.filter((r) => r.status === 'rejected').length
|
||||
|
||||
if (successCount > 0) {
|
||||
toast.success(
|
||||
i18next.t('{{count}} channel(s) disabled', { count: successCount })
|
||||
)
|
||||
queryClient?.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
|
||||
onSuccess?.()
|
||||
}
|
||||
|
||||
if (failCount > 0) {
|
||||
toast.error(
|
||||
i18next.t('{{count}} channel(s) failed to disable', {
|
||||
count: failCount,
|
||||
})
|
||||
)
|
||||
}
|
||||
} catch (_error) {
|
||||
toast.error(i18next.t('Failed to disable channels'))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch set tag
|
||||
*/
|
||||
export async function handleBatchSetTag(
|
||||
ids: number[],
|
||||
tag: string | null,
|
||||
queryClient?: QueryClient,
|
||||
onSuccess?: () => void
|
||||
): Promise<void> {
|
||||
if (ids.length === 0) {
|
||||
toast.error(i18next.t('No channels selected'))
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await batchSetChannelTag({ ids, tag })
|
||||
if (response.success) {
|
||||
toast.success(i18next.t(SUCCESS_MESSAGES.TAG_SET))
|
||||
queryClient?.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
|
||||
onSuccess?.()
|
||||
}
|
||||
} catch (_error) {
|
||||
toast.error(i18next.t('Failed to set tag'))
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tag-Based Actions
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Enable all channels with a tag
|
||||
*/
|
||||
export async function handleEnableTagChannels(
|
||||
tag: string,
|
||||
queryClient?: QueryClient,
|
||||
onSuccess?: () => void
|
||||
): Promise<void> {
|
||||
try {
|
||||
const response = await enableTagChannels(tag)
|
||||
if (response.success) {
|
||||
toast.success(
|
||||
i18next.t('Enabled all channels with tag: {{tag}}', { tag })
|
||||
)
|
||||
queryClient?.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
|
||||
onSuccess?.()
|
||||
}
|
||||
} catch (_error) {
|
||||
toast.error(i18next.t('Failed to enable tag channels'))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable all channels with a tag
|
||||
*/
|
||||
export async function handleDisableTagChannels(
|
||||
tag: string,
|
||||
queryClient?: QueryClient,
|
||||
onSuccess?: () => void
|
||||
): Promise<void> {
|
||||
try {
|
||||
const response = await disableTagChannels(tag)
|
||||
if (response.success) {
|
||||
toast.success(
|
||||
i18next.t('Disabled all channels with tag: {{tag}}', { tag })
|
||||
)
|
||||
queryClient?.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
|
||||
onSuccess?.()
|
||||
}
|
||||
} catch (_error) {
|
||||
toast.error(i18next.t('Failed to disable tag channels'))
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// System Actions
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Delete all disabled channels
|
||||
*/
|
||||
export async function handleDeleteAllDisabled(
|
||||
queryClient?: QueryClient,
|
||||
onSuccess?: (deletedCount: number) => void
|
||||
): Promise<void> {
|
||||
try {
|
||||
const response = await deleteDisabledChannels()
|
||||
if (response.success) {
|
||||
toast.success(
|
||||
i18next.t('{{count}} disabled channel(s) deleted', {
|
||||
count: response.data || 0,
|
||||
})
|
||||
)
|
||||
queryClient?.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
|
||||
onSuccess?.(response.data || 0)
|
||||
}
|
||||
} catch (_error) {
|
||||
toast.error(i18next.t('Failed to delete disabled channels'))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fix channel abilities
|
||||
*/
|
||||
export async function handleFixAbilities(
|
||||
queryClient?: QueryClient,
|
||||
onSuccess?: (result: { success: number; fails: number }) => void
|
||||
): Promise<void> {
|
||||
try {
|
||||
const response = await fixChannelAbilities()
|
||||
if (response.success && response.data) {
|
||||
toast.success(
|
||||
i18next.t('Fixed abilities: {{success}} succeeded, {{fails}} failed', {
|
||||
success: response.data.success,
|
||||
fails: response.data.fails,
|
||||
})
|
||||
)
|
||||
queryClient?.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
|
||||
onSuccess?.(response.data)
|
||||
}
|
||||
} catch (_error) {
|
||||
toast.error(i18next.t('Failed to fix abilities'))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test all enabled channels
|
||||
*/
|
||||
export async function handleTestAllChannels(
|
||||
queryClient?: QueryClient,
|
||||
onSuccess?: () => void
|
||||
): Promise<void> {
|
||||
try {
|
||||
const response = await testAllChannels()
|
||||
if (response.success) {
|
||||
toast.success(
|
||||
i18next.t(
|
||||
'Testing all enabled channels started. Please refresh to see results.'
|
||||
)
|
||||
)
|
||||
queryClient?.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
|
||||
onSuccess?.()
|
||||
} else {
|
||||
toast.error(
|
||||
response.message || i18next.t('Failed to start testing all channels')
|
||||
)
|
||||
}
|
||||
} catch (_error) {
|
||||
toast.error(i18next.t('Failed to test all channels'))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update balance for all enabled channels
|
||||
*/
|
||||
export async function handleUpdateAllBalances(
|
||||
queryClient?: QueryClient,
|
||||
onSuccess?: () => void
|
||||
): Promise<void> {
|
||||
try {
|
||||
const response = await updateAllChannelsBalance()
|
||||
if (response.success) {
|
||||
toast.success(
|
||||
i18next.t(
|
||||
'Updating all channel balances. This may take a while. Please refresh to see results.'
|
||||
)
|
||||
)
|
||||
queryClient?.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
|
||||
onSuccess?.()
|
||||
} else {
|
||||
toast.error(
|
||||
response.message || i18next.t('Failed to update all balances')
|
||||
)
|
||||
}
|
||||
} catch (_error) {
|
||||
toast.error(i18next.t('Failed to update all balances'))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,502 @@
|
||||
import { z } from 'zod'
|
||||
import { CHANNEL_STATUS, MODEL_FETCHABLE_TYPES } from '../constants'
|
||||
import type { Channel } from '../types'
|
||||
|
||||
// ============================================================================
|
||||
// Form Validation Schema
|
||||
// ============================================================================
|
||||
|
||||
export const channelFormSchema = z.object({
|
||||
name: z.string().min(1, 'Channel name is required'),
|
||||
type: z.number().min(0, 'Channel type is required'),
|
||||
base_url: z.string().optional(),
|
||||
key: z.string(),
|
||||
openai_organization: z.string().optional(),
|
||||
models: z.string().min(1, 'At least one model is required'),
|
||||
group: z.array(z.string()).min(1, 'At least one group is required'),
|
||||
model_mapping: z.string().optional(),
|
||||
priority: z.number().optional(),
|
||||
weight: z.number().optional(),
|
||||
test_model: z.string().optional(),
|
||||
auto_ban: z.number().optional(),
|
||||
status: z.number(),
|
||||
status_code_mapping: z.string().optional(),
|
||||
tag: z.string().optional(),
|
||||
remark: z
|
||||
.string()
|
||||
.max(255, 'Remark must be less than 255 characters')
|
||||
.optional(),
|
||||
setting: z.string().optional(),
|
||||
param_override: z.string().optional(),
|
||||
header_override: z.string().optional(),
|
||||
settings: z.string().optional(),
|
||||
other: z.string().optional(),
|
||||
// Multi-key options (not sent to backend directly)
|
||||
multi_key_mode: z.enum(['single', 'batch', 'multi_to_single']).optional(),
|
||||
multi_key_type: z.enum(['random', 'polling']).optional(),
|
||||
batch_add_set_key_prefix_2_name: z.boolean().optional(),
|
||||
key_mode: z.enum(['append', 'replace']).optional(), // For editing multi-key channels
|
||||
// Channel extra settings (stored in setting JSON, not sent directly)
|
||||
force_format: z.boolean().optional(),
|
||||
thinking_to_content: z.boolean().optional(),
|
||||
proxy: z.string().optional(),
|
||||
pass_through_body_enabled: z.boolean().optional(),
|
||||
system_prompt: z.string().optional(),
|
||||
system_prompt_override: z.boolean().optional(),
|
||||
// Type-specific settings (stored in settings JSON)
|
||||
is_enterprise_account: z.boolean().optional(), // OpenRouter specific
|
||||
vertex_key_type: z.enum(['json', 'api_key']).optional(), // Vertex AI specific
|
||||
aws_key_type: z.enum(['ak_sk', 'api_key']).optional(), // AWS specific
|
||||
azure_responses_version: z.string().optional(), // Azure specific
|
||||
// Field passthrough controls (stored in settings JSON)
|
||||
allow_service_tier: z.boolean().optional(), // OpenAI/Anthropic
|
||||
disable_store: z.boolean().optional(), // OpenAI only
|
||||
allow_safety_identifier: z.boolean().optional(), // OpenAI only
|
||||
allow_include_obfuscation: z.boolean().optional(), // OpenAI: include usage obfuscation
|
||||
allow_inference_geo: z.boolean().optional(), // OpenAI/Anthropic: inference geography
|
||||
allow_speed: z.boolean().optional(), // Anthropic: speed mode control
|
||||
claude_beta_query: z.boolean().optional(), // Anthropic: beta query passthrough
|
||||
// Upstream model update settings (stored in settings JSON)
|
||||
upstream_model_update_check_enabled: z.boolean().optional(),
|
||||
upstream_model_update_auto_sync_enabled: z.boolean().optional(),
|
||||
})
|
||||
|
||||
export type ChannelFormValues = z.infer<typeof channelFormSchema>
|
||||
|
||||
// ============================================================================
|
||||
// Default Form Values
|
||||
// ============================================================================
|
||||
|
||||
export const CHANNEL_FORM_DEFAULT_VALUES: ChannelFormValues = {
|
||||
name: '',
|
||||
type: 1,
|
||||
base_url: '',
|
||||
key: '',
|
||||
openai_organization: '',
|
||||
models: '',
|
||||
group: ['default'],
|
||||
model_mapping: '',
|
||||
priority: 0,
|
||||
weight: 0,
|
||||
test_model: '',
|
||||
auto_ban: 1,
|
||||
status: CHANNEL_STATUS.ENABLED,
|
||||
status_code_mapping: '',
|
||||
tag: '',
|
||||
remark: '',
|
||||
setting: '',
|
||||
param_override: '',
|
||||
header_override: '',
|
||||
settings: '{}',
|
||||
other: '',
|
||||
multi_key_mode: 'single',
|
||||
multi_key_type: 'random',
|
||||
batch_add_set_key_prefix_2_name: false,
|
||||
key_mode: 'append',
|
||||
// Channel extra settings
|
||||
force_format: false,
|
||||
thinking_to_content: false,
|
||||
proxy: '',
|
||||
pass_through_body_enabled: false,
|
||||
system_prompt: '',
|
||||
system_prompt_override: false,
|
||||
// Type-specific settings
|
||||
is_enterprise_account: false,
|
||||
vertex_key_type: 'json',
|
||||
aws_key_type: 'ak_sk',
|
||||
azure_responses_version: '',
|
||||
// Field passthrough controls
|
||||
allow_service_tier: false,
|
||||
disable_store: false,
|
||||
allow_safety_identifier: false,
|
||||
allow_include_obfuscation: false,
|
||||
allow_inference_geo: false,
|
||||
allow_speed: false,
|
||||
claude_beta_query: false,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Transform Functions
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Transform Channel from API to Form default values
|
||||
*/
|
||||
export function transformChannelToFormDefaults(
|
||||
channel: Channel
|
||||
): ChannelFormValues {
|
||||
// Parse channel extra settings from setting field
|
||||
let extraSettings = {
|
||||
force_format: false,
|
||||
thinking_to_content: false,
|
||||
proxy: '',
|
||||
pass_through_body_enabled: false,
|
||||
system_prompt: '',
|
||||
system_prompt_override: false,
|
||||
}
|
||||
|
||||
if (channel.setting) {
|
||||
try {
|
||||
const parsed = JSON.parse(channel.setting)
|
||||
extraSettings = {
|
||||
force_format: parsed.force_format || false,
|
||||
thinking_to_content: parsed.thinking_to_content || false,
|
||||
proxy: parsed.proxy || '',
|
||||
pass_through_body_enabled: parsed.pass_through_body_enabled || false,
|
||||
system_prompt: parsed.system_prompt || '',
|
||||
system_prompt_override: parsed.system_prompt_override || false,
|
||||
}
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Failed to parse channel setting:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Parse type-specific settings from settings field
|
||||
let vertexKeyType: 'json' | 'api_key' = 'json'
|
||||
let azureResponsesVersion = ''
|
||||
let isEnterpriseAccount = false
|
||||
let awsKeyType: 'ak_sk' | 'api_key' = 'ak_sk'
|
||||
let allowServiceTier = false
|
||||
let disableStore = false
|
||||
let allowSafetyIdentifier = false
|
||||
let allowIncludeObfuscation = false
|
||||
let allowInferenceGeo = false
|
||||
let allowSpeed = false
|
||||
let claudeBetaQuery = false
|
||||
let upstreamModelUpdateCheckEnabled = false
|
||||
let upstreamModelUpdateAutoSyncEnabled = false
|
||||
|
||||
if (channel.settings) {
|
||||
try {
|
||||
const parsed = JSON.parse(channel.settings)
|
||||
vertexKeyType = parsed.vertex_key_type || 'json'
|
||||
azureResponsesVersion = parsed.azure_responses_version || ''
|
||||
isEnterpriseAccount = parsed.openrouter_enterprise === true
|
||||
awsKeyType = parsed.aws_key_type || 'ak_sk'
|
||||
allowServiceTier = parsed.allow_service_tier === true
|
||||
disableStore = parsed.disable_store === true
|
||||
allowSafetyIdentifier = parsed.allow_safety_identifier === true
|
||||
allowIncludeObfuscation = parsed.allow_include_obfuscation === true
|
||||
allowInferenceGeo = parsed.allow_inference_geo === true
|
||||
allowSpeed = parsed.allow_speed === true
|
||||
claudeBetaQuery = parsed.claude_beta_query === true
|
||||
upstreamModelUpdateCheckEnabled =
|
||||
parsed.upstream_model_update_check_enabled === true
|
||||
upstreamModelUpdateAutoSyncEnabled =
|
||||
parsed.upstream_model_update_auto_sync_enabled === true
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Failed to parse channel settings:', error)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
name: channel.name || '',
|
||||
type: channel.type,
|
||||
base_url: channel.base_url || '',
|
||||
key: '', // Never populate key from backend for security
|
||||
openai_organization: channel.openai_organization || '',
|
||||
models: channel.models || '',
|
||||
group: parseGroups(channel.group || 'default'),
|
||||
model_mapping: channel.model_mapping || '',
|
||||
priority: channel.priority || 0,
|
||||
weight: channel.weight || 0,
|
||||
test_model: channel.test_model || '',
|
||||
auto_ban: channel.auto_ban ?? 1,
|
||||
status: channel.status,
|
||||
status_code_mapping: channel.status_code_mapping || '',
|
||||
tag: channel.tag || '',
|
||||
remark: channel.remark || '',
|
||||
setting: channel.setting || '',
|
||||
param_override: channel.param_override || '',
|
||||
header_override: channel.header_override || '',
|
||||
settings: channel.settings || '{}',
|
||||
other: channel.other || '',
|
||||
multi_key_mode: 'single',
|
||||
multi_key_type: channel.channel_info.multi_key_mode || 'random',
|
||||
batch_add_set_key_prefix_2_name: false,
|
||||
key_mode: 'append', // Default to append mode for editing multi-key channels
|
||||
// Channel extra settings
|
||||
...extraSettings,
|
||||
// Type-specific settings
|
||||
is_enterprise_account: isEnterpriseAccount,
|
||||
vertex_key_type: vertexKeyType,
|
||||
azure_responses_version: azureResponsesVersion,
|
||||
aws_key_type: awsKeyType,
|
||||
allow_service_tier: allowServiceTier,
|
||||
disable_store: disableStore,
|
||||
allow_include_obfuscation: allowIncludeObfuscation,
|
||||
allow_inference_geo: allowInferenceGeo,
|
||||
allow_speed: allowSpeed,
|
||||
claude_beta_query: claudeBetaQuery,
|
||||
allow_safety_identifier: allowSafetyIdentifier,
|
||||
upstream_model_update_check_enabled: upstreamModelUpdateCheckEnabled,
|
||||
upstream_model_update_auto_sync_enabled: upstreamModelUpdateAutoSyncEnabled,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the setting JSON string from form extra settings
|
||||
*/
|
||||
function buildSettingJSON(formData: ChannelFormValues): string {
|
||||
const settingObj = {
|
||||
force_format: formData.force_format || false,
|
||||
thinking_to_content: formData.thinking_to_content || false,
|
||||
proxy: formData.proxy || '',
|
||||
pass_through_body_enabled: formData.pass_through_body_enabled || false,
|
||||
system_prompt: formData.system_prompt || '',
|
||||
system_prompt_override: formData.system_prompt_override || false,
|
||||
}
|
||||
return JSON.stringify(settingObj)
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the settings JSON string (for type-specific config like vertex_key_type)
|
||||
*/
|
||||
function buildSettingsJSON(formData: ChannelFormValues): string {
|
||||
let settingsObj: Record<string, unknown> = {}
|
||||
|
||||
// Try to parse existing settings first
|
||||
if (formData.settings && formData.settings !== '{}') {
|
||||
try {
|
||||
settingsObj = JSON.parse(formData.settings)
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Failed to parse existing settings:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Add vertex_key_type for Vertex AI channels (type 41)
|
||||
if (formData.type === 41) {
|
||||
settingsObj.vertex_key_type = formData.vertex_key_type || 'json'
|
||||
} else if ('vertex_key_type' in settingsObj) {
|
||||
delete settingsObj.vertex_key_type
|
||||
}
|
||||
|
||||
// Add azure_responses_version for Azure channels (type 3)
|
||||
if (formData.type === 3 && formData.azure_responses_version) {
|
||||
settingsObj.azure_responses_version = formData.azure_responses_version
|
||||
} else if ('azure_responses_version' in settingsObj) {
|
||||
delete settingsObj.azure_responses_version
|
||||
}
|
||||
|
||||
// Add enterprise account setting for OpenRouter (type 20)
|
||||
if (formData.type === 20) {
|
||||
settingsObj.openrouter_enterprise = formData.is_enterprise_account === true
|
||||
} else if ('openrouter_enterprise' in settingsObj) {
|
||||
delete settingsObj.openrouter_enterprise
|
||||
}
|
||||
|
||||
// Add aws_key_type for AWS channels (type 33)
|
||||
if (formData.type === 33) {
|
||||
settingsObj.aws_key_type = formData.aws_key_type || 'ak_sk'
|
||||
} else if ('aws_key_type' in settingsObj) {
|
||||
delete settingsObj.aws_key_type
|
||||
}
|
||||
|
||||
// Field passthrough controls:
|
||||
// - OpenAI (type 1) and Anthropic (type 14): allow_service_tier
|
||||
// - OpenAI only: disable_store, allow_safety_identifier
|
||||
if (formData.type === 1 || formData.type === 14) {
|
||||
settingsObj.allow_service_tier = formData.allow_service_tier === true
|
||||
} else if ('allow_service_tier' in settingsObj) {
|
||||
delete settingsObj.allow_service_tier
|
||||
}
|
||||
|
||||
if (formData.type === 1) {
|
||||
settingsObj.disable_store = formData.disable_store === true
|
||||
settingsObj.allow_safety_identifier =
|
||||
formData.allow_safety_identifier === true
|
||||
settingsObj.allow_include_obfuscation =
|
||||
formData.allow_include_obfuscation === true
|
||||
settingsObj.allow_inference_geo = formData.allow_inference_geo === true
|
||||
} else {
|
||||
if ('disable_store' in settingsObj) delete settingsObj.disable_store
|
||||
if ('allow_safety_identifier' in settingsObj)
|
||||
delete settingsObj.allow_safety_identifier
|
||||
if ('allow_include_obfuscation' in settingsObj)
|
||||
delete settingsObj.allow_include_obfuscation
|
||||
if (formData.type !== 14 && 'allow_inference_geo' in settingsObj)
|
||||
delete settingsObj.allow_inference_geo
|
||||
}
|
||||
|
||||
// Anthropic (type 14): claude_beta_query, allow_inference_geo, allow_speed
|
||||
if (formData.type === 14) {
|
||||
settingsObj.allow_inference_geo = formData.allow_inference_geo === true
|
||||
settingsObj.allow_speed = formData.allow_speed === true
|
||||
settingsObj.claude_beta_query = formData.claude_beta_query === true
|
||||
} else {
|
||||
if ('allow_speed' in settingsObj) delete settingsObj.allow_speed
|
||||
if ('claude_beta_query' in settingsObj) delete settingsObj.claude_beta_query
|
||||
}
|
||||
|
||||
// Upstream model update settings (for model-fetchable channel types)
|
||||
if (MODEL_FETCHABLE_TYPES.has(formData.type)) {
|
||||
settingsObj.upstream_model_update_check_enabled =
|
||||
formData.upstream_model_update_check_enabled === true
|
||||
settingsObj.upstream_model_update_auto_sync_enabled =
|
||||
formData.upstream_model_update_auto_sync_enabled === true
|
||||
}
|
||||
|
||||
return JSON.stringify(settingsObj)
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform form data to API payload for creating channel
|
||||
*/
|
||||
export function transformFormDataToCreatePayload(formData: ChannelFormValues): {
|
||||
mode: 'single' | 'batch' | 'multi_to_single'
|
||||
multi_key_mode?: 'random' | 'polling'
|
||||
batch_add_set_key_prefix_2_name?: boolean
|
||||
channel: Partial<Channel>
|
||||
} {
|
||||
const mode = formData.multi_key_mode || 'single'
|
||||
|
||||
const channel: Partial<Channel> = {
|
||||
name: formData.name,
|
||||
type: formData.type,
|
||||
base_url: formData.base_url || null,
|
||||
key: formData.key,
|
||||
openai_organization: formData.openai_organization || null,
|
||||
models: formData.models,
|
||||
group: formatGroups(formData.group),
|
||||
model_mapping: formData.model_mapping || null,
|
||||
priority: formData.priority || null,
|
||||
weight: formData.weight || null,
|
||||
test_model: formData.test_model || null,
|
||||
auto_ban: formData.auto_ban ?? 1,
|
||||
status: formData.status,
|
||||
status_code_mapping: formData.status_code_mapping || null,
|
||||
tag: formData.tag || null,
|
||||
remark: formData.remark || '',
|
||||
setting: buildSettingJSON(formData),
|
||||
param_override: formData.param_override || null,
|
||||
header_override: formData.header_override || null,
|
||||
settings: buildSettingsJSON(formData),
|
||||
other: formData.other || '',
|
||||
}
|
||||
|
||||
// Clean up empty strings to null for optional fields
|
||||
Object.keys(channel).forEach((key) => {
|
||||
if (channel[key as keyof typeof channel] === '') {
|
||||
;(channel as Record<string, unknown>)[key] = null
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
mode,
|
||||
multi_key_mode:
|
||||
mode === 'multi_to_single' ? formData.multi_key_type : undefined,
|
||||
batch_add_set_key_prefix_2_name:
|
||||
mode === 'batch' ? formData.batch_add_set_key_prefix_2_name : undefined,
|
||||
channel,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform form data to API payload for updating channel
|
||||
*/
|
||||
export function transformFormDataToUpdatePayload(
|
||||
formData: ChannelFormValues,
|
||||
channelId: number
|
||||
): Partial<Channel> {
|
||||
const payload: Partial<Channel> = {
|
||||
id: channelId,
|
||||
name: formData.name,
|
||||
type: formData.type,
|
||||
base_url: formData.base_url || null,
|
||||
openai_organization: formData.openai_organization || null,
|
||||
models: formData.models,
|
||||
group: formatGroups(formData.group),
|
||||
model_mapping: formData.model_mapping || null,
|
||||
priority: formData.priority || null,
|
||||
weight: formData.weight || null,
|
||||
test_model: formData.test_model || null,
|
||||
auto_ban: formData.auto_ban ?? 1,
|
||||
status: formData.status,
|
||||
status_code_mapping: formData.status_code_mapping || null,
|
||||
tag: formData.tag || null,
|
||||
remark: formData.remark || '',
|
||||
setting: buildSettingJSON(formData),
|
||||
param_override: formData.param_override || null,
|
||||
header_override: formData.header_override || null,
|
||||
settings: buildSettingsJSON(formData),
|
||||
other: formData.other || '',
|
||||
}
|
||||
|
||||
// Only include key if it was changed (not empty)
|
||||
if (formData.key && formData.key.trim()) {
|
||||
payload.key = formData.key
|
||||
}
|
||||
|
||||
// Clean up empty strings to null for optional fields
|
||||
Object.keys(payload).forEach((key) => {
|
||||
if (payload[key as keyof typeof payload] === '') {
|
||||
;(payload as Record<string, unknown>)[key] = null
|
||||
}
|
||||
})
|
||||
|
||||
return payload
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Validation Helpers
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Validate JSON string
|
||||
*/
|
||||
export function validateJSON(value: string): boolean {
|
||||
if (!value || value.trim() === '') return true
|
||||
try {
|
||||
JSON.parse(value)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate model mapping format
|
||||
*/
|
||||
export function validateModelMapping(value: string): boolean {
|
||||
if (!value || value.trim() === '') return true
|
||||
return validateJSON(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse models string to array
|
||||
*/
|
||||
export function parseModels(models: string): string[] {
|
||||
if (!models) return []
|
||||
return models
|
||||
.split(',')
|
||||
.map((m) => m.trim())
|
||||
.filter((m) => m.length > 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse groups string to array
|
||||
*/
|
||||
export function parseGroups(groups: string): string[] {
|
||||
if (!groups) return []
|
||||
return groups
|
||||
.split(',')
|
||||
.map((g) => g.trim())
|
||||
.filter((g) => g.length > 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Format models array to string
|
||||
*/
|
||||
export function formatModels(models: string[]): string {
|
||||
return models.join(',')
|
||||
}
|
||||
|
||||
/**
|
||||
* Format groups array to string
|
||||
*/
|
||||
export function formatGroups(groups: string[]): string {
|
||||
return groups.join(',')
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import { CHANNEL_TYPES } from '../constants'
|
||||
|
||||
// ============================================================================
|
||||
// Channel Type Configuration
|
||||
// ============================================================================
|
||||
|
||||
export interface ChannelTypeConfig {
|
||||
id: number
|
||||
name: string
|
||||
icon: string
|
||||
defaultBaseUrl?: string
|
||||
requiresOrganization?: boolean
|
||||
requiresRegion?: boolean
|
||||
supportedModels?: string[]
|
||||
hints?: {
|
||||
baseUrl?: string
|
||||
key?: string
|
||||
models?: string
|
||||
other?: string
|
||||
}
|
||||
validation?: {
|
||||
keyFormat?: RegExp
|
||||
keyMinLength?: number
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for each channel type
|
||||
*/
|
||||
export const CHANNEL_TYPE_CONFIGS: Record<number, ChannelTypeConfig> = {
|
||||
1: {
|
||||
id: 1,
|
||||
name: CHANNEL_TYPES[1],
|
||||
icon: 'openai',
|
||||
defaultBaseUrl: 'https://api.openai.com',
|
||||
requiresOrganization: true,
|
||||
hints: {
|
||||
baseUrl: 'Default: https://api.openai.com',
|
||||
key: 'Format: sk-...',
|
||||
models: 'gpt-4,gpt-4-turbo,gpt-3.5-turbo',
|
||||
},
|
||||
validation: {
|
||||
keyFormat: /^sk-/,
|
||||
keyMinLength: 20,
|
||||
},
|
||||
},
|
||||
3: {
|
||||
id: 3,
|
||||
name: CHANNEL_TYPES[3],
|
||||
icon: 'azure',
|
||||
requiresRegion: true,
|
||||
hints: {
|
||||
baseUrl: 'Azure OpenAI Endpoint',
|
||||
key: 'Azure API Key',
|
||||
models: 'Deployment names',
|
||||
},
|
||||
},
|
||||
14: {
|
||||
id: 14,
|
||||
name: CHANNEL_TYPES[14],
|
||||
icon: 'anthropic',
|
||||
defaultBaseUrl: 'https://api.anthropic.com',
|
||||
hints: {
|
||||
key: 'Format: sk-ant-...',
|
||||
models: 'claude-3-opus,claude-3-sonnet,claude-3-haiku',
|
||||
},
|
||||
},
|
||||
24: {
|
||||
id: 24,
|
||||
name: CHANNEL_TYPES[24],
|
||||
icon: 'google',
|
||||
hints: {
|
||||
key: 'Google API Key',
|
||||
models: 'gemini-pro,gemini-pro-vision',
|
||||
},
|
||||
},
|
||||
41: {
|
||||
id: 41,
|
||||
name: CHANNEL_TYPES[41],
|
||||
icon: 'google',
|
||||
requiresRegion: true,
|
||||
hints: {
|
||||
key: 'Service account JSON or API key',
|
||||
models: 'gemini-pro,gemini-1.5-pro',
|
||||
other: 'Region config: {"default": "us-central1"}',
|
||||
},
|
||||
},
|
||||
43: {
|
||||
id: 43,
|
||||
name: CHANNEL_TYPES[43],
|
||||
icon: 'deepseek',
|
||||
defaultBaseUrl: 'https://api.deepseek.com',
|
||||
hints: {
|
||||
key: 'DeepSeek API Key',
|
||||
models: 'deepseek-chat,deepseek-coder',
|
||||
},
|
||||
},
|
||||
20: {
|
||||
id: 20,
|
||||
name: CHANNEL_TYPES[20],
|
||||
icon: 'openrouter',
|
||||
defaultBaseUrl: 'https://openrouter.ai/api',
|
||||
hints: {
|
||||
key: 'OpenRouter API Key',
|
||||
models: 'Use model IDs from OpenRouter',
|
||||
},
|
||||
},
|
||||
56: {
|
||||
id: 56,
|
||||
name: CHANNEL_TYPES[56],
|
||||
icon: 'replicate',
|
||||
defaultBaseUrl: 'https://api.replicate.com',
|
||||
hints: {
|
||||
key: 'Replicate API Token',
|
||||
models: 'Replicate model IDs',
|
||||
baseUrl: 'Default: https://api.replicate.com',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Get configuration for a channel type
|
||||
*/
|
||||
export function getChannelTypeConfig(type: number): ChannelTypeConfig {
|
||||
return (
|
||||
CHANNEL_TYPE_CONFIGS[type] || {
|
||||
id: type,
|
||||
name: CHANNEL_TYPES[type as keyof typeof CHANNEL_TYPES] || 'Unknown',
|
||||
icon: 'openai',
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if channel type requires organization field
|
||||
*/
|
||||
export function requiresOrganization(type: number): boolean {
|
||||
return CHANNEL_TYPE_CONFIGS[type]?.requiresOrganization || false
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if channel type requires region configuration
|
||||
*/
|
||||
export function requiresRegion(type: number): boolean {
|
||||
return CHANNEL_TYPE_CONFIGS[type]?.requiresRegion || false
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default base URL for channel type
|
||||
*/
|
||||
export function getDefaultBaseUrl(type: number): string {
|
||||
return CHANNEL_TYPE_CONFIGS[type]?.defaultBaseUrl || ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Get hints for channel type
|
||||
*/
|
||||
export function getChannelTypeHints(type: number) {
|
||||
return CHANNEL_TYPE_CONFIGS[type]?.hints || {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate API key format for channel type
|
||||
*/
|
||||
export function validateKeyFormat(type: number, key: string): boolean {
|
||||
const config = CHANNEL_TYPE_CONFIGS[type]
|
||||
if (!config?.validation) return true
|
||||
|
||||
const { keyFormat, keyMinLength } = config.validation
|
||||
|
||||
if (keyMinLength && key.length < keyMinLength) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (keyFormat && !keyFormat.test(key)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,643 @@
|
||||
import { formatCurrencyFromUSD, formatQuotaWithCurrency } from '@/lib/currency'
|
||||
import dayjs from '@/lib/dayjs'
|
||||
import { formatTimestampToDate } from '@/lib/format'
|
||||
import {
|
||||
CHANNEL_STATUS_CONFIG,
|
||||
CHANNEL_TYPES,
|
||||
MULTI_KEY_STATUS_CONFIG,
|
||||
RESPONSE_TIME_CONFIG,
|
||||
RESPONSE_TIME_THRESHOLDS,
|
||||
TYPE_TO_KEY_PROMPT,
|
||||
} from '../constants'
|
||||
import type { Channel, ChannelSettings, ChannelOtherSettings } from '../types'
|
||||
|
||||
// ============================================================================
|
||||
// Channel Type Utilities
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get human-readable channel type label
|
||||
*/
|
||||
export function getChannelTypeLabel(type: number): string {
|
||||
return CHANNEL_TYPES[type as keyof typeof CHANNEL_TYPES] || 'Unknown'
|
||||
}
|
||||
|
||||
/**
|
||||
* Get channel type icon name for getLobeIcon
|
||||
* Maps channel types to Lobe icon names using type number (language-independent)
|
||||
*/
|
||||
export function getChannelTypeIcon(type: number): string {
|
||||
const TYPE_TO_ICON: Record<number, string> = {
|
||||
// OpenAI family
|
||||
1: 'OpenAI', // OpenAI
|
||||
6: 'OpenAI', // OpenAIMax
|
||||
7: 'OpenAI', // OhMyGPT
|
||||
8: 'OpenAI', // Custom
|
||||
3: 'Azure', // Azure
|
||||
|
||||
// Anthropic
|
||||
14: 'Claude', // Anthropic
|
||||
|
||||
// Google family
|
||||
24: 'Gemini', // Gemini
|
||||
11: 'Google', // PaLM
|
||||
41: 'Gemini', // Vertex AI
|
||||
|
||||
// Cloud providers
|
||||
33: 'Aws', // AWS
|
||||
39: 'Cloudflare', // Cloudflare
|
||||
|
||||
// Chinese providers
|
||||
15: 'Baidu', // Baidu
|
||||
46: 'Baidu', // Baidu V2
|
||||
16: 'Zhipu', // Zhipu
|
||||
26: 'Zhipu', // Zhipu V4
|
||||
17: 'Qwen', // Ali
|
||||
18: 'Spark', // Xunfei
|
||||
23: 'Hunyuan', // Tencent
|
||||
19: 'Ai360', // 360
|
||||
25: 'Moonshot', // Moonshot
|
||||
31: 'Yi', // LingYiWanWu
|
||||
35: 'Minimax', // MiniMax
|
||||
45: 'Volcengine', // VolcEngine
|
||||
|
||||
// Other AI providers
|
||||
4: 'Ollama', // Ollama
|
||||
27: 'Perplexity', // Perplexity
|
||||
34: 'Cohere', // Cohere
|
||||
42: 'Mistral', // Mistral
|
||||
43: 'DeepSeek', // DeepSeek
|
||||
48: 'XAI', // xAI
|
||||
49: 'Coze', // Coze
|
||||
40: 'SiliconCloud', // SiliconFlow
|
||||
44: 'OpenAI', // MokaAI
|
||||
20: 'OpenRouter', // OpenRouter
|
||||
|
||||
// Image/Video generation
|
||||
2: 'Midjourney', // Midjourney
|
||||
5: 'Midjourney', // MidjourneyPlus
|
||||
50: 'Kling', // Kling
|
||||
51: 'Jimeng', // Jimeng
|
||||
52: 'Vidu', // Vidu
|
||||
36: 'Suno', // SunoAPI
|
||||
55: 'OpenAI', // Sora
|
||||
54: 'Doubao', // DoubaoVideo
|
||||
56: 'Replicate', // Replicate
|
||||
|
||||
// Tools & Platforms
|
||||
37: 'Dify', // Dify
|
||||
38: 'Jina', // Jina
|
||||
22: 'FastGPT', // FastGPT
|
||||
47: 'Xinference', // Xinference
|
||||
53: 'OpenAI', // Submodel
|
||||
|
||||
// AI Proxy services
|
||||
10: 'OpenAI', // AI Proxy
|
||||
21: 'OpenAI', // AI Proxy Library
|
||||
12: 'OpenAI', // API2GPT
|
||||
13: 'OpenAI', // AIGC2D
|
||||
9: 'OpenAI', // AILS
|
||||
}
|
||||
|
||||
return TYPE_TO_ICON[type] || 'OpenAI'
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Status Utilities
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get status badge configuration
|
||||
*/
|
||||
export function getChannelStatusBadge(status: number) {
|
||||
return (
|
||||
CHANNEL_STATUS_CONFIG[status as keyof typeof CHANNEL_STATUS_CONFIG] ||
|
||||
CHANNEL_STATUS_CONFIG[0]
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get multi-key status badge configuration
|
||||
*/
|
||||
export function getMultiKeyStatusBadge(status: number) {
|
||||
return (
|
||||
MULTI_KEY_STATUS_CONFIG[status as keyof typeof MULTI_KEY_STATUS_CONFIG] ||
|
||||
MULTI_KEY_STATUS_CONFIG[1]
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if channel is enabled
|
||||
*/
|
||||
export function isChannelEnabled(channel: Channel): boolean {
|
||||
return channel.status === 1
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if channel is multi-key
|
||||
*/
|
||||
export function isMultiKeyChannel(channel: Channel): boolean {
|
||||
return channel.channel_info?.is_multi_key || false
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Key Formatting
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Format channel key for display
|
||||
* Masks the key for security, showing only first and last few characters
|
||||
*/
|
||||
export function formatChannelKey(
|
||||
key: string,
|
||||
isMultiKey: boolean = false
|
||||
): string {
|
||||
if (!key) return ''
|
||||
|
||||
if (isMultiKey) {
|
||||
const keys = key.split('\n').filter((k) => k.trim())
|
||||
return `${keys.length} keys`
|
||||
}
|
||||
|
||||
if (key.length <= 16) {
|
||||
// For short keys, mask middle part
|
||||
return `${key.slice(0, 4)}...${key.slice(-4)}`
|
||||
}
|
||||
|
||||
// For longer keys, show more context
|
||||
return `${key.slice(0, 8)}...${key.slice(-8)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Format key preview for multi-key display
|
||||
*/
|
||||
export function formatKeyPreview(key: string, maxLength: number = 10): string {
|
||||
if (!key) return ''
|
||||
if (key.length <= maxLength) return key
|
||||
return `${key.slice(0, maxLength)}...`
|
||||
}
|
||||
|
||||
/**
|
||||
* Count keys in multi-key string
|
||||
*/
|
||||
export function countKeys(key: string): number {
|
||||
if (!key) return 0
|
||||
return key.split('\n').filter((k) => k.trim()).length
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Model & Group Parsing
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Parse comma-separated models list
|
||||
*/
|
||||
export function parseModelsList(models: string): string[] {
|
||||
if (!models) return []
|
||||
return models
|
||||
.split(',')
|
||||
.map((m) => m.trim())
|
||||
.filter((m) => m.length > 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse comma-separated groups list.
|
||||
* Sorts with 'default' group first, then locale-sorted alphabetically.
|
||||
*/
|
||||
export function parseGroupsList(groups: string): string[] {
|
||||
if (!groups) return []
|
||||
const list = groups
|
||||
.split(',')
|
||||
.map((g) => g.trim())
|
||||
.filter((g) => g.length > 0)
|
||||
return list.sort((a, b) => {
|
||||
if (a === 'default') return -1
|
||||
if (b === 'default') return 1
|
||||
return a.localeCompare(b)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Format models array back to string
|
||||
*/
|
||||
export function formatModelsString(models: string[]): string {
|
||||
return models.join(',')
|
||||
}
|
||||
|
||||
/**
|
||||
* Format groups array back to string
|
||||
*/
|
||||
export function formatGroupsString(groups: string[]): string {
|
||||
return groups.join(',')
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Settings Parsing
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Parse channel settings JSON
|
||||
*/
|
||||
export function parseChannelSettings(
|
||||
settingStr: string | null | undefined
|
||||
): ChannelSettings {
|
||||
if (!settingStr) return {}
|
||||
try {
|
||||
return JSON.parse(settingStr) as ChannelSettings
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse channel other settings JSON
|
||||
*/
|
||||
export function parseChannelOtherSettings(
|
||||
settingsStr: string | null | undefined
|
||||
): ChannelOtherSettings {
|
||||
if (!settingsStr || settingsStr === '{}') return {}
|
||||
try {
|
||||
return JSON.parse(settingsStr) as ChannelOtherSettings
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate JSON string
|
||||
*/
|
||||
export function validateChannelSettings(settings: string): boolean {
|
||||
if (!settings || settings.trim() === '') return true
|
||||
try {
|
||||
JSON.parse(settings)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Balance Formatting
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Format balance with currency symbol
|
||||
*/
|
||||
export function formatBalance(balance: number | null | undefined): string {
|
||||
if (balance == null || Number.isNaN(balance)) return '-'
|
||||
return formatCurrencyFromUSD(balance, {
|
||||
digitsLarge: 2,
|
||||
digitsSmall: 4,
|
||||
abbreviate: false,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get balance status color
|
||||
*/
|
||||
export function getBalanceVariant(
|
||||
balance: number
|
||||
): 'success' | 'warning' | 'danger' | 'neutral' {
|
||||
if (balance === 0) return 'neutral'
|
||||
if (balance < 1) return 'danger'
|
||||
if (balance < 10) return 'warning'
|
||||
return 'success'
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Response Time Utilities
|
||||
// ============================================================================
|
||||
|
||||
/** Optional i18n: (key, options) => string, e.g. useTranslation().t */
|
||||
type TFunction = (key: string, options?: { value?: number | string }) => string
|
||||
|
||||
/**
|
||||
* Format response time in milliseconds to human-readable.
|
||||
* Pass `t` from useTranslation() for i18n (e.g. "Not tested", "{{value}}ms", "{{value}}s").
|
||||
*/
|
||||
export function formatResponseTime(timeMs: number, t?: TFunction): string {
|
||||
if (timeMs === 0) return t ? t('Not tested') : 'Not tested'
|
||||
if (timeMs < 1000)
|
||||
return t ? t('{{value}}ms', { value: timeMs }) : `${timeMs}ms`
|
||||
return t
|
||||
? t('{{value}}s', { value: (timeMs / 1000).toFixed(2) })
|
||||
: `${(timeMs / 1000).toFixed(2)}s`
|
||||
}
|
||||
|
||||
/**
|
||||
* Get response time performance rating
|
||||
*/
|
||||
export function getResponseTimeConfig(timeMs: number) {
|
||||
if (timeMs === 0) return RESPONSE_TIME_CONFIG.UNKNOWN
|
||||
if (timeMs <= RESPONSE_TIME_THRESHOLDS.EXCELLENT)
|
||||
return RESPONSE_TIME_CONFIG.EXCELLENT
|
||||
if (timeMs <= RESPONSE_TIME_THRESHOLDS.GOOD) return RESPONSE_TIME_CONFIG.GOOD
|
||||
if (timeMs <= RESPONSE_TIME_THRESHOLDS.FAIR) return RESPONSE_TIME_CONFIG.FAIR
|
||||
if (timeMs <= RESPONSE_TIME_THRESHOLDS.POOR) return RESPONSE_TIME_CONFIG.POOR
|
||||
return RESPONSE_TIME_CONFIG.POOR
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Time Formatting
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Format Unix timestamp to relative time
|
||||
* e.g., "2 hours ago", "3 days ago"
|
||||
*/
|
||||
export function formatRelativeTime(timestamp: number): string {
|
||||
if (!timestamp || timestamp === 0) return 'Never'
|
||||
|
||||
try {
|
||||
return dayjs(timestamp * 1000).fromNow()
|
||||
} catch {
|
||||
return 'Unknown'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format Unix timestamp to date string
|
||||
*/
|
||||
export function formatTimestamp(timestamp: number): string {
|
||||
if (!timestamp || timestamp === 0) return 'N/A'
|
||||
|
||||
try {
|
||||
return formatTimestampToDate(timestamp)
|
||||
} catch {
|
||||
return 'Invalid date'
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Quota Formatting
|
||||
// ============================================================================
|
||||
|
||||
/** Format quota units using the global currency display configuration. */
|
||||
export function formatQuota(quota: number): string {
|
||||
return formatQuotaWithCurrency(quota, {
|
||||
digitsLarge: 2,
|
||||
digitsSmall: 4,
|
||||
abbreviate: true,
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Priority & Weight Utilities
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get priority display value
|
||||
*/
|
||||
export function getPriorityDisplay(
|
||||
priority: number | null | undefined
|
||||
): string {
|
||||
if (priority === null || priority === undefined) return '0'
|
||||
return String(priority)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get weight display value
|
||||
*/
|
||||
export function getWeightDisplay(weight: number | null | undefined): string {
|
||||
if (weight === null || weight === undefined) return '0'
|
||||
return String(weight)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Validation Utilities
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Validate channel name
|
||||
*/
|
||||
export function validateChannelName(name: string): boolean {
|
||||
return name.trim().length > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate API key format
|
||||
*/
|
||||
export function validateApiKey(key: string): boolean {
|
||||
return key.trim().length > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate models list
|
||||
*/
|
||||
export function validateModels(models: string): boolean {
|
||||
return parseModelsList(models).length > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate groups list
|
||||
*/
|
||||
export function validateGroups(groups: string): boolean {
|
||||
return parseGroupsList(groups).length > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if channel needs attention (low balance, auto-disabled, etc.)
|
||||
*/
|
||||
export function channelNeedsAttention(channel: Channel): boolean {
|
||||
// Auto-disabled
|
||||
if (channel.status === 3) return true
|
||||
|
||||
// Low balance (less than $1)
|
||||
if (channel.balance > 0 && channel.balance < 1) return true
|
||||
|
||||
// Multi-key channel with all keys disabled
|
||||
if (
|
||||
channel.channel_info?.is_multi_key &&
|
||||
channel.channel_info.multi_key_status_list &&
|
||||
Object.keys(channel.channel_info.multi_key_status_list).length >=
|
||||
channel.channel_info.multi_key_size
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Get attention reason for channel
|
||||
*/
|
||||
export function getAttentionReason(channel: Channel): string | null {
|
||||
if (channel.status === 3) return 'Auto-disabled'
|
||||
if (channel.balance > 0 && channel.balance < 1) return 'Low balance'
|
||||
if (
|
||||
channel.channel_info?.is_multi_key &&
|
||||
channel.channel_info.multi_key_status_list &&
|
||||
Object.keys(channel.channel_info.multi_key_status_list).length >=
|
||||
channel.channel_info.multi_key_size
|
||||
) {
|
||||
return 'All keys disabled'
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tag Aggregation Utilities
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Tag row type (extends Channel with children)
|
||||
*/
|
||||
export type TagRow = Channel & {
|
||||
children: Channel[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check whether a row is a tag aggregate row
|
||||
*/
|
||||
export function isTagAggregateRow(row: Channel | TagRow): row is TagRow {
|
||||
return Array.isArray((row as TagRow).children)
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate channels by tag for tag mode display
|
||||
* Converts flat array into tree structure grouped by tag
|
||||
*/
|
||||
export function aggregateChannelsByTag(
|
||||
channels: Channel[]
|
||||
): (Channel | TagRow)[] {
|
||||
const tagMap = new Map<string, TagRow>()
|
||||
const result: (Channel | TagRow)[] = []
|
||||
|
||||
for (const channel of channels) {
|
||||
const tag = channel.tag || ''
|
||||
|
||||
if (!tagMap.has(tag)) {
|
||||
// Create tag aggregate row
|
||||
const tagRow = {
|
||||
...channel,
|
||||
key: tag,
|
||||
id: tag as unknown as number,
|
||||
tag: tag,
|
||||
name: tag,
|
||||
type: 0,
|
||||
status: undefined as unknown as number,
|
||||
group: '',
|
||||
used_quota: 0,
|
||||
response_time: 0,
|
||||
priority: -1 as unknown as number | null,
|
||||
weight: -1 as unknown as number | null,
|
||||
balance: 0,
|
||||
test_time: 0,
|
||||
created_time: 0,
|
||||
balance_updated_time: 0,
|
||||
models: '',
|
||||
children: [],
|
||||
} as TagRow
|
||||
tagMap.set(tag, tagRow)
|
||||
result.push(tagRow)
|
||||
}
|
||||
|
||||
const tagRow = tagMap.get(tag)!
|
||||
|
||||
// Add to children
|
||||
tagRow.children.push(channel)
|
||||
const childCount = tagRow.children.length
|
||||
|
||||
// Aggregate used_quota (sum)
|
||||
tagRow.used_quota += channel.used_quota
|
||||
|
||||
// Aggregate response_time (average)
|
||||
tagRow.response_time =
|
||||
(tagRow.response_time * (childCount - 1) + channel.response_time) /
|
||||
childCount
|
||||
|
||||
// Aggregate priority (same value or null if different)
|
||||
if (tagRow.priority === -1) {
|
||||
tagRow.priority = channel.priority
|
||||
} else if (tagRow.priority !== channel.priority) {
|
||||
tagRow.priority = null
|
||||
}
|
||||
|
||||
// Aggregate weight (same value or null if different)
|
||||
if (tagRow.weight === -1) {
|
||||
tagRow.weight = channel.weight
|
||||
} else if (tagRow.weight !== channel.weight) {
|
||||
tagRow.weight = null
|
||||
}
|
||||
|
||||
// Aggregate group (concatenate and deduplicate)
|
||||
if (tagRow.group === '') {
|
||||
tagRow.group = channel.group
|
||||
} else {
|
||||
const existingGroups = new Set(tagRow.group.split(',').filter(Boolean))
|
||||
const newGroups = channel.group.split(',').filter(Boolean)
|
||||
newGroups.forEach((g) => {
|
||||
if (!existingGroups.has(g)) {
|
||||
tagRow.group += ',' + g
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Aggregate status (enabled if any child is enabled)
|
||||
if (channel.status === 1) {
|
||||
tagRow.status = 1
|
||||
} else if (tagRow.status === undefined) {
|
||||
tagRow.status = channel.status
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Key Management Utilities
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Deduplicate keys from a multiline string
|
||||
* @param keysText - Text with one key per line
|
||||
* @returns Object with deduplicated keys and statistics
|
||||
*/
|
||||
export function deduplicateKeys(keysText: string): {
|
||||
deduplicatedText: string
|
||||
beforeCount: number
|
||||
afterCount: number
|
||||
removedCount: number
|
||||
} {
|
||||
if (!keysText || keysText.trim() === '') {
|
||||
return {
|
||||
deduplicatedText: '',
|
||||
beforeCount: 0,
|
||||
afterCount: 0,
|
||||
removedCount: 0,
|
||||
}
|
||||
}
|
||||
|
||||
// Split by lines
|
||||
const keyLines = keysText.split('\n')
|
||||
const beforeCount = keyLines.length
|
||||
|
||||
// Use Set for deduplication, maintaining order
|
||||
const keySet = new Set<string>()
|
||||
const deduplicatedKeys: string[] = []
|
||||
|
||||
keyLines.forEach((line) => {
|
||||
const trimmedLine = line.trim()
|
||||
if (trimmedLine && !keySet.has(trimmedLine)) {
|
||||
keySet.add(trimmedLine)
|
||||
deduplicatedKeys.push(trimmedLine)
|
||||
}
|
||||
})
|
||||
|
||||
const afterCount = deduplicatedKeys.length
|
||||
const deduplicatedText = deduplicatedKeys.join('\n')
|
||||
|
||||
return {
|
||||
deduplicatedText,
|
||||
beforeCount,
|
||||
afterCount,
|
||||
removedCount: beforeCount - afterCount,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get key prompt based on channel type
|
||||
*/
|
||||
export function getKeyPromptForType(type: number): string {
|
||||
return TYPE_TO_KEY_PROMPT[type] || 'Enter API key for this channel'
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
// Re-export all library functions
|
||||
export * from './channel-actions'
|
||||
export * from './channel-form'
|
||||
export * from './channel-type-config'
|
||||
export * from './channel-utils'
|
||||
export * from './multi-key-utils'
|
||||
export * from './model-mapping-validation'
|
||||
@@ -0,0 +1,229 @@
|
||||
// ============================================================================
|
||||
// Model Mapping Validation Utilities
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Parse models string to array
|
||||
*/
|
||||
export function parseModelsString(modelsStr: string): string[] {
|
||||
return modelsStr
|
||||
? modelsStr
|
||||
.split(',')
|
||||
.map((m) => m.trim())
|
||||
.filter(Boolean)
|
||||
: []
|
||||
}
|
||||
|
||||
/**
|
||||
* Format models array to string
|
||||
*/
|
||||
export function formatModelsArray(models: string[]): string {
|
||||
return Array.from(new Set(models)).join(',')
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize model name
|
||||
*/
|
||||
export function normalizeModelName(model: string): string {
|
||||
return typeof model === 'string' ? model.trim() : ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract source keys from model_mapping JSON
|
||||
* (the keys of the mapping object — models being remapped FROM)
|
||||
*/
|
||||
export function extractMappingSourceModels(modelMapping: string): string[] {
|
||||
if (typeof modelMapping !== 'string') return []
|
||||
const trimmed = modelMapping.trim()
|
||||
if (!trimmed) return []
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed)
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
return []
|
||||
}
|
||||
|
||||
const keys = Object.keys(parsed)
|
||||
.map((key) => key.trim())
|
||||
.filter(Boolean)
|
||||
|
||||
return Array.from(new Set(keys))
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract redirect models from model_mapping JSON
|
||||
*/
|
||||
export function extractRedirectModels(modelMapping: string): string[] {
|
||||
const mapping = modelMapping
|
||||
if (typeof mapping !== 'string') return []
|
||||
const trimmed = mapping.trim()
|
||||
if (!trimmed) return []
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed)
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
return []
|
||||
}
|
||||
|
||||
const values = Object.values(parsed)
|
||||
.map((value) => (typeof value === 'string' ? value.trim() : undefined))
|
||||
.filter((value): value is string => Boolean(value))
|
||||
|
||||
return Array.from(new Set(values))
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if model configuration has changed
|
||||
*/
|
||||
export function hasModelConfigChanged(
|
||||
currentModels: string[],
|
||||
currentModelMapping: string,
|
||||
initialModels: string[],
|
||||
initialModelMapping: string
|
||||
): boolean {
|
||||
// Always return true if not editing (new channel)
|
||||
if (initialModels.length === 0 && !initialModelMapping) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check if models array changed
|
||||
if (currentModels.length !== initialModels.length) {
|
||||
return true
|
||||
}
|
||||
for (let i = 0; i < currentModels.length; i++) {
|
||||
if (currentModels[i] !== initialModels[i]) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Check if model_mapping changed
|
||||
const normalizedCurrent = (currentModelMapping || '').trim()
|
||||
const normalizedInitial = (initialModelMapping || '').trim()
|
||||
|
||||
return normalizedCurrent !== normalizedInitial
|
||||
}
|
||||
|
||||
/**
|
||||
* Find models in model_mapping that are missing from the models list
|
||||
*/
|
||||
export function findMissingModelsInMapping(
|
||||
modelMapping: string,
|
||||
currentModels: string[]
|
||||
): string[] {
|
||||
if (!modelMapping || modelMapping.trim() === '') {
|
||||
return []
|
||||
}
|
||||
|
||||
let parsedMapping: Record<string, unknown>
|
||||
try {
|
||||
parsedMapping = JSON.parse(modelMapping)
|
||||
if (
|
||||
!parsedMapping ||
|
||||
typeof parsedMapping !== 'object' ||
|
||||
Array.isArray(parsedMapping)
|
||||
) {
|
||||
return []
|
||||
}
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
|
||||
const modelSet = new Set(currentModels.map((m) => normalizeModelName(m)))
|
||||
const missingModels = Object.keys(parsedMapping)
|
||||
.map((key) => normalizeModelName(key))
|
||||
.filter((key) => key && !modelSet.has(key))
|
||||
|
||||
return Array.from(new Set(missingModels))
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate model mapping JSON format
|
||||
*/
|
||||
export function validateModelMappingJson(modelMapping: string): {
|
||||
valid: boolean
|
||||
error?: string
|
||||
} {
|
||||
if (!modelMapping || modelMapping.trim() === '') {
|
||||
return { valid: true }
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(modelMapping)
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'Model mapping must be a valid JSON object',
|
||||
}
|
||||
}
|
||||
return { valid: true }
|
||||
} catch {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'Model mapping must be valid JSON format',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get redirect models that are also in the models list
|
||||
* (These should be removed from models list to keep /v1/models clean)
|
||||
*/
|
||||
export function findExposedTargetModels(
|
||||
modelMapping: string,
|
||||
currentModels: string[]
|
||||
): string[] {
|
||||
const redirectModels = extractRedirectModels(modelMapping)
|
||||
if (redirectModels.length === 0) return []
|
||||
|
||||
const normalizedModels = currentModels.map((m) => normalizeModelName(m))
|
||||
const modelSet = new Set(normalizedModels)
|
||||
|
||||
return redirectModels.filter((model) =>
|
||||
modelSet.has(normalizeModelName(model))
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Categorize models into different sets for UI display
|
||||
*/
|
||||
export function categorizeModelsWithRedirect(
|
||||
currentModels: string[],
|
||||
redirectModels: string[]
|
||||
): {
|
||||
normalizedCurrentModels: Set<string>
|
||||
normalizedRedirectModels: Set<string>
|
||||
classificationSet: Set<string>
|
||||
redirectOnlySet: Set<string>
|
||||
} {
|
||||
const normalizedCurrentModels = new Set(
|
||||
currentModels.map((m) => normalizeModelName(m)).filter(Boolean)
|
||||
)
|
||||
|
||||
const normalizedRedirectModels = new Set(
|
||||
redirectModels.map((m) => normalizeModelName(m)).filter(Boolean)
|
||||
)
|
||||
|
||||
const classificationSet = new Set([
|
||||
...normalizedCurrentModels,
|
||||
...normalizedRedirectModels,
|
||||
])
|
||||
|
||||
const redirectOnlySet = new Set(
|
||||
Array.from(normalizedRedirectModels).filter(
|
||||
(m) => !normalizedCurrentModels.has(m)
|
||||
)
|
||||
)
|
||||
|
||||
return {
|
||||
normalizedCurrentModels,
|
||||
normalizedRedirectModels,
|
||||
classificationSet,
|
||||
redirectOnlySet,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import {
|
||||
MULTI_KEY_STATUS_CONFIG,
|
||||
MULTI_KEY_CONFIRM_MESSAGES,
|
||||
} from '../constants'
|
||||
import type { MultiKeyConfirmAction } from '../types'
|
||||
|
||||
/**
|
||||
* Get status badge configuration for multi-key status
|
||||
*/
|
||||
export function getMultiKeyStatusConfig(status: number) {
|
||||
return (
|
||||
MULTI_KEY_STATUS_CONFIG[status as keyof typeof MULTI_KEY_STATUS_CONFIG] || {
|
||||
variant: 'neutral' as const,
|
||||
label: 'Unknown',
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get confirmation message for multi-key action
|
||||
*/
|
||||
export function getMultiKeyConfirmMessage(
|
||||
action: MultiKeyConfirmAction | null
|
||||
): string {
|
||||
if (!action) return ''
|
||||
|
||||
switch (action.type) {
|
||||
case 'delete':
|
||||
return MULTI_KEY_CONFIRM_MESSAGES.DELETE
|
||||
case 'enable':
|
||||
return MULTI_KEY_CONFIRM_MESSAGES.ENABLE
|
||||
case 'disable':
|
||||
return MULTI_KEY_CONFIRM_MESSAGES.DISABLE
|
||||
case 'enable-all':
|
||||
return MULTI_KEY_CONFIRM_MESSAGES.ENABLE_ALL
|
||||
case 'disable-all':
|
||||
return MULTI_KEY_CONFIRM_MESSAGES.DISABLE_ALL
|
||||
case 'delete-disabled':
|
||||
return MULTI_KEY_CONFIRM_MESSAGES.DELETE_DISABLED
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if action is destructive
|
||||
*/
|
||||
export function isDestructiveAction(
|
||||
action: MultiKeyConfirmAction | null
|
||||
): boolean {
|
||||
if (!action) return false
|
||||
return (
|
||||
action.type === 'delete' ||
|
||||
action.type === 'delete-disabled' ||
|
||||
action.type === 'disable-all'
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import type { Channel } from '../types'
|
||||
|
||||
export type PullProgress = {
|
||||
status?: string
|
||||
completed?: number
|
||||
total?: number
|
||||
// backend may include extra fields
|
||||
[k: string]: unknown
|
||||
}
|
||||
|
||||
export type OllamaModel = {
|
||||
id: string
|
||||
owned_by?: string
|
||||
size?: number
|
||||
digest?: string
|
||||
modified_at?: string
|
||||
details?: unknown
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null
|
||||
}
|
||||
|
||||
function getString(value: unknown): string | undefined {
|
||||
return typeof value === 'string' ? value : undefined
|
||||
}
|
||||
|
||||
function getNumber(value: unknown): number | undefined {
|
||||
return typeof value === 'number' ? value : undefined
|
||||
}
|
||||
|
||||
function parseMaybeJSON(value: unknown) {
|
||||
if (!value) return null
|
||||
if (typeof value === 'object') return value
|
||||
if (typeof value === 'string') {
|
||||
try {
|
||||
return JSON.parse(value)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve Ollama base URL from channel fields (supports legacy/alternate fields).
|
||||
*/
|
||||
export function resolveOllamaBaseUrl(channel: Channel | null) {
|
||||
if (!channel) return ''
|
||||
|
||||
const direct =
|
||||
typeof channel.base_url === 'string' ? channel.base_url.trim() : ''
|
||||
if (direct) return direct
|
||||
|
||||
const alt =
|
||||
typeof (channel as unknown as { ollama_base_url?: unknown })
|
||||
?.ollama_base_url === 'string'
|
||||
? String(
|
||||
(channel as unknown as { ollama_base_url?: string }).ollama_base_url
|
||||
).trim()
|
||||
: ''
|
||||
if (alt) return alt
|
||||
|
||||
const parsed = parseMaybeJSON(channel.other_info)
|
||||
if (isRecord(parsed)) {
|
||||
const baseUrl = getString(parsed.base_url)?.trim()
|
||||
if (baseUrl) return baseUrl
|
||||
const publicUrl = getString(parsed.public_url)?.trim()
|
||||
if (publicUrl) return publicUrl
|
||||
const apiUrl = getString(parsed.api_url)?.trim()
|
||||
if (apiUrl) return apiUrl
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
export function normalizeOllamaModels(items: unknown): OllamaModel[] {
|
||||
if (!Array.isArray(items)) return []
|
||||
|
||||
return items
|
||||
.map((item) => {
|
||||
if (!item) return null
|
||||
|
||||
if (typeof item === 'string') {
|
||||
return { id: item, owned_by: 'ollama' } satisfies OllamaModel
|
||||
}
|
||||
|
||||
if (isRecord(item)) {
|
||||
const candidateId =
|
||||
getString(item.id) ||
|
||||
getString(item.ID) ||
|
||||
getString(item.name) ||
|
||||
getString(item.model) ||
|
||||
getString(item.Model)
|
||||
if (!candidateId) return null
|
||||
|
||||
const metadata = item.metadata ?? item.Metadata
|
||||
const normalized: OllamaModel = {
|
||||
...item,
|
||||
id: candidateId,
|
||||
owned_by:
|
||||
getString(item.owned_by) || getString(item.ownedBy) || 'ollama',
|
||||
}
|
||||
|
||||
const itemSize = getNumber(item.size)
|
||||
if (typeof itemSize === 'number' && !normalized.size) {
|
||||
normalized.size = itemSize
|
||||
}
|
||||
if (isRecord(metadata)) {
|
||||
const metaSize = getNumber(metadata.size)
|
||||
if (typeof metaSize === 'number' && !normalized.size) {
|
||||
normalized.size = metaSize
|
||||
}
|
||||
const metaDigest = getString(metadata.digest)
|
||||
if (!normalized.digest && metaDigest) {
|
||||
normalized.digest = metaDigest
|
||||
}
|
||||
const metaModifiedAt = getString(metadata.modified_at)
|
||||
if (!normalized.modified_at && metaModifiedAt) {
|
||||
normalized.modified_at = metaModifiedAt
|
||||
}
|
||||
if (metadata.details && !normalized.details) {
|
||||
normalized.details = metadata.details
|
||||
}
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
return null
|
||||
})
|
||||
.filter(Boolean) as OllamaModel[]
|
||||
}
|
||||
|
||||
export function formatBytes(bytes?: number) {
|
||||
if (typeof bytes !== 'number' || Number.isNaN(bytes)) return '-'
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
const kb = bytes / 1024
|
||||
if (kb < 1024) return `${kb.toFixed(1)} KB`
|
||||
const mb = kb / 1024
|
||||
if (mb < 1024) return `${mb.toFixed(1)} MB`
|
||||
const gb = mb / 1024
|
||||
return `${gb.toFixed(2)} GB`
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
const NON_REDIRECTABLE_STATUS_CODES = new Set([504, 524])
|
||||
|
||||
function parseStatusCodeKey(rawKey: string): number | null {
|
||||
const normalized = rawKey.trim()
|
||||
if (!/^[1-5]\d{2}$/.test(normalized)) return null
|
||||
return Number.parseInt(normalized, 10)
|
||||
}
|
||||
|
||||
function parseStatusCodeMappingTarget(rawValue: unknown): number | null {
|
||||
if (typeof rawValue === 'number' && Number.isInteger(rawValue)) {
|
||||
return rawValue >= 100 && rawValue <= 599 ? rawValue : null
|
||||
}
|
||||
if (typeof rawValue === 'string') {
|
||||
const normalized = rawValue.trim()
|
||||
if (!/^[1-5]\d{2}$/.test(normalized)) return null
|
||||
const code = Number.parseInt(normalized, 10)
|
||||
return code >= 100 && code <= 599 ? code : null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function collectInvalidStatusCodeEntries(
|
||||
statusCodeMappingStr: string
|
||||
): string[] {
|
||||
if (!statusCodeMappingStr?.trim()) return []
|
||||
|
||||
let parsed: Record<string, unknown>
|
||||
try {
|
||||
parsed = JSON.parse(statusCodeMappingStr)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return []
|
||||
|
||||
const invalid: string[] = []
|
||||
for (const [rawKey, rawValue] of Object.entries(parsed)) {
|
||||
const fromCode = parseStatusCodeKey(rawKey)
|
||||
const toCode = parseStatusCodeMappingTarget(rawValue)
|
||||
if (fromCode === null || toCode === null) {
|
||||
invalid.push(`${rawKey} → ${rawValue}`)
|
||||
}
|
||||
}
|
||||
return invalid
|
||||
}
|
||||
|
||||
export function collectDisallowedStatusCodeRedirects(
|
||||
statusCodeMappingStr: string
|
||||
): string[] {
|
||||
if (!statusCodeMappingStr?.trim()) return []
|
||||
|
||||
let parsed: Record<string, unknown>
|
||||
try {
|
||||
parsed = JSON.parse(statusCodeMappingStr)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return []
|
||||
|
||||
const riskyMappings: string[] = []
|
||||
for (const [rawFrom, rawTo] of Object.entries(parsed)) {
|
||||
const fromCode = parseStatusCodeKey(rawFrom)
|
||||
const toCode = parseStatusCodeMappingTarget(rawTo)
|
||||
if (fromCode === null || toCode === null) continue
|
||||
if (!NON_REDIRECTABLE_STATUS_CODES.has(fromCode)) continue
|
||||
if (fromCode === toCode) continue
|
||||
riskyMappings.push(`${fromCode} -> ${toCode}`)
|
||||
}
|
||||
|
||||
return [...new Set(riskyMappings)].sort()
|
||||
}
|
||||
|
||||
export function collectNewDisallowedStatusCodeRedirects(
|
||||
originalStr: string,
|
||||
currentStr: string
|
||||
): string[] {
|
||||
const currentRisky = collectDisallowedStatusCodeRedirects(currentStr)
|
||||
if (currentRisky.length === 0) return []
|
||||
|
||||
const originalRiskySet = new Set(
|
||||
collectDisallowedStatusCodeRedirects(originalStr)
|
||||
)
|
||||
return currentRisky.filter((mapping) => !originalRiskySet.has(mapping))
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
export function normalizeModelList(models: unknown[] = []): string[] {
|
||||
return Array.from(
|
||||
new Set(
|
||||
(models || []).map((model) => String(model || '').trim()).filter(Boolean)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
export function parseUpstreamUpdateMeta(settings: unknown): {
|
||||
enabled: boolean
|
||||
pendingAddModels: string[]
|
||||
pendingRemoveModels: string[]
|
||||
} {
|
||||
let parsed: Record<string, unknown> | null = null
|
||||
if (settings && typeof settings === 'object' && !Array.isArray(settings)) {
|
||||
parsed = settings as Record<string, unknown>
|
||||
} else if (typeof settings === 'string') {
|
||||
try {
|
||||
parsed = JSON.parse(settings)
|
||||
} catch {
|
||||
parsed = null
|
||||
}
|
||||
}
|
||||
|
||||
if (!parsed || typeof parsed !== 'object') {
|
||||
return { enabled: false, pendingAddModels: [], pendingRemoveModels: [] }
|
||||
}
|
||||
|
||||
return {
|
||||
enabled: parsed.upstream_model_update_check_enabled === true,
|
||||
pendingAddModels: normalizeModelList(
|
||||
(parsed.upstream_model_update_last_detected_models as unknown[]) || []
|
||||
),
|
||||
pendingRemoveModels: normalizeModelList(
|
||||
(parsed.upstream_model_update_last_removed_models as unknown[]) || []
|
||||
),
|
||||
}
|
||||
}
|
||||
+295
@@ -0,0 +1,295 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
// ============================================================================
|
||||
// Channel Schema & Types
|
||||
// ============================================================================
|
||||
|
||||
export const channelInfoSchema = z.object({
|
||||
is_multi_key: z.boolean().default(false),
|
||||
multi_key_size: z.number().default(0),
|
||||
multi_key_status_list: z.record(z.string(), z.number()).optional(),
|
||||
multi_key_disabled_reason: z.record(z.string(), z.string()).optional(),
|
||||
multi_key_disabled_time: z.record(z.string(), z.number()).optional(),
|
||||
multi_key_polling_index: z.number().default(0),
|
||||
multi_key_mode: z.enum(['random', 'polling']).default('random'),
|
||||
})
|
||||
|
||||
export type ChannelInfo = z.infer<typeof channelInfoSchema>
|
||||
|
||||
export const channelSchema = z.object({
|
||||
id: z.number(),
|
||||
type: z.number(),
|
||||
key: z.string(),
|
||||
openai_organization: z.string().nullish(),
|
||||
test_model: z.string().nullish(),
|
||||
status: z.number(), // 1: enabled, 0: manual disabled, 2: auto disabled
|
||||
name: z.string(),
|
||||
weight: z.number().nullish(),
|
||||
created_time: z.number(),
|
||||
test_time: z.number(),
|
||||
response_time: z.number(), // in milliseconds
|
||||
base_url: z.string().nullish(),
|
||||
other: z.string().default(''),
|
||||
balance: z.number().default(0), // in USD
|
||||
balance_updated_time: z.number(),
|
||||
models: z.string().default(''),
|
||||
group: z.string().default('default'),
|
||||
used_quota: z.number().default(0),
|
||||
model_mapping: z.string().nullish(),
|
||||
status_code_mapping: z.string().nullish(),
|
||||
priority: z.number().nullish(),
|
||||
auto_ban: z.number().nullish(),
|
||||
other_info: z.string().default(''),
|
||||
tag: z.string().nullish(),
|
||||
setting: z.string().nullish(),
|
||||
param_override: z.string().nullish(),
|
||||
header_override: z.string().nullish(),
|
||||
remark: z.string().default(''),
|
||||
max_input_tokens: z.number().default(0),
|
||||
channel_info: channelInfoSchema.default({
|
||||
is_multi_key: false,
|
||||
multi_key_size: 0,
|
||||
multi_key_polling_index: 0,
|
||||
multi_key_mode: 'random',
|
||||
}),
|
||||
settings: z.string().default('{}'), // other_settings JSON
|
||||
})
|
||||
|
||||
export type Channel = z.infer<typeof channelSchema>
|
||||
|
||||
// ============================================================================
|
||||
// Channel Settings Types
|
||||
// ============================================================================
|
||||
|
||||
export interface ChannelSettings {
|
||||
force_format?: boolean
|
||||
thinking_to_content?: boolean
|
||||
proxy?: string
|
||||
pass_through_body_enabled?: boolean
|
||||
system_prompt?: string
|
||||
system_prompt_override?: boolean
|
||||
}
|
||||
|
||||
export interface ChannelOtherSettings {
|
||||
azure_responses_version?: string
|
||||
vertex_key_type?: 'json' | 'api_key'
|
||||
openrouter_enterprise?: boolean
|
||||
aws_key_type?: 'ak_sk' | 'api_key'
|
||||
allow_service_tier?: boolean
|
||||
disable_store?: boolean
|
||||
allow_safety_identifier?: boolean
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// API Response Types
|
||||
// ============================================================================
|
||||
|
||||
export interface GetChannelsResponse {
|
||||
success: boolean
|
||||
message?: string
|
||||
data?: {
|
||||
items: Channel[]
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
type_counts?: Record<string, number>
|
||||
}
|
||||
}
|
||||
|
||||
export interface SearchChannelsResponse {
|
||||
success: boolean
|
||||
message?: string
|
||||
data?: {
|
||||
items: Channel[]
|
||||
total: number
|
||||
type_counts?: Record<string, number>
|
||||
}
|
||||
}
|
||||
|
||||
export interface GetChannelResponse {
|
||||
success: boolean
|
||||
message?: string
|
||||
data?: Channel
|
||||
}
|
||||
|
||||
export interface ChannelTestResponse {
|
||||
success: boolean
|
||||
message?: string
|
||||
error_code?: string
|
||||
data?: {
|
||||
response_time?: number
|
||||
error?: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface ChannelBalanceResponse {
|
||||
success: boolean
|
||||
message?: string
|
||||
balance?: number
|
||||
currency?: string
|
||||
}
|
||||
|
||||
export interface FetchModelsResponse {
|
||||
success: boolean
|
||||
message?: string
|
||||
data?: string[]
|
||||
}
|
||||
|
||||
export interface CopyChannelResponse {
|
||||
success: boolean
|
||||
message?: string
|
||||
data?: {
|
||||
id: number
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Multi-Key Management Types
|
||||
// ============================================================================
|
||||
|
||||
export interface KeyStatus {
|
||||
index: number
|
||||
status: number // 1: enabled, 2: manual disabled, 3: auto disabled
|
||||
disabled_time?: number
|
||||
reason?: string
|
||||
key_preview?: string
|
||||
}
|
||||
|
||||
export type MultiKeyConfirmAction = {
|
||||
type:
|
||||
| 'enable'
|
||||
| 'disable'
|
||||
| 'delete'
|
||||
| 'enable-all'
|
||||
| 'disable-all'
|
||||
| 'delete-disabled'
|
||||
keyIndex?: number
|
||||
}
|
||||
|
||||
export interface MultiKeyStatusResponse {
|
||||
success: boolean
|
||||
message?: string
|
||||
data?: {
|
||||
keys: KeyStatus[]
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
total_pages: number
|
||||
enabled_count: number
|
||||
manual_disabled_count: number
|
||||
auto_disabled_count: number
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// API Request Parameters
|
||||
// ============================================================================
|
||||
|
||||
export interface GetChannelsParams {
|
||||
p?: number
|
||||
page_size?: number
|
||||
status?: string // 'enabled', 'disabled', or empty for all
|
||||
type?: number
|
||||
group?: string
|
||||
id_sort?: boolean
|
||||
tag_mode?: boolean
|
||||
}
|
||||
|
||||
export interface SearchChannelsParams {
|
||||
keyword?: string
|
||||
group?: string
|
||||
model?: string
|
||||
status?: string
|
||||
type?: number
|
||||
id_sort?: boolean
|
||||
tag_mode?: boolean
|
||||
p?: number
|
||||
page_size?: number
|
||||
}
|
||||
|
||||
export interface ChannelTestParams {
|
||||
test_model?: string
|
||||
}
|
||||
|
||||
export interface CopyChannelParams {
|
||||
suffix?: string
|
||||
reset_balance?: boolean
|
||||
}
|
||||
|
||||
export interface MultiKeyManageParams {
|
||||
channel_id: number
|
||||
action:
|
||||
| 'get_key_status'
|
||||
| 'disable_key'
|
||||
| 'enable_key'
|
||||
| 'enable_all_keys'
|
||||
| 'disable_all_keys'
|
||||
| 'delete_key'
|
||||
| 'delete_disabled_keys'
|
||||
key_index?: number
|
||||
page?: number
|
||||
page_size?: number
|
||||
status?: number // 1=enabled, 2=manual_disabled, 3=auto_disabled
|
||||
}
|
||||
|
||||
export interface BatchDeleteParams {
|
||||
ids: number[]
|
||||
}
|
||||
|
||||
export interface BatchSetTagParams {
|
||||
ids: number[]
|
||||
tag: string | null
|
||||
}
|
||||
|
||||
export interface TagOperationParams {
|
||||
tag: string
|
||||
new_tag?: string
|
||||
priority?: number
|
||||
weight?: number
|
||||
model_mapping?: string
|
||||
models?: string
|
||||
groups?: string
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Form Data Types
|
||||
// ============================================================================
|
||||
|
||||
export interface ChannelFormData {
|
||||
name: string
|
||||
type: number
|
||||
base_url: string
|
||||
key: string
|
||||
openai_organization?: string
|
||||
models: string
|
||||
group: string
|
||||
model_mapping?: string
|
||||
priority?: number
|
||||
weight?: number
|
||||
test_model?: string
|
||||
auto_ban?: number
|
||||
status: number
|
||||
status_code_mapping?: string
|
||||
tag?: string
|
||||
remark?: string
|
||||
setting?: string
|
||||
param_override?: string
|
||||
header_override?: string
|
||||
settings?: string
|
||||
other?: string
|
||||
// Multi-key specific
|
||||
multi_key_mode?: 'single' | 'batch' | 'multi_to_single'
|
||||
multi_key_type?: 'random' | 'polling'
|
||||
batch_add_set_key_prefix_2_name?: boolean
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Add Channel Request (special structure)
|
||||
// ============================================================================
|
||||
|
||||
export interface AddChannelRequest {
|
||||
mode: 'single' | 'batch' | 'multi_to_single'
|
||||
multi_key_mode?: 'random' | 'polling'
|
||||
batch_add_set_key_prefix_2_name?: boolean
|
||||
channel: Partial<Channel>
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { getApiKeys } from '@/features/keys/api'
|
||||
import { API_KEY_STATUS } from '@/features/keys/constants'
|
||||
|
||||
/**
|
||||
* Get the currently active API key for chat links
|
||||
*/
|
||||
export function useActiveChatKey(enabled: boolean) {
|
||||
return useQuery({
|
||||
queryKey: ['chat-active-key'],
|
||||
queryFn: async () => {
|
||||
const result = await getApiKeys({ p: 1, size: 50 })
|
||||
if (!result.success) {
|
||||
throw new Error(result.message || 'Failed to load API keys')
|
||||
}
|
||||
const items = result.data?.items ?? []
|
||||
const active = items.find(
|
||||
(item) => item.status === API_KEY_STATUS.ENABLED
|
||||
)
|
||||
if (!active) {
|
||||
throw new Error(
|
||||
'No enabled API keys found. Create or enable one first.'
|
||||
)
|
||||
}
|
||||
return active.key
|
||||
},
|
||||
enabled,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
gcTime: 10 * 60 * 1000,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useStatus } from '@/hooks/use-status'
|
||||
import type { SystemStatus } from '@/features/auth/types'
|
||||
import {
|
||||
type ChatPreset,
|
||||
parseChatConfig,
|
||||
type RawChatConfig,
|
||||
} from '../lib/chat-links'
|
||||
|
||||
function getStoredStatusChats(): RawChatConfig {
|
||||
if (typeof window === 'undefined') return undefined
|
||||
try {
|
||||
const raw = window.localStorage.getItem('status')
|
||||
if (!raw) return undefined
|
||||
const parsed = JSON.parse(raw)
|
||||
return parsed?.chats ?? parsed?.Chats
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function extractServerAddress(status: SystemStatus | null) {
|
||||
const fromStatus =
|
||||
(status?.server_address as string | undefined) ??
|
||||
(status?.serverAddress as string | undefined) ??
|
||||
status?.data?.server_address ??
|
||||
(status?.data as Record<string, unknown> | undefined)?.serverAddress
|
||||
|
||||
if (fromStatus && typeof fromStatus === 'string') {
|
||||
return fromStatus
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
return window.location.origin
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
function extractChats(status: SystemStatus | null): RawChatConfig {
|
||||
const raw =
|
||||
status?.Chats ?? status?.chats ?? status?.data?.Chats ?? status?.data?.chats
|
||||
|
||||
return (raw as RawChatConfig) ?? getStoredStatusChats()
|
||||
}
|
||||
|
||||
export function useChatPresets(): {
|
||||
chatPresets: ChatPreset[]
|
||||
serverAddress: string
|
||||
} {
|
||||
const { status } = useStatus()
|
||||
|
||||
const serverAddress = useMemo(() => extractServerAddress(status), [status])
|
||||
|
||||
const chatPresets = useMemo(() => {
|
||||
const raw = extractChats(status)
|
||||
return parseChatConfig(raw)
|
||||
}, [status])
|
||||
|
||||
return {
|
||||
chatPresets,
|
||||
serverAddress,
|
||||
}
|
||||
}
|
||||
+166
@@ -0,0 +1,166 @@
|
||||
import { API_KEY_STATUS } from '@/features/keys/constants'
|
||||
|
||||
export type ChatLinkType = 'web' | 'custom-protocol' | 'fluent'
|
||||
|
||||
export type ChatPreset = {
|
||||
id: string
|
||||
name: string
|
||||
url: string
|
||||
type: ChatLinkType
|
||||
}
|
||||
|
||||
export type RawChatConfig =
|
||||
| string
|
||||
| Record<string, unknown>
|
||||
| Array<Record<string, unknown>>
|
||||
| null
|
||||
| undefined
|
||||
|
||||
export type ResolveChatUrlParams = {
|
||||
template: string
|
||||
apiKey?: string
|
||||
serverAddress: string
|
||||
}
|
||||
|
||||
export type ActiveApiKey = {
|
||||
key: string
|
||||
status: number
|
||||
}
|
||||
|
||||
const HTTP_REGEX = /^https?:\/\//i
|
||||
|
||||
function toBase64(value: string) {
|
||||
if (typeof window !== 'undefined' && typeof window.btoa === 'function') {
|
||||
return window.btoa(value)
|
||||
}
|
||||
|
||||
type BufferConstructorLike = {
|
||||
from(data: string, encoding: string): { toString(encoding: string): string }
|
||||
}
|
||||
|
||||
const globalObj =
|
||||
typeof globalThis !== 'undefined'
|
||||
? (globalThis as Record<string, unknown>)
|
||||
: undefined
|
||||
const bufferCtor = globalObj?.Buffer
|
||||
|
||||
if (
|
||||
typeof bufferCtor === 'function' &&
|
||||
typeof (bufferCtor as unknown as BufferConstructorLike).from === 'function'
|
||||
) {
|
||||
return (bufferCtor as unknown as BufferConstructorLike)
|
||||
.from(value, 'utf-8')
|
||||
.toString('base64')
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
export function detectChatLinkType(url: string): ChatLinkType {
|
||||
if (HTTP_REGEX.test(url)) {
|
||||
return 'web'
|
||||
}
|
||||
if (url.toLowerCase().startsWith('fluent')) {
|
||||
return 'fluent'
|
||||
}
|
||||
return 'custom-protocol'
|
||||
}
|
||||
|
||||
export function parseChatConfig(raw: RawChatConfig): ChatPreset[] {
|
||||
let parsed: unknown = raw
|
||||
|
||||
if (typeof raw === 'string') {
|
||||
try {
|
||||
parsed = JSON.parse(raw)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
if (!Array.isArray(parsed)) {
|
||||
return []
|
||||
}
|
||||
|
||||
return parsed
|
||||
.map((entry, index) => {
|
||||
if (
|
||||
!entry ||
|
||||
typeof entry !== 'object' ||
|
||||
Array.isArray(entry) ||
|
||||
Object.keys(entry).length !== 1
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
const [name, value] = Object.entries(entry)[0]
|
||||
if (typeof value !== 'string' || typeof name !== 'string') {
|
||||
return null
|
||||
}
|
||||
|
||||
const url = value.trim()
|
||||
if (!url) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
id: String(index),
|
||||
name,
|
||||
url,
|
||||
type: detectChatLinkType(url),
|
||||
} satisfies ChatPreset
|
||||
})
|
||||
.filter((item): item is ChatPreset => item !== null)
|
||||
}
|
||||
|
||||
function replaceToken(source: string, token: string, value: string) {
|
||||
return source.split(token).join(value)
|
||||
}
|
||||
|
||||
export function resolveChatUrl({
|
||||
template,
|
||||
apiKey,
|
||||
serverAddress,
|
||||
}: ResolveChatUrlParams): string {
|
||||
let url = template
|
||||
const safeServerAddress = serverAddress || ''
|
||||
|
||||
const safeApiKey = apiKey || ''
|
||||
|
||||
if (url.includes('{cherryConfig}')) {
|
||||
const payload = {
|
||||
id: 'new-api',
|
||||
baseUrl: safeServerAddress,
|
||||
apiKey: safeApiKey,
|
||||
}
|
||||
const encoded = encodeURIComponent(toBase64(JSON.stringify(payload)))
|
||||
return replaceToken(url, '{cherryConfig}', encoded)
|
||||
}
|
||||
|
||||
if (url.includes('{aionuiConfig}')) {
|
||||
const payload = {
|
||||
platform: 'new-api',
|
||||
baseUrl: safeServerAddress,
|
||||
apiKey: safeApiKey,
|
||||
}
|
||||
const encoded = encodeURIComponent(toBase64(JSON.stringify(payload)))
|
||||
return replaceToken(url, '{aionuiConfig}', encoded)
|
||||
}
|
||||
|
||||
if (safeServerAddress) {
|
||||
const encodedAddress = encodeURIComponent(safeServerAddress)
|
||||
url = replaceToken(url, '{address}', encodedAddress)
|
||||
}
|
||||
|
||||
if (safeApiKey) {
|
||||
url = replaceToken(url, '{key}', safeApiKey)
|
||||
}
|
||||
|
||||
return url
|
||||
}
|
||||
|
||||
export function getFirstActiveKey(
|
||||
keys: ActiveApiKey[] | undefined
|
||||
): ActiveApiKey | undefined {
|
||||
if (!Array.isArray(keys)) return undefined
|
||||
return keys.find((item) => item.status === API_KEY_STATUS.ENABLED)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
export function sendToFluent(apiKey: string, serverAddress?: string): boolean {
|
||||
if (typeof window === 'undefined') {
|
||||
return false
|
||||
}
|
||||
|
||||
const container = document.getElementById('fluent-new-api-container')
|
||||
if (!container) {
|
||||
return false
|
||||
}
|
||||
|
||||
const payload = {
|
||||
id: 'new-api',
|
||||
baseUrl: serverAddress || window.location.origin,
|
||||
apiKey: `sk-${apiKey}`,
|
||||
}
|
||||
|
||||
container.dispatchEvent(
|
||||
new CustomEvent('fluent:prefill', {
|
||||
detail: payload,
|
||||
})
|
||||
)
|
||||
|
||||
return true
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
import { api } from '@/lib/api'
|
||||
import type { QuotaDataItem, UptimeGroupResult } from './types'
|
||||
|
||||
// ============================================================================
|
||||
// Dashboard APIs
|
||||
// ============================================================================
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Quota & Usage Data
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
// Get user quota data within a time range
|
||||
// Admin users can specify 'username' to view other users' data
|
||||
export async function getUserQuotaDates(params: {
|
||||
start_timestamp: number
|
||||
end_timestamp: number
|
||||
default_time?: string
|
||||
username?: string
|
||||
}) {
|
||||
const endpoint = params.username ? '/api/data' : '/api/data/self'
|
||||
const res = await api.get<{ success: boolean; data: QuotaDataItem[] }>(
|
||||
endpoint,
|
||||
{ params }
|
||||
)
|
||||
return res.data
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// System Monitoring
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
export async function getUserQuotaDataByUsers(params: {
|
||||
start_timestamp: number
|
||||
end_timestamp: number
|
||||
}) {
|
||||
const res = await api.get<{ success: boolean; data: QuotaDataItem[] }>(
|
||||
'/api/data/users',
|
||||
{ params }
|
||||
)
|
||||
return res.data
|
||||
}
|
||||
|
||||
// Get uptime monitoring status for all services
|
||||
export async function getUptimeStatus() {
|
||||
const res = await api.get<{ success: boolean; data: UptimeGroupResult[] }>(
|
||||
'/api/uptime/status'
|
||||
)
|
||||
return res.data
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { formatNumber, formatQuota } from '@/lib/format'
|
||||
import { computeTimeRange } from '@/lib/time'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { getUserQuotaDates } from '@/features/dashboard/api'
|
||||
import { useModelStatCardsConfig } from '@/features/dashboard/hooks/use-dashboard-config'
|
||||
import {
|
||||
buildQueryParams,
|
||||
calculateDashboardStats,
|
||||
getDefaultDays,
|
||||
} from '@/features/dashboard/lib'
|
||||
import type {
|
||||
QuotaDataItem,
|
||||
DashboardFilters,
|
||||
} from '@/features/dashboard/types'
|
||||
|
||||
interface LogStatCardsProps {
|
||||
filters?: DashboardFilters
|
||||
onDataUpdate?: (data: QuotaDataItem[], loading: boolean) => void
|
||||
}
|
||||
|
||||
export function LogStatCards(props: LogStatCardsProps) {
|
||||
const statCardsConfig = useModelStatCardsConfig()
|
||||
const [stats, setStats] = useState<{
|
||||
totalQuota: number
|
||||
totalCount: number
|
||||
totalTokens: number
|
||||
} | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(false)
|
||||
|
||||
const [timeRangeMinutes, setTimeRangeMinutes] = useState(0)
|
||||
|
||||
const { filters, onDataUpdate } = props
|
||||
|
||||
useEffect(() => {
|
||||
const abortController = new AbortController()
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setLoading(true)
|
||||
|
||||
setError(false)
|
||||
onDataUpdate?.([], true)
|
||||
|
||||
const timeRange = computeTimeRange(
|
||||
getDefaultDays(filters?.time_granularity),
|
||||
filters?.start_timestamp,
|
||||
filters?.end_timestamp
|
||||
)
|
||||
const timeDiff = (timeRange.end_timestamp - timeRange.start_timestamp) / 60
|
||||
setTimeRangeMinutes(timeDiff)
|
||||
|
||||
getUserQuotaDates(buildQueryParams(timeRange, filters))
|
||||
.then((res) => {
|
||||
if (abortController.signal.aborted) return
|
||||
const data = res?.data || []
|
||||
setStats(calculateDashboardStats(data))
|
||||
onDataUpdate?.(data, false)
|
||||
})
|
||||
.catch(() => {
|
||||
if (abortController.signal.aborted) return
|
||||
setStats(null)
|
||||
setError(true)
|
||||
onDataUpdate?.([], false)
|
||||
})
|
||||
.finally(() => {
|
||||
if (!abortController.signal.aborted) {
|
||||
setLoading(false)
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
abortController.abort()
|
||||
}
|
||||
}, [filters, onDataUpdate])
|
||||
|
||||
const adaptedStats = {
|
||||
rpm: stats?.totalCount ?? 0,
|
||||
quota: stats?.totalQuota ?? 0,
|
||||
tpm: stats?.totalTokens ?? 0,
|
||||
}
|
||||
|
||||
const items = statCardsConfig.map((config) => ({
|
||||
title: config.title,
|
||||
value:
|
||||
config.key === 'quota'
|
||||
? formatQuota(config.getValue(adaptedStats, timeRangeMinutes))
|
||||
: formatNumber(config.getValue(adaptedStats, timeRangeMinutes)),
|
||||
desc: config.description,
|
||||
icon: config.icon,
|
||||
}))
|
||||
|
||||
return (
|
||||
<div className='overflow-hidden rounded-lg border'>
|
||||
<div className='divide-border/60 grid grid-cols-2 divide-x sm:grid-cols-3 lg:grid-cols-5'>
|
||||
{items.map((it) => {
|
||||
const Icon = it.icon
|
||||
return (
|
||||
<div key={it.title} className='px-4 py-3.5 sm:px-5 sm:py-4'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Icon className='text-muted-foreground/60 size-3.5 shrink-0' />
|
||||
<div className='text-muted-foreground truncate text-xs font-medium tracking-wider uppercase'>
|
||||
{it.title}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className='mt-2 space-y-1.5'>
|
||||
<Skeleton className='h-7 w-20' />
|
||||
<Skeleton className='h-3.5 w-28' />
|
||||
</div>
|
||||
) : error ? (
|
||||
<>
|
||||
<div className='text-muted-foreground mt-2 font-mono text-2xl font-bold tracking-tight tabular-nums'>
|
||||
--
|
||||
</div>
|
||||
<div className='text-muted-foreground/40 mt-1 hidden text-xs md:block'>
|
||||
{it.desc}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className='text-foreground mt-2 font-mono text-2xl font-bold tracking-tight tabular-nums'>
|
||||
{it.value}
|
||||
</div>
|
||||
<div className='text-muted-foreground/60 mt-1 hidden text-xs md:block'>
|
||||
{it.desc}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { useEffect, useMemo, useState, useRef } from 'react'
|
||||
import { VChart } from '@visactor/react-vchart'
|
||||
import { PieChart as PieChartIcon } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { TimeGranularity } from '@/lib/time'
|
||||
import { VCHART_OPTION } from '@/lib/vchart'
|
||||
import { useTheme } from '@/context/theme-provider'
|
||||
import { DEFAULT_TIME_GRANULARITY } from '@/features/dashboard/constants'
|
||||
import { processChartData } from '@/features/dashboard/lib'
|
||||
import type {
|
||||
ProcessedChartData,
|
||||
QuotaDataItem,
|
||||
} from '@/features/dashboard/types'
|
||||
|
||||
let themeManagerPromise: Promise<
|
||||
(typeof import('@visactor/vchart'))['ThemeManager']
|
||||
> | null = null
|
||||
|
||||
type ChartTab = '1' | '2' | '3' | '4'
|
||||
|
||||
const CHART_TABS: {
|
||||
value: ChartTab
|
||||
labelKey: string
|
||||
specKey: keyof ProcessedChartData
|
||||
}[] = [
|
||||
{ value: '1', labelKey: 'Quota Distribution', specKey: 'spec_line' },
|
||||
{ value: '2', labelKey: 'Call Trend', specKey: 'spec_model_line' },
|
||||
{ value: '3', labelKey: 'Call Proportion', specKey: 'spec_pie' },
|
||||
{ value: '4', labelKey: 'Top Models', specKey: 'spec_rank_bar' },
|
||||
]
|
||||
|
||||
interface ModelChartsProps {
|
||||
data: QuotaDataItem[]
|
||||
loading?: boolean
|
||||
timeGranularity?: TimeGranularity
|
||||
}
|
||||
|
||||
export function ModelCharts(props: ModelChartsProps) {
|
||||
const { t } = useTranslation()
|
||||
const { resolvedTheme } = useTheme()
|
||||
const [activeTab, setActiveTab] = useState<ChartTab>('1')
|
||||
const [themeReady, setThemeReady] = useState(false)
|
||||
const themeManagerRef = useRef<
|
||||
(typeof import('@visactor/vchart'))['ThemeManager'] | null
|
||||
>(null)
|
||||
const timeGranularity = props.timeGranularity ?? DEFAULT_TIME_GRANULARITY
|
||||
|
||||
useEffect(() => {
|
||||
const updateTheme = async () => {
|
||||
setThemeReady(false)
|
||||
|
||||
if (!themeManagerPromise) {
|
||||
themeManagerPromise = import('@visactor/vchart').then(
|
||||
(m) => m.ThemeManager
|
||||
)
|
||||
}
|
||||
|
||||
const ThemeManager = await themeManagerPromise
|
||||
themeManagerRef.current = ThemeManager
|
||||
ThemeManager.setCurrentTheme(resolvedTheme === 'dark' ? 'dark' : 'light')
|
||||
setThemeReady(true)
|
||||
}
|
||||
|
||||
updateTheme()
|
||||
}, [resolvedTheme])
|
||||
|
||||
const chartData = useMemo(
|
||||
() => processChartData(props.loading ? [] : props.data, timeGranularity, t),
|
||||
[props.data, props.loading, timeGranularity, t]
|
||||
)
|
||||
|
||||
const activeSpec = CHART_TABS.find((tab) => tab.value === activeTab)
|
||||
const spec = activeSpec ? chartData[activeSpec.specKey] : null
|
||||
|
||||
return (
|
||||
<div className='overflow-hidden rounded-lg border'>
|
||||
<div className='flex w-full flex-col gap-3 border-b px-4 py-3 sm:px-5 lg:flex-row lg:items-center lg:justify-between'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<PieChartIcon className='text-muted-foreground/60 size-4' />
|
||||
<div className='text-sm font-semibold'>{t('Model Analytics')}</div>
|
||||
</div>
|
||||
|
||||
<div className='bg-muted/60 inline-flex h-8 rounded-md border p-0.5'>
|
||||
{CHART_TABS.map((tab) => (
|
||||
<button
|
||||
key={tab.value}
|
||||
type='button'
|
||||
onClick={() => setActiveTab(tab.value)}
|
||||
className={`rounded-[5px] px-3 text-xs font-medium transition-colors ${
|
||||
activeTab === tab.value
|
||||
? 'bg-background text-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
{t(tab.labelKey)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='h-96 p-2'>
|
||||
{themeReady && spec && (
|
||||
<VChart
|
||||
key={`${activeTab}-${resolvedTheme}`}
|
||||
spec={{
|
||||
...spec,
|
||||
theme: resolvedTheme === 'dark' ? 'dark' : 'light',
|
||||
background: 'transparent',
|
||||
}}
|
||||
option={VCHART_OPTION}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+259
@@ -0,0 +1,259 @@
|
||||
import { useState } from 'react'
|
||||
import { Filter, RotateCcw, Calendar, Search } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useAuthStore } from '@/stores/auth-store'
|
||||
import { getNormalizedDateRange, type TimeGranularity } from '@/lib/time'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { DateTimePicker } from '@/components/datetime-picker'
|
||||
import {
|
||||
DEFAULT_TIME_GRANULARITY,
|
||||
TIME_GRANULARITY_OPTIONS,
|
||||
TIME_RANGE_PRESETS,
|
||||
EMPTY_DASHBOARD_FILTERS,
|
||||
} from '@/features/dashboard/constants'
|
||||
import {
|
||||
cleanFilters,
|
||||
getSavedGranularity,
|
||||
saveGranularity,
|
||||
getDefaultDays,
|
||||
} from '@/features/dashboard/lib'
|
||||
import { type DashboardFilters } from '@/features/dashboard/types'
|
||||
|
||||
interface ModelsFilterProps {
|
||||
onFilterChange: (filters: DashboardFilters) => void
|
||||
onReset: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Section divider component for better visual organization
|
||||
*/
|
||||
const SectionDivider = ({ label }: { label: string }) => (
|
||||
<div className='relative'>
|
||||
<div className='absolute inset-0 flex items-center'>
|
||||
<span className='w-full border-t' />
|
||||
</div>
|
||||
<div className='relative flex justify-center text-xs uppercase'>
|
||||
<span className='bg-background text-muted-foreground px-2'>{label}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
export function ModelsFilter({ onFilterChange, onReset }: ModelsFilterProps) {
|
||||
const { t } = useTranslation()
|
||||
// 使用已缓存的用户数据,避免重复调用 API
|
||||
const user = useAuthStore((state) => state.auth.user)
|
||||
const isAdmin = user?.role && user.role >= 10
|
||||
|
||||
const [open, setOpen] = useState(false)
|
||||
const [filters, setFilters] = useState<DashboardFilters>(() => {
|
||||
const granularity = getSavedGranularity()
|
||||
const days = getDefaultDays(granularity)
|
||||
const { start, end } = getNormalizedDateRange(days)
|
||||
return {
|
||||
...EMPTY_DASHBOARD_FILTERS,
|
||||
start_timestamp: start,
|
||||
end_timestamp: end,
|
||||
time_granularity: granularity,
|
||||
}
|
||||
})
|
||||
const [selectedRange, setSelectedRange] = useState<number | null>(() =>
|
||||
getDefaultDays()
|
||||
)
|
||||
|
||||
const handleApply = () => {
|
||||
onFilterChange(
|
||||
cleanFilters(
|
||||
filters as unknown as Record<string, unknown>
|
||||
) as typeof filters
|
||||
)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
const days = getDefaultDays(DEFAULT_TIME_GRANULARITY)
|
||||
const { start, end } = getNormalizedDateRange(days)
|
||||
setFilters({
|
||||
...EMPTY_DASHBOARD_FILTERS,
|
||||
start_timestamp: start,
|
||||
end_timestamp: end,
|
||||
time_granularity: DEFAULT_TIME_GRANULARITY,
|
||||
})
|
||||
setSelectedRange(days)
|
||||
saveGranularity(DEFAULT_TIME_GRANULARITY)
|
||||
onReset()
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const handleChange = (
|
||||
field: keyof DashboardFilters,
|
||||
value: Date | string | undefined
|
||||
) => {
|
||||
setFilters((prev) => ({ ...prev, [field]: value }))
|
||||
if (field === 'start_timestamp' || field === 'end_timestamp')
|
||||
setSelectedRange(null)
|
||||
if (field === 'time_granularity' && typeof value === 'string')
|
||||
saveGranularity(value as TimeGranularity)
|
||||
}
|
||||
|
||||
const handleQuickRange = (days: number) => {
|
||||
const { start, end } = getNormalizedDateRange(days)
|
||||
|
||||
setFilters((prev) => ({
|
||||
...prev,
|
||||
start_timestamp: start,
|
||||
end_timestamp: end,
|
||||
}))
|
||||
setSelectedRange(days)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant='outline' size='sm'>
|
||||
<Filter className='mr-2 h-4 w-4' />
|
||||
{t('Filter')}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className='flex max-h-[calc(100dvh-2rem)] flex-col sm:max-w-lg'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('Filter Dashboard Models')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t(
|
||||
'Set filters to customize your dashboard statistics and charts.'
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<ScrollArea className='flex-1 pr-4'>
|
||||
<div className='grid gap-4 py-4'>
|
||||
{/* Quick time range selection */}
|
||||
<div className='grid gap-2'>
|
||||
<Label className='flex items-center gap-2'>
|
||||
<Calendar className='h-4 w-4' />
|
||||
{t('Quick Range')}
|
||||
</Label>
|
||||
<div className='flex gap-2'>
|
||||
{TIME_RANGE_PRESETS.map((range) => (
|
||||
<Button
|
||||
key={range.days}
|
||||
type='button'
|
||||
size='sm'
|
||||
variant={
|
||||
selectedRange === range.days ? 'default' : 'outline'
|
||||
}
|
||||
onClick={() => handleQuickRange(range.days)}
|
||||
className={cn(
|
||||
'flex-1',
|
||||
selectedRange === range.days &&
|
||||
'ring-ring ring-2 ring-offset-2'
|
||||
)}
|
||||
>
|
||||
{t(range.label)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SectionDivider label={t('Custom Time Range')} />
|
||||
|
||||
{/* Custom time range */}
|
||||
<div className='grid gap-4'>
|
||||
<div className='grid gap-2'>
|
||||
<Label htmlFor='start_timestamp'>{t('Start Time')}</Label>
|
||||
<DateTimePicker
|
||||
value={filters.start_timestamp}
|
||||
onChange={(date) =>
|
||||
handleChange('start_timestamp', date || undefined)
|
||||
}
|
||||
placeholder={t('Select start time')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='grid gap-2'>
|
||||
<Label htmlFor='end_timestamp'>{t('End Time')}</Label>
|
||||
<DateTimePicker
|
||||
value={filters.end_timestamp}
|
||||
onChange={(date) =>
|
||||
handleChange('end_timestamp', date || undefined)
|
||||
}
|
||||
placeholder={t('Select end time')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SectionDivider label={t('Chart Settings')} />
|
||||
|
||||
<div className='grid gap-2'>
|
||||
<Label htmlFor='time_granularity'>{t('Time Granularity')}</Label>
|
||||
<Select
|
||||
value={filters.time_granularity}
|
||||
onValueChange={(value) =>
|
||||
handleChange('time_granularity', value as TimeGranularity)
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t('Select time granularity')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{TIME_GRANULARITY_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{t(option.label)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Admin-only fields */}
|
||||
{isAdmin && (
|
||||
<>
|
||||
<SectionDivider label={t('Admin Only')} />
|
||||
|
||||
<div className='grid gap-2'>
|
||||
<Label htmlFor='username'>{t('Username')}</Label>
|
||||
<Input
|
||||
id='username'
|
||||
placeholder={t('Filter by username')}
|
||||
value={filters.username}
|
||||
onChange={(e) => handleChange('username', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<DialogFooter>
|
||||
<Button onClick={handleReset} variant='outline' type='button'>
|
||||
<RotateCcw className='mr-2 h-4 w-4' />
|
||||
{t('Reset')}
|
||||
</Button>
|
||||
<Button onClick={handleApply} type='submit'>
|
||||
<Search className='mr-2 h-4 w-4' />
|
||||
{t('Apply Filters')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { formatDateTimeObject } from '@/lib/time'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Markdown } from '@/components/ui/markdown'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
|
||||
interface AnnouncementDetailModalProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
announcement: {
|
||||
title?: string
|
||||
content?: string
|
||||
tag?: string
|
||||
publishDate?: string
|
||||
extra?: string
|
||||
} | null
|
||||
}
|
||||
|
||||
export function AnnouncementDetailModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
announcement,
|
||||
}: AnnouncementDetailModalProps) {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className='sm:max-w-lg'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('Announcement Details')}</DialogTitle>
|
||||
{announcement?.publishDate && (
|
||||
<DialogDescription>
|
||||
{t('Published:')}{' '}
|
||||
{formatDateTimeObject(new Date(announcement.publishDate))}
|
||||
</DialogDescription>
|
||||
)}
|
||||
</DialogHeader>
|
||||
<ScrollArea className='max-h-[60vh] pr-4'>
|
||||
<div className='space-y-4'>
|
||||
{announcement?.content && (
|
||||
<div>
|
||||
<h4 className='mb-2 font-medium'>{t('Content')}</h4>
|
||||
<Markdown>{announcement.content}</Markdown>
|
||||
</div>
|
||||
)}
|
||||
{announcement?.extra && (
|
||||
<div>
|
||||
<h4 className='mb-2 font-medium'>
|
||||
{t('Additional Information')}
|
||||
</h4>
|
||||
<Markdown className='text-muted-foreground'>
|
||||
{announcement.extra}
|
||||
</Markdown>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
+97
@@ -0,0 +1,97 @@
|
||||
import { memo, useState } from 'react'
|
||||
import { Megaphone } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { getAnnouncementColorClass } from '@/lib/colors'
|
||||
import { formatDateTimeObject } from '@/lib/time'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { useAnnouncements } from '@/features/dashboard/hooks/use-status-data'
|
||||
import { getPreviewText } from '@/features/dashboard/lib'
|
||||
import type { AnnouncementItem } from '@/features/dashboard/types'
|
||||
import { PanelWrapper } from '../ui/panel-wrapper'
|
||||
import { AnnouncementDetailModal } from './announcement-detail-dialog'
|
||||
|
||||
const AnnouncementStatusDot = memo(function AnnouncementStatusDot(props: {
|
||||
type?: string
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'mt-1.5 inline-block size-2 shrink-0 rounded-full',
|
||||
getAnnouncementColorClass(props.type)
|
||||
)}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
export function AnnouncementsPanel() {
|
||||
const { t } = useTranslation()
|
||||
const { items: list, loading } = useAnnouncements()
|
||||
const [selectedAnnouncement, setSelectedAnnouncement] =
|
||||
useState<AnnouncementItem | null>(null)
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false)
|
||||
|
||||
const handleAnnouncementClick = (item: AnnouncementItem) => {
|
||||
setSelectedAnnouncement(item)
|
||||
setIsDialogOpen(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<PanelWrapper
|
||||
title={
|
||||
<span className='flex items-center gap-2'>
|
||||
<Megaphone className='text-muted-foreground/60 size-4' />
|
||||
{t('Announcements')}
|
||||
</span>
|
||||
}
|
||||
loading={loading}
|
||||
empty={!list.length}
|
||||
emptyMessage={t('No announcements at this time')}
|
||||
height='h-64'
|
||||
>
|
||||
<ScrollArea className='h-64'>
|
||||
<div className='-mx-4 sm:-mx-5'>
|
||||
{list.map((item: AnnouncementItem, idx: number) => {
|
||||
const key = item.id ?? `announcement-${idx}`
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
type='button'
|
||||
onClick={() => handleAnnouncementClick(item)}
|
||||
className={cn(
|
||||
'group hover:bg-muted/40 w-full px-4 py-3.5 text-left transition-colors sm:px-5',
|
||||
idx < list.length - 1 && 'border-border/60 border-b'
|
||||
)}
|
||||
>
|
||||
<div className='flex items-start gap-2.5'>
|
||||
<AnnouncementStatusDot type={item.type} />
|
||||
<div className='min-w-0 flex-1 space-y-1'>
|
||||
<p className='line-clamp-1 text-sm font-medium'>
|
||||
{getPreviewText(item.content)}
|
||||
</p>
|
||||
<div className='flex items-center justify-between'>
|
||||
{item.publishDate && (
|
||||
<time className='text-muted-foreground/60 text-xs'>
|
||||
{formatDateTimeObject(new Date(item.publishDate))}
|
||||
</time>
|
||||
)}
|
||||
<span className='text-muted-foreground/40 text-xs opacity-0 transition-opacity group-hover:opacity-100'>
|
||||
{t('Click for details')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<AnnouncementDetailModal
|
||||
open={isDialogOpen}
|
||||
onOpenChange={setIsDialogOpen}
|
||||
announcement={selectedAnnouncement}
|
||||
/>
|
||||
</PanelWrapper>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { Zap, ExternalLink, Gauge } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { getBgColorClass } from '@/lib/colors'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { CopyButton } from '@/components/copy-button'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import {
|
||||
getLatencyColorClass,
|
||||
openExternalSpeedTest,
|
||||
} from '@/features/dashboard/lib/api-info'
|
||||
import type { ApiInfoItem, PingStatus } from '@/features/dashboard/types'
|
||||
|
||||
interface ApiInfoItemProps {
|
||||
item: ApiInfoItem
|
||||
status: PingStatus
|
||||
onTest: (url: string) => void
|
||||
}
|
||||
|
||||
export function ApiInfoItemComponent(props: ApiInfoItemProps) {
|
||||
const { t } = useTranslation()
|
||||
const item = props.item
|
||||
const status = props.status
|
||||
|
||||
return (
|
||||
<div className='group hover:bg-muted/40 flex items-center justify-between gap-3 px-4 py-3 transition-colors sm:px-5'>
|
||||
<div className='flex min-w-0 flex-1 items-center gap-3'>
|
||||
<span
|
||||
className={cn(
|
||||
'inline-block size-2 shrink-0 rounded-full',
|
||||
getBgColorClass(item.color)
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className='flex min-w-0 flex-1 flex-col gap-0.5'>
|
||||
<div className='flex items-baseline gap-2'>
|
||||
<span className='font-mono text-sm font-semibold'>
|
||||
{item.route}
|
||||
</span>
|
||||
<span className='text-muted-foreground/60 hidden truncate text-xs md:inline'>
|
||||
{item.description}
|
||||
</span>
|
||||
</div>
|
||||
<span className='text-muted-foreground/40 truncate font-mono text-xs'>
|
||||
{item.url}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex shrink-0 items-center gap-2'>
|
||||
<div className='flex items-center'>
|
||||
{status.testing && (
|
||||
<StatusBadge
|
||||
label={t('Testing...')}
|
||||
variant='warning'
|
||||
className='animate-pulse'
|
||||
copyable={false}
|
||||
/>
|
||||
)}
|
||||
{status.latency !== null && !status.testing && (
|
||||
<StatusBadge
|
||||
variant='success'
|
||||
label={`${status.latency}${t('ms')}`}
|
||||
className={cn(
|
||||
'font-mono font-medium',
|
||||
getLatencyColorClass(status.latency)
|
||||
)}
|
||||
copyable={false}
|
||||
/>
|
||||
)}
|
||||
{status.error && (
|
||||
<StatusBadge label={t('N/A')} variant='neutral' copyable={false} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='flex items-center gap-0.5'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={() => props.onTest(item.url)}
|
||||
disabled={status.testing}
|
||||
className='size-7 p-0'
|
||||
title={t('Test Latency')}
|
||||
>
|
||||
<Zap
|
||||
className={cn('size-3.5', status.testing && 'animate-pulse')}
|
||||
/>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={() => openExternalSpeedTest(item.url)}
|
||||
className='size-7 p-0'
|
||||
title={t('External Speed Test')}
|
||||
>
|
||||
<Gauge className='size-3.5' />
|
||||
</Button>
|
||||
|
||||
<CopyButton
|
||||
value={item.url}
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='size-7 p-0'
|
||||
iconClassName='size-3.5'
|
||||
tooltip={t('Copy URL')}
|
||||
aria-label={t('Copy URL')}
|
||||
/>
|
||||
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
asChild
|
||||
className='size-7 p-0'
|
||||
title={t('Open in New Tab')}
|
||||
>
|
||||
<a href={item.url} target='_blank' rel='noreferrer'>
|
||||
<ExternalLink className='size-3.5' />
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useState, useCallback } from 'react'
|
||||
import { Route } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { useApiInfo } from '@/features/dashboard/hooks/use-status-data'
|
||||
import {
|
||||
testUrlLatency,
|
||||
getDefaultPingStatus,
|
||||
} from '@/features/dashboard/lib/api-info'
|
||||
import type { PingStatusMap, ApiInfoItem } from '@/features/dashboard/types'
|
||||
import { PanelWrapper } from '../ui/panel-wrapper'
|
||||
import { ApiInfoItemComponent } from './api-info-item'
|
||||
|
||||
export function ApiInfoPanel() {
|
||||
const { t } = useTranslation()
|
||||
const { items: list, loading } = useApiInfo()
|
||||
const [pingStatus, setPingStatus] = useState<PingStatusMap>({})
|
||||
|
||||
const handleTest = useCallback(async (url: string) => {
|
||||
setPingStatus((prev) => ({
|
||||
...prev,
|
||||
[url]: { latency: null, testing: true, error: false },
|
||||
}))
|
||||
|
||||
const result = await testUrlLatency(url)
|
||||
setPingStatus((prev) => ({ ...prev, [url]: result }))
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<PanelWrapper
|
||||
title={
|
||||
<span className='flex items-center gap-2'>
|
||||
<Route className='text-muted-foreground/60 size-4' />
|
||||
{t('API Info')}
|
||||
</span>
|
||||
}
|
||||
loading={loading}
|
||||
empty={!list.length}
|
||||
emptyMessage={t('No API routes configured')}
|
||||
height='h-64'
|
||||
>
|
||||
<ScrollArea className='h-64'>
|
||||
<div className='-mx-4 sm:-mx-5'>
|
||||
{list.map((item: ApiInfoItem, idx: number) => (
|
||||
<div
|
||||
key={item.url}
|
||||
className={
|
||||
idx < list.length - 1 ? 'border-border/60 border-b' : ''
|
||||
}
|
||||
>
|
||||
<ApiInfoItemComponent
|
||||
item={item}
|
||||
status={pingStatus[item.url] || getDefaultPingStatus()}
|
||||
onTest={handleTest}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</PanelWrapper>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { HelpCircle } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from '@/components/ui/accordion'
|
||||
import { Markdown } from '@/components/ui/markdown'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { useFAQ } from '@/features/dashboard/hooks/use-status-data'
|
||||
import type { FAQItem } from '@/features/dashboard/types'
|
||||
import { PanelWrapper } from '../ui/panel-wrapper'
|
||||
|
||||
export function FAQPanel() {
|
||||
const { t } = useTranslation()
|
||||
const { items: list, loading } = useFAQ()
|
||||
|
||||
return (
|
||||
<PanelWrapper
|
||||
title={
|
||||
<span className='flex items-center gap-2'>
|
||||
<HelpCircle className='text-muted-foreground/60 size-4' />
|
||||
{t('FAQ')}
|
||||
</span>
|
||||
}
|
||||
loading={loading}
|
||||
empty={!list.length}
|
||||
emptyMessage={t('No FAQ entries available')}
|
||||
height='h-80'
|
||||
>
|
||||
<ScrollArea className='h-80'>
|
||||
<Accordion type='single' collapsible className='w-full'>
|
||||
{list.map((item: FAQItem, idx: number) => {
|
||||
const key = item.id ?? `faq-${idx}`
|
||||
const value = `item-${key}`
|
||||
return (
|
||||
<AccordionItem
|
||||
key={key}
|
||||
value={value}
|
||||
className='border-border/60'
|
||||
>
|
||||
<AccordionTrigger className='text-start hover:no-underline'>
|
||||
<Markdown className='text-sm leading-relaxed font-semibold'>
|
||||
{item.question}
|
||||
</Markdown>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<Markdown className='text-muted-foreground/60 text-sm'>
|
||||
{item.answer}
|
||||
</Markdown>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
)
|
||||
})}
|
||||
</Accordion>
|
||||
</ScrollArea>
|
||||
</PanelWrapper>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { useMemo } from 'react'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { CreditCard } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useAuthStore } from '@/stores/auth-store'
|
||||
import { getCurrencyLabel, isCurrencyDisplayEnabled } from '@/lib/currency'
|
||||
import { formatNumber, formatQuota } from '@/lib/format'
|
||||
import { useStatus } from '@/hooks/use-status'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { StaggerContainer, StaggerItem } from '@/components/page-transition'
|
||||
import { useSummaryCardsConfig } from '@/features/dashboard/hooks/use-dashboard-config'
|
||||
import { StatCard } from '../ui/stat-card'
|
||||
|
||||
export function SummaryCards() {
|
||||
const { t } = useTranslation()
|
||||
const user = useAuthStore((state) => state.auth.user)
|
||||
const { status, loading } = useStatus()
|
||||
|
||||
const summaryValues = useMemo(() => {
|
||||
const remainQuota = Number(user?.quota ?? 0)
|
||||
const usedQuota = Number(user?.used_quota ?? 0)
|
||||
const requestCount = Number(user?.request_count ?? 0)
|
||||
|
||||
return {
|
||||
remainDisplay: formatQuota(remainQuota),
|
||||
usedDisplay: formatQuota(usedQuota),
|
||||
requestCountDisplay: formatNumber(requestCount),
|
||||
}
|
||||
}, [user])
|
||||
|
||||
const currencyEnabledFromStore = isCurrencyDisplayEnabled()
|
||||
const statusCurrencyFlag =
|
||||
typeof status?.display_in_currency === 'boolean'
|
||||
? Boolean(status.display_in_currency)
|
||||
: undefined
|
||||
const currencyEnabled =
|
||||
statusCurrencyFlag !== undefined
|
||||
? statusCurrencyFlag
|
||||
: currencyEnabledFromStore
|
||||
const currencyLabel = currencyEnabled ? getCurrencyLabel() : 'Tokens'
|
||||
|
||||
const items = useSummaryCardsConfig({
|
||||
...summaryValues,
|
||||
currencyEnabled,
|
||||
currencyLabel,
|
||||
}).map((config, index) => ({
|
||||
title: config.title,
|
||||
value: config.value,
|
||||
desc: config.description,
|
||||
icon: config.icon,
|
||||
isBalance: index === 0,
|
||||
}))
|
||||
|
||||
return (
|
||||
<div className='overflow-hidden rounded-lg border'>
|
||||
<StaggerContainer className='grid sm:grid-cols-2 lg:grid-cols-3'>
|
||||
{items.map((it, idx) => (
|
||||
<StaggerItem
|
||||
key={it.title}
|
||||
className={`px-4 sm:px-5 ${
|
||||
idx > 0 ? 'border-t sm:border-t-0 sm:border-l' : ''
|
||||
}`}
|
||||
>
|
||||
<StatCard
|
||||
title={it.title}
|
||||
value={it.value}
|
||||
description={it.desc}
|
||||
icon={it.icon}
|
||||
loading={loading}
|
||||
action={
|
||||
it.isBalance ? (
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
className='h-6 gap-1 px-2 text-xs'
|
||||
asChild
|
||||
>
|
||||
<Link to='/wallet'>
|
||||
<CreditCard className='size-3' />
|
||||
{t('Recharge')}
|
||||
</Link>
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</StaggerItem>
|
||||
))}
|
||||
</StaggerContainer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import { memo, useEffect, useState } from 'react'
|
||||
import { Activity, RotateCw } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { getUptimeStatus } from '@/features/dashboard/api'
|
||||
import type {
|
||||
UptimeGroupResult,
|
||||
UptimeMonitor,
|
||||
} from '@/features/dashboard/types'
|
||||
import { PanelWrapper } from '../ui/panel-wrapper'
|
||||
|
||||
const STATUS_COLOR_MAP: Record<number, string> = {
|
||||
1: 'bg-emerald-500',
|
||||
0: 'bg-red-500',
|
||||
2: 'bg-amber-500',
|
||||
3: 'bg-blue-500',
|
||||
}
|
||||
const DEFAULT_STATUS_COLOR = 'bg-muted-foreground/40'
|
||||
|
||||
const StatusDot = memo(function StatusDot(props: { status: number }) {
|
||||
const color = STATUS_COLOR_MAP[props.status] ?? DEFAULT_STATUS_COLOR
|
||||
return <span className={cn('inline-block size-2 rounded-full', color)} />
|
||||
})
|
||||
|
||||
export function UptimePanel() {
|
||||
const { t } = useTranslation()
|
||||
const [groups, setGroups] = useState<UptimeGroupResult[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const abortController = new AbortController()
|
||||
|
||||
getUptimeStatus()
|
||||
.then((res) => {
|
||||
if (abortController.signal.aborted) return
|
||||
setGroups(res?.data || [])
|
||||
})
|
||||
.catch(() => {
|
||||
if (abortController.signal.aborted) return
|
||||
setGroups([])
|
||||
})
|
||||
.finally(() => {
|
||||
if (!abortController.signal.aborted) {
|
||||
setLoading(false)
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
abortController.abort()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleRefresh = () => {
|
||||
const abortController = new AbortController()
|
||||
setRefreshing(true)
|
||||
|
||||
getUptimeStatus()
|
||||
.then((res) => {
|
||||
if (abortController.signal.aborted) return
|
||||
setGroups(res?.data || [])
|
||||
})
|
||||
.catch(() => {
|
||||
if (abortController.signal.aborted) return
|
||||
setGroups([])
|
||||
})
|
||||
.finally(() => {
|
||||
if (!abortController.signal.aborted) {
|
||||
setRefreshing(false)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<PanelWrapper
|
||||
title={
|
||||
<span className='flex items-center gap-2'>
|
||||
<Activity className='text-muted-foreground/60 size-4' />
|
||||
{t('Uptime')}
|
||||
</span>
|
||||
}
|
||||
loading={loading}
|
||||
empty={!groups.length}
|
||||
emptyMessage={t('No uptime monitoring configured')}
|
||||
height='h-80'
|
||||
headerActions={
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={handleRefresh}
|
||||
disabled={refreshing}
|
||||
className='size-7 p-0'
|
||||
>
|
||||
<RotateCw
|
||||
className={cn('size-3.5', refreshing && 'animate-spin')}
|
||||
aria-label={t('Refresh')}
|
||||
/>
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<ScrollArea className='h-80'>
|
||||
<div className='-mx-4 space-y-0 sm:-mx-5'>
|
||||
{groups.map((group, groupIdx) => (
|
||||
<div key={group.categoryName}>
|
||||
<div className='bg-muted/30 border-border/60 border-b px-4 py-2 sm:px-5'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<h4 className='text-muted-foreground text-xs font-semibold tracking-wider uppercase'>
|
||||
{group.categoryName}
|
||||
</h4>
|
||||
<span className='text-muted-foreground/40 font-mono text-xs tabular-nums'>
|
||||
{group.monitors?.length || 0}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{group.monitors?.map(
|
||||
(monitor: UptimeMonitor, monitorIdx: number) => (
|
||||
<div
|
||||
key={monitor.name}
|
||||
className={cn(
|
||||
'hover:bg-muted/40 flex items-center justify-between px-4 py-2.5 transition-colors sm:px-5',
|
||||
monitorIdx < (group.monitors?.length || 0) - 1 &&
|
||||
'border-border/40 border-b',
|
||||
groupIdx < groups.length - 1 &&
|
||||
monitorIdx === (group.monitors?.length || 0) - 1 &&
|
||||
'border-border/60 border-b'
|
||||
)}
|
||||
>
|
||||
<div className='flex min-w-0 items-center gap-2.5'>
|
||||
<StatusDot status={monitor.status} />
|
||||
<span className='truncate text-sm'>{monitor.name}</span>
|
||||
{monitor.group && (
|
||||
<span className='text-muted-foreground/40 shrink-0 text-xs'>
|
||||
({monitor.group})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className='text-foreground shrink-0 font-mono text-sm font-semibold tabular-nums'>
|
||||
{((monitor.uptime ?? 0) * 100).toFixed(2)}%
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</PanelWrapper>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { type ReactNode } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
|
||||
interface PanelWrapperProps {
|
||||
title: ReactNode
|
||||
loading?: boolean
|
||||
empty?: boolean
|
||||
emptyMessage?: string
|
||||
height?: string
|
||||
headerActions?: ReactNode
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
export function PanelWrapper(props: PanelWrapperProps) {
|
||||
const { t } = useTranslation()
|
||||
const resolvedEmptyMessage = props.emptyMessage ?? t('No data available')
|
||||
const height = props.height ?? 'h-64'
|
||||
|
||||
if (props.loading) {
|
||||
return (
|
||||
<div className='overflow-hidden rounded-lg border'>
|
||||
<div className='border-b px-4 py-3 sm:px-5'>
|
||||
<div className='text-sm font-semibold'>{props.title}</div>
|
||||
</div>
|
||||
<div className='p-4 sm:p-5'>
|
||||
<Skeleton className={`w-full ${height}`} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (props.empty) {
|
||||
return (
|
||||
<div className='overflow-hidden rounded-lg border'>
|
||||
<div className='border-b px-4 py-3 sm:px-5'>
|
||||
<div className='text-sm font-semibold'>{props.title}</div>
|
||||
</div>
|
||||
<div
|
||||
className={`text-muted-foreground flex items-center justify-center text-sm ${height}`}
|
||||
>
|
||||
{resolvedEmptyMessage}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='overflow-hidden rounded-lg border'>
|
||||
<div className='border-b px-4 py-3 sm:px-5'>
|
||||
{props.headerActions ? (
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='text-sm font-semibold'>{props.title}</div>
|
||||
{props.headerActions}
|
||||
</div>
|
||||
) : (
|
||||
<div className='text-sm font-semibold'>{props.title}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className='p-4 sm:p-5'>{props.children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { type LucideIcon } from 'lucide-react'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
|
||||
interface StatCardProps {
|
||||
title: string
|
||||
value: string | number
|
||||
description: string
|
||||
icon: LucideIcon
|
||||
loading?: boolean
|
||||
error?: boolean
|
||||
action?: React.ReactNode
|
||||
}
|
||||
|
||||
export function StatCard(props: StatCardProps) {
|
||||
const Icon = props.icon
|
||||
|
||||
return (
|
||||
<div className='group flex flex-col gap-1.5 py-3'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='text-muted-foreground flex items-center gap-2 text-xs font-medium tracking-wider uppercase'>
|
||||
<Icon className='text-muted-foreground/60 size-3.5' />
|
||||
{props.title}
|
||||
</div>
|
||||
{props.action}
|
||||
</div>
|
||||
|
||||
{props.loading ? (
|
||||
<div className='space-y-1.5'>
|
||||
<Skeleton className='h-7 w-24' />
|
||||
<Skeleton className='h-3.5 w-32' />
|
||||
</div>
|
||||
) : props.error ? (
|
||||
<>
|
||||
<div className='text-muted-foreground font-mono text-2xl font-bold tracking-tight tabular-nums'>
|
||||
--
|
||||
</div>
|
||||
<p className='text-muted-foreground/60 text-xs'>
|
||||
{props.description}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className='text-foreground font-mono text-2xl font-bold tracking-tight tabular-nums'>
|
||||
{props.value}
|
||||
</div>
|
||||
<p className='text-muted-foreground/60 text-xs'>
|
||||
{props.description}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
import { useEffect, useMemo, useState, useRef, useCallback } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { VChart } from '@visactor/react-vchart'
|
||||
import { Users, Loader2 } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { getNormalizedDateRange, type TimeGranularity } from '@/lib/time'
|
||||
import { VCHART_OPTION } from '@/lib/vchart'
|
||||
import { useTheme } from '@/context/theme-provider'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { getUserQuotaDataByUsers } from '@/features/dashboard/api'
|
||||
import {
|
||||
TIME_GRANULARITY_OPTIONS,
|
||||
TIME_RANGE_PRESETS,
|
||||
} from '@/features/dashboard/constants'
|
||||
import {
|
||||
getDefaultDays,
|
||||
getSavedGranularity,
|
||||
saveGranularity,
|
||||
processUserChartData,
|
||||
} from '@/features/dashboard/lib'
|
||||
import type { ProcessedUserChartData } from '@/features/dashboard/types'
|
||||
|
||||
let themeManagerPromise: Promise<
|
||||
(typeof import('@visactor/vchart'))['ThemeManager']
|
||||
> | null = null
|
||||
|
||||
type UserChartTab = 'rank' | 'trend'
|
||||
|
||||
const CHART_TABS: {
|
||||
value: UserChartTab
|
||||
labelKey: string
|
||||
specKey: keyof ProcessedUserChartData
|
||||
}[] = [
|
||||
{
|
||||
value: 'rank',
|
||||
labelKey: 'User Consumption Ranking',
|
||||
specKey: 'spec_user_rank',
|
||||
},
|
||||
{
|
||||
value: 'trend',
|
||||
labelKey: 'User Consumption Trend',
|
||||
specKey: 'spec_user_trend',
|
||||
},
|
||||
]
|
||||
|
||||
export function UserCharts() {
|
||||
const { t } = useTranslation()
|
||||
const { resolvedTheme } = useTheme()
|
||||
const [activeTab, setActiveTab] = useState<UserChartTab>('rank')
|
||||
const [themeReady, setThemeReady] = useState(false)
|
||||
const themeManagerRef = useRef<
|
||||
(typeof import('@visactor/vchart'))['ThemeManager'] | null
|
||||
>(null)
|
||||
|
||||
const [timeGranularity, setTimeGranularity] = useState<TimeGranularity>(() =>
|
||||
getSavedGranularity()
|
||||
)
|
||||
const [selectedRange, setSelectedRange] = useState<number>(() =>
|
||||
getDefaultDays(timeGranularity)
|
||||
)
|
||||
const [timeRange, setTimeRange] = useState(() => {
|
||||
const days = getDefaultDays(timeGranularity)
|
||||
const { start, end } = getNormalizedDateRange(days)
|
||||
return {
|
||||
start_timestamp: Math.floor(start.getTime() / 1000),
|
||||
end_timestamp: Math.floor(end.getTime() / 1000),
|
||||
}
|
||||
})
|
||||
|
||||
const handleRangeChange = useCallback((days: number) => {
|
||||
setSelectedRange(days)
|
||||
const { start, end } = getNormalizedDateRange(days)
|
||||
setTimeRange({
|
||||
start_timestamp: Math.floor(start.getTime() / 1000),
|
||||
end_timestamp: Math.floor(end.getTime() / 1000),
|
||||
})
|
||||
}, [])
|
||||
|
||||
const handleGranularityChange = useCallback(
|
||||
(g: TimeGranularity) => {
|
||||
setTimeGranularity(g)
|
||||
saveGranularity(g)
|
||||
const days = getDefaultDays(g)
|
||||
if (days !== selectedRange) {
|
||||
handleRangeChange(days)
|
||||
}
|
||||
},
|
||||
[selectedRange, handleRangeChange]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const updateTheme = async () => {
|
||||
setThemeReady(false)
|
||||
if (!themeManagerPromise) {
|
||||
themeManagerPromise = import('@visactor/vchart').then(
|
||||
(m) => m.ThemeManager
|
||||
)
|
||||
}
|
||||
const ThemeManager = await themeManagerPromise
|
||||
themeManagerRef.current = ThemeManager
|
||||
ThemeManager.setCurrentTheme(resolvedTheme === 'dark' ? 'dark' : 'light')
|
||||
setThemeReady(true)
|
||||
}
|
||||
updateTheme()
|
||||
}, [resolvedTheme])
|
||||
|
||||
const { data: userData, isLoading } = useQuery({
|
||||
queryKey: ['dashboard', 'user-quota', timeRange],
|
||||
queryFn: () => getUserQuotaDataByUsers(timeRange),
|
||||
select: (res) => (res.success ? res.data : []),
|
||||
staleTime: 60_000,
|
||||
})
|
||||
|
||||
const chartData = useMemo(
|
||||
() =>
|
||||
processUserChartData(
|
||||
isLoading ? [] : (userData ?? []),
|
||||
timeGranularity,
|
||||
t
|
||||
),
|
||||
[userData, isLoading, timeGranularity, t]
|
||||
)
|
||||
|
||||
const activeSpec = CHART_TABS.find((tab) => tab.value === activeTab)
|
||||
const spec = activeSpec ? chartData[activeSpec.specKey] : null
|
||||
|
||||
return (
|
||||
<div className='space-y-4'>
|
||||
{/* Toolbar: time range presets + granularity */}
|
||||
<div className='flex flex-wrap items-center gap-2'>
|
||||
<div className='flex items-center gap-1.5 rounded-md border p-0.5'>
|
||||
{TIME_RANGE_PRESETS.map((preset) => (
|
||||
<button
|
||||
key={preset.days}
|
||||
type='button'
|
||||
onClick={() => handleRangeChange(preset.days)}
|
||||
className={`rounded-[5px] px-2.5 py-1 text-xs font-medium transition-colors ${
|
||||
selectedRange === preset.days
|
||||
? 'bg-primary text-primary-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
{t(preset.label)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className='flex items-center gap-1.5 rounded-md border p-0.5'>
|
||||
{TIME_GRANULARITY_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type='button'
|
||||
onClick={() =>
|
||||
handleGranularityChange(opt.value as TimeGranularity)
|
||||
}
|
||||
className={`rounded-[5px] px-2.5 py-1 text-xs font-medium transition-colors ${
|
||||
timeGranularity === opt.value
|
||||
? 'bg-primary text-primary-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
{t(opt.label)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{isLoading && (
|
||||
<Loader2 className='text-muted-foreground size-4 animate-spin' />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Chart card */}
|
||||
<div className='overflow-hidden rounded-lg border'>
|
||||
<div className='flex w-full flex-col gap-3 border-b px-4 py-3 sm:px-5 lg:flex-row lg:items-center lg:justify-between'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Users className='text-muted-foreground/60 size-4' />
|
||||
<div className='text-sm font-semibold'>{t('User Analytics')}</div>
|
||||
</div>
|
||||
|
||||
<div className='bg-muted/60 inline-flex h-8 rounded-md border p-0.5'>
|
||||
{CHART_TABS.map((tab) => (
|
||||
<button
|
||||
key={tab.value}
|
||||
type='button'
|
||||
onClick={() => setActiveTab(tab.value)}
|
||||
className={`rounded-[5px] px-3 text-xs font-medium transition-colors ${
|
||||
activeTab === tab.value
|
||||
? 'bg-background text-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
{t(tab.labelKey)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='h-96 p-2'>
|
||||
{isLoading ? (
|
||||
<Skeleton className='h-full w-full' />
|
||||
) : (
|
||||
themeReady &&
|
||||
spec && (
|
||||
<VChart
|
||||
key={`user-${activeTab}-${resolvedTheme}`}
|
||||
spec={{
|
||||
...spec,
|
||||
theme: resolvedTheme === 'dark' ? 'dark' : 'light',
|
||||
background: 'transparent',
|
||||
}}
|
||||
option={VCHART_OPTION}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
import type { DashboardFilters } from './types'
|
||||
|
||||
export const TIME_GRANULARITY_STORAGE_KEY = 'data_export_default_time'
|
||||
export const DEFAULT_TIME_GRANULARITY = 'hour' as const
|
||||
export const MAX_CHART_TREND_POINTS = 7
|
||||
|
||||
export const TIME_RANGE_BY_GRANULARITY = {
|
||||
hour: 1,
|
||||
day: 7,
|
||||
week: 30,
|
||||
} as const
|
||||
|
||||
export const TIME_GRANULARITY_OPTIONS = [
|
||||
{ label: 'Hour', value: 'hour' },
|
||||
{ label: 'Day', value: 'day' },
|
||||
{ label: 'Week', value: 'week' },
|
||||
] as const
|
||||
|
||||
export const TIME_RANGE_PRESETS = [
|
||||
{ label: '1 Day', days: 1 },
|
||||
{ label: '7 Days', days: 7 },
|
||||
{ label: '14 Days', days: 14 },
|
||||
{ label: '29 Days', days: 29 },
|
||||
] as const
|
||||
|
||||
export const EMPTY_DASHBOARD_FILTERS: DashboardFilters = {
|
||||
start_timestamp: undefined,
|
||||
end_timestamp: undefined,
|
||||
time_granularity: 'hour',
|
||||
username: '',
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import {
|
||||
Hash,
|
||||
Coins,
|
||||
Layers,
|
||||
Gauge,
|
||||
Zap,
|
||||
Wallet,
|
||||
TrendingUp,
|
||||
Activity,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { safeDivide } from '@/features/dashboard/lib'
|
||||
|
||||
interface StatCardConfig {
|
||||
key: string
|
||||
title: string
|
||||
description: string
|
||||
icon: LucideIcon
|
||||
getValue: (stat: Record<string, number>, days?: number) => number
|
||||
}
|
||||
|
||||
export function useModelStatCardsConfig(): StatCardConfig[] {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return [
|
||||
{
|
||||
key: 'count',
|
||||
title: t('Total Count'),
|
||||
description: t('Statistical count'),
|
||||
icon: Hash,
|
||||
getValue: (stat) => stat?.rpm ?? 0,
|
||||
},
|
||||
{
|
||||
key: 'quota',
|
||||
title: t('Total Quota'),
|
||||
description: t('Statistical quota'),
|
||||
icon: Coins,
|
||||
getValue: (stat) => stat?.quota ?? 0,
|
||||
},
|
||||
{
|
||||
key: 'tokens',
|
||||
title: t('Total Tokens'),
|
||||
description: t('Statistical tokens'),
|
||||
icon: Layers,
|
||||
getValue: (stat) => stat?.tpm ?? 0,
|
||||
},
|
||||
{
|
||||
key: 'avgRpm',
|
||||
title: t('Average RPM'),
|
||||
description: t('Requests per minute'),
|
||||
icon: Gauge,
|
||||
getValue: (stat, timeRangeMinutes = 1) =>
|
||||
safeDivide(stat?.rpm ?? 0, timeRangeMinutes),
|
||||
},
|
||||
{
|
||||
key: 'avgTpm',
|
||||
title: t('Average TPM'),
|
||||
description: t('Tokens per minute'),
|
||||
icon: Zap,
|
||||
getValue: (stat, timeRangeMinutes = 1) =>
|
||||
safeDivide(stat?.tpm ?? 0, timeRangeMinutes),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
export function useSummaryCardsConfig(totals: {
|
||||
remainDisplay: string
|
||||
usedDisplay: string
|
||||
requestCountDisplay: string
|
||||
currencyLabel: string
|
||||
currencyEnabled: boolean
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return [
|
||||
{
|
||||
key: 'balance',
|
||||
title: totals.currencyEnabled
|
||||
? `${t('Current Balance')} (${totals.currencyLabel})`
|
||||
: t('Current Balance'),
|
||||
value: totals.remainDisplay,
|
||||
description: totals.currencyEnabled
|
||||
? `${t('Remaining quota')} (${totals.currencyLabel})`
|
||||
: t('Remaining quota units'),
|
||||
icon: Wallet,
|
||||
},
|
||||
{
|
||||
key: 'usage',
|
||||
title: totals.currencyEnabled
|
||||
? `${t('Historical Usage')} (${totals.currencyLabel})`
|
||||
: t('Historical Usage'),
|
||||
value: totals.usedDisplay,
|
||||
description: totals.currencyEnabled
|
||||
? `${t('Total consumed')} (${totals.currencyLabel})`
|
||||
: t('Total consumed quota'),
|
||||
icon: TrendingUp,
|
||||
},
|
||||
{
|
||||
key: 'requests',
|
||||
title: t('Request Count'),
|
||||
value: totals.requestCountDisplay,
|
||||
description: t('Total requests made'),
|
||||
icon: Activity,
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useStatus } from '@/hooks/use-status'
|
||||
import type { AnnouncementItem, ApiInfoItem, FAQItem } from '../types'
|
||||
|
||||
/**
|
||||
* Get specific list from status data
|
||||
*/
|
||||
export function useStatusData<T = unknown>(
|
||||
enabledKey: string,
|
||||
dataKey: string
|
||||
): { items: T[]; loading: boolean } {
|
||||
const { status, loading } = useStatus()
|
||||
const enabled = status?.[enabledKey] ?? false
|
||||
const items = (enabled ? status?.[dataKey] || [] : []) as T[]
|
||||
|
||||
return { items, loading }
|
||||
}
|
||||
|
||||
/**
|
||||
* Get API info list
|
||||
*/
|
||||
export function useApiInfo() {
|
||||
return useStatusData<ApiInfoItem>('api_info_enabled', 'api_info')
|
||||
}
|
||||
|
||||
/**
|
||||
* Get announcements list
|
||||
*/
|
||||
export function useAnnouncements() {
|
||||
return useStatusData<AnnouncementItem>(
|
||||
'announcements_enabled',
|
||||
'announcements'
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get FAQ list
|
||||
*/
|
||||
export function useFAQ() {
|
||||
return useStatusData<FAQItem>('faq_enabled', 'faq')
|
||||
}
|
||||
+189
@@ -0,0 +1,189 @@
|
||||
import { useState, useCallback, lazy, Suspense } from 'react'
|
||||
import { getRouteApi } from '@tanstack/react-router'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { SectionPageLayout } from '@/components/layout'
|
||||
import {
|
||||
CardStaggerContainer,
|
||||
CardStaggerItem,
|
||||
FadeIn,
|
||||
} from '@/components/page-transition'
|
||||
import { ModelsFilter } from './components/models/models-filter-dialog'
|
||||
import { AnnouncementsPanel } from './components/overview/announcements-panel'
|
||||
import { ApiInfoPanel } from './components/overview/api-info-panel'
|
||||
import { FAQPanel } from './components/overview/faq-panel'
|
||||
import { SummaryCards } from './components/overview/summary-cards'
|
||||
import { UptimePanel } from './components/overview/uptime-panel'
|
||||
import { DEFAULT_TIME_GRANULARITY } from './constants'
|
||||
import {
|
||||
type DashboardSectionId,
|
||||
DASHBOARD_DEFAULT_SECTION,
|
||||
} from './section-registry'
|
||||
import { type DashboardFilters, type QuotaDataItem } from './types'
|
||||
|
||||
const route = getRouteApi('/_authenticated/dashboard/$section')
|
||||
|
||||
const LazyLogStatCards = lazy(() =>
|
||||
import('./components/models/log-stat-cards').then((m) => ({
|
||||
default: m.LogStatCards,
|
||||
}))
|
||||
)
|
||||
|
||||
const LazyModelCharts = lazy(() =>
|
||||
import('./components/models/model-charts').then((m) => ({
|
||||
default: m.ModelCharts,
|
||||
}))
|
||||
)
|
||||
|
||||
const LazyUserCharts = lazy(() =>
|
||||
import('./components/users/user-charts').then((m) => ({
|
||||
default: m.UserCharts,
|
||||
}))
|
||||
)
|
||||
|
||||
function LogStatCardsFallback() {
|
||||
return (
|
||||
<div className='overflow-hidden rounded-lg border'>
|
||||
<div className='divide-border/60 grid grid-cols-2 divide-x sm:grid-cols-3 lg:grid-cols-5'>
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className='px-4 py-3.5 sm:px-5 sm:py-4'>
|
||||
<Skeleton className='h-3.5 w-16' />
|
||||
<Skeleton className='mt-2 h-7 w-20' />
|
||||
<Skeleton className='mt-1.5 h-3.5 w-28' />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ModelChartsFallback() {
|
||||
return (
|
||||
<div className='overflow-hidden rounded-lg border'>
|
||||
<div className='flex items-center justify-between border-b px-4 py-3 sm:px-5'>
|
||||
<Skeleton className='h-5 w-32' />
|
||||
<Skeleton className='h-8 w-72' />
|
||||
</div>
|
||||
<div className='h-96 p-2'>
|
||||
<Skeleton className='h-full w-full' />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const SECTION_META: Record<
|
||||
DashboardSectionId,
|
||||
{ titleKey: string; descriptionKey: string }
|
||||
> = {
|
||||
overview: {
|
||||
titleKey: 'Overview',
|
||||
descriptionKey: 'View dashboard overview and statistics',
|
||||
},
|
||||
models: {
|
||||
titleKey: 'Models',
|
||||
descriptionKey: 'View model statistics and charts',
|
||||
},
|
||||
users: {
|
||||
titleKey: 'User Analytics',
|
||||
descriptionKey: 'View user consumption statistics and charts',
|
||||
},
|
||||
}
|
||||
|
||||
export function Dashboard() {
|
||||
const { t } = useTranslation()
|
||||
const params = route.useParams()
|
||||
const activeSection = (params.section ??
|
||||
DASHBOARD_DEFAULT_SECTION) as DashboardSectionId
|
||||
|
||||
const [modelFilters, setModelFilters] = useState<DashboardFilters>({})
|
||||
const [modelData, setModelData] = useState<QuotaDataItem[]>([])
|
||||
const [dataLoading, setDataLoading] = useState(false)
|
||||
|
||||
const handleFilterChange = useCallback((filters: DashboardFilters) => {
|
||||
setModelFilters(filters)
|
||||
}, [])
|
||||
|
||||
const handleResetFilters = useCallback(() => {
|
||||
setModelFilters({})
|
||||
}, [])
|
||||
|
||||
const handleDataUpdate = useCallback(
|
||||
(data: QuotaDataItem[], loading: boolean) => {
|
||||
setModelData(data)
|
||||
setDataLoading(loading)
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const meta = SECTION_META[activeSection] ?? SECTION_META.overview
|
||||
|
||||
return (
|
||||
<SectionPageLayout>
|
||||
<SectionPageLayout.Title>{t(meta.titleKey)}</SectionPageLayout.Title>
|
||||
<SectionPageLayout.Description>
|
||||
{t(meta.descriptionKey)}
|
||||
</SectionPageLayout.Description>
|
||||
{activeSection === 'models' && (
|
||||
<SectionPageLayout.Actions>
|
||||
<ModelsFilter
|
||||
onFilterChange={handleFilterChange}
|
||||
onReset={handleResetFilters}
|
||||
/>
|
||||
</SectionPageLayout.Actions>
|
||||
)}
|
||||
<SectionPageLayout.Content>
|
||||
<div className='space-y-4'>
|
||||
{activeSection === 'overview' && (
|
||||
<>
|
||||
<SummaryCards />
|
||||
<CardStaggerContainer className='grid grid-cols-1 gap-4 lg:grid-cols-2'>
|
||||
<CardStaggerItem>
|
||||
<ApiInfoPanel />
|
||||
</CardStaggerItem>
|
||||
<CardStaggerItem>
|
||||
<AnnouncementsPanel />
|
||||
</CardStaggerItem>
|
||||
<CardStaggerItem>
|
||||
<FAQPanel />
|
||||
</CardStaggerItem>
|
||||
<CardStaggerItem>
|
||||
<UptimePanel />
|
||||
</CardStaggerItem>
|
||||
</CardStaggerContainer>
|
||||
</>
|
||||
)}
|
||||
{activeSection === 'models' && (
|
||||
<>
|
||||
<FadeIn>
|
||||
<Suspense fallback={<LogStatCardsFallback />}>
|
||||
<LazyLogStatCards
|
||||
filters={modelFilters}
|
||||
onDataUpdate={handleDataUpdate}
|
||||
/>
|
||||
</Suspense>
|
||||
</FadeIn>
|
||||
<FadeIn delay={0.1}>
|
||||
<Suspense fallback={<ModelChartsFallback />}>
|
||||
<LazyModelCharts
|
||||
data={modelData}
|
||||
loading={dataLoading}
|
||||
timeGranularity={
|
||||
modelFilters.time_granularity || DEFAULT_TIME_GRANULARITY
|
||||
}
|
||||
/>
|
||||
</Suspense>
|
||||
</FadeIn>
|
||||
</>
|
||||
)}
|
||||
{activeSection === 'users' && (
|
||||
<FadeIn>
|
||||
<Suspense fallback={<ModelChartsFallback />}>
|
||||
<LazyUserCharts />
|
||||
</Suspense>
|
||||
</FadeIn>
|
||||
)}
|
||||
</div>
|
||||
</SectionPageLayout.Content>
|
||||
</SectionPageLayout>
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user