🚀 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:
@@ -0,0 +1,56 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
export const normalizeModelList = (models = []) =>
|
||||
Array.from(
|
||||
new Set(
|
||||
(models || []).map((model) => String(model || '').trim()).filter(Boolean),
|
||||
),
|
||||
);
|
||||
|
||||
export const parseUpstreamUpdateMeta = (settings) => {
|
||||
let parsed = null;
|
||||
if (settings && typeof settings === 'object') {
|
||||
parsed = settings;
|
||||
} else if (typeof settings === 'string') {
|
||||
try {
|
||||
parsed = JSON.parse(settings);
|
||||
} catch (error) {
|
||||
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,
|
||||
),
|
||||
pendingRemoveModels: normalizeModelList(
|
||||
parsed.upstream_model_update_last_removed_models,
|
||||
),
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,309 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import { useRef, useState } from 'react';
|
||||
import { API, showError, showInfo, showSuccess } from '../../helpers';
|
||||
import { normalizeModelList } from './upstreamUpdateUtils';
|
||||
|
||||
const getManualIgnoredModelCountFromSettings = (settings) => {
|
||||
let parsed = null;
|
||||
if (settings && typeof settings === 'object') {
|
||||
parsed = settings;
|
||||
} else if (typeof settings === 'string') {
|
||||
try {
|
||||
parsed = JSON.parse(settings);
|
||||
} catch (error) {
|
||||
parsed = null;
|
||||
}
|
||||
}
|
||||
if (!parsed || typeof parsed !== 'object') {
|
||||
return 0;
|
||||
}
|
||||
return normalizeModelList(parsed.upstream_model_update_ignored_models).length;
|
||||
};
|
||||
|
||||
export const useChannelUpstreamUpdates = ({ t, refresh }) => {
|
||||
const [showUpstreamUpdateModal, setShowUpstreamUpdateModal] = useState(false);
|
||||
const [upstreamUpdateChannel, setUpstreamUpdateChannel] = useState(null);
|
||||
const [upstreamUpdateAddModels, setUpstreamUpdateAddModels] = useState([]);
|
||||
const [upstreamUpdateRemoveModels, setUpstreamUpdateRemoveModels] = useState(
|
||||
[],
|
||||
);
|
||||
const [upstreamUpdatePreferredTab, setUpstreamUpdatePreferredTab] =
|
||||
useState('add');
|
||||
const [upstreamApplyLoading, setUpstreamApplyLoading] = useState(false);
|
||||
const [detectAllUpstreamUpdatesLoading, setDetectAllUpstreamUpdatesLoading] =
|
||||
useState(false);
|
||||
const [applyAllUpstreamUpdatesLoading, setApplyAllUpstreamUpdatesLoading] =
|
||||
useState(false);
|
||||
|
||||
const applyUpstreamUpdatesInFlightRef = useRef(false);
|
||||
const detectChannelUpstreamUpdatesInFlightRef = useRef(false);
|
||||
const detectAllUpstreamUpdatesInFlightRef = useRef(false);
|
||||
const applyAllUpstreamUpdatesInFlightRef = useRef(false);
|
||||
|
||||
const openUpstreamUpdateModal = (
|
||||
record,
|
||||
pendingAddModels = [],
|
||||
pendingRemoveModels = [],
|
||||
preferredTab = 'add',
|
||||
) => {
|
||||
const normalizedAddModels = normalizeModelList(pendingAddModels);
|
||||
const normalizedRemoveModels = normalizeModelList(pendingRemoveModels);
|
||||
if (
|
||||
!record?.id ||
|
||||
(normalizedAddModels.length === 0 && normalizedRemoveModels.length === 0)
|
||||
) {
|
||||
showInfo(t('该渠道暂无可处理的上游模型更新'));
|
||||
return;
|
||||
}
|
||||
setUpstreamUpdateChannel(record);
|
||||
setUpstreamUpdateAddModels(normalizedAddModels);
|
||||
setUpstreamUpdateRemoveModels(normalizedRemoveModels);
|
||||
const normalizedPreferredTab = preferredTab === 'remove' ? 'remove' : 'add';
|
||||
setUpstreamUpdatePreferredTab(normalizedPreferredTab);
|
||||
setShowUpstreamUpdateModal(true);
|
||||
};
|
||||
|
||||
const closeUpstreamUpdateModal = () => {
|
||||
setShowUpstreamUpdateModal(false);
|
||||
setUpstreamUpdateChannel(null);
|
||||
setUpstreamUpdateAddModels([]);
|
||||
setUpstreamUpdateRemoveModels([]);
|
||||
setUpstreamUpdatePreferredTab('add');
|
||||
};
|
||||
|
||||
const applyUpstreamUpdates = async ({
|
||||
addModels: selectedAddModels = [],
|
||||
removeModels: selectedRemoveModels = [],
|
||||
} = {}) => {
|
||||
if (applyUpstreamUpdatesInFlightRef.current) {
|
||||
showInfo(t('正在处理,请稍候'));
|
||||
return;
|
||||
}
|
||||
if (!upstreamUpdateChannel?.id) {
|
||||
closeUpstreamUpdateModal();
|
||||
return;
|
||||
}
|
||||
applyUpstreamUpdatesInFlightRef.current = true;
|
||||
setUpstreamApplyLoading(true);
|
||||
|
||||
try {
|
||||
const normalizedSelectedAddModels = normalizeModelList(selectedAddModels);
|
||||
const normalizedSelectedRemoveModels =
|
||||
normalizeModelList(selectedRemoveModels);
|
||||
const selectedAddSet = new Set(normalizedSelectedAddModels);
|
||||
const ignoreModels = upstreamUpdateAddModels.filter(
|
||||
(model) => !selectedAddSet.has(model),
|
||||
);
|
||||
|
||||
const res = await API.post(
|
||||
'/api/channel/upstream_updates/apply',
|
||||
{
|
||||
id: upstreamUpdateChannel.id,
|
||||
add_models: normalizedSelectedAddModels,
|
||||
ignore_models: ignoreModels,
|
||||
remove_models: normalizedSelectedRemoveModels,
|
||||
},
|
||||
{ skipErrorHandler: true },
|
||||
);
|
||||
const { success, message, data } = res.data || {};
|
||||
if (!success) {
|
||||
showError(message || t('操作失败'));
|
||||
return;
|
||||
}
|
||||
|
||||
const addedCount = data?.added_models?.length || 0;
|
||||
const removedCount = data?.removed_models?.length || 0;
|
||||
const totalIgnoredCount = getManualIgnoredModelCountFromSettings(
|
||||
data?.settings,
|
||||
);
|
||||
const ignoredCount = normalizeModelList(ignoreModels).length;
|
||||
showSuccess(
|
||||
t(
|
||||
'已处理上游模型更新:加入 {{added}} 个,删除 {{removed}} 个,本次忽略 {{ignored}} 个,当前已忽略模型 {{totalIgnored}} 个',
|
||||
{
|
||||
added: addedCount,
|
||||
removed: removedCount,
|
||||
ignored: ignoredCount,
|
||||
totalIgnored: totalIgnoredCount,
|
||||
},
|
||||
),
|
||||
);
|
||||
closeUpstreamUpdateModal();
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
showError(
|
||||
error?.response?.data?.message || error?.message || t('操作失败'),
|
||||
);
|
||||
} finally {
|
||||
applyUpstreamUpdatesInFlightRef.current = false;
|
||||
setUpstreamApplyLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const applyAllUpstreamUpdates = async () => {
|
||||
if (applyAllUpstreamUpdatesInFlightRef.current) {
|
||||
showInfo(t('正在批量处理,请稍候'));
|
||||
return;
|
||||
}
|
||||
applyAllUpstreamUpdatesInFlightRef.current = true;
|
||||
setApplyAllUpstreamUpdatesLoading(true);
|
||||
try {
|
||||
const res = await API.post(
|
||||
'/api/channel/upstream_updates/apply_all',
|
||||
{},
|
||||
{ skipErrorHandler: true },
|
||||
);
|
||||
const { success, message, data } = res.data || {};
|
||||
if (!success) {
|
||||
showError(message || t('批量处理失败'));
|
||||
return;
|
||||
}
|
||||
|
||||
const channelCount = data?.processed_channels || 0;
|
||||
const addedCount = data?.added_models || 0;
|
||||
const removedCount = data?.removed_models || 0;
|
||||
const failedCount = (data?.failed_channel_ids || []).length;
|
||||
showSuccess(
|
||||
t(
|
||||
'已批量处理上游模型更新:渠道 {{channels}} 个,加入 {{added}} 个,删除 {{removed}} 个,失败 {{fails}} 个',
|
||||
{
|
||||
channels: channelCount,
|
||||
added: addedCount,
|
||||
removed: removedCount,
|
||||
fails: failedCount,
|
||||
},
|
||||
),
|
||||
);
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
showError(
|
||||
error?.response?.data?.message || error?.message || t('批量处理失败'),
|
||||
);
|
||||
} finally {
|
||||
applyAllUpstreamUpdatesInFlightRef.current = false;
|
||||
setApplyAllUpstreamUpdatesLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const detectChannelUpstreamUpdates = async (channel) => {
|
||||
if (detectChannelUpstreamUpdatesInFlightRef.current) {
|
||||
showInfo(t('正在检测,请稍候'));
|
||||
return;
|
||||
}
|
||||
if (!channel?.id) {
|
||||
return;
|
||||
}
|
||||
detectChannelUpstreamUpdatesInFlightRef.current = true;
|
||||
try {
|
||||
const res = await API.post(
|
||||
'/api/channel/upstream_updates/detect',
|
||||
{
|
||||
id: channel.id,
|
||||
},
|
||||
{ skipErrorHandler: true },
|
||||
);
|
||||
const { success, message, data } = res.data || {};
|
||||
if (!success) {
|
||||
showError(message || t('检测失败'));
|
||||
return;
|
||||
}
|
||||
|
||||
const addCount = data?.add_models?.length || 0;
|
||||
const removeCount = data?.remove_models?.length || 0;
|
||||
showSuccess(
|
||||
t('检测完成:新增 {{add}} 个,删除 {{remove}} 个', {
|
||||
add: addCount,
|
||||
remove: removeCount,
|
||||
}),
|
||||
);
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
showError(
|
||||
error?.response?.data?.message || error?.message || t('检测失败'),
|
||||
);
|
||||
} finally {
|
||||
detectChannelUpstreamUpdatesInFlightRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
const detectAllUpstreamUpdates = async () => {
|
||||
if (detectAllUpstreamUpdatesInFlightRef.current) {
|
||||
showInfo(t('正在批量检测,请稍候'));
|
||||
return;
|
||||
}
|
||||
detectAllUpstreamUpdatesInFlightRef.current = true;
|
||||
setDetectAllUpstreamUpdatesLoading(true);
|
||||
try {
|
||||
const res = await API.post(
|
||||
'/api/channel/upstream_updates/detect_all',
|
||||
{},
|
||||
{ skipErrorHandler: true },
|
||||
);
|
||||
const { success, message, data } = res.data || {};
|
||||
if (!success) {
|
||||
showError(message || t('批量检测失败'));
|
||||
return;
|
||||
}
|
||||
|
||||
const channelCount = data?.processed_channels || 0;
|
||||
const addCount = data?.detected_add_models || 0;
|
||||
const removeCount = data?.detected_remove_models || 0;
|
||||
const failedCount = (data?.failed_channel_ids || []).length;
|
||||
showSuccess(
|
||||
t(
|
||||
'批量检测完成:渠道 {{channels}} 个,新增 {{add}} 个,删除 {{remove}} 个,失败 {{fails}} 个',
|
||||
{
|
||||
channels: channelCount,
|
||||
add: addCount,
|
||||
remove: removeCount,
|
||||
fails: failedCount,
|
||||
},
|
||||
),
|
||||
);
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
showError(
|
||||
error?.response?.data?.message || error?.message || t('批量检测失败'),
|
||||
);
|
||||
} finally {
|
||||
detectAllUpstreamUpdatesInFlightRef.current = false;
|
||||
setDetectAllUpstreamUpdatesLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
showUpstreamUpdateModal,
|
||||
setShowUpstreamUpdateModal,
|
||||
upstreamUpdateChannel,
|
||||
upstreamUpdateAddModels,
|
||||
upstreamUpdateRemoveModels,
|
||||
upstreamUpdatePreferredTab,
|
||||
upstreamApplyLoading,
|
||||
detectAllUpstreamUpdatesLoading,
|
||||
applyAllUpstreamUpdatesLoading,
|
||||
openUpstreamUpdateModal,
|
||||
closeUpstreamUpdateModal,
|
||||
applyUpstreamUpdates,
|
||||
applyAllUpstreamUpdates,
|
||||
detectChannelUpstreamUpdates,
|
||||
detectAllUpstreamUpdates,
|
||||
};
|
||||
};
|
||||
+1257
File diff suppressed because it is too large
Load Diff
+49
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { fetchTokenKeys, getServerAddress } from '../../helpers/token';
|
||||
import { showError } from '../../helpers';
|
||||
|
||||
export function useTokenKeys(id) {
|
||||
const [keys, setKeys] = useState([]);
|
||||
const [serverAddress, setServerAddress] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const loadAllData = async () => {
|
||||
const fetchedKeys = await fetchTokenKeys();
|
||||
if (fetchedKeys.length === 0) {
|
||||
showError('当前没有可用的启用令牌,请确认是否有令牌处于启用状态!');
|
||||
setTimeout(() => {
|
||||
window.location.href = '/console/token';
|
||||
}, 1500); // 延迟 1.5 秒后跳转
|
||||
}
|
||||
setKeys(fetchedKeys);
|
||||
setIsLoading(false);
|
||||
|
||||
const address = getServerAddress();
|
||||
setServerAddress(address);
|
||||
};
|
||||
|
||||
loadAllData();
|
||||
}, []);
|
||||
|
||||
return { keys, serverAddress, isLoading };
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
|
||||
/**
|
||||
* 检测容器宽度的 Hook
|
||||
* @returns {[ref, width]} 容器引用和当前宽度
|
||||
*/
|
||||
export const useContainerWidth = () => {
|
||||
const [width, setWidth] = useState(0);
|
||||
const ref = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
const element = ref.current;
|
||||
if (!element) return;
|
||||
|
||||
const resizeObserver = new ResizeObserver((entries) => {
|
||||
for (let entry of entries) {
|
||||
const { width: newWidth } = entry.contentRect;
|
||||
setWidth(newWidth);
|
||||
}
|
||||
});
|
||||
|
||||
resizeObserver.observe(element);
|
||||
|
||||
// 初始化宽度
|
||||
setWidth(element.getBoundingClientRect().width);
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return [ref, width];
|
||||
};
|
||||
+250
@@ -0,0 +1,250 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useContext, useCallback, useMemo } from 'react';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { UserContext } from '../../context/User';
|
||||
import { StatusContext } from '../../context/Status';
|
||||
import { useSetTheme, useTheme, useActualTheme } from '../../context/Theme';
|
||||
import { getLogo, getSystemName, API, showSuccess } from '../../helpers';
|
||||
import { normalizeLanguage } from '../../i18n/language';
|
||||
import { useIsMobile } from './useIsMobile';
|
||||
import { useSidebarCollapsed } from './useSidebarCollapsed';
|
||||
import { useMinimumLoadingTime } from './useMinimumLoadingTime';
|
||||
|
||||
export const useHeaderBar = ({ onMobileMenuToggle, drawerOpen }) => {
|
||||
const { t, i18n } = useTranslation();
|
||||
const [userState, userDispatch] = useContext(UserContext);
|
||||
const [statusState] = useContext(StatusContext);
|
||||
const isMobile = useIsMobile();
|
||||
const [collapsed, toggleCollapsed] = useSidebarCollapsed();
|
||||
const [logoLoaded, setLogoLoaded] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const [currentLang, setCurrentLang] = useState(normalizeLanguage(i18n.language));
|
||||
const location = useLocation();
|
||||
|
||||
const loading = statusState?.status === undefined;
|
||||
const isLoading = useMinimumLoadingTime(loading, 200);
|
||||
|
||||
const systemName = getSystemName();
|
||||
const logo = getLogo();
|
||||
const currentDate = new Date();
|
||||
const isNewYear = currentDate.getMonth() === 0 && currentDate.getDate() === 1;
|
||||
|
||||
const isSelfUseMode = statusState?.status?.self_use_mode_enabled || false;
|
||||
const docsLink = statusState?.status?.docs_link || '';
|
||||
const isDemoSiteMode = statusState?.status?.demo_site_enabled || false;
|
||||
|
||||
// 获取顶栏模块配置
|
||||
const headerNavModulesConfig = statusState?.status?.HeaderNavModules;
|
||||
|
||||
// 使用useMemo确保headerNavModules正确响应statusState变化
|
||||
const headerNavModules = useMemo(() => {
|
||||
if (headerNavModulesConfig) {
|
||||
try {
|
||||
const modules = JSON.parse(headerNavModulesConfig);
|
||||
|
||||
// 处理向后兼容性:如果pricing是boolean,转换为对象格式
|
||||
if (typeof modules.pricing === 'boolean') {
|
||||
modules.pricing = {
|
||||
enabled: modules.pricing,
|
||||
requireAuth: false, // 默认不需要登录鉴权
|
||||
};
|
||||
}
|
||||
|
||||
return modules;
|
||||
} catch (error) {
|
||||
console.error('解析顶栏模块配置失败:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}, [headerNavModulesConfig]);
|
||||
|
||||
// 获取模型广场权限配置
|
||||
const pricingRequireAuth = useMemo(() => {
|
||||
if (headerNavModules?.pricing) {
|
||||
return typeof headerNavModules.pricing === 'object'
|
||||
? headerNavModules.pricing.requireAuth
|
||||
: false; // 默认不需要登录
|
||||
}
|
||||
return false; // 默认不需要登录
|
||||
}, [headerNavModules]);
|
||||
|
||||
const isConsoleRoute = location.pathname.startsWith('/console');
|
||||
|
||||
const theme = useTheme();
|
||||
const actualTheme = useActualTheme();
|
||||
const setTheme = useSetTheme();
|
||||
|
||||
// Logo loading effect
|
||||
useEffect(() => {
|
||||
setLogoLoaded(false);
|
||||
if (!logo) return;
|
||||
const img = new Image();
|
||||
img.src = logo;
|
||||
img.onload = () => setLogoLoaded(true);
|
||||
}, [logo]);
|
||||
|
||||
// Send theme to iframe
|
||||
useEffect(() => {
|
||||
try {
|
||||
const iframe = document.querySelector('iframe');
|
||||
const cw = iframe && iframe.contentWindow;
|
||||
if (cw) {
|
||||
cw.postMessage({ themeMode: actualTheme }, '*');
|
||||
}
|
||||
} catch (e) {
|
||||
// Silently ignore cross-origin or access errors
|
||||
}
|
||||
}, [actualTheme]);
|
||||
|
||||
// Language change effect
|
||||
useEffect(() => {
|
||||
const handleLanguageChanged = (lng) => {
|
||||
const normalizedLang = normalizeLanguage(lng);
|
||||
setCurrentLang(normalizedLang);
|
||||
try {
|
||||
const iframe = document.querySelector('iframe');
|
||||
const cw = iframe && iframe.contentWindow;
|
||||
if (cw) {
|
||||
cw.postMessage({ lang: normalizedLang }, '*');
|
||||
}
|
||||
} catch (e) {
|
||||
// Silently ignore cross-origin or access errors
|
||||
}
|
||||
};
|
||||
|
||||
i18n.on('languageChanged', handleLanguageChanged);
|
||||
return () => {
|
||||
i18n.off('languageChanged', handleLanguageChanged);
|
||||
};
|
||||
}, [i18n]);
|
||||
|
||||
// Actions
|
||||
const logout = useCallback(async () => {
|
||||
await API.get('/api/user/logout');
|
||||
showSuccess(t('注销成功!'));
|
||||
userDispatch({ type: 'logout' });
|
||||
localStorage.removeItem('user');
|
||||
navigate('/login');
|
||||
}, [navigate, t, userDispatch]);
|
||||
|
||||
const handleLanguageChange = useCallback(
|
||||
async (lang) => {
|
||||
// Change language immediately for responsive UX
|
||||
const previousLang = normalizeLanguage(i18n.language);
|
||||
i18n.changeLanguage(lang);
|
||||
localStorage.setItem('i18nextLng', lang);
|
||||
|
||||
// If user is logged in, save preference to backend
|
||||
if (userState?.user?.id) {
|
||||
try {
|
||||
const res = await API.put('/api/user/self', {
|
||||
language: lang,
|
||||
});
|
||||
if (res.data.success) {
|
||||
// Keep user preference and local cache in sync so route changes
|
||||
// don't reapply an older remembered language.
|
||||
let settings = {};
|
||||
if (userState?.user?.setting) {
|
||||
try {
|
||||
settings = JSON.parse(userState.user.setting) || {};
|
||||
} catch (e) {
|
||||
settings = {};
|
||||
}
|
||||
}
|
||||
|
||||
settings.language = lang;
|
||||
const nextUser = {
|
||||
...userState.user,
|
||||
setting: JSON.stringify(settings),
|
||||
};
|
||||
|
||||
userDispatch({
|
||||
type: 'login',
|
||||
payload: nextUser,
|
||||
});
|
||||
localStorage.setItem('user', JSON.stringify(nextUser));
|
||||
}
|
||||
} catch (error) {
|
||||
if (previousLang) {
|
||||
i18n.changeLanguage(previousLang);
|
||||
localStorage.setItem('i18nextLng', previousLang);
|
||||
}
|
||||
console.error('Failed to save language preference:', error);
|
||||
}
|
||||
}
|
||||
},
|
||||
[i18n, userState, userDispatch],
|
||||
);
|
||||
|
||||
const handleThemeToggle = useCallback(
|
||||
(newTheme) => {
|
||||
if (
|
||||
!newTheme ||
|
||||
(newTheme !== 'light' && newTheme !== 'dark' && newTheme !== 'auto')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setTheme(newTheme);
|
||||
},
|
||||
[setTheme],
|
||||
);
|
||||
|
||||
const handleMobileMenuToggle = useCallback(() => {
|
||||
if (isMobile) {
|
||||
onMobileMenuToggle();
|
||||
} else {
|
||||
toggleCollapsed();
|
||||
}
|
||||
}, [isMobile, onMobileMenuToggle, toggleCollapsed]);
|
||||
|
||||
return {
|
||||
// State
|
||||
userState,
|
||||
statusState,
|
||||
isMobile,
|
||||
collapsed,
|
||||
logoLoaded,
|
||||
currentLang,
|
||||
location,
|
||||
isLoading,
|
||||
systemName,
|
||||
logo,
|
||||
isNewYear,
|
||||
isSelfUseMode,
|
||||
docsLink,
|
||||
isDemoSiteMode,
|
||||
isConsoleRoute,
|
||||
theme,
|
||||
drawerOpen,
|
||||
headerNavModules,
|
||||
pricingRequireAuth,
|
||||
|
||||
// Actions
|
||||
logout,
|
||||
handleLanguageChange,
|
||||
handleThemeToggle,
|
||||
handleMobileMenuToggle,
|
||||
navigate,
|
||||
t,
|
||||
};
|
||||
};
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
export const MOBILE_BREAKPOINT = 768;
|
||||
|
||||
import { useSyncExternalStore } from 'react';
|
||||
|
||||
export const useIsMobile = () => {
|
||||
const query = `(max-width: ${MOBILE_BREAKPOINT - 1}px)`;
|
||||
return useSyncExternalStore(
|
||||
(callback) => {
|
||||
const mql = window.matchMedia(query);
|
||||
mql.addEventListener('change', callback);
|
||||
return () => mql.removeEventListener('change', callback);
|
||||
},
|
||||
() => window.matchMedia(query).matches,
|
||||
() => false,
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
|
||||
/**
|
||||
* 自定义 Hook:确保骨架屏至少显示指定的时间
|
||||
* @param {boolean} loading - 实际的加载状态
|
||||
* @param {number} minimumTime - 最小显示时间(毫秒),默认 1000ms
|
||||
* @returns {boolean} showSkeleton - 是否显示骨架屏的状态
|
||||
*/
|
||||
export const useMinimumLoadingTime = (loading, minimumTime = 1000) => {
|
||||
const [showSkeleton, setShowSkeleton] = useState(loading);
|
||||
const loadingStartRef = useRef(Date.now());
|
||||
|
||||
useEffect(() => {
|
||||
if (loading) {
|
||||
loadingStartRef.current = Date.now();
|
||||
setShowSkeleton(true);
|
||||
} else {
|
||||
const elapsed = Date.now() - loadingStartRef.current;
|
||||
const remaining = Math.max(0, minimumTime - elapsed);
|
||||
|
||||
if (remaining === 0) {
|
||||
setShowSkeleton(false);
|
||||
} else {
|
||||
const timer = setTimeout(() => setShowSkeleton(false), remaining);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
}, [loading, minimumTime]);
|
||||
|
||||
return showSkeleton;
|
||||
};
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react';
|
||||
|
||||
export const useNavigation = (t, docsLink, headerNavModules) => {
|
||||
const mainNavLinks = useMemo(() => {
|
||||
// 默认配置,如果没有传入配置则显示所有模块
|
||||
const defaultModules = {
|
||||
home: true,
|
||||
console: true,
|
||||
pricing: true,
|
||||
docs: true,
|
||||
about: true,
|
||||
};
|
||||
|
||||
// 使用传入的配置或默认配置
|
||||
const modules = headerNavModules || defaultModules;
|
||||
|
||||
const allLinks = [
|
||||
{
|
||||
text: t('首页'),
|
||||
itemKey: 'home',
|
||||
to: '/',
|
||||
},
|
||||
{
|
||||
text: t('控制台'),
|
||||
itemKey: 'console',
|
||||
to: '/console',
|
||||
},
|
||||
{
|
||||
text: t('模型广场'),
|
||||
itemKey: 'pricing',
|
||||
to: '/pricing',
|
||||
},
|
||||
...(docsLink
|
||||
? [
|
||||
{
|
||||
text: t('文档'),
|
||||
itemKey: 'docs',
|
||||
isExternal: true,
|
||||
externalLink: docsLink,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
text: t('关于'),
|
||||
itemKey: 'about',
|
||||
to: '/about',
|
||||
},
|
||||
];
|
||||
|
||||
// 根据配置过滤导航链接
|
||||
return allLinks.filter((link) => {
|
||||
if (link.itemKey === 'docs') {
|
||||
return docsLink && modules.docs;
|
||||
}
|
||||
if (link.itemKey === 'pricing') {
|
||||
// 支持新的pricing配置格式
|
||||
return typeof modules.pricing === 'object'
|
||||
? modules.pricing.enabled
|
||||
: modules.pricing;
|
||||
}
|
||||
return modules[link.itemKey] === true;
|
||||
});
|
||||
}, [t, docsLink, headerNavModules]);
|
||||
|
||||
return {
|
||||
mainNavLinks,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,94 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
export const useNotifications = (statusState) => {
|
||||
const [noticeVisible, setNoticeVisible] = useState(false);
|
||||
const [unreadCount, setUnreadCount] = useState(0);
|
||||
|
||||
const announcements = statusState?.status?.announcements || [];
|
||||
|
||||
// Helper functions
|
||||
const getAnnouncementKey = (a) =>
|
||||
`${a?.publishDate || ''}-${(a?.content || '').slice(0, 30)}`;
|
||||
|
||||
const calculateUnreadCount = () => {
|
||||
if (!announcements.length) return 0;
|
||||
let readKeys = [];
|
||||
try {
|
||||
readKeys = JSON.parse(localStorage.getItem('notice_read_keys')) || [];
|
||||
} catch (_) {
|
||||
readKeys = [];
|
||||
}
|
||||
const readSet = new Set(readKeys);
|
||||
return announcements.filter((a) => !readSet.has(getAnnouncementKey(a)))
|
||||
.length;
|
||||
};
|
||||
|
||||
const getUnreadKeys = () => {
|
||||
if (!announcements.length) return [];
|
||||
let readKeys = [];
|
||||
try {
|
||||
readKeys = JSON.parse(localStorage.getItem('notice_read_keys')) || [];
|
||||
} catch (_) {
|
||||
readKeys = [];
|
||||
}
|
||||
const readSet = new Set(readKeys);
|
||||
return announcements
|
||||
.filter((a) => !readSet.has(getAnnouncementKey(a)))
|
||||
.map(getAnnouncementKey);
|
||||
};
|
||||
|
||||
// Effects
|
||||
useEffect(() => {
|
||||
setUnreadCount(calculateUnreadCount());
|
||||
}, [announcements]);
|
||||
|
||||
// Actions
|
||||
const handleNoticeOpen = () => {
|
||||
setNoticeVisible(true);
|
||||
};
|
||||
|
||||
const handleNoticeClose = () => {
|
||||
setNoticeVisible(false);
|
||||
if (announcements.length) {
|
||||
let readKeys = [];
|
||||
try {
|
||||
readKeys = JSON.parse(localStorage.getItem('notice_read_keys')) || [];
|
||||
} catch (_) {
|
||||
readKeys = [];
|
||||
}
|
||||
const mergedKeys = Array.from(
|
||||
new Set([...readKeys, ...announcements.map(getAnnouncementKey)]),
|
||||
);
|
||||
localStorage.setItem('notice_read_keys', JSON.stringify(mergedKeys));
|
||||
}
|
||||
setUnreadCount(0);
|
||||
};
|
||||
|
||||
return {
|
||||
noticeVisible,
|
||||
unreadCount,
|
||||
announcements,
|
||||
handleNoticeOpen,
|
||||
handleNoticeClose,
|
||||
getUnreadKeys,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,274 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { SecureVerificationService } from '../../services/secureVerification';
|
||||
import { showError, showSuccess } from '../../helpers';
|
||||
import { isVerificationRequiredError } from '../../helpers/secureApiCall';
|
||||
|
||||
/**
|
||||
* 通用安全验证 Hook
|
||||
* @param {Object} options - 配置选项
|
||||
* @param {Function} options.onSuccess - 验证成功回调
|
||||
* @param {Function} options.onError - 验证失败回调
|
||||
* @param {string} options.successMessage - 成功提示消息
|
||||
* @param {boolean} options.autoReset - 验证完成后是否自动重置状态,默认为 true
|
||||
*/
|
||||
export const useSecureVerification = ({
|
||||
onSuccess,
|
||||
onError,
|
||||
successMessage,
|
||||
autoReset = true,
|
||||
} = {}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// 验证方式可用性状态
|
||||
const [verificationMethods, setVerificationMethods] = useState({
|
||||
has2FA: false,
|
||||
hasPasskey: false,
|
||||
passkeySupported: false,
|
||||
});
|
||||
|
||||
// 模态框状态
|
||||
const [isModalVisible, setIsModalVisible] = useState(false);
|
||||
|
||||
// 当前验证状态
|
||||
const [verificationState, setVerificationState] = useState({
|
||||
method: null, // '2fa' | 'passkey'
|
||||
loading: false,
|
||||
code: '',
|
||||
apiCall: null,
|
||||
});
|
||||
|
||||
// 检查可用的验证方式
|
||||
const checkVerificationMethods = useCallback(async () => {
|
||||
const methods =
|
||||
await SecureVerificationService.checkAvailableVerificationMethods();
|
||||
setVerificationMethods(methods);
|
||||
return methods;
|
||||
}, []);
|
||||
|
||||
// 初始化时检查验证方式
|
||||
useEffect(() => {
|
||||
checkVerificationMethods();
|
||||
}, [checkVerificationMethods]);
|
||||
|
||||
// 重置状态
|
||||
const resetState = useCallback(() => {
|
||||
setVerificationState({
|
||||
method: null,
|
||||
loading: false,
|
||||
code: '',
|
||||
apiCall: null,
|
||||
});
|
||||
setIsModalVisible(false);
|
||||
}, []);
|
||||
|
||||
// 开始验证流程
|
||||
const startVerification = useCallback(
|
||||
async (apiCall, options = {}) => {
|
||||
const { preferredMethod, title, description } = options;
|
||||
|
||||
// 检查验证方式
|
||||
const methods = await checkVerificationMethods();
|
||||
|
||||
if (!methods.has2FA && !methods.hasPasskey) {
|
||||
const errorMessage = t('您需要先启用两步验证或 Passkey 才能执行此操作');
|
||||
showError(errorMessage);
|
||||
onError?.(new Error(errorMessage));
|
||||
return false;
|
||||
}
|
||||
|
||||
// 设置默认验证方式
|
||||
let defaultMethod = preferredMethod;
|
||||
if (!defaultMethod) {
|
||||
if (methods.hasPasskey && methods.passkeySupported) {
|
||||
defaultMethod = 'passkey';
|
||||
} else if (methods.has2FA) {
|
||||
defaultMethod = '2fa';
|
||||
}
|
||||
}
|
||||
|
||||
setVerificationState((prev) => ({
|
||||
...prev,
|
||||
method: defaultMethod,
|
||||
apiCall,
|
||||
title,
|
||||
description,
|
||||
}));
|
||||
setIsModalVisible(true);
|
||||
|
||||
return true;
|
||||
},
|
||||
[checkVerificationMethods, onError, t],
|
||||
);
|
||||
|
||||
// 执行验证
|
||||
const executeVerification = useCallback(
|
||||
async (method, code = '') => {
|
||||
if (!verificationState.apiCall) {
|
||||
showError(t('验证配置错误'));
|
||||
return;
|
||||
}
|
||||
|
||||
setVerificationState((prev) => ({ ...prev, loading: true }));
|
||||
|
||||
try {
|
||||
// 先调用验证 API,成功后后端会设置 session
|
||||
await SecureVerificationService.verify(method, code);
|
||||
|
||||
// 验证成功,调用业务 API(此时中间件会通过)
|
||||
const result = await verificationState.apiCall();
|
||||
|
||||
// 显示成功消息
|
||||
if (successMessage) {
|
||||
showSuccess(successMessage);
|
||||
}
|
||||
|
||||
// 调用成功回调
|
||||
onSuccess?.(result, method);
|
||||
|
||||
// 自动重置状态
|
||||
if (autoReset) {
|
||||
resetState();
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
showError(error.message || t('验证失败,请重试'));
|
||||
onError?.(error);
|
||||
throw error;
|
||||
} finally {
|
||||
setVerificationState((prev) => ({ ...prev, loading: false }));
|
||||
}
|
||||
},
|
||||
[
|
||||
verificationState.apiCall,
|
||||
successMessage,
|
||||
onSuccess,
|
||||
onError,
|
||||
autoReset,
|
||||
resetState,
|
||||
t,
|
||||
],
|
||||
);
|
||||
|
||||
// 设置验证码
|
||||
const setVerificationCode = useCallback((code) => {
|
||||
setVerificationState((prev) => ({ ...prev, code }));
|
||||
}, []);
|
||||
|
||||
// 切换验证方式
|
||||
const switchVerificationMethod = useCallback((method) => {
|
||||
setVerificationState((prev) => ({ ...prev, method, code: '' }));
|
||||
}, []);
|
||||
|
||||
// 取消验证
|
||||
const cancelVerification = useCallback(() => {
|
||||
resetState();
|
||||
}, [resetState]);
|
||||
|
||||
// 检查是否可以使用某种验证方式
|
||||
const canUseMethod = useCallback(
|
||||
(method) => {
|
||||
switch (method) {
|
||||
case '2fa':
|
||||
return verificationMethods.has2FA;
|
||||
case 'passkey':
|
||||
return (
|
||||
verificationMethods.hasPasskey &&
|
||||
verificationMethods.passkeySupported
|
||||
);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[verificationMethods],
|
||||
);
|
||||
|
||||
// 获取推荐的验证方式
|
||||
const getRecommendedMethod = useCallback(() => {
|
||||
if (
|
||||
verificationMethods.hasPasskey &&
|
||||
verificationMethods.passkeySupported
|
||||
) {
|
||||
return 'passkey';
|
||||
}
|
||||
if (verificationMethods.has2FA) {
|
||||
return '2fa';
|
||||
}
|
||||
return null;
|
||||
}, [verificationMethods]);
|
||||
|
||||
/**
|
||||
* 包装 API 调用,自动处理验证错误
|
||||
* 当 API 返回需要验证的错误时,自动弹出验证模态框
|
||||
* @param {Function} apiCall - API 调用函数
|
||||
* @param {Object} options - 验证选项(同 startVerification)
|
||||
* @returns {Promise<any>}
|
||||
*/
|
||||
const withVerification = useCallback(
|
||||
async (apiCall, options = {}) => {
|
||||
try {
|
||||
// 直接尝试调用 API
|
||||
return await apiCall();
|
||||
} catch (error) {
|
||||
// 检查是否是需要验证的错误
|
||||
if (isVerificationRequiredError(error)) {
|
||||
// 自动触发验证流程
|
||||
await startVerification(apiCall, options);
|
||||
// 不抛出错误,让验证模态框处理
|
||||
return null;
|
||||
}
|
||||
// 其他错误继续抛出
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[startVerification],
|
||||
);
|
||||
|
||||
return {
|
||||
// 状态
|
||||
isModalVisible,
|
||||
verificationMethods,
|
||||
verificationState,
|
||||
|
||||
// 方法
|
||||
startVerification,
|
||||
executeVerification,
|
||||
cancelVerification,
|
||||
resetState,
|
||||
setVerificationCode,
|
||||
switchVerificationMethod,
|
||||
checkVerificationMethods,
|
||||
|
||||
// 辅助方法
|
||||
canUseMethod,
|
||||
getRecommendedMethod,
|
||||
withVerification, // 新增:自动处理验证的包装函数
|
||||
|
||||
// 便捷属性
|
||||
hasAnyVerificationMethod:
|
||||
verificationMethods.has2FA || verificationMethods.hasPasskey,
|
||||
isLoading: verificationState.loading,
|
||||
currentMethod: verificationState.method,
|
||||
code: verificationState.code,
|
||||
};
|
||||
};
|
||||
+301
@@ -0,0 +1,301 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useMemo, useContext, useRef } from 'react';
|
||||
import { StatusContext } from '../../context/Status';
|
||||
import { API } from '../../helpers';
|
||||
|
||||
// 创建一个全局事件系统来同步所有useSidebar实例
|
||||
const sidebarEventTarget = new EventTarget();
|
||||
const SIDEBAR_REFRESH_EVENT = 'sidebar-refresh';
|
||||
|
||||
export const DEFAULT_ADMIN_CONFIG = {
|
||||
chat: {
|
||||
enabled: true,
|
||||
playground: true,
|
||||
chat: true,
|
||||
},
|
||||
console: {
|
||||
enabled: true,
|
||||
detail: true,
|
||||
token: true,
|
||||
log: true,
|
||||
midjourney: true,
|
||||
task: true,
|
||||
},
|
||||
personal: {
|
||||
enabled: true,
|
||||
topup: true,
|
||||
personal: true,
|
||||
},
|
||||
admin: {
|
||||
enabled: true,
|
||||
channel: true,
|
||||
models: true,
|
||||
deployment: true,
|
||||
redemption: true,
|
||||
user: true,
|
||||
subscription: true,
|
||||
setting: true,
|
||||
},
|
||||
};
|
||||
|
||||
const deepClone = (value) => JSON.parse(JSON.stringify(value));
|
||||
|
||||
export const mergeAdminConfig = (savedConfig) => {
|
||||
const merged = deepClone(DEFAULT_ADMIN_CONFIG);
|
||||
if (!savedConfig || typeof savedConfig !== 'object') return merged;
|
||||
|
||||
for (const [sectionKey, sectionConfig] of Object.entries(savedConfig)) {
|
||||
if (!sectionConfig || typeof sectionConfig !== 'object') continue;
|
||||
|
||||
if (!merged[sectionKey]) {
|
||||
merged[sectionKey] = { ...sectionConfig };
|
||||
continue;
|
||||
}
|
||||
|
||||
merged[sectionKey] = { ...merged[sectionKey], ...sectionConfig };
|
||||
}
|
||||
|
||||
return merged;
|
||||
};
|
||||
|
||||
export const useSidebar = () => {
|
||||
const [statusState] = useContext(StatusContext);
|
||||
const [userConfig, setUserConfig] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const instanceIdRef = useRef(null);
|
||||
const hasLoadedOnceRef = useRef(false);
|
||||
|
||||
if (!instanceIdRef.current) {
|
||||
const randomPart = Math.random().toString(16).slice(2);
|
||||
instanceIdRef.current = `sidebar-${Date.now()}-${randomPart}`;
|
||||
}
|
||||
|
||||
// 获取管理员配置
|
||||
const adminConfig = useMemo(() => {
|
||||
if (statusState?.status?.SidebarModulesAdmin) {
|
||||
try {
|
||||
const config = JSON.parse(statusState.status.SidebarModulesAdmin);
|
||||
return mergeAdminConfig(config);
|
||||
} catch (error) {
|
||||
return mergeAdminConfig(null);
|
||||
}
|
||||
}
|
||||
return mergeAdminConfig(null);
|
||||
}, [statusState?.status?.SidebarModulesAdmin]);
|
||||
|
||||
// 加载用户配置的通用方法
|
||||
const loadUserConfig = async ({ withLoading } = {}) => {
|
||||
const shouldShowLoader =
|
||||
typeof withLoading === 'boolean'
|
||||
? withLoading
|
||||
: !hasLoadedOnceRef.current;
|
||||
|
||||
try {
|
||||
if (shouldShowLoader) {
|
||||
setLoading(true);
|
||||
}
|
||||
|
||||
const res = await API.get('/api/user/self');
|
||||
if (res.data.success && res.data.data.sidebar_modules) {
|
||||
let config;
|
||||
// 检查sidebar_modules是字符串还是对象
|
||||
if (typeof res.data.data.sidebar_modules === 'string') {
|
||||
config = JSON.parse(res.data.data.sidebar_modules);
|
||||
} else {
|
||||
config = res.data.data.sidebar_modules;
|
||||
}
|
||||
setUserConfig(config);
|
||||
} else {
|
||||
// 当用户没有配置时,生成一个基于管理员配置的默认用户配置
|
||||
// 这样可以确保权限控制正确生效
|
||||
const defaultUserConfig = {};
|
||||
Object.keys(adminConfig).forEach((sectionKey) => {
|
||||
if (adminConfig[sectionKey]?.enabled) {
|
||||
defaultUserConfig[sectionKey] = { enabled: true };
|
||||
// 为每个管理员允许的模块设置默认值为true
|
||||
Object.keys(adminConfig[sectionKey]).forEach((moduleKey) => {
|
||||
if (
|
||||
moduleKey !== 'enabled' &&
|
||||
adminConfig[sectionKey][moduleKey]
|
||||
) {
|
||||
defaultUserConfig[sectionKey][moduleKey] = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
setUserConfig(defaultUserConfig);
|
||||
}
|
||||
} catch (error) {
|
||||
// 出错时也生成默认配置,而不是设置为空对象
|
||||
const defaultUserConfig = {};
|
||||
Object.keys(adminConfig).forEach((sectionKey) => {
|
||||
if (adminConfig[sectionKey]?.enabled) {
|
||||
defaultUserConfig[sectionKey] = { enabled: true };
|
||||
Object.keys(adminConfig[sectionKey]).forEach((moduleKey) => {
|
||||
if (moduleKey !== 'enabled' && adminConfig[sectionKey][moduleKey]) {
|
||||
defaultUserConfig[sectionKey][moduleKey] = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
setUserConfig(defaultUserConfig);
|
||||
} finally {
|
||||
if (shouldShowLoader) {
|
||||
setLoading(false);
|
||||
}
|
||||
hasLoadedOnceRef.current = true;
|
||||
}
|
||||
};
|
||||
|
||||
// 刷新用户配置的方法(供外部调用)
|
||||
const refreshUserConfig = async () => {
|
||||
if (Object.keys(adminConfig).length > 0) {
|
||||
await loadUserConfig({ withLoading: false });
|
||||
}
|
||||
|
||||
// 触发全局刷新事件,通知所有useSidebar实例更新
|
||||
sidebarEventTarget.dispatchEvent(
|
||||
new CustomEvent(SIDEBAR_REFRESH_EVENT, {
|
||||
detail: { sourceId: instanceIdRef.current, skipLoader: true },
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
// 加载用户配置
|
||||
useEffect(() => {
|
||||
// 只有当管理员配置加载完成后才加载用户配置
|
||||
if (Object.keys(adminConfig).length > 0) {
|
||||
loadUserConfig();
|
||||
}
|
||||
}, [adminConfig]);
|
||||
|
||||
// 监听全局刷新事件
|
||||
useEffect(() => {
|
||||
const handleRefresh = (event) => {
|
||||
if (event?.detail?.sourceId === instanceIdRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Object.keys(adminConfig).length > 0) {
|
||||
loadUserConfig({
|
||||
withLoading: event?.detail?.skipLoader ? false : undefined,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
sidebarEventTarget.addEventListener(SIDEBAR_REFRESH_EVENT, handleRefresh);
|
||||
|
||||
return () => {
|
||||
sidebarEventTarget.removeEventListener(
|
||||
SIDEBAR_REFRESH_EVENT,
|
||||
handleRefresh,
|
||||
);
|
||||
};
|
||||
}, [adminConfig]);
|
||||
|
||||
// 计算最终的显示配置
|
||||
const finalConfig = useMemo(() => {
|
||||
const result = {};
|
||||
|
||||
// 确保adminConfig已加载
|
||||
if (!adminConfig || Object.keys(adminConfig).length === 0) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// 如果userConfig未加载,等待加载完成
|
||||
if (!userConfig) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// 遍历所有区域
|
||||
Object.keys(adminConfig).forEach((sectionKey) => {
|
||||
const adminSection = adminConfig[sectionKey];
|
||||
const userSection = userConfig[sectionKey];
|
||||
|
||||
// 如果管理员禁用了整个区域,则该区域不显示
|
||||
if (!adminSection?.enabled) {
|
||||
result[sectionKey] = { enabled: false };
|
||||
return;
|
||||
}
|
||||
|
||||
// 区域级别:用户可以选择隐藏管理员允许的区域
|
||||
// 当userSection存在时检查enabled状态,否则默认为true
|
||||
const sectionEnabled = userSection ? userSection.enabled !== false : true;
|
||||
result[sectionKey] = { enabled: sectionEnabled };
|
||||
|
||||
// 功能级别:只有管理员和用户都允许的功能才显示
|
||||
Object.keys(adminSection).forEach((moduleKey) => {
|
||||
if (moduleKey === 'enabled') return;
|
||||
|
||||
const adminAllowed = adminSection[moduleKey];
|
||||
// 当userSection存在时检查模块状态,否则默认为true
|
||||
const userAllowed = userSection
|
||||
? userSection[moduleKey] !== false
|
||||
: true;
|
||||
|
||||
result[sectionKey][moduleKey] =
|
||||
adminAllowed && userAllowed && sectionEnabled;
|
||||
});
|
||||
});
|
||||
|
||||
return result;
|
||||
}, [adminConfig, userConfig]);
|
||||
|
||||
// 检查特定功能是否应该显示
|
||||
const isModuleVisible = (sectionKey, moduleKey = null) => {
|
||||
if (moduleKey) {
|
||||
return finalConfig[sectionKey]?.[moduleKey] === true;
|
||||
} else {
|
||||
return finalConfig[sectionKey]?.enabled === true;
|
||||
}
|
||||
};
|
||||
|
||||
// 检查区域是否有任何可见的功能
|
||||
const hasSectionVisibleModules = (sectionKey) => {
|
||||
const section = finalConfig[sectionKey];
|
||||
if (!section?.enabled) return false;
|
||||
|
||||
return Object.keys(section).some(
|
||||
(key) => key !== 'enabled' && section[key] === true,
|
||||
);
|
||||
};
|
||||
|
||||
// 获取区域的可见功能列表
|
||||
const getVisibleModules = (sectionKey) => {
|
||||
const section = finalConfig[sectionKey];
|
||||
if (!section?.enabled) return [];
|
||||
|
||||
return Object.keys(section).filter(
|
||||
(key) => key !== 'enabled' && section[key] === true,
|
||||
);
|
||||
};
|
||||
|
||||
return {
|
||||
loading,
|
||||
adminConfig,
|
||||
userConfig,
|
||||
finalConfig,
|
||||
isModuleVisible,
|
||||
hasSectionVisibleModules,
|
||||
getVisibleModules,
|
||||
refreshUserConfig,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
|
||||
const KEY = 'default_collapse_sidebar';
|
||||
|
||||
export const useSidebarCollapsed = () => {
|
||||
const [collapsed, setCollapsed] = useState(
|
||||
() => localStorage.getItem(KEY) === 'true',
|
||||
);
|
||||
|
||||
const toggle = useCallback(() => {
|
||||
setCollapsed((prev) => {
|
||||
const next = !prev;
|
||||
localStorage.setItem(KEY, next.toString());
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const set = useCallback((value) => {
|
||||
setCollapsed(value);
|
||||
localStorage.setItem(KEY, value.toString());
|
||||
}, []);
|
||||
|
||||
return [collapsed, toggle, set];
|
||||
};
|
||||
@@ -0,0 +1,58 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { getTableCompactMode, setTableCompactMode } from '../../helpers';
|
||||
import { TABLE_COMPACT_MODES_KEY } from '../../constants';
|
||||
|
||||
/**
|
||||
* 自定义 Hook:管理表格紧凑/自适应模式
|
||||
* 返回 [compactMode, setCompactMode]。
|
||||
* 内部使用 localStorage 保存状态,并监听 storage 事件保持多标签页同步。
|
||||
*/
|
||||
export function useTableCompactMode(tableKey = 'global') {
|
||||
const [compactMode, setCompactModeState] = useState(() =>
|
||||
getTableCompactMode(tableKey),
|
||||
);
|
||||
|
||||
const setCompactMode = useCallback(
|
||||
(value) => {
|
||||
setCompactModeState(value);
|
||||
setTableCompactMode(value, tableKey);
|
||||
},
|
||||
[tableKey],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handleStorage = (e) => {
|
||||
if (e.key === TABLE_COMPACT_MODES_KEY) {
|
||||
try {
|
||||
const modes = JSON.parse(e.newValue || '{}');
|
||||
setCompactModeState(!!modes[tableKey]);
|
||||
} catch {
|
||||
// ignore parse error
|
||||
}
|
||||
}
|
||||
};
|
||||
window.addEventListener('storage', handleStorage);
|
||||
return () => window.removeEventListener('storage', handleStorage);
|
||||
}, [tableKey]);
|
||||
|
||||
return [compactMode, setCompactMode];
|
||||
}
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { useState, useEffect } from 'react';
|
||||
import { API } from '../../helpers';
|
||||
|
||||
/**
|
||||
* 用户权限钩子 - 从后端获取用户权限,替代前端角色判断
|
||||
* 确保权限控制的安全性,防止前端绕过
|
||||
*/
|
||||
export const useUserPermissions = () => {
|
||||
const [permissions, setPermissions] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
// 加载用户权限(从用户信息接口获取)
|
||||
const loadPermissions = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const res = await API.get('/api/user/self');
|
||||
if (res.data.success) {
|
||||
const userPermissions = res.data.data.permissions;
|
||||
setPermissions(userPermissions);
|
||||
console.log('用户权限加载成功:', userPermissions);
|
||||
} else {
|
||||
setError(res.data.message || '获取权限失败');
|
||||
console.error('获取权限失败:', res.data.message);
|
||||
}
|
||||
} catch (error) {
|
||||
setError('网络错误,请重试');
|
||||
console.error('加载用户权限异常:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadPermissions();
|
||||
}, []);
|
||||
|
||||
// 检查是否有边栏设置权限
|
||||
const hasSidebarSettingsPermission = () => {
|
||||
return permissions?.sidebar_settings === true;
|
||||
};
|
||||
|
||||
// 检查是否允许访问特定的边栏区域
|
||||
const isSidebarSectionAllowed = (sectionKey) => {
|
||||
if (!permissions?.sidebar_modules) return true;
|
||||
const sectionPerms = permissions.sidebar_modules[sectionKey];
|
||||
return sectionPerms !== false;
|
||||
};
|
||||
|
||||
// 检查是否允许访问特定的边栏模块
|
||||
const isSidebarModuleAllowed = (sectionKey, moduleKey) => {
|
||||
if (!permissions?.sidebar_modules) return true;
|
||||
const sectionPerms = permissions.sidebar_modules[sectionKey];
|
||||
|
||||
// 如果整个区域被禁用
|
||||
if (sectionPerms === false) return false;
|
||||
|
||||
// 如果区域存在但模块被禁用
|
||||
if (sectionPerms && sectionPerms[moduleKey] === false) return false;
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
// 获取允许的边栏区域列表
|
||||
const getAllowedSidebarSections = () => {
|
||||
if (!permissions?.sidebar_modules) return [];
|
||||
|
||||
return Object.keys(permissions.sidebar_modules).filter((sectionKey) =>
|
||||
isSidebarSectionAllowed(sectionKey),
|
||||
);
|
||||
};
|
||||
|
||||
// 获取特定区域允许的模块列表
|
||||
const getAllowedSidebarModules = (sectionKey) => {
|
||||
if (!permissions?.sidebar_modules) return [];
|
||||
const sectionPerms = permissions.sidebar_modules[sectionKey];
|
||||
|
||||
if (sectionPerms === false) return [];
|
||||
if (!sectionPerms || typeof sectionPerms !== 'object') return [];
|
||||
|
||||
return Object.keys(sectionPerms).filter(
|
||||
(moduleKey) =>
|
||||
moduleKey !== 'enabled' && sectionPerms[moduleKey] === true,
|
||||
);
|
||||
};
|
||||
|
||||
return {
|
||||
permissions,
|
||||
loading,
|
||||
error,
|
||||
loadPermissions,
|
||||
hasSidebarSettingsPermission,
|
||||
isSidebarSectionAllowed,
|
||||
isSidebarModuleAllowed,
|
||||
getAllowedSidebarSections,
|
||||
getAllowedSidebarModules,
|
||||
};
|
||||
};
|
||||
|
||||
export default useUserPermissions;
|
||||
@@ -0,0 +1,628 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { initVChartSemiTheme } from '@visactor/vchart-semi-theme';
|
||||
import {
|
||||
modelColorMap,
|
||||
renderNumber,
|
||||
renderQuota,
|
||||
modelToColor,
|
||||
getQuotaWithUnit,
|
||||
} from '../../helpers';
|
||||
import {
|
||||
processRawData,
|
||||
calculateTrendData,
|
||||
aggregateDataByTimeAndModel,
|
||||
generateChartTimePoints,
|
||||
updateChartSpec,
|
||||
updateMapValue,
|
||||
initializeMaps,
|
||||
processUserData,
|
||||
} from '../../helpers/dashboard';
|
||||
|
||||
const USER_COLORS = [
|
||||
'#3b82f6', '#ef4444', '#10b981', '#f59e0b', '#8b5cf6',
|
||||
'#ec4899', '#06b6d4', '#f97316', '#6366f1', '#14b8a6',
|
||||
];
|
||||
|
||||
export const useDashboardCharts = (
|
||||
dataExportDefaultTime,
|
||||
setTrendData,
|
||||
setConsumeQuota,
|
||||
setTimes,
|
||||
setConsumeTokens,
|
||||
setPieData,
|
||||
setLineData,
|
||||
setModelColors,
|
||||
t,
|
||||
) => {
|
||||
// ========== 图表规格状态 ==========
|
||||
const [spec_pie, setSpecPie] = useState({
|
||||
type: 'pie',
|
||||
data: [
|
||||
{
|
||||
id: 'id0',
|
||||
values: [{ type: 'null', value: '0' }],
|
||||
},
|
||||
],
|
||||
outerRadius: 0.8,
|
||||
innerRadius: 0.5,
|
||||
padAngle: 0.6,
|
||||
valueField: 'value',
|
||||
categoryField: 'type',
|
||||
pie: {
|
||||
style: {
|
||||
cornerRadius: 10,
|
||||
},
|
||||
state: {
|
||||
hover: {
|
||||
outerRadius: 0.85,
|
||||
stroke: '#000',
|
||||
lineWidth: 1,
|
||||
},
|
||||
selected: {
|
||||
outerRadius: 0.85,
|
||||
stroke: '#000',
|
||||
lineWidth: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
title: {
|
||||
visible: true,
|
||||
text: t('模型调用次数占比'),
|
||||
subtext: `${t('总计')}:${renderNumber(0)}`,
|
||||
},
|
||||
legends: {
|
||||
visible: true,
|
||||
orient: 'left',
|
||||
},
|
||||
label: {
|
||||
visible: true,
|
||||
},
|
||||
tooltip: {
|
||||
mark: {
|
||||
content: [
|
||||
{
|
||||
key: (datum) => datum['type'],
|
||||
value: (datum) => renderNumber(datum['value']),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
color: {
|
||||
specified: modelColorMap,
|
||||
},
|
||||
});
|
||||
|
||||
const [spec_line, setSpecLine] = useState({
|
||||
type: 'bar',
|
||||
data: [
|
||||
{
|
||||
id: 'barData',
|
||||
values: [],
|
||||
},
|
||||
],
|
||||
xField: 'Time',
|
||||
yField: 'Usage',
|
||||
seriesField: 'Model',
|
||||
stack: true,
|
||||
legends: {
|
||||
visible: true,
|
||||
selectMode: 'single',
|
||||
},
|
||||
title: {
|
||||
visible: true,
|
||||
text: t('模型消耗分布'),
|
||||
subtext: `${t('总计')}:${renderQuota(0, 2)}`,
|
||||
},
|
||||
bar: {
|
||||
state: {
|
||||
hover: {
|
||||
stroke: '#000',
|
||||
lineWidth: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
mark: {
|
||||
content: [
|
||||
{
|
||||
key: (datum) => datum['Model'],
|
||||
value: (datum) => renderQuota(datum['rawQuota'] || 0, 4),
|
||||
},
|
||||
],
|
||||
},
|
||||
dimension: {
|
||||
content: [
|
||||
{
|
||||
key: (datum) => datum['Model'],
|
||||
value: (datum) => datum['rawQuota'] || 0,
|
||||
},
|
||||
],
|
||||
updateContent: (array) => {
|
||||
array.sort((a, b) => b.value - a.value);
|
||||
let sum = 0;
|
||||
for (let i = 0; i < array.length; i++) {
|
||||
if (array[i].key == '其他') {
|
||||
continue;
|
||||
}
|
||||
let value = parseFloat(array[i].value);
|
||||
if (isNaN(value)) {
|
||||
value = 0;
|
||||
}
|
||||
if (array[i].datum && array[i].datum.TimeSum) {
|
||||
sum = array[i].datum.TimeSum;
|
||||
}
|
||||
array[i].value = renderQuota(value, 4);
|
||||
}
|
||||
array.unshift({
|
||||
key: t('总计'),
|
||||
value: renderQuota(sum, 4),
|
||||
});
|
||||
return array;
|
||||
},
|
||||
},
|
||||
},
|
||||
color: {
|
||||
specified: modelColorMap,
|
||||
},
|
||||
});
|
||||
|
||||
const [spec_model_line, setSpecModelLine] = useState({
|
||||
type: 'line',
|
||||
data: [
|
||||
{
|
||||
id: 'lineData',
|
||||
values: [],
|
||||
},
|
||||
],
|
||||
xField: 'Time',
|
||||
yField: 'Count',
|
||||
seriesField: 'Model',
|
||||
legends: {
|
||||
visible: true,
|
||||
selectMode: 'single',
|
||||
},
|
||||
title: {
|
||||
visible: true,
|
||||
text: t('调用趋势'),
|
||||
subtext: '',
|
||||
},
|
||||
tooltip: {
|
||||
mark: {
|
||||
content: [
|
||||
{
|
||||
key: (datum) => datum['Model'],
|
||||
value: (datum) => renderNumber(datum['Count']),
|
||||
},
|
||||
],
|
||||
},
|
||||
dimension: {
|
||||
content: [
|
||||
{
|
||||
key: (datum) => datum['Model'],
|
||||
value: (datum) => datum['Count'] || 0,
|
||||
},
|
||||
],
|
||||
updateContent: (array) => {
|
||||
array.sort((a, b) => b.value - a.value);
|
||||
let sum = 0;
|
||||
for (let i = 0; i < array.length; i++) {
|
||||
let value = parseFloat(array[i].value);
|
||||
if (isNaN(value)) value = 0;
|
||||
sum += value;
|
||||
array[i].value = renderNumber(value);
|
||||
}
|
||||
array.unshift({
|
||||
key: t('总计'),
|
||||
value: renderNumber(sum),
|
||||
});
|
||||
return array;
|
||||
},
|
||||
},
|
||||
},
|
||||
color: {
|
||||
specified: modelColorMap,
|
||||
},
|
||||
});
|
||||
|
||||
const [spec_rank_bar, setSpecRankBar] = useState({
|
||||
type: 'bar',
|
||||
data: [
|
||||
{
|
||||
id: 'rankData',
|
||||
values: [],
|
||||
},
|
||||
],
|
||||
xField: 'Model',
|
||||
yField: 'Count',
|
||||
seriesField: 'Model',
|
||||
legends: {
|
||||
visible: true,
|
||||
selectMode: 'single',
|
||||
},
|
||||
title: {
|
||||
visible: true,
|
||||
text: t('模型调用次数排行'),
|
||||
subtext: '',
|
||||
},
|
||||
bar: {
|
||||
state: {
|
||||
hover: {
|
||||
stroke: '#000',
|
||||
lineWidth: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
mark: {
|
||||
content: [
|
||||
{
|
||||
key: (datum) => datum['Model'],
|
||||
value: (datum) => renderNumber(datum['Count']),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
color: {
|
||||
specified: modelColorMap,
|
||||
},
|
||||
});
|
||||
|
||||
// ========== Admin: 用户消耗排行 ==========
|
||||
const [spec_user_rank, setSpecUserRank] = useState({
|
||||
type: 'bar',
|
||||
data: [{ id: 'userRankData', values: [] }],
|
||||
xField: 'rawQuota',
|
||||
yField: 'User',
|
||||
seriesField: 'User',
|
||||
direction: 'horizontal',
|
||||
legends: { visible: false },
|
||||
title: {
|
||||
visible: true,
|
||||
text: t('用户消耗排行'),
|
||||
subtext: '',
|
||||
},
|
||||
bar: {
|
||||
state: { hover: { stroke: '#000', lineWidth: 1 } },
|
||||
},
|
||||
label: {
|
||||
visible: true,
|
||||
position: 'outside',
|
||||
formatMethod: (value, datum) => renderQuota(datum['rawQuota'] || 0, 2),
|
||||
},
|
||||
axes: [{
|
||||
orient: 'left',
|
||||
type: 'band',
|
||||
label: { visible: true },
|
||||
}, {
|
||||
orient: 'bottom',
|
||||
type: 'linear',
|
||||
visible: false,
|
||||
}],
|
||||
tooltip: {
|
||||
mark: {
|
||||
content: [{
|
||||
key: (datum) => datum['User'],
|
||||
value: (datum) => renderQuota(datum['rawQuota'] || 0, 4),
|
||||
}],
|
||||
},
|
||||
},
|
||||
color: { type: 'ordinal', range: USER_COLORS },
|
||||
});
|
||||
|
||||
// ========== Admin: 用户消耗趋势 ==========
|
||||
const [spec_user_trend, setSpecUserTrend] = useState({
|
||||
type: 'area',
|
||||
data: [{ id: 'userTrendData', values: [] }],
|
||||
xField: 'Time',
|
||||
yField: 'rawQuota',
|
||||
seriesField: 'User',
|
||||
stack: false,
|
||||
legends: { visible: true, selectMode: 'single' },
|
||||
title: {
|
||||
visible: true,
|
||||
text: t('用户消耗趋势'),
|
||||
subtext: '',
|
||||
},
|
||||
axes: [{
|
||||
orient: 'left',
|
||||
label: {
|
||||
formatMethod: (value) => renderQuota(value, 2),
|
||||
},
|
||||
}],
|
||||
area: { style: { fillOpacity: 0.15 } },
|
||||
line: { style: { lineWidth: 2 } },
|
||||
point: { visible: false },
|
||||
tooltip: {
|
||||
mark: {
|
||||
content: [{
|
||||
key: (datum) => datum['User'],
|
||||
value: (datum) => renderQuota(datum['rawQuota'] || 0, 4),
|
||||
}],
|
||||
},
|
||||
dimension: {
|
||||
content: [{
|
||||
key: (datum) => datum['User'],
|
||||
value: (datum) => datum['rawQuota'] || 0,
|
||||
}],
|
||||
updateContent: (array) => {
|
||||
array.sort((a, b) => b.value - a.value);
|
||||
let sum = 0;
|
||||
for (let i = 0; i < array.length; i++) {
|
||||
let value = parseFloat(array[i].value);
|
||||
if (isNaN(value)) value = 0;
|
||||
sum += value;
|
||||
array[i].value = renderQuota(value, 4);
|
||||
}
|
||||
array.unshift({
|
||||
key: t('总计'),
|
||||
value: renderQuota(sum, 4),
|
||||
});
|
||||
return array;
|
||||
},
|
||||
},
|
||||
},
|
||||
color: { type: 'ordinal', range: USER_COLORS },
|
||||
});
|
||||
|
||||
// ========== 数据处理函数 ==========
|
||||
const generateModelColors = useCallback((uniqueModels, modelColors) => {
|
||||
const newModelColors = {};
|
||||
Array.from(uniqueModels).forEach((modelName) => {
|
||||
newModelColors[modelName] =
|
||||
modelColorMap[modelName] ||
|
||||
modelColors[modelName] ||
|
||||
modelToColor(modelName);
|
||||
});
|
||||
return newModelColors;
|
||||
}, []);
|
||||
|
||||
const updateChartData = useCallback(
|
||||
(data) => {
|
||||
const processedData = processRawData(
|
||||
data,
|
||||
dataExportDefaultTime,
|
||||
initializeMaps,
|
||||
updateMapValue,
|
||||
);
|
||||
|
||||
const {
|
||||
totalQuota,
|
||||
totalTimes,
|
||||
totalTokens,
|
||||
uniqueModels,
|
||||
timePoints,
|
||||
timeQuotaMap,
|
||||
timeTokensMap,
|
||||
timeCountMap,
|
||||
} = processedData;
|
||||
|
||||
const trendDataResult = calculateTrendData(
|
||||
timePoints,
|
||||
timeQuotaMap,
|
||||
timeTokensMap,
|
||||
timeCountMap,
|
||||
dataExportDefaultTime,
|
||||
);
|
||||
setTrendData(trendDataResult);
|
||||
|
||||
const newModelColors = generateModelColors(uniqueModels, {});
|
||||
setModelColors(newModelColors);
|
||||
|
||||
const aggregatedData = aggregateDataByTimeAndModel(
|
||||
data,
|
||||
dataExportDefaultTime,
|
||||
);
|
||||
|
||||
const modelTotals = new Map();
|
||||
for (let [_, value] of aggregatedData) {
|
||||
updateMapValue(modelTotals, value.model, value.count);
|
||||
}
|
||||
|
||||
const newPieData = Array.from(modelTotals)
|
||||
.map(([model, count]) => ({
|
||||
type: model,
|
||||
value: count,
|
||||
}))
|
||||
.sort((a, b) => b.value - a.value);
|
||||
|
||||
const chartTimePoints = generateChartTimePoints(
|
||||
aggregatedData,
|
||||
data,
|
||||
dataExportDefaultTime,
|
||||
);
|
||||
|
||||
let newLineData = [];
|
||||
|
||||
chartTimePoints.forEach((time) => {
|
||||
let timeData = Array.from(uniqueModels).map((model) => {
|
||||
const key = `${time}-${model}`;
|
||||
const aggregated = aggregatedData.get(key);
|
||||
return {
|
||||
Time: time,
|
||||
Model: model,
|
||||
rawQuota: aggregated?.quota || 0,
|
||||
Usage: aggregated?.quota
|
||||
? getQuotaWithUnit(aggregated.quota, 4)
|
||||
: 0,
|
||||
};
|
||||
});
|
||||
|
||||
const timeSum = timeData.reduce((sum, item) => sum + item.rawQuota, 0);
|
||||
timeData.sort((a, b) => b.rawQuota - a.rawQuota);
|
||||
timeData = timeData.map((item) => ({ ...item, TimeSum: timeSum }));
|
||||
newLineData.push(...timeData);
|
||||
});
|
||||
|
||||
newLineData.sort((a, b) => a.Time.localeCompare(b.Time));
|
||||
|
||||
updateChartSpec(
|
||||
setSpecPie,
|
||||
newPieData,
|
||||
`${t('总计')}:${renderNumber(totalTimes)}`,
|
||||
newModelColors,
|
||||
'id0',
|
||||
);
|
||||
|
||||
updateChartSpec(
|
||||
setSpecLine,
|
||||
newLineData,
|
||||
`${t('总计')}:${renderQuota(totalQuota, 2)}`,
|
||||
newModelColors,
|
||||
'barData',
|
||||
);
|
||||
|
||||
// ===== 模型调用次数折线图 =====
|
||||
let modelLineData = [];
|
||||
chartTimePoints.forEach((time) => {
|
||||
const timeData = Array.from(uniqueModels).map((model) => {
|
||||
const key = `${time}-${model}`;
|
||||
const aggregated = aggregatedData.get(key);
|
||||
return {
|
||||
Time: time,
|
||||
Model: model,
|
||||
Count: aggregated?.count || 0,
|
||||
};
|
||||
});
|
||||
modelLineData.push(...timeData);
|
||||
});
|
||||
modelLineData.sort((a, b) => a.Time.localeCompare(b.Time));
|
||||
|
||||
// ===== 模型调用次数排行柱状图 =====
|
||||
const MAX_RANK_MODELS = 20;
|
||||
const allRankData = Array.from(modelTotals)
|
||||
.map(([model, count]) => ({
|
||||
Model: model,
|
||||
Count: count,
|
||||
}))
|
||||
.sort((a, b) => b.Count - a.Count);
|
||||
|
||||
let rankData;
|
||||
if (allRankData.length > MAX_RANK_MODELS) {
|
||||
const topModels = allRankData.slice(0, MAX_RANK_MODELS);
|
||||
const otherCount = allRankData
|
||||
.slice(MAX_RANK_MODELS)
|
||||
.reduce((sum, item) => sum + item.Count, 0);
|
||||
rankData = [...topModels, { Model: t('其他'), Count: otherCount }];
|
||||
} else {
|
||||
rankData = allRankData;
|
||||
}
|
||||
|
||||
updateChartSpec(
|
||||
setSpecModelLine,
|
||||
modelLineData,
|
||||
`${t('总计')}:${renderNumber(totalTimes)}`,
|
||||
newModelColors,
|
||||
'lineData',
|
||||
);
|
||||
|
||||
updateChartSpec(
|
||||
setSpecRankBar,
|
||||
rankData,
|
||||
`${t('总计')}:${renderNumber(totalTimes)}`,
|
||||
newModelColors,
|
||||
'rankData',
|
||||
);
|
||||
|
||||
setPieData(newPieData);
|
||||
setLineData(newLineData);
|
||||
setConsumeQuota(totalQuota);
|
||||
setTimes(totalTimes);
|
||||
setConsumeTokens(totalTokens);
|
||||
},
|
||||
[
|
||||
dataExportDefaultTime,
|
||||
setTrendData,
|
||||
generateModelColors,
|
||||
setModelColors,
|
||||
setPieData,
|
||||
setLineData,
|
||||
setConsumeQuota,
|
||||
setTimes,
|
||||
setConsumeTokens,
|
||||
t,
|
||||
],
|
||||
);
|
||||
|
||||
// ========== 用户维度图表数据处理 ==========
|
||||
const updateUserChartData = useCallback(
|
||||
(data) => {
|
||||
const { rankingData, trendData: userTrend } = processUserData(
|
||||
data,
|
||||
dataExportDefaultTime,
|
||||
10,
|
||||
);
|
||||
|
||||
const userRankValues = rankingData.map((item) => ({
|
||||
User: item.User,
|
||||
rawQuota: item.Quota,
|
||||
Quota: getQuotaWithUnit(item.Quota, 4),
|
||||
})).sort((a, b) => b.rawQuota - a.rawQuota);
|
||||
|
||||
const totalUserQuota = rankingData.reduce((s, i) => s + i.Quota, 0);
|
||||
|
||||
setSpecUserRank((prev) => ({
|
||||
...prev,
|
||||
data: [{ id: 'userRankData', values: userRankValues }],
|
||||
title: {
|
||||
...prev.title,
|
||||
subtext: `${t('总计')}:${renderQuota(totalUserQuota, 2)}`,
|
||||
},
|
||||
}));
|
||||
|
||||
const userTrendValues = userTrend.map((item) => ({
|
||||
Time: item.Time,
|
||||
User: item.User,
|
||||
rawQuota: item.Quota,
|
||||
Usage: item.Quota ? getQuotaWithUnit(item.Quota, 4) : 0,
|
||||
}));
|
||||
|
||||
setSpecUserTrend((prev) => ({
|
||||
...prev,
|
||||
data: [{ id: 'userTrendData', values: userTrendValues }],
|
||||
title: {
|
||||
...prev.title,
|
||||
subtext: `${t('总计')}:${renderQuota(totalUserQuota, 2)}`,
|
||||
},
|
||||
}));
|
||||
},
|
||||
[dataExportDefaultTime, t],
|
||||
);
|
||||
|
||||
// ========== 初始化图表主题 ==========
|
||||
useEffect(() => {
|
||||
initVChartSemiTheme({
|
||||
isWatchingThemeSwitch: true,
|
||||
});
|
||||
}, []);
|
||||
|
||||
return {
|
||||
spec_pie,
|
||||
spec_line,
|
||||
spec_model_line,
|
||||
spec_rank_bar,
|
||||
spec_user_rank,
|
||||
spec_user_trend,
|
||||
updateChartData,
|
||||
updateUserChartData,
|
||||
generateModelColors,
|
||||
};
|
||||
};
|
||||
+346
@@ -0,0 +1,346 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { API, isAdmin, showError, timestamp2string } from '../../helpers';
|
||||
import { getDefaultTime, getInitialTimestamp } from '../../helpers/dashboard';
|
||||
import { TIME_OPTIONS } from '../../constants/dashboard.constants';
|
||||
import { useIsMobile } from '../common/useIsMobile';
|
||||
import { useMinimumLoadingTime } from '../common/useMinimumLoadingTime';
|
||||
|
||||
export const useDashboardData = (userState, userDispatch, statusState) => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const isMobile = useIsMobile();
|
||||
const initialized = useRef(false);
|
||||
|
||||
// ========== 基础状态 ==========
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [greetingVisible, setGreetingVisible] = useState(false);
|
||||
const [searchModalVisible, setSearchModalVisible] = useState(false);
|
||||
const showLoading = useMinimumLoadingTime(loading);
|
||||
|
||||
// ========== 输入状态 ==========
|
||||
const [inputs, setInputs] = useState({
|
||||
username: '',
|
||||
token_name: '',
|
||||
model_name: '',
|
||||
start_timestamp: getInitialTimestamp(),
|
||||
end_timestamp: timestamp2string(new Date().getTime() / 1000 + 3600),
|
||||
channel: '',
|
||||
data_export_default_time: '',
|
||||
});
|
||||
|
||||
const [dataExportDefaultTime, setDataExportDefaultTime] =
|
||||
useState(getDefaultTime());
|
||||
|
||||
// ========== 数据状态 ==========
|
||||
const [quotaData, setQuotaData] = useState([]);
|
||||
const [consumeQuota, setConsumeQuota] = useState(0);
|
||||
const [consumeTokens, setConsumeTokens] = useState(0);
|
||||
const [times, setTimes] = useState(0);
|
||||
const [pieData, setPieData] = useState([{ type: 'null', value: '0' }]);
|
||||
const [lineData, setLineData] = useState([]);
|
||||
const [modelColors, setModelColors] = useState({});
|
||||
|
||||
// ========== 图表状态 ==========
|
||||
const [activeChartTab, setActiveChartTab] = useState('1');
|
||||
|
||||
// ========== 趋势数据 ==========
|
||||
const [trendData, setTrendData] = useState({
|
||||
balance: [],
|
||||
usedQuota: [],
|
||||
requestCount: [],
|
||||
times: [],
|
||||
consumeQuota: [],
|
||||
tokens: [],
|
||||
rpm: [],
|
||||
tpm: [],
|
||||
});
|
||||
|
||||
// ========== Uptime 数据 ==========
|
||||
const [uptimeData, setUptimeData] = useState([]);
|
||||
const [uptimeLoading, setUptimeLoading] = useState(false);
|
||||
const [activeUptimeTab, setActiveUptimeTab] = useState('');
|
||||
|
||||
// ========== 常量 ==========
|
||||
const now = new Date();
|
||||
const isAdminUser = isAdmin();
|
||||
|
||||
// ========== Panel enable flags ==========
|
||||
const apiInfoEnabled = statusState?.status?.api_info_enabled ?? true;
|
||||
const announcementsEnabled =
|
||||
statusState?.status?.announcements_enabled ?? true;
|
||||
const faqEnabled = statusState?.status?.faq_enabled ?? true;
|
||||
const uptimeEnabled = statusState?.status?.uptime_kuma_enabled ?? true;
|
||||
|
||||
const hasApiInfoPanel = apiInfoEnabled;
|
||||
const hasInfoPanels = announcementsEnabled || faqEnabled || uptimeEnabled;
|
||||
|
||||
// ========== Memoized Values ==========
|
||||
const timeOptions = useMemo(
|
||||
() =>
|
||||
TIME_OPTIONS.map((option) => ({
|
||||
...option,
|
||||
label: t(option.label),
|
||||
})),
|
||||
[t],
|
||||
);
|
||||
|
||||
const performanceMetrics = useMemo(() => {
|
||||
const { start_timestamp, end_timestamp } = inputs;
|
||||
const timeDiff =
|
||||
(Date.parse(end_timestamp) - Date.parse(start_timestamp)) / 60000;
|
||||
const avgRPM = isNaN(times / timeDiff)
|
||||
? '0'
|
||||
: (times / timeDiff).toFixed(3);
|
||||
const avgTPM = isNaN(consumeTokens / timeDiff)
|
||||
? '0'
|
||||
: (consumeTokens / timeDiff).toFixed(3);
|
||||
|
||||
return { avgRPM, avgTPM, timeDiff };
|
||||
}, [times, consumeTokens, inputs.start_timestamp, inputs.end_timestamp]);
|
||||
|
||||
const getGreeting = useMemo(() => {
|
||||
const hours = new Date().getHours();
|
||||
let greeting = '';
|
||||
|
||||
if (hours >= 5 && hours < 12) {
|
||||
greeting = t('早上好');
|
||||
} else if (hours >= 12 && hours < 14) {
|
||||
greeting = t('中午好');
|
||||
} else if (hours >= 14 && hours < 18) {
|
||||
greeting = t('下午好');
|
||||
} else {
|
||||
greeting = t('晚上好');
|
||||
}
|
||||
|
||||
const username = userState?.user?.username || '';
|
||||
return `👋${greeting},${username}`;
|
||||
}, [t, userState?.user?.username]);
|
||||
|
||||
// ========== 回调函数 ==========
|
||||
const handleInputChange = useCallback((value, name) => {
|
||||
if (name === 'data_export_default_time') {
|
||||
setDataExportDefaultTime(value);
|
||||
localStorage.setItem('data_export_default_time', value);
|
||||
return;
|
||||
}
|
||||
setInputs((inputs) => ({ ...inputs, [name]: value }));
|
||||
}, []);
|
||||
|
||||
const showSearchModal = useCallback(() => {
|
||||
setSearchModalVisible(true);
|
||||
}, []);
|
||||
|
||||
const handleCloseModal = useCallback(() => {
|
||||
setSearchModalVisible(false);
|
||||
}, []);
|
||||
|
||||
// ========== API 调用函数 ==========
|
||||
const loadQuotaData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
let url = '';
|
||||
const { start_timestamp, end_timestamp, username } = inputs;
|
||||
let localStartTimestamp = Date.parse(start_timestamp) / 1000;
|
||||
let localEndTimestamp = Date.parse(end_timestamp) / 1000;
|
||||
|
||||
if (isAdminUser) {
|
||||
url = `/api/data/?username=${username}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}&default_time=${dataExportDefaultTime}`;
|
||||
} else {
|
||||
url = `/api/data/self/?start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}&default_time=${dataExportDefaultTime}`;
|
||||
}
|
||||
|
||||
const res = await API.get(url);
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
setQuotaData(data);
|
||||
if (data.length === 0) {
|
||||
data.push({
|
||||
count: 0,
|
||||
model_name: '无数据',
|
||||
quota: 0,
|
||||
created_at: now.getTime() / 1000,
|
||||
});
|
||||
}
|
||||
data.sort((a, b) => a.created_at - b.created_at);
|
||||
return data;
|
||||
} else {
|
||||
showError(message);
|
||||
return [];
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [inputs, dataExportDefaultTime, isAdminUser, now]);
|
||||
|
||||
const loadUptimeData = useCallback(async () => {
|
||||
setUptimeLoading(true);
|
||||
try {
|
||||
const res = await API.get('/api/uptime/status');
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
setUptimeData(data || []);
|
||||
if (data && data.length > 0 && !activeUptimeTab) {
|
||||
setActiveUptimeTab(data[0].categoryName);
|
||||
}
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
setUptimeLoading(false);
|
||||
}
|
||||
}, [activeUptimeTab]);
|
||||
|
||||
const loadUserQuotaData = useCallback(async () => {
|
||||
if (!isAdminUser) return [];
|
||||
try {
|
||||
const { start_timestamp, end_timestamp } = inputs;
|
||||
const localStartTimestamp = Date.parse(start_timestamp) / 1000;
|
||||
const localEndTimestamp = Date.parse(end_timestamp) / 1000;
|
||||
const url = `/api/data/users?start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}`;
|
||||
const res = await API.get(url);
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
return data || [];
|
||||
} else {
|
||||
showError(message);
|
||||
return [];
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
return [];
|
||||
}
|
||||
}, [inputs, isAdminUser]);
|
||||
|
||||
const getUserData = useCallback(async () => {
|
||||
let res = await API.get(`/api/user/self`);
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
userDispatch({ type: 'login', payload: data });
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
}, [userDispatch]);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
const data = await loadQuotaData();
|
||||
await loadUptimeData();
|
||||
return data;
|
||||
}, [loadQuotaData, loadUptimeData]);
|
||||
|
||||
const handleSearchConfirm = useCallback(
|
||||
async (updateChartDataCallback) => {
|
||||
const data = await refresh();
|
||||
if (data && data.length > 0 && updateChartDataCallback) {
|
||||
updateChartDataCallback(data);
|
||||
}
|
||||
setSearchModalVisible(false);
|
||||
},
|
||||
[refresh],
|
||||
);
|
||||
|
||||
// ========== Effects ==========
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setGreetingVisible(true);
|
||||
}, 100);
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!initialized.current) {
|
||||
getUserData();
|
||||
initialized.current = true;
|
||||
}
|
||||
}, [getUserData]);
|
||||
|
||||
return {
|
||||
// 基础状态
|
||||
loading: showLoading,
|
||||
greetingVisible,
|
||||
searchModalVisible,
|
||||
|
||||
// 输入状态
|
||||
inputs,
|
||||
dataExportDefaultTime,
|
||||
|
||||
// 数据状态
|
||||
quotaData,
|
||||
consumeQuota,
|
||||
setConsumeQuota,
|
||||
consumeTokens,
|
||||
setConsumeTokens,
|
||||
times,
|
||||
setTimes,
|
||||
pieData,
|
||||
setPieData,
|
||||
lineData,
|
||||
setLineData,
|
||||
modelColors,
|
||||
setModelColors,
|
||||
|
||||
// 图表状态
|
||||
activeChartTab,
|
||||
setActiveChartTab,
|
||||
|
||||
// 趋势数据
|
||||
trendData,
|
||||
setTrendData,
|
||||
|
||||
// Uptime 数据
|
||||
uptimeData,
|
||||
uptimeLoading,
|
||||
activeUptimeTab,
|
||||
setActiveUptimeTab,
|
||||
|
||||
// 计算值
|
||||
timeOptions,
|
||||
performanceMetrics,
|
||||
getGreeting,
|
||||
isAdminUser,
|
||||
hasApiInfoPanel,
|
||||
hasInfoPanels,
|
||||
apiInfoEnabled,
|
||||
announcementsEnabled,
|
||||
faqEnabled,
|
||||
uptimeEnabled,
|
||||
|
||||
// 函数
|
||||
handleInputChange,
|
||||
showSearchModal,
|
||||
handleCloseModal,
|
||||
loadQuotaData,
|
||||
loadUserQuotaData,
|
||||
loadUptimeData,
|
||||
getUserData,
|
||||
refresh,
|
||||
handleSearchConfirm,
|
||||
|
||||
// 导航和翻译
|
||||
navigate,
|
||||
t,
|
||||
isMobile,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,153 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { Wallet, Activity, Zap, Gauge } from 'lucide-react';
|
||||
import {
|
||||
IconMoneyExchangeStroked,
|
||||
IconHistogram,
|
||||
IconCoinMoneyStroked,
|
||||
IconTextStroked,
|
||||
IconPulse,
|
||||
IconStopwatchStroked,
|
||||
IconTypograph,
|
||||
IconSend,
|
||||
} from '@douyinfe/semi-icons';
|
||||
import { renderQuota } from '../../helpers';
|
||||
import { createSectionTitle } from '../../helpers/dashboard';
|
||||
|
||||
export const useDashboardStats = (
|
||||
userState,
|
||||
consumeQuota,
|
||||
consumeTokens,
|
||||
times,
|
||||
trendData,
|
||||
performanceMetrics,
|
||||
navigate,
|
||||
t,
|
||||
) => {
|
||||
const groupedStatsData = useMemo(
|
||||
() => [
|
||||
{
|
||||
title: createSectionTitle(Wallet, t('账户数据')),
|
||||
color: 'bg-blue-50',
|
||||
items: [
|
||||
{
|
||||
title: t('当前余额'),
|
||||
value: renderQuota(userState?.user?.quota),
|
||||
icon: <IconMoneyExchangeStroked />,
|
||||
avatarColor: 'blue',
|
||||
trendData: [],
|
||||
trendColor: '#3b82f6',
|
||||
},
|
||||
{
|
||||
title: t('历史消耗'),
|
||||
value: renderQuota(userState?.user?.used_quota),
|
||||
icon: <IconHistogram />,
|
||||
avatarColor: 'purple',
|
||||
trendData: [],
|
||||
trendColor: '#8b5cf6',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: createSectionTitle(Activity, t('使用统计')),
|
||||
color: 'bg-green-50',
|
||||
items: [
|
||||
{
|
||||
title: t('请求次数'),
|
||||
value: userState.user?.request_count,
|
||||
icon: <IconSend />,
|
||||
avatarColor: 'green',
|
||||
trendData: [],
|
||||
trendColor: '#10b981',
|
||||
},
|
||||
{
|
||||
title: t('统计次数'),
|
||||
value: times,
|
||||
icon: <IconPulse />,
|
||||
avatarColor: 'cyan',
|
||||
trendData: trendData.times,
|
||||
trendColor: '#06b6d4',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: createSectionTitle(Zap, t('资源消耗')),
|
||||
color: 'bg-yellow-50',
|
||||
items: [
|
||||
{
|
||||
title: t('统计额度'),
|
||||
value: renderQuota(consumeQuota),
|
||||
icon: <IconCoinMoneyStroked />,
|
||||
avatarColor: 'yellow',
|
||||
trendData: trendData.consumeQuota,
|
||||
trendColor: '#f59e0b',
|
||||
},
|
||||
{
|
||||
title: t('统计Tokens'),
|
||||
value: isNaN(consumeTokens) ? 0 : consumeTokens.toLocaleString(),
|
||||
icon: <IconTextStroked />,
|
||||
avatarColor: 'pink',
|
||||
trendData: trendData.tokens,
|
||||
trendColor: '#ec4899',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: createSectionTitle(Gauge, t('性能指标')),
|
||||
color: 'bg-indigo-50',
|
||||
items: [
|
||||
{
|
||||
title: t('平均RPM'),
|
||||
value: performanceMetrics.avgRPM,
|
||||
icon: <IconStopwatchStroked />,
|
||||
avatarColor: 'indigo',
|
||||
trendData: trendData.rpm,
|
||||
trendColor: '#6366f1',
|
||||
},
|
||||
{
|
||||
title: t('平均TPM'),
|
||||
value: performanceMetrics.avgTPM,
|
||||
icon: <IconTypograph />,
|
||||
avatarColor: 'orange',
|
||||
trendData: trendData.tpm,
|
||||
trendColor: '#f97316',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
[
|
||||
userState?.user?.quota,
|
||||
userState?.user?.used_quota,
|
||||
userState?.user?.request_count,
|
||||
times,
|
||||
consumeQuota,
|
||||
consumeTokens,
|
||||
trendData,
|
||||
performanceMetrics,
|
||||
navigate,
|
||||
t,
|
||||
],
|
||||
);
|
||||
|
||||
return {
|
||||
groupedStatsData,
|
||||
};
|
||||
};
|
||||
+338
@@ -0,0 +1,338 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Modal } from '@douyinfe/semi-ui';
|
||||
import {
|
||||
API,
|
||||
copy,
|
||||
isAdmin,
|
||||
showError,
|
||||
showSuccess,
|
||||
timestamp2string,
|
||||
} from '../../helpers';
|
||||
import { ITEMS_PER_PAGE } from '../../constants';
|
||||
import { useTableCompactMode } from '../common/useTableCompactMode';
|
||||
|
||||
export const useMjLogsData = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Define column keys for selection
|
||||
const COLUMN_KEYS = {
|
||||
SUBMIT_TIME: 'submit_time',
|
||||
DURATION: 'duration',
|
||||
CHANNEL: 'channel',
|
||||
TYPE: 'type',
|
||||
TASK_ID: 'task_id',
|
||||
SUBMIT_RESULT: 'submit_result',
|
||||
TASK_STATUS: 'task_status',
|
||||
PROGRESS: 'progress',
|
||||
IMAGE: 'image',
|
||||
PROMPT: 'prompt',
|
||||
PROMPT_EN: 'prompt_en',
|
||||
FAIL_REASON: 'fail_reason',
|
||||
};
|
||||
|
||||
// Basic state
|
||||
const [logs, setLogs] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [activePage, setActivePage] = useState(1);
|
||||
const [logCount, setLogCount] = useState(0);
|
||||
const [pageSize, setPageSize] = useState(ITEMS_PER_PAGE);
|
||||
const [showBanner, setShowBanner] = useState(false);
|
||||
|
||||
// User and admin
|
||||
const isAdminUser = isAdmin();
|
||||
// Role-specific storage key to prevent different roles from overwriting each other
|
||||
const STORAGE_KEY = isAdminUser
|
||||
? 'mj-logs-table-columns-admin'
|
||||
: 'mj-logs-table-columns-user';
|
||||
|
||||
// Modal states
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [modalContent, setModalContent] = useState('');
|
||||
const [isModalOpenurl, setIsModalOpenurl] = useState(false);
|
||||
const [modalImageUrl, setModalImageUrl] = useState('');
|
||||
|
||||
// Form state
|
||||
const [formApi, setFormApi] = useState(null);
|
||||
let now = new Date();
|
||||
const formInitValues = {
|
||||
channel_id: '',
|
||||
mj_id: '',
|
||||
dateRange: [
|
||||
timestamp2string(now.getTime() / 1000 - 2592000),
|
||||
timestamp2string(now.getTime() / 1000 + 3600),
|
||||
],
|
||||
};
|
||||
|
||||
// Column visibility state
|
||||
const [visibleColumns, setVisibleColumns] = useState({});
|
||||
const [showColumnSelector, setShowColumnSelector] = useState(false);
|
||||
|
||||
// Compact mode
|
||||
const [compactMode, setCompactMode] = useTableCompactMode('mjLogs');
|
||||
|
||||
// Load saved column preferences from localStorage
|
||||
useEffect(() => {
|
||||
const savedColumns = localStorage.getItem(STORAGE_KEY);
|
||||
if (savedColumns) {
|
||||
try {
|
||||
const parsed = JSON.parse(savedColumns);
|
||||
const defaults = getDefaultColumnVisibility();
|
||||
const merged = { ...defaults, ...parsed };
|
||||
|
||||
// For non-admin users, force-hide admin-only columns (does not touch admin settings)
|
||||
if (!isAdminUser) {
|
||||
merged[COLUMN_KEYS.CHANNEL] = false;
|
||||
merged[COLUMN_KEYS.SUBMIT_RESULT] = false;
|
||||
}
|
||||
setVisibleColumns(merged);
|
||||
} catch (e) {
|
||||
console.error('Failed to parse saved column preferences', e);
|
||||
initDefaultColumns();
|
||||
}
|
||||
} else {
|
||||
initDefaultColumns();
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Check banner notification
|
||||
useEffect(() => {
|
||||
const mjNotifyEnabled = localStorage.getItem('mj_notify_enabled');
|
||||
if (mjNotifyEnabled !== 'true') {
|
||||
setShowBanner(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Get default column visibility based on user role
|
||||
const getDefaultColumnVisibility = () => {
|
||||
return {
|
||||
[COLUMN_KEYS.SUBMIT_TIME]: true,
|
||||
[COLUMN_KEYS.DURATION]: true,
|
||||
[COLUMN_KEYS.CHANNEL]: isAdminUser,
|
||||
[COLUMN_KEYS.TYPE]: true,
|
||||
[COLUMN_KEYS.TASK_ID]: true,
|
||||
[COLUMN_KEYS.SUBMIT_RESULT]: isAdminUser,
|
||||
[COLUMN_KEYS.TASK_STATUS]: true,
|
||||
[COLUMN_KEYS.PROGRESS]: true,
|
||||
[COLUMN_KEYS.IMAGE]: true,
|
||||
[COLUMN_KEYS.PROMPT]: true,
|
||||
[COLUMN_KEYS.PROMPT_EN]: true,
|
||||
[COLUMN_KEYS.FAIL_REASON]: true,
|
||||
};
|
||||
};
|
||||
|
||||
// Initialize default column visibility
|
||||
const initDefaultColumns = () => {
|
||||
const defaults = getDefaultColumnVisibility();
|
||||
setVisibleColumns(defaults);
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(defaults));
|
||||
};
|
||||
|
||||
// Handle column visibility change
|
||||
const handleColumnVisibilityChange = (columnKey, checked) => {
|
||||
const updatedColumns = { ...visibleColumns, [columnKey]: checked };
|
||||
setVisibleColumns(updatedColumns);
|
||||
};
|
||||
|
||||
// Handle "Select All" checkbox
|
||||
const handleSelectAll = (checked) => {
|
||||
const allKeys = Object.keys(COLUMN_KEYS).map((key) => COLUMN_KEYS[key]);
|
||||
const updatedColumns = {};
|
||||
|
||||
allKeys.forEach((key) => {
|
||||
if (
|
||||
(key === COLUMN_KEYS.CHANNEL || key === COLUMN_KEYS.SUBMIT_RESULT) &&
|
||||
!isAdminUser
|
||||
) {
|
||||
updatedColumns[key] = false;
|
||||
} else {
|
||||
updatedColumns[key] = checked;
|
||||
}
|
||||
});
|
||||
|
||||
setVisibleColumns(updatedColumns);
|
||||
};
|
||||
|
||||
// Persist column settings to the role-specific STORAGE_KEY
|
||||
useEffect(() => {
|
||||
if (Object.keys(visibleColumns).length > 0) {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(visibleColumns));
|
||||
}
|
||||
}, [visibleColumns]);
|
||||
|
||||
// Get form values helper function
|
||||
const getFormValues = () => {
|
||||
const formValues = formApi ? formApi.getValues() : {};
|
||||
|
||||
let start_timestamp = timestamp2string(now.getTime() / 1000 - 2592000);
|
||||
let end_timestamp = timestamp2string(now.getTime() / 1000 + 3600);
|
||||
|
||||
if (
|
||||
formValues.dateRange &&
|
||||
Array.isArray(formValues.dateRange) &&
|
||||
formValues.dateRange.length === 2
|
||||
) {
|
||||
start_timestamp = formValues.dateRange[0];
|
||||
end_timestamp = formValues.dateRange[1];
|
||||
}
|
||||
|
||||
return {
|
||||
channel_id: formValues.channel_id || '',
|
||||
mj_id: formValues.mj_id || '',
|
||||
start_timestamp,
|
||||
end_timestamp,
|
||||
};
|
||||
};
|
||||
|
||||
// Enrich logs data
|
||||
const enrichLogs = (items) => {
|
||||
return items.map((log) => ({
|
||||
...log,
|
||||
timestamp2string: timestamp2string(log.created_at),
|
||||
key: '' + log.id,
|
||||
}));
|
||||
};
|
||||
|
||||
// Sync page data
|
||||
const syncPageData = (payload) => {
|
||||
const items = enrichLogs(payload.items || []);
|
||||
setLogs(items);
|
||||
setLogCount(payload.total || 0);
|
||||
setActivePage(payload.page || 1);
|
||||
setPageSize(payload.page_size || pageSize);
|
||||
};
|
||||
|
||||
// Load logs function
|
||||
const loadLogs = async (page = 1, size = pageSize) => {
|
||||
setLoading(true);
|
||||
const { channel_id, mj_id, start_timestamp, end_timestamp } =
|
||||
getFormValues();
|
||||
let localStartTimestamp = Date.parse(start_timestamp);
|
||||
let localEndTimestamp = Date.parse(end_timestamp);
|
||||
const url = isAdminUser
|
||||
? `/api/mj/?p=${page}&page_size=${size}&channel_id=${channel_id}&mj_id=${mj_id}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}`
|
||||
: `/api/mj/self/?p=${page}&page_size=${size}&mj_id=${mj_id}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}`;
|
||||
const res = await API.get(url);
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
syncPageData(data);
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
// Page handlers
|
||||
const handlePageChange = (page) => {
|
||||
loadLogs(page, pageSize).then();
|
||||
};
|
||||
|
||||
const handlePageSizeChange = async (size) => {
|
||||
localStorage.setItem('mj-page-size', size + '');
|
||||
await loadLogs(1, size);
|
||||
};
|
||||
|
||||
// Refresh function
|
||||
const refresh = async () => {
|
||||
await loadLogs(1, pageSize);
|
||||
};
|
||||
|
||||
// Copy text function
|
||||
const copyText = async (text) => {
|
||||
if (await copy(text)) {
|
||||
showSuccess(t('已复制:') + text);
|
||||
} else {
|
||||
Modal.error({ title: t('无法复制到剪贴板,请手动复制'), content: text });
|
||||
}
|
||||
};
|
||||
|
||||
// Modal handlers
|
||||
const openContentModal = (content) => {
|
||||
setModalContent(content);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const openImageModal = (imageUrl) => {
|
||||
setModalImageUrl(imageUrl);
|
||||
setIsModalOpenurl(true);
|
||||
};
|
||||
|
||||
// Initialize data
|
||||
useEffect(() => {
|
||||
const localPageSize =
|
||||
parseInt(localStorage.getItem('mj-page-size')) || ITEMS_PER_PAGE;
|
||||
setPageSize(localPageSize);
|
||||
loadLogs(1, localPageSize).then();
|
||||
}, []);
|
||||
|
||||
return {
|
||||
// Basic state
|
||||
logs,
|
||||
loading,
|
||||
activePage,
|
||||
logCount,
|
||||
pageSize,
|
||||
showBanner,
|
||||
isAdminUser,
|
||||
|
||||
// Modal state
|
||||
isModalOpen,
|
||||
setIsModalOpen,
|
||||
modalContent,
|
||||
isModalOpenurl,
|
||||
setIsModalOpenurl,
|
||||
modalImageUrl,
|
||||
|
||||
// Form state
|
||||
formApi,
|
||||
setFormApi,
|
||||
formInitValues,
|
||||
getFormValues,
|
||||
|
||||
// Column visibility
|
||||
visibleColumns,
|
||||
showColumnSelector,
|
||||
setShowColumnSelector,
|
||||
handleColumnVisibilityChange,
|
||||
handleSelectAll,
|
||||
initDefaultColumns,
|
||||
COLUMN_KEYS,
|
||||
|
||||
// Compact mode
|
||||
compactMode,
|
||||
setCompactMode,
|
||||
|
||||
// Functions
|
||||
loadLogs,
|
||||
handlePageChange,
|
||||
handlePageSizeChange,
|
||||
refresh,
|
||||
copyText,
|
||||
openContentModal,
|
||||
openImageModal,
|
||||
enrichLogs,
|
||||
syncPageData,
|
||||
|
||||
// Translation
|
||||
t,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,312 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { API } from '../../helpers';
|
||||
import { showError } from '../../helpers';
|
||||
|
||||
export const useDeploymentResources = () => {
|
||||
const [hardwareTypes, setHardwareTypes] = useState([]);
|
||||
const [hardwareTotalAvailable, setHardwareTotalAvailable] = useState(0);
|
||||
const [locations, setLocations] = useState([]);
|
||||
const [locationsTotalAvailable, setLocationsTotalAvailable] = useState(0);
|
||||
const [availableReplicas, setAvailableReplicas] = useState([]);
|
||||
const [priceEstimation, setPriceEstimation] = useState(null);
|
||||
|
||||
const [loadingHardware, setLoadingHardware] = useState(false);
|
||||
const [loadingLocations, setLoadingLocations] = useState(false);
|
||||
const [loadingReplicas, setLoadingReplicas] = useState(false);
|
||||
const [loadingPrice, setLoadingPrice] = useState(false);
|
||||
|
||||
const fetchHardwareTypes = useCallback(async () => {
|
||||
try {
|
||||
setLoadingHardware(true);
|
||||
const response = await API.get('/api/deployments/hardware-types');
|
||||
if (response.data.success) {
|
||||
const { hardware_types: hardwareList = [], total_available } =
|
||||
response.data.data || {};
|
||||
const normalizedHardware = hardwareList.map((hardware) => {
|
||||
const availableCountValue = Number(hardware.available_count);
|
||||
const availableCount = Number.isNaN(availableCountValue)
|
||||
? 0
|
||||
: availableCountValue;
|
||||
const availableBool =
|
||||
typeof hardware.available === 'boolean'
|
||||
? hardware.available
|
||||
: availableCount > 0;
|
||||
|
||||
return {
|
||||
...hardware,
|
||||
available: availableBool,
|
||||
available_count: availableCount,
|
||||
};
|
||||
});
|
||||
|
||||
const providedTotal = Number(total_available);
|
||||
const fallbackTotal = normalizedHardware.reduce(
|
||||
(acc, item) =>
|
||||
acc +
|
||||
(Number.isNaN(item.available_count) ? 0 : item.available_count),
|
||||
0,
|
||||
);
|
||||
const hasProvidedTotal =
|
||||
total_available !== undefined &&
|
||||
total_available !== null &&
|
||||
total_available !== '' &&
|
||||
!Number.isNaN(providedTotal);
|
||||
|
||||
setHardwareTypes(normalizedHardware);
|
||||
setHardwareTotalAvailable(
|
||||
hasProvidedTotal ? providedTotal : fallbackTotal,
|
||||
);
|
||||
return normalizedHardware;
|
||||
} else {
|
||||
showError('获取硬件类型失败: ' + response.data.message);
|
||||
setHardwareTotalAvailable(0);
|
||||
return [];
|
||||
}
|
||||
} catch (error) {
|
||||
showError('获取硬件类型失败: ' + error.message);
|
||||
setHardwareTotalAvailable(0);
|
||||
return [];
|
||||
} finally {
|
||||
setLoadingHardware(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchLocations = useCallback(async (hardwareId, gpuCount = 1) => {
|
||||
if (!hardwareId) {
|
||||
setLocations([]);
|
||||
setLocationsTotalAvailable(0);
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
setLoadingLocations(true);
|
||||
const response = await API.get(
|
||||
`/api/deployments/available-replicas?hardware_id=${hardwareId}&gpu_count=${gpuCount}`,
|
||||
);
|
||||
if (response.data.success) {
|
||||
const replicas = response.data.data?.replicas || [];
|
||||
const nextLocationsMap = new Map();
|
||||
replicas.forEach((replica) => {
|
||||
const rawId = replica?.location_id ?? replica?.location?.id;
|
||||
if (rawId === null || rawId === undefined) return;
|
||||
|
||||
const mapKey = String(rawId);
|
||||
if (nextLocationsMap.has(mapKey)) return;
|
||||
|
||||
const rawIso2 =
|
||||
replica?.iso2 ?? replica?.location_iso2 ?? replica?.location?.iso2;
|
||||
const iso2 = rawIso2 ? String(rawIso2).toUpperCase() : '';
|
||||
const name =
|
||||
replica?.location_name ??
|
||||
replica?.location?.name ??
|
||||
replica?.name ??
|
||||
String(rawId);
|
||||
|
||||
nextLocationsMap.set(mapKey, {
|
||||
id: rawId,
|
||||
name: String(name),
|
||||
iso2,
|
||||
region:
|
||||
replica?.region ??
|
||||
replica?.location_region ??
|
||||
replica?.location?.region,
|
||||
country:
|
||||
replica?.country ??
|
||||
replica?.location_country ??
|
||||
replica?.location?.country,
|
||||
code:
|
||||
replica?.code ??
|
||||
replica?.location_code ??
|
||||
replica?.location?.code,
|
||||
available: Number(replica?.available_count) || 0,
|
||||
});
|
||||
});
|
||||
|
||||
const normalizedLocations = Array.from(nextLocationsMap.values());
|
||||
setLocations(normalizedLocations);
|
||||
setLocationsTotalAvailable(
|
||||
normalizedLocations.reduce(
|
||||
(acc, item) => acc + (item.available || 0),
|
||||
0,
|
||||
),
|
||||
);
|
||||
return normalizedLocations;
|
||||
} else {
|
||||
showError('获取部署位置失败: ' + response.data.message);
|
||||
setLocationsTotalAvailable(0);
|
||||
return [];
|
||||
}
|
||||
} catch (error) {
|
||||
showError('获取部署位置失败: ' + error.message);
|
||||
setLocationsTotalAvailable(0);
|
||||
return [];
|
||||
} finally {
|
||||
setLoadingLocations(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchAvailableReplicas = useCallback(
|
||||
async (hardwareId, gpuCount = 1) => {
|
||||
if (!hardwareId) {
|
||||
setAvailableReplicas([]);
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
setLoadingReplicas(true);
|
||||
const response = await API.get(
|
||||
`/api/deployments/available-replicas?hardware_id=${hardwareId}&gpu_count=${gpuCount}`,
|
||||
);
|
||||
if (response.data.success) {
|
||||
const replicas = response.data.data.replicas || [];
|
||||
setAvailableReplicas(replicas);
|
||||
return replicas;
|
||||
} else {
|
||||
showError('获取可用资源失败: ' + response.data.message);
|
||||
setAvailableReplicas([]);
|
||||
return [];
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Load available replicas error:', error);
|
||||
setAvailableReplicas([]);
|
||||
return [];
|
||||
} finally {
|
||||
setLoadingReplicas(false);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const calculatePrice = useCallback(async (params) => {
|
||||
const {
|
||||
locationIds,
|
||||
hardwareId,
|
||||
gpusPerContainer,
|
||||
durationHours,
|
||||
replicaCount,
|
||||
} = params;
|
||||
|
||||
if (
|
||||
!locationIds?.length ||
|
||||
!hardwareId ||
|
||||
!gpusPerContainer ||
|
||||
!durationHours ||
|
||||
!replicaCount
|
||||
) {
|
||||
setPriceEstimation(null);
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoadingPrice(true);
|
||||
const requestData = {
|
||||
location_ids: locationIds,
|
||||
hardware_id: hardwareId,
|
||||
gpus_per_container: gpusPerContainer,
|
||||
duration_hours: durationHours,
|
||||
replica_count: replicaCount,
|
||||
};
|
||||
|
||||
const response = await API.post(
|
||||
'/api/deployments/price-estimation',
|
||||
requestData,
|
||||
);
|
||||
if (response.data.success) {
|
||||
const estimation = response.data.data;
|
||||
setPriceEstimation(estimation);
|
||||
return estimation;
|
||||
} else {
|
||||
showError('价格计算失败: ' + response.data.message);
|
||||
setPriceEstimation(null);
|
||||
return null;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Price calculation error:', error);
|
||||
setPriceEstimation(null);
|
||||
return null;
|
||||
} finally {
|
||||
setLoadingPrice(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const checkClusterNameAvailability = useCallback(async (name) => {
|
||||
if (!name?.trim()) return false;
|
||||
|
||||
try {
|
||||
const response = await API.get(
|
||||
`/api/deployments/check-name?name=${encodeURIComponent(name.trim())}`,
|
||||
);
|
||||
if (response.data.success) {
|
||||
return response.data.data.available;
|
||||
} else {
|
||||
showError('检查名称可用性失败: ' + response.data.message);
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Check cluster name availability error:', error);
|
||||
return false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const createDeployment = useCallback(async (deploymentData) => {
|
||||
try {
|
||||
const response = await API.post('/api/deployments', deploymentData);
|
||||
if (response.data.success) {
|
||||
return response.data.data;
|
||||
} else {
|
||||
throw new Error(response.data.message || '创建部署失败');
|
||||
}
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
// Data
|
||||
hardwareTypes,
|
||||
hardwareTotalAvailable,
|
||||
locations,
|
||||
locationsTotalAvailable,
|
||||
availableReplicas,
|
||||
priceEstimation,
|
||||
|
||||
// Loading states
|
||||
loadingHardware,
|
||||
loadingLocations,
|
||||
loadingReplicas,
|
||||
loadingPrice,
|
||||
|
||||
// Functions
|
||||
fetchHardwareTypes,
|
||||
fetchLocations,
|
||||
fetchAvailableReplicas,
|
||||
calculatePrice,
|
||||
checkClusterNameAvailability,
|
||||
createDeployment,
|
||||
|
||||
// Clear functions
|
||||
clearPriceEstimation: () => setPriceEstimation(null),
|
||||
clearAvailableReplicas: () => setAvailableReplicas([]),
|
||||
};
|
||||
};
|
||||
|
||||
export default useDeploymentResources;
|
||||
@@ -0,0 +1,522 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useMemo, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { API, showError, showSuccess } from '../../helpers';
|
||||
import { ITEMS_PER_PAGE } from '../../constants';
|
||||
import { useTableCompactMode } from '../common/useTableCompactMode';
|
||||
|
||||
export const useDeploymentsData = () => {
|
||||
const { t } = useTranslation();
|
||||
const [compactMode, setCompactMode] = useTableCompactMode('deployments');
|
||||
const requestSeq = useRef(0);
|
||||
|
||||
// State management
|
||||
const [deployments, setDeployments] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [activePage, setActivePage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(ITEMS_PER_PAGE);
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [deploymentCount, setDeploymentCount] = useState(0);
|
||||
const [query, setQuery] = useState({ keyword: '', status: '' });
|
||||
|
||||
// Modal states
|
||||
const [showEdit, setShowEdit] = useState(false);
|
||||
const [editingDeployment, setEditingDeployment] = useState({
|
||||
id: undefined,
|
||||
});
|
||||
|
||||
// Row selection
|
||||
const [selectedKeys, setSelectedKeys] = useState([]);
|
||||
const rowSelection = {
|
||||
getCheckboxProps: (record) => ({
|
||||
name: record.deployment_name,
|
||||
}),
|
||||
selectedRowKeys: selectedKeys.map((deployment) => deployment.id),
|
||||
onChange: (selectedRowKeys, selectedRows) => {
|
||||
setSelectedKeys(selectedRows);
|
||||
},
|
||||
};
|
||||
|
||||
// Form initial values
|
||||
const formInitValues = {
|
||||
searchKeyword: '',
|
||||
searchStatus: '',
|
||||
};
|
||||
|
||||
// ---------- helpers ----------
|
||||
// Safely extract array items from API payload
|
||||
const extractItems = (payload) => {
|
||||
const items = payload?.items || payload || [];
|
||||
return Array.isArray(items) ? items : [];
|
||||
};
|
||||
|
||||
// Form API reference
|
||||
const [formApi, setFormApi] = useState(null);
|
||||
|
||||
// Get form values helper function
|
||||
const getFormValues = () => formApi?.getValues() || formInitValues;
|
||||
|
||||
// Close edit modal
|
||||
const closeEdit = () => {
|
||||
setShowEdit(false);
|
||||
setTimeout(() => {
|
||||
setEditingDeployment({ id: undefined });
|
||||
}, 500);
|
||||
};
|
||||
|
||||
const normalizeQuery = (terms) => {
|
||||
const keyword = (terms?.searchKeyword ?? '').trim();
|
||||
const status = (terms?.searchStatus ?? '').trim();
|
||||
return { keyword, status };
|
||||
};
|
||||
|
||||
// Column visibility
|
||||
const COLUMN_KEYS = useMemo(
|
||||
() => ({
|
||||
id: 'id',
|
||||
status: 'status',
|
||||
provider: 'provider',
|
||||
container_name: 'container_name',
|
||||
time_remaining: 'time_remaining',
|
||||
hardware_info: 'hardware_info',
|
||||
created_at: 'created_at',
|
||||
actions: 'actions',
|
||||
// Legacy keys for compatibility
|
||||
deployment_name: 'deployment_name',
|
||||
model_name: 'model_name',
|
||||
instance_count: 'instance_count',
|
||||
resource_config: 'resource_config',
|
||||
updated_at: 'updated_at',
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
const ensureRequiredColumns = (columns = {}) => {
|
||||
const normalized = {
|
||||
...columns,
|
||||
[COLUMN_KEYS.container_name]: true,
|
||||
[COLUMN_KEYS.actions]: true,
|
||||
};
|
||||
|
||||
if (normalized[COLUMN_KEYS.provider] === undefined) {
|
||||
normalized[COLUMN_KEYS.provider] = true;
|
||||
}
|
||||
|
||||
return normalized;
|
||||
};
|
||||
|
||||
const [visibleColumns, setVisibleColumnsState] = useState(() => {
|
||||
const saved = localStorage.getItem('deployments_visible_columns');
|
||||
if (saved) {
|
||||
try {
|
||||
const parsed = JSON.parse(saved);
|
||||
return ensureRequiredColumns(parsed);
|
||||
} catch (e) {
|
||||
console.error('Failed to parse saved column visibility:', e);
|
||||
}
|
||||
}
|
||||
return ensureRequiredColumns({
|
||||
[COLUMN_KEYS.container_name]: true,
|
||||
[COLUMN_KEYS.status]: true,
|
||||
[COLUMN_KEYS.provider]: true,
|
||||
[COLUMN_KEYS.time_remaining]: true,
|
||||
[COLUMN_KEYS.hardware_info]: true,
|
||||
[COLUMN_KEYS.created_at]: true,
|
||||
[COLUMN_KEYS.actions]: true,
|
||||
// Legacy columns (hidden by default)
|
||||
[COLUMN_KEYS.deployment_name]: false,
|
||||
[COLUMN_KEYS.model_name]: false,
|
||||
[COLUMN_KEYS.instance_count]: false,
|
||||
[COLUMN_KEYS.resource_config]: false,
|
||||
[COLUMN_KEYS.updated_at]: false,
|
||||
});
|
||||
});
|
||||
|
||||
// Column selector modal
|
||||
const [showColumnSelector, setShowColumnSelector] = useState(false);
|
||||
|
||||
// Save column visibility to localStorage
|
||||
const saveColumnVisibility = (newVisibleColumns) => {
|
||||
const normalized = ensureRequiredColumns(newVisibleColumns);
|
||||
localStorage.setItem(
|
||||
'deployments_visible_columns',
|
||||
JSON.stringify(normalized),
|
||||
);
|
||||
setVisibleColumnsState(normalized);
|
||||
};
|
||||
|
||||
const applyDeploymentsData = ({ data, page }) => {
|
||||
const items = extractItems(data);
|
||||
setActivePage(data?.page ?? page);
|
||||
setDeploymentCount(data?.total ?? items.length);
|
||||
setSelectedKeys([]);
|
||||
setDeployments(
|
||||
items.map((deployment) => ({ ...deployment, key: deployment.id })),
|
||||
);
|
||||
};
|
||||
|
||||
const fetchDeployments = async ({ page, size, keyword, status }) => {
|
||||
const seq = ++requestSeq.current;
|
||||
const isSearchMode = Boolean(keyword) || Boolean(status);
|
||||
|
||||
if (isSearchMode) {
|
||||
setSearching(true);
|
||||
} else {
|
||||
setLoading(true);
|
||||
}
|
||||
|
||||
try {
|
||||
let url;
|
||||
if (isSearchMode) {
|
||||
const params = new URLSearchParams({
|
||||
p: String(page),
|
||||
page_size: String(size),
|
||||
});
|
||||
|
||||
if (keyword) params.append('keyword', keyword);
|
||||
if (status) params.append('status', status);
|
||||
|
||||
url = `/api/deployments/search?${params.toString()}`;
|
||||
} else {
|
||||
url = `/api/deployments/?p=${page}&page_size=${size}`;
|
||||
}
|
||||
|
||||
const res = await API.get(url);
|
||||
if (seq !== requestSeq.current) return;
|
||||
|
||||
const { success, message, data } = res.data;
|
||||
if (!success) {
|
||||
showError(message);
|
||||
setDeployments([]);
|
||||
setDeploymentCount(0);
|
||||
return;
|
||||
}
|
||||
|
||||
applyDeploymentsData({ data, page });
|
||||
} catch (error) {
|
||||
if (seq !== requestSeq.current) return;
|
||||
console.error(error);
|
||||
showError(isSearchMode ? t('搜索失败') : t('获取部署列表失败'));
|
||||
setDeployments([]);
|
||||
setDeploymentCount(0);
|
||||
} finally {
|
||||
if (seq !== requestSeq.current) return;
|
||||
setLoading(false);
|
||||
setSearching(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Refresh data
|
||||
const refresh = async (page = activePage) => {
|
||||
await fetchDeployments({
|
||||
page,
|
||||
size: pageSize,
|
||||
keyword: query.keyword,
|
||||
status: query.status,
|
||||
});
|
||||
};
|
||||
|
||||
// Handle page change
|
||||
const handlePageChange = (page) => {
|
||||
setActivePage(page);
|
||||
fetchDeployments({
|
||||
page,
|
||||
size: pageSize,
|
||||
keyword: query.keyword,
|
||||
status: query.status,
|
||||
});
|
||||
};
|
||||
|
||||
// Handle page size change
|
||||
const handlePageSizeChange = (size) => {
|
||||
setPageSize(size);
|
||||
setActivePage(1);
|
||||
fetchDeployments({
|
||||
page: 1,
|
||||
size,
|
||||
keyword: query.keyword,
|
||||
status: query.status,
|
||||
});
|
||||
};
|
||||
|
||||
const loadDeployments = async (page = 1, size = pageSize) => {
|
||||
await fetchDeployments({
|
||||
page,
|
||||
size,
|
||||
keyword: query.keyword,
|
||||
status: query.status,
|
||||
});
|
||||
};
|
||||
|
||||
// Search deployments (also supports pagination)
|
||||
const searchDeployments = async (searchTerms) => {
|
||||
const nextQuery = normalizeQuery(searchTerms);
|
||||
setQuery(nextQuery);
|
||||
setActivePage(1);
|
||||
await fetchDeployments({
|
||||
page: 1,
|
||||
size: pageSize,
|
||||
keyword: nextQuery.keyword,
|
||||
status: nextQuery.status,
|
||||
});
|
||||
};
|
||||
|
||||
// Deployment operations
|
||||
const startDeployment = async (deploymentId) => {
|
||||
try {
|
||||
const res = await API.post(`/api/deployments/${deploymentId}/start`);
|
||||
if (res.data.success) {
|
||||
showSuccess(t('部署启动成功'));
|
||||
await refresh();
|
||||
} else {
|
||||
showError(res.data.message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
showError(t('启动部署失败'));
|
||||
}
|
||||
};
|
||||
|
||||
const restartDeployment = async (deploymentId) => {
|
||||
try {
|
||||
const res = await API.post(`/api/deployments/${deploymentId}/restart`);
|
||||
if (res.data.success) {
|
||||
showSuccess(t('部署重启成功'));
|
||||
await refresh();
|
||||
} else {
|
||||
showError(res.data.message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
showError(t('重启部署失败'));
|
||||
}
|
||||
};
|
||||
|
||||
const deleteDeployment = async (deploymentId) => {
|
||||
try {
|
||||
const res = await API.delete(`/api/deployments/${deploymentId}`);
|
||||
if (res.data.success) {
|
||||
showSuccess(t('部署删除成功'));
|
||||
await refresh();
|
||||
} else {
|
||||
showError(res.data.message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
showError(t('删除部署失败'));
|
||||
}
|
||||
};
|
||||
|
||||
const syncDeploymentToChannel = async (deployment) => {
|
||||
if (!deployment?.id) {
|
||||
showError(t('同步渠道失败:缺少部署信息'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const containersResp = await API.get(
|
||||
`/api/deployments/${deployment.id}/containers`,
|
||||
);
|
||||
if (!containersResp.data?.success) {
|
||||
showError(containersResp.data?.message || t('获取容器信息失败'));
|
||||
return;
|
||||
}
|
||||
|
||||
const containers = containersResp.data?.data?.containers || [];
|
||||
const activeContainer = containers.find((ctr) => ctr?.public_url);
|
||||
|
||||
if (!activeContainer?.public_url) {
|
||||
showError(t('未找到可用的容器访问地址'));
|
||||
return;
|
||||
}
|
||||
|
||||
const rawUrl = String(activeContainer.public_url).trim();
|
||||
const baseUrl = rawUrl.replace(/\/+$/, '');
|
||||
if (!baseUrl) {
|
||||
showError(t('容器访问地址无效'));
|
||||
return;
|
||||
}
|
||||
|
||||
const baseName =
|
||||
deployment.container_name ||
|
||||
deployment.deployment_name ||
|
||||
deployment.name ||
|
||||
deployment.id;
|
||||
const safeName = String(baseName || 'ionet').slice(0, 60);
|
||||
const channelName = `[IO.NET] ${safeName}`;
|
||||
|
||||
let randomKey;
|
||||
try {
|
||||
randomKey =
|
||||
typeof crypto !== 'undefined' && crypto.randomUUID
|
||||
? `ionet-${crypto.randomUUID().replace(/-/g, '')}`
|
||||
: null;
|
||||
} catch (err) {
|
||||
randomKey = null;
|
||||
}
|
||||
if (!randomKey) {
|
||||
randomKey = `ionet-${Math.random().toString(36).slice(2)}${Math.random().toString(36).slice(2)}`;
|
||||
}
|
||||
|
||||
const otherInfo = {
|
||||
source: 'ionet',
|
||||
deployment_id: deployment.id,
|
||||
deployment_name: safeName,
|
||||
container_id: activeContainer.container_id || null,
|
||||
public_url: baseUrl,
|
||||
};
|
||||
|
||||
const payload = {
|
||||
mode: 'single',
|
||||
channel: {
|
||||
name: channelName,
|
||||
type: 4,
|
||||
key: randomKey,
|
||||
base_url: baseUrl,
|
||||
group: 'default',
|
||||
tag: 'ionet',
|
||||
remark: `[IO.NET] Auto-synced from deployment ${deployment.id}`,
|
||||
other_info: JSON.stringify(otherInfo),
|
||||
},
|
||||
};
|
||||
|
||||
const createResp = await API.post('/api/channel/', payload);
|
||||
if (createResp.data?.success) {
|
||||
showSuccess(t('已同步到渠道'));
|
||||
} else {
|
||||
showError(createResp.data?.message || t('同步渠道失败'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
showError(t('同步渠道失败'));
|
||||
}
|
||||
};
|
||||
|
||||
const updateDeploymentName = async (deploymentId, newName) => {
|
||||
try {
|
||||
const res = await API.put(`/api/deployments/${deploymentId}/name`, {
|
||||
name: newName,
|
||||
});
|
||||
if (res.data.success) {
|
||||
showSuccess(t('部署名称更新成功'));
|
||||
await refresh();
|
||||
return true;
|
||||
} else {
|
||||
showError(res.data.message);
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
showError(t('更新部署名称失败'));
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// Batch operations
|
||||
const batchDeleteDeployments = async () => {
|
||||
if (selectedKeys.length === 0) return;
|
||||
|
||||
try {
|
||||
const ids = selectedKeys.map((deployment) => deployment.id);
|
||||
const res = await API.post('/api/deployments/batch_delete', { ids });
|
||||
if (res.data.success) {
|
||||
showSuccess(t('批量删除成功'));
|
||||
setSelectedKeys([]);
|
||||
await refresh();
|
||||
} else {
|
||||
showError(res.data.message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
showError(t('批量删除失败'));
|
||||
}
|
||||
};
|
||||
|
||||
// Table row click handler
|
||||
const handleRow = (record) => ({
|
||||
onClick: () => {
|
||||
// Handle row click if needed
|
||||
},
|
||||
});
|
||||
|
||||
// Initial load
|
||||
useEffect(() => {
|
||||
loadDeployments();
|
||||
}, []);
|
||||
|
||||
return {
|
||||
// Data
|
||||
deployments,
|
||||
loading,
|
||||
searching,
|
||||
activePage,
|
||||
pageSize,
|
||||
deploymentCount,
|
||||
compactMode,
|
||||
setCompactMode,
|
||||
|
||||
// Selection
|
||||
selectedKeys,
|
||||
setSelectedKeys,
|
||||
rowSelection,
|
||||
|
||||
// Modals
|
||||
showEdit,
|
||||
setShowEdit,
|
||||
editingDeployment,
|
||||
setEditingDeployment,
|
||||
closeEdit,
|
||||
|
||||
// Column visibility
|
||||
visibleColumns,
|
||||
setVisibleColumns: saveColumnVisibility,
|
||||
showColumnSelector,
|
||||
setShowColumnSelector,
|
||||
COLUMN_KEYS,
|
||||
|
||||
// Form
|
||||
formInitValues,
|
||||
formApi,
|
||||
setFormApi,
|
||||
getFormValues,
|
||||
|
||||
// Operations
|
||||
loadDeployments,
|
||||
searchDeployments,
|
||||
refresh,
|
||||
handlePageChange,
|
||||
handlePageSizeChange,
|
||||
handleRow,
|
||||
|
||||
// Deployment operations
|
||||
startDeployment,
|
||||
restartDeployment,
|
||||
deleteDeployment,
|
||||
updateDeploymentName,
|
||||
syncDeploymentToChannel,
|
||||
|
||||
// Batch operations
|
||||
batchDeleteDeployments,
|
||||
|
||||
// Translation
|
||||
t,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,286 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { API, showError, showSuccess } from '../../helpers';
|
||||
|
||||
export const useEnhancedDeploymentActions = (t) => {
|
||||
const [loading, setLoading] = useState({});
|
||||
|
||||
// Set loading state for specific operation
|
||||
const setOperationLoading = (operation, deploymentId, isLoading) => {
|
||||
setLoading((prev) => ({
|
||||
...prev,
|
||||
[`${operation}_${deploymentId}`]: isLoading,
|
||||
}));
|
||||
};
|
||||
|
||||
// Get loading state for specific operation
|
||||
const isOperationLoading = (operation, deploymentId) => {
|
||||
return loading[`${operation}_${deploymentId}`] || false;
|
||||
};
|
||||
|
||||
// Extend deployment duration
|
||||
const extendDeployment = async (deploymentId, durationHours) => {
|
||||
try {
|
||||
setOperationLoading('extend', deploymentId, true);
|
||||
|
||||
const response = await API.post(
|
||||
`/api/deployments/${deploymentId}/extend`,
|
||||
{
|
||||
duration_hours: durationHours,
|
||||
},
|
||||
);
|
||||
|
||||
if (response.data.success) {
|
||||
showSuccess(t('容器时长延长成功'));
|
||||
return response.data.data;
|
||||
}
|
||||
} catch (error) {
|
||||
showError(
|
||||
t('延长时长失败') +
|
||||
': ' +
|
||||
(error.response?.data?.message || error.message),
|
||||
);
|
||||
throw error;
|
||||
} finally {
|
||||
setOperationLoading('extend', deploymentId, false);
|
||||
}
|
||||
};
|
||||
|
||||
// Get deployment details
|
||||
const getDeploymentDetails = async (deploymentId) => {
|
||||
try {
|
||||
setOperationLoading('details', deploymentId, true);
|
||||
|
||||
const response = await API.get(`/api/deployments/${deploymentId}`);
|
||||
|
||||
if (response.data.success) {
|
||||
return response.data.data;
|
||||
}
|
||||
} catch (error) {
|
||||
showError(
|
||||
t('获取详情失败') +
|
||||
': ' +
|
||||
(error.response?.data?.message || error.message),
|
||||
);
|
||||
throw error;
|
||||
} finally {
|
||||
setOperationLoading('details', deploymentId, false);
|
||||
}
|
||||
};
|
||||
|
||||
// Get deployment logs
|
||||
const getDeploymentLogs = async (deploymentId, options = {}) => {
|
||||
try {
|
||||
setOperationLoading('logs', deploymentId, true);
|
||||
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (options.containerId)
|
||||
params.append('container_id', options.containerId);
|
||||
if (options.level) params.append('level', options.level);
|
||||
if (options.limit) params.append('limit', options.limit.toString());
|
||||
if (options.cursor) params.append('cursor', options.cursor);
|
||||
if (options.follow) params.append('follow', 'true');
|
||||
if (options.startTime) params.append('start_time', options.startTime);
|
||||
if (options.endTime) params.append('end_time', options.endTime);
|
||||
|
||||
const response = await API.get(
|
||||
`/api/deployments/${deploymentId}/logs?${params}`,
|
||||
);
|
||||
|
||||
if (response.data.success) {
|
||||
return response.data.data;
|
||||
}
|
||||
} catch (error) {
|
||||
showError(
|
||||
t('获取日志失败') +
|
||||
': ' +
|
||||
(error.response?.data?.message || error.message),
|
||||
);
|
||||
throw error;
|
||||
} finally {
|
||||
setOperationLoading('logs', deploymentId, false);
|
||||
}
|
||||
};
|
||||
|
||||
// Update deployment configuration
|
||||
const updateDeploymentConfig = async (deploymentId, config) => {
|
||||
try {
|
||||
setOperationLoading('config', deploymentId, true);
|
||||
|
||||
const response = await API.put(
|
||||
`/api/deployments/${deploymentId}`,
|
||||
config,
|
||||
);
|
||||
|
||||
if (response.data.success) {
|
||||
showSuccess(t('容器配置更新成功'));
|
||||
return response.data.data;
|
||||
}
|
||||
} catch (error) {
|
||||
showError(
|
||||
t('更新配置失败') +
|
||||
': ' +
|
||||
(error.response?.data?.message || error.message),
|
||||
);
|
||||
throw error;
|
||||
} finally {
|
||||
setOperationLoading('config', deploymentId, false);
|
||||
}
|
||||
};
|
||||
|
||||
// Delete (destroy) deployment
|
||||
const deleteDeployment = async (deploymentId) => {
|
||||
try {
|
||||
setOperationLoading('delete', deploymentId, true);
|
||||
|
||||
const response = await API.delete(`/api/deployments/${deploymentId}`);
|
||||
|
||||
if (response.data.success) {
|
||||
showSuccess(t('容器销毁请求已提交'));
|
||||
return response.data.data;
|
||||
}
|
||||
} catch (error) {
|
||||
showError(
|
||||
t('销毁容器失败') +
|
||||
': ' +
|
||||
(error.response?.data?.message || error.message),
|
||||
);
|
||||
throw error;
|
||||
} finally {
|
||||
setOperationLoading('delete', deploymentId, false);
|
||||
}
|
||||
};
|
||||
|
||||
// Update deployment name
|
||||
const updateDeploymentName = async (deploymentId, newName) => {
|
||||
try {
|
||||
setOperationLoading('rename', deploymentId, true);
|
||||
|
||||
const response = await API.put(`/api/deployments/${deploymentId}/name`, {
|
||||
name: newName,
|
||||
});
|
||||
|
||||
if (response.data.success) {
|
||||
showSuccess(t('容器名称更新成功'));
|
||||
return response.data.data;
|
||||
}
|
||||
} catch (error) {
|
||||
showError(
|
||||
t('更新名称失败') +
|
||||
': ' +
|
||||
(error.response?.data?.message || error.message),
|
||||
);
|
||||
throw error;
|
||||
} finally {
|
||||
setOperationLoading('rename', deploymentId, false);
|
||||
}
|
||||
};
|
||||
|
||||
// Batch operations
|
||||
const batchDelete = async (deploymentIds) => {
|
||||
try {
|
||||
setOperationLoading('batch_delete', 'all', true);
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
deploymentIds.map((id) => deleteDeployment(id)),
|
||||
);
|
||||
|
||||
const successful = results.filter((r) => r.status === 'fulfilled').length;
|
||||
const failed = results.filter((r) => r.status === 'rejected').length;
|
||||
|
||||
if (successful > 0) {
|
||||
showSuccess(
|
||||
t('批量操作完成: {{success}}个成功, {{failed}}个失败', {
|
||||
success: successful,
|
||||
failed: failed,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return { successful, failed };
|
||||
} catch (error) {
|
||||
showError(t('批量操作失败') + ': ' + error.message);
|
||||
throw error;
|
||||
} finally {
|
||||
setOperationLoading('batch_delete', 'all', false);
|
||||
}
|
||||
};
|
||||
|
||||
// Export logs
|
||||
const exportLogs = async (deploymentId, options = {}) => {
|
||||
try {
|
||||
setOperationLoading('export_logs', deploymentId, true);
|
||||
|
||||
const logs = await getDeploymentLogs(deploymentId, {
|
||||
...options,
|
||||
limit: 10000, // Get more logs for export
|
||||
});
|
||||
|
||||
if (logs && logs.logs) {
|
||||
const logText = logs.logs
|
||||
.map(
|
||||
(log) =>
|
||||
`[${new Date(log.timestamp).toISOString()}] [${log.level}] ${log.source ? `[${log.source}] ` : ''}${log.message}`,
|
||||
)
|
||||
.join('\n');
|
||||
|
||||
const blob = new Blob([logText], { type: 'text/plain' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `deployment-${deploymentId}-logs-${new Date().toISOString().split('T')[0]}.txt`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
showSuccess(t('日志导出成功'));
|
||||
}
|
||||
} catch (error) {
|
||||
showError(t('导出日志失败') + ': ' + error.message);
|
||||
throw error;
|
||||
} finally {
|
||||
setOperationLoading('export_logs', deploymentId, false);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
// Actions
|
||||
extendDeployment,
|
||||
getDeploymentDetails,
|
||||
getDeploymentLogs,
|
||||
updateDeploymentConfig,
|
||||
deleteDeployment,
|
||||
updateDeploymentName,
|
||||
batchDelete,
|
||||
exportLogs,
|
||||
|
||||
// Loading states
|
||||
isOperationLoading,
|
||||
loading,
|
||||
|
||||
// Utility
|
||||
setOperationLoading,
|
||||
};
|
||||
};
|
||||
|
||||
export default useEnhancedDeploymentActions;
|
||||
@@ -0,0 +1,137 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { API } from '../../helpers';
|
||||
|
||||
export const useModelDeploymentSettings = () => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [settings, setSettings] = useState({
|
||||
'model_deployment.ionet.enabled': false,
|
||||
});
|
||||
const [connectionState, setConnectionState] = useState({
|
||||
loading: false,
|
||||
ok: null,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const getSettings = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await API.get('/api/deployments/settings');
|
||||
const { success, data } = res.data;
|
||||
|
||||
if (success) {
|
||||
setSettings({
|
||||
'model_deployment.ionet.enabled': data?.enabled === true,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to get model deployment settings:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
getSettings();
|
||||
}, []);
|
||||
|
||||
const isIoNetEnabled = settings['model_deployment.ionet.enabled'];
|
||||
|
||||
const buildConnectionError = (
|
||||
rawMessage,
|
||||
fallbackMessage = 'Connection failed',
|
||||
) => {
|
||||
const message = (rawMessage || fallbackMessage).trim();
|
||||
const normalized = message.toLowerCase();
|
||||
if (normalized.includes('expired') || normalized.includes('expire')) {
|
||||
return { type: 'expired', message };
|
||||
}
|
||||
if (
|
||||
normalized.includes('invalid') ||
|
||||
normalized.includes('unauthorized') ||
|
||||
normalized.includes('api key')
|
||||
) {
|
||||
return { type: 'invalid', message };
|
||||
}
|
||||
if (normalized.includes('network') || normalized.includes('timeout')) {
|
||||
return { type: 'network', message };
|
||||
}
|
||||
return { type: 'unknown', message };
|
||||
};
|
||||
|
||||
const testConnection = useCallback(async () => {
|
||||
setConnectionState({ loading: true, ok: null, error: null });
|
||||
try {
|
||||
const response = await API.post(
|
||||
'/api/deployments/settings/test-connection',
|
||||
{},
|
||||
{ skipErrorHandler: true },
|
||||
);
|
||||
|
||||
if (response?.data?.success) {
|
||||
setConnectionState({ loading: false, ok: true, error: null });
|
||||
return;
|
||||
}
|
||||
|
||||
const message = response?.data?.message || 'Connection failed';
|
||||
setConnectionState({
|
||||
loading: false,
|
||||
ok: false,
|
||||
error: buildConnectionError(message),
|
||||
});
|
||||
} catch (error) {
|
||||
if (error?.code === 'ERR_NETWORK') {
|
||||
setConnectionState({
|
||||
loading: false,
|
||||
ok: false,
|
||||
error: { type: 'network', message: 'Network connection failed' },
|
||||
});
|
||||
return;
|
||||
}
|
||||
const rawMessage =
|
||||
error?.response?.data?.message || error?.message || 'Unknown error';
|
||||
setConnectionState({
|
||||
loading: false,
|
||||
ok: false,
|
||||
error: buildConnectionError(rawMessage, 'Connection failed'),
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && isIoNetEnabled) {
|
||||
testConnection();
|
||||
return;
|
||||
}
|
||||
setConnectionState({ loading: false, ok: null, error: null });
|
||||
}, [loading, isIoNetEnabled, testConnection]);
|
||||
|
||||
return {
|
||||
loading,
|
||||
settings,
|
||||
isIoNetEnabled,
|
||||
refresh: getSettings,
|
||||
connectionLoading: connectionState.loading,
|
||||
connectionOk: connectionState.ok,
|
||||
connectionError: connectionState.error,
|
||||
testConnection,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,408 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useContext, useRef, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { API, copy, showError, showInfo, showSuccess } from '../../helpers';
|
||||
import { Modal } from '@douyinfe/semi-ui';
|
||||
import { UserContext } from '../../context/User';
|
||||
import { StatusContext } from '../../context/Status';
|
||||
|
||||
export const useModelPricingData = () => {
|
||||
const { t } = useTranslation();
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
const compositionRef = useRef({ isComposition: false });
|
||||
const [selectedRowKeys, setSelectedRowKeys] = useState([]);
|
||||
const [modalImageUrl, setModalImageUrl] = useState('');
|
||||
const [isModalOpenurl, setIsModalOpenurl] = useState(false);
|
||||
const [selectedGroup, setSelectedGroup] = useState('all');
|
||||
const [showModelDetail, setShowModelDetail] = useState(false);
|
||||
const [selectedModel, setSelectedModel] = useState(null);
|
||||
const [filterGroup, setFilterGroup] = useState('all'); // 用于 Table 的可用分组筛选,"all" 表示不过滤
|
||||
const [filterQuotaType, setFilterQuotaType] = useState('all'); // 计费类型筛选: 'all' | 0 | 1
|
||||
const [filterEndpointType, setFilterEndpointType] = useState('all'); // 端点类型筛选: 'all' | string
|
||||
const [filterVendor, setFilterVendor] = useState('all'); // 供应商筛选: 'all' | 'unknown' | string
|
||||
const [filterTag, setFilterTag] = useState('all'); // 模型标签筛选: 'all' | string
|
||||
const [pageSize, setPageSize] = useState(20);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [currency, setCurrency] = useState('USD');
|
||||
const [showWithRecharge, setShowWithRecharge] = useState(false);
|
||||
const [tokenUnit, setTokenUnit] = useState('M');
|
||||
const [models, setModels] = useState([]);
|
||||
const [vendorsMap, setVendorsMap] = useState({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [groupRatio, setGroupRatio] = useState({});
|
||||
const [usableGroup, setUsableGroup] = useState({});
|
||||
const [endpointMap, setEndpointMap] = useState({});
|
||||
const [autoGroups, setAutoGroups] = useState([]);
|
||||
|
||||
const [statusState] = useContext(StatusContext);
|
||||
const [userState] = useContext(UserContext);
|
||||
|
||||
// 充值汇率(price)与美元兑人民币汇率(usd_exchange_rate)
|
||||
const priceRate = useMemo(
|
||||
() => statusState?.status?.price ?? 1,
|
||||
[statusState],
|
||||
);
|
||||
const usdExchangeRate = useMemo(
|
||||
() => statusState?.status?.usd_exchange_rate ?? priceRate,
|
||||
[statusState, priceRate],
|
||||
);
|
||||
const customExchangeRate = useMemo(
|
||||
() => statusState?.status?.custom_currency_exchange_rate ?? 1,
|
||||
[statusState],
|
||||
);
|
||||
const customCurrencySymbol = useMemo(
|
||||
() => statusState?.status?.custom_currency_symbol ?? '¤',
|
||||
[statusState],
|
||||
);
|
||||
|
||||
// 默认货币与站点展示类型同步;TOKENS 由视图层走倍率展示
|
||||
const siteDisplayType = useMemo(
|
||||
() => statusState?.status?.quota_display_type || 'USD',
|
||||
[statusState],
|
||||
);
|
||||
useEffect(() => {
|
||||
if (
|
||||
siteDisplayType === 'USD' ||
|
||||
siteDisplayType === 'CNY' ||
|
||||
siteDisplayType === 'CUSTOM'
|
||||
) {
|
||||
setCurrency(siteDisplayType);
|
||||
}
|
||||
}, [siteDisplayType]);
|
||||
|
||||
useEffect(() => {
|
||||
if (siteDisplayType === 'TOKENS') {
|
||||
setShowWithRecharge(false);
|
||||
setCurrency('USD');
|
||||
}
|
||||
}, [siteDisplayType]);
|
||||
|
||||
const filteredModels = useMemo(() => {
|
||||
let result = models;
|
||||
|
||||
// 分组筛选
|
||||
if (filterGroup !== 'all') {
|
||||
result = result.filter((model) =>
|
||||
model.enable_groups.includes(filterGroup),
|
||||
);
|
||||
}
|
||||
|
||||
// 计费类型筛选
|
||||
if (filterQuotaType !== 'all') {
|
||||
result = result.filter((model) => model.quota_type === filterQuotaType);
|
||||
}
|
||||
|
||||
// 端点类型筛选
|
||||
if (filterEndpointType !== 'all') {
|
||||
result = result.filter(
|
||||
(model) =>
|
||||
model.supported_endpoint_types &&
|
||||
model.supported_endpoint_types.includes(filterEndpointType),
|
||||
);
|
||||
}
|
||||
|
||||
// 供应商筛选
|
||||
if (filterVendor !== 'all') {
|
||||
if (filterVendor === 'unknown') {
|
||||
result = result.filter((model) => !model.vendor_name);
|
||||
} else {
|
||||
result = result.filter((model) => model.vendor_name === filterVendor);
|
||||
}
|
||||
}
|
||||
|
||||
// 标签筛选
|
||||
if (filterTag !== 'all') {
|
||||
const tagLower = filterTag.toLowerCase();
|
||||
result = result.filter((model) => {
|
||||
if (!model.tags) return false;
|
||||
const tagsArr = model.tags
|
||||
.toLowerCase()
|
||||
.split(/[,;|]+/)
|
||||
.map((tag) => tag.trim())
|
||||
.filter(Boolean);
|
||||
return tagsArr.includes(tagLower);
|
||||
});
|
||||
}
|
||||
|
||||
// 搜索筛选
|
||||
if (searchValue.length > 0) {
|
||||
const searchTerm = searchValue.toLowerCase();
|
||||
result = result.filter(
|
||||
(model) =>
|
||||
(model.model_name &&
|
||||
model.model_name.toLowerCase().includes(searchTerm)) ||
|
||||
(model.description &&
|
||||
model.description.toLowerCase().includes(searchTerm)) ||
|
||||
(model.tags && model.tags.toLowerCase().includes(searchTerm)) ||
|
||||
(model.vendor_name &&
|
||||
model.vendor_name.toLowerCase().includes(searchTerm)),
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [
|
||||
models,
|
||||
searchValue,
|
||||
filterGroup,
|
||||
filterQuotaType,
|
||||
filterEndpointType,
|
||||
filterVendor,
|
||||
filterTag,
|
||||
]);
|
||||
|
||||
const rowSelection = useMemo(
|
||||
() => ({
|
||||
selectedRowKeys,
|
||||
onChange: (keys) => {
|
||||
setSelectedRowKeys(keys);
|
||||
},
|
||||
}),
|
||||
[selectedRowKeys],
|
||||
);
|
||||
|
||||
const displayPrice = (usdPrice) => {
|
||||
let priceInUSD = usdPrice;
|
||||
if (showWithRecharge) {
|
||||
priceInUSD = (usdPrice * priceRate) / usdExchangeRate;
|
||||
}
|
||||
|
||||
if (currency === 'CNY') {
|
||||
return `¥${(priceInUSD * usdExchangeRate).toFixed(3)}`;
|
||||
} else if (currency === 'CUSTOM') {
|
||||
return `${customCurrencySymbol}${(priceInUSD * customExchangeRate).toFixed(3)}`;
|
||||
}
|
||||
return `$${priceInUSD.toFixed(3)}`;
|
||||
};
|
||||
|
||||
const setModelsFormat = (models, groupRatio, vendorMap) => {
|
||||
for (let i = 0; i < models.length; i++) {
|
||||
const m = models[i];
|
||||
m.key = m.model_name;
|
||||
m.group_ratio = groupRatio[m.model_name];
|
||||
|
||||
if (m.vendor_id && vendorMap[m.vendor_id]) {
|
||||
const vendor = vendorMap[m.vendor_id];
|
||||
m.vendor_name = vendor.name;
|
||||
m.vendor_icon = vendor.icon;
|
||||
m.vendor_description = vendor.description;
|
||||
}
|
||||
}
|
||||
models.sort((a, b) => {
|
||||
return a.quota_type - b.quota_type;
|
||||
});
|
||||
|
||||
models.sort((a, b) => {
|
||||
if (a.model_name.startsWith('gpt') && !b.model_name.startsWith('gpt')) {
|
||||
return -1;
|
||||
} else if (
|
||||
!a.model_name.startsWith('gpt') &&
|
||||
b.model_name.startsWith('gpt')
|
||||
) {
|
||||
return 1;
|
||||
} else {
|
||||
return a.model_name.localeCompare(b.model_name);
|
||||
}
|
||||
});
|
||||
|
||||
setModels(models);
|
||||
};
|
||||
|
||||
const loadPricing = async () => {
|
||||
setLoading(true);
|
||||
let url = '/api/pricing';
|
||||
const res = await API.get(url);
|
||||
const {
|
||||
success,
|
||||
message,
|
||||
data,
|
||||
vendors,
|
||||
group_ratio,
|
||||
usable_group,
|
||||
supported_endpoint,
|
||||
auto_groups,
|
||||
} = res.data;
|
||||
if (success) {
|
||||
setGroupRatio(group_ratio);
|
||||
setUsableGroup(usable_group);
|
||||
setSelectedGroup('all');
|
||||
// 构建供应商 Map 方便查找
|
||||
const vendorMap = {};
|
||||
if (Array.isArray(vendors)) {
|
||||
vendors.forEach((v) => {
|
||||
vendorMap[v.id] = v;
|
||||
});
|
||||
}
|
||||
setVendorsMap(vendorMap);
|
||||
setEndpointMap(supported_endpoint || {});
|
||||
setAutoGroups(auto_groups || []);
|
||||
setModelsFormat(data, group_ratio, vendorMap);
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const refresh = async () => {
|
||||
await loadPricing();
|
||||
};
|
||||
|
||||
const copyText = async (text) => {
|
||||
if (await copy(text)) {
|
||||
showSuccess(t('已复制:') + text);
|
||||
} else {
|
||||
Modal.error({ title: t('无法复制到剪贴板,请手动复制'), content: text });
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (value) => {
|
||||
const newSearchValue = value ? value : '';
|
||||
setSearchValue(newSearchValue);
|
||||
};
|
||||
|
||||
const handleCompositionStart = () => {
|
||||
compositionRef.current.isComposition = true;
|
||||
};
|
||||
|
||||
const handleCompositionEnd = (event) => {
|
||||
compositionRef.current.isComposition = false;
|
||||
const value = event.target.value;
|
||||
const newSearchValue = value ? value : '';
|
||||
setSearchValue(newSearchValue);
|
||||
};
|
||||
|
||||
const handleGroupClick = (group) => {
|
||||
setSelectedGroup(group);
|
||||
setFilterGroup(group);
|
||||
if (group === 'all') {
|
||||
showInfo(t('已切换至最优倍率视图,每个模型使用其最低倍率分组'));
|
||||
} else {
|
||||
showInfo(
|
||||
t('当前查看的分组为:{{group}},倍率为:{{ratio}}', {
|
||||
group: group,
|
||||
ratio: groupRatio[group] ?? 1,
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const openModelDetail = (model) => {
|
||||
setSelectedModel(model);
|
||||
setShowModelDetail(true);
|
||||
};
|
||||
|
||||
const closeModelDetail = () => {
|
||||
setShowModelDetail(false);
|
||||
setTimeout(() => {
|
||||
setSelectedModel(null);
|
||||
}, 300);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
refresh().then();
|
||||
}, []);
|
||||
|
||||
// 当筛选条件变化时重置到第一页
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [
|
||||
filterGroup,
|
||||
filterQuotaType,
|
||||
filterEndpointType,
|
||||
filterVendor,
|
||||
filterTag,
|
||||
searchValue,
|
||||
]);
|
||||
|
||||
return {
|
||||
// 状态
|
||||
searchValue,
|
||||
setSearchValue,
|
||||
selectedRowKeys,
|
||||
setSelectedRowKeys,
|
||||
modalImageUrl,
|
||||
setModalImageUrl,
|
||||
isModalOpenurl,
|
||||
setIsModalOpenurl,
|
||||
selectedGroup,
|
||||
setSelectedGroup,
|
||||
showModelDetail,
|
||||
setShowModelDetail,
|
||||
selectedModel,
|
||||
setSelectedModel,
|
||||
filterGroup,
|
||||
setFilterGroup,
|
||||
filterQuotaType,
|
||||
setFilterQuotaType,
|
||||
filterEndpointType,
|
||||
setFilterEndpointType,
|
||||
filterVendor,
|
||||
setFilterVendor,
|
||||
filterTag,
|
||||
setFilterTag,
|
||||
pageSize,
|
||||
setPageSize,
|
||||
currentPage,
|
||||
setCurrentPage,
|
||||
currency,
|
||||
setCurrency,
|
||||
siteDisplayType,
|
||||
showWithRecharge,
|
||||
setShowWithRecharge,
|
||||
tokenUnit,
|
||||
setTokenUnit,
|
||||
models,
|
||||
loading,
|
||||
groupRatio,
|
||||
usableGroup,
|
||||
endpointMap,
|
||||
autoGroups,
|
||||
|
||||
// 计算属性
|
||||
priceRate,
|
||||
usdExchangeRate,
|
||||
filteredModels,
|
||||
rowSelection,
|
||||
|
||||
// 供应商
|
||||
vendorsMap,
|
||||
|
||||
// 用户和状态
|
||||
userState,
|
||||
statusState,
|
||||
|
||||
// 方法
|
||||
displayPrice,
|
||||
refresh,
|
||||
copyText,
|
||||
handleChange,
|
||||
handleCompositionStart,
|
||||
handleCompositionEnd,
|
||||
handleGroupClick,
|
||||
openModelDetail,
|
||||
closeModelDetail,
|
||||
|
||||
// 引用
|
||||
compositionRef,
|
||||
|
||||
// 国际化
|
||||
t,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,174 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react';
|
||||
|
||||
// 工具函数:将 tags 字符串转为小写去重数组
|
||||
const normalizeTags = (tags = '') =>
|
||||
tags
|
||||
.toLowerCase()
|
||||
.split(/[,;|]+/)
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
/**
|
||||
* 统一计算模型筛选后的各种集合与动态计数,供多个组件复用
|
||||
*/
|
||||
export const usePricingFilterCounts = ({
|
||||
models = [],
|
||||
filterGroup = 'all',
|
||||
filterQuotaType = 'all',
|
||||
filterEndpointType = 'all',
|
||||
filterVendor = 'all',
|
||||
filterTag = 'all',
|
||||
searchValue = '',
|
||||
}) => {
|
||||
// 均使用同一份模型列表,避免创建新引用
|
||||
const allModels = models;
|
||||
|
||||
/**
|
||||
* 通用过滤函数
|
||||
* @param {Object} model
|
||||
* @param {Array<string>} ignore 需要忽略的过滤条件 key
|
||||
* @returns {boolean}
|
||||
*/
|
||||
const matchesFilters = (model, ignore = []) => {
|
||||
// 分组
|
||||
if (!ignore.includes('group') && filterGroup !== 'all') {
|
||||
if (!model.enable_groups || !model.enable_groups.includes(filterGroup))
|
||||
return false;
|
||||
}
|
||||
|
||||
// 计费类型
|
||||
if (!ignore.includes('quota') && filterQuotaType !== 'all') {
|
||||
if (model.quota_type !== filterQuotaType) return false;
|
||||
}
|
||||
|
||||
// 端点类型
|
||||
if (!ignore.includes('endpoint') && filterEndpointType !== 'all') {
|
||||
if (
|
||||
!model.supported_endpoint_types ||
|
||||
!model.supported_endpoint_types.includes(filterEndpointType)
|
||||
)
|
||||
return false;
|
||||
}
|
||||
|
||||
// 供应商
|
||||
if (!ignore.includes('vendor') && filterVendor !== 'all') {
|
||||
if (filterVendor === 'unknown') {
|
||||
if (model.vendor_name) return false;
|
||||
} else if (model.vendor_name !== filterVendor) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 标签
|
||||
if (!ignore.includes('tag') && filterTag !== 'all') {
|
||||
const tagsArr = normalizeTags(model.tags);
|
||||
if (!tagsArr.includes(filterTag.toLowerCase())) return false;
|
||||
}
|
||||
|
||||
// 搜索
|
||||
if (!ignore.includes('search') && searchValue) {
|
||||
const term = searchValue.toLowerCase();
|
||||
const tags = model.tags ? model.tags.toLowerCase() : '';
|
||||
if (
|
||||
!(
|
||||
model.model_name.toLowerCase().includes(term) ||
|
||||
(model.description &&
|
||||
model.description.toLowerCase().includes(term)) ||
|
||||
tags.includes(term) ||
|
||||
(model.vendor_name && model.vendor_name.toLowerCase().includes(term))
|
||||
)
|
||||
)
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
// 生成不同视图所需的模型集合
|
||||
const quotaTypeModels = useMemo(
|
||||
() => allModels.filter((m) => matchesFilters(m, ['quota'])),
|
||||
[
|
||||
allModels,
|
||||
filterGroup,
|
||||
filterEndpointType,
|
||||
filterVendor,
|
||||
filterTag,
|
||||
searchValue,
|
||||
],
|
||||
);
|
||||
|
||||
const endpointTypeModels = useMemo(
|
||||
() => allModels.filter((m) => matchesFilters(m, ['endpoint'])),
|
||||
[
|
||||
allModels,
|
||||
filterGroup,
|
||||
filterQuotaType,
|
||||
filterVendor,
|
||||
filterTag,
|
||||
searchValue,
|
||||
],
|
||||
);
|
||||
|
||||
const vendorModels = useMemo(
|
||||
() => allModels.filter((m) => matchesFilters(m, ['vendor'])),
|
||||
[
|
||||
allModels,
|
||||
filterGroup,
|
||||
filterQuotaType,
|
||||
filterEndpointType,
|
||||
filterTag,
|
||||
searchValue,
|
||||
],
|
||||
);
|
||||
|
||||
const tagModels = useMemo(
|
||||
() => allModels.filter((m) => matchesFilters(m, ['tag'])),
|
||||
[
|
||||
allModels,
|
||||
filterGroup,
|
||||
filterQuotaType,
|
||||
filterEndpointType,
|
||||
filterVendor,
|
||||
searchValue,
|
||||
],
|
||||
);
|
||||
|
||||
const groupCountModels = useMemo(
|
||||
() => allModels.filter((m) => matchesFilters(m, ['group'])),
|
||||
[
|
||||
allModels,
|
||||
filterQuotaType,
|
||||
filterEndpointType,
|
||||
filterVendor,
|
||||
filterTag,
|
||||
searchValue,
|
||||
],
|
||||
);
|
||||
|
||||
return {
|
||||
quotaTypeModels,
|
||||
endpointTypeModels,
|
||||
vendorModels,
|
||||
groupCountModels,
|
||||
tagModels,
|
||||
};
|
||||
};
|
||||
+497
@@ -0,0 +1,497 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { API, showError, showSuccess } from '../../helpers';
|
||||
import { ITEMS_PER_PAGE } from '../../constants';
|
||||
import { useTableCompactMode } from '../common/useTableCompactMode';
|
||||
|
||||
export const useModelsData = () => {
|
||||
const { t } = useTranslation();
|
||||
const [compactMode, setCompactMode] = useTableCompactMode('models');
|
||||
|
||||
// State management
|
||||
const [models, setModels] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [activePage, setActivePage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(ITEMS_PER_PAGE);
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [modelCount, setModelCount] = useState(0);
|
||||
|
||||
// Modal states
|
||||
const [showEdit, setShowEdit] = useState(false);
|
||||
const [editingModel, setEditingModel] = useState({
|
||||
id: undefined,
|
||||
});
|
||||
|
||||
// Row selection
|
||||
const [selectedKeys, setSelectedKeys] = useState([]);
|
||||
const rowSelection = {
|
||||
getCheckboxProps: (record) => ({
|
||||
name: record.model_name,
|
||||
}),
|
||||
selectedRowKeys: selectedKeys.map((model) => model.id),
|
||||
onChange: (selectedRowKeys, selectedRows) => {
|
||||
setSelectedKeys(selectedRows);
|
||||
},
|
||||
};
|
||||
|
||||
// Form initial values
|
||||
const formInitValues = {
|
||||
searchKeyword: '',
|
||||
searchVendor: '',
|
||||
};
|
||||
|
||||
// ---------- helpers ----------
|
||||
// Safely extract array items from API payload
|
||||
const extractItems = (payload) => {
|
||||
const items = payload?.items || payload || [];
|
||||
return Array.isArray(items) ? items : [];
|
||||
};
|
||||
|
||||
// Form API reference
|
||||
const [formApi, setFormApi] = useState(null);
|
||||
|
||||
// Get form values helper function
|
||||
const getFormValues = () => formApi?.getValues() || formInitValues;
|
||||
|
||||
// Close edit modal
|
||||
const closeEdit = () => {
|
||||
setShowEdit(false);
|
||||
setTimeout(() => {
|
||||
setEditingModel({ id: undefined });
|
||||
}, 500);
|
||||
};
|
||||
|
||||
// Set model format with key field
|
||||
const setModelFormat = (models) => {
|
||||
for (let i = 0; i < models.length; i++) {
|
||||
models[i].key = models[i].id;
|
||||
}
|
||||
setModels(models);
|
||||
};
|
||||
|
||||
// Vendor list
|
||||
const [vendors, setVendors] = useState([]);
|
||||
const [vendorCounts, setVendorCounts] = useState({});
|
||||
const [activeVendorKey, setActiveVendorKey] = useState('all');
|
||||
const [showAddVendor, setShowAddVendor] = useState(false);
|
||||
const [showEditVendor, setShowEditVendor] = useState(false);
|
||||
const [editingVendor, setEditingVendor] = useState({ id: undefined });
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [previewing, setPreviewing] = useState(false);
|
||||
|
||||
const vendorMap = useMemo(() => {
|
||||
const map = {};
|
||||
vendors.forEach((v) => {
|
||||
map[v.id] = v;
|
||||
});
|
||||
return map;
|
||||
}, [vendors]);
|
||||
|
||||
// Load vendor list
|
||||
const loadVendors = async () => {
|
||||
try {
|
||||
const res = await API.get('/api/vendors/?page_size=1000');
|
||||
if (res.data.success) {
|
||||
const items = res.data.data.items || res.data.data || [];
|
||||
setVendors(Array.isArray(items) ? items : []);
|
||||
}
|
||||
} catch (_) {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
// Load models data
|
||||
const loadModels = async (
|
||||
page = 1,
|
||||
size = pageSize,
|
||||
vendorKey = activeVendorKey,
|
||||
) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
let url = `/api/models/?p=${page}&page_size=${size}`;
|
||||
if (vendorKey && vendorKey !== 'all') {
|
||||
// Filter by vendor ID
|
||||
url = `/api/models/search?vendor=${vendorKey}&p=${page}&page_size=${size}`;
|
||||
}
|
||||
|
||||
const res = await API.get(url);
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
const newPageData = extractItems(data);
|
||||
setActivePage(data.page || page);
|
||||
setModelCount(data.total || newPageData.length);
|
||||
setModelFormat(newPageData);
|
||||
|
||||
if (data.vendor_counts) {
|
||||
const sumAll = Object.values(data.vendor_counts).reduce(
|
||||
(acc, v) => acc + v,
|
||||
0,
|
||||
);
|
||||
setVendorCounts({ ...data.vendor_counts, all: sumAll });
|
||||
}
|
||||
} else {
|
||||
showError(message);
|
||||
setModels([]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
showError(t('获取模型列表失败'));
|
||||
setModels([]);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
// Refresh data
|
||||
const refresh = async (page = activePage) => {
|
||||
await loadModels(page, pageSize);
|
||||
};
|
||||
|
||||
// Sync upstream models/vendors for missing models only
|
||||
const syncUpstream = async (opts = {}) => {
|
||||
const locale = opts?.locale;
|
||||
setSyncing(true);
|
||||
try {
|
||||
const body = {};
|
||||
if (locale) body.locale = locale;
|
||||
const res = await API.post('/api/models/sync_upstream', body);
|
||||
const { success, message, data } = res.data || {};
|
||||
if (success) {
|
||||
const createdModels = data?.created_models || 0;
|
||||
const createdVendors = data?.created_vendors || 0;
|
||||
const skipped = (data?.skipped_models || []).length || 0;
|
||||
showSuccess(
|
||||
t(
|
||||
`已同步:新增 ${createdModels} 模型,新增 ${createdVendors} 供应商,跳过 ${skipped} 项`,
|
||||
),
|
||||
);
|
||||
await loadVendors();
|
||||
await refresh();
|
||||
} else {
|
||||
showError(message || t('同步失败'));
|
||||
}
|
||||
} catch (e) {
|
||||
showError(t('同步失败'));
|
||||
}
|
||||
setSyncing(false);
|
||||
};
|
||||
|
||||
// Preview upstream differences
|
||||
const previewUpstreamDiff = async (opts = {}) => {
|
||||
const locale = opts?.locale;
|
||||
setPreviewing(true);
|
||||
try {
|
||||
const url = `/api/models/sync_upstream/preview${locale ? `?locale=${locale}` : ''}`;
|
||||
const res = await API.get(url);
|
||||
const { success, message, data } = res.data || {};
|
||||
if (success) {
|
||||
return data || { missing: [], conflicts: [] };
|
||||
}
|
||||
showError(message || t('预览失败'));
|
||||
return { missing: [], conflicts: [] };
|
||||
} catch (e) {
|
||||
showError(t('预览失败'));
|
||||
return { missing: [], conflicts: [] };
|
||||
} finally {
|
||||
setPreviewing(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Apply selected overwrite
|
||||
const applyUpstreamOverwrite = async (payloadOrArray = []) => {
|
||||
const isArray = Array.isArray(payloadOrArray);
|
||||
const overwrite = isArray ? payloadOrArray : payloadOrArray.overwrite || [];
|
||||
const locale = isArray ? undefined : payloadOrArray.locale;
|
||||
setSyncing(true);
|
||||
try {
|
||||
const body = { overwrite };
|
||||
if (locale) body.locale = locale;
|
||||
const res = await API.post('/api/models/sync_upstream', body);
|
||||
const { success, message, data } = res.data || {};
|
||||
if (success) {
|
||||
const createdModels = data?.created_models || 0;
|
||||
const updatedModels = data?.updated_models || 0;
|
||||
const createdVendors = data?.created_vendors || 0;
|
||||
const skipped = (data?.skipped_models || []).length || 0;
|
||||
showSuccess(
|
||||
t(
|
||||
`完成:新增 ${createdModels} 模型,更新 ${updatedModels} 模型,新增 ${createdVendors} 供应商,跳过 ${skipped} 项`,
|
||||
),
|
||||
);
|
||||
await loadVendors();
|
||||
await refresh();
|
||||
return true;
|
||||
}
|
||||
showError(message || t('同步失败'));
|
||||
return false;
|
||||
} catch (e) {
|
||||
showError(t('同步失败'));
|
||||
return false;
|
||||
} finally {
|
||||
setSyncing(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Search models with keyword and vendor
|
||||
const searchModels = async () => {
|
||||
const { searchKeyword = '', searchVendor = '' } = getFormValues();
|
||||
|
||||
if (searchKeyword === '' && searchVendor === '') {
|
||||
// If keyword is blank, load models instead
|
||||
await loadModels(1, pageSize);
|
||||
return;
|
||||
}
|
||||
|
||||
setSearching(true);
|
||||
try {
|
||||
const res = await API.get(
|
||||
`/api/models/search?keyword=${searchKeyword}&vendor=${searchVendor}&p=1&page_size=${pageSize}`,
|
||||
);
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
const newPageData = extractItems(data);
|
||||
setActivePage(data.page || 1);
|
||||
setModelCount(data.total || newPageData.length);
|
||||
setModelFormat(newPageData);
|
||||
if (data.vendor_counts) {
|
||||
const sumAll = Object.values(data.vendor_counts).reduce(
|
||||
(acc, v) => acc + v,
|
||||
0,
|
||||
);
|
||||
setVendorCounts({ ...data.vendor_counts, all: sumAll });
|
||||
}
|
||||
} else {
|
||||
showError(message);
|
||||
setModels([]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
showError(t('搜索模型失败'));
|
||||
setModels([]);
|
||||
}
|
||||
setSearching(false);
|
||||
};
|
||||
|
||||
// Manage model (enable/disable/delete)
|
||||
const manageModel = async (id, action, record) => {
|
||||
let res;
|
||||
switch (action) {
|
||||
case 'delete':
|
||||
res = await API.delete(`/api/models/${id}`);
|
||||
break;
|
||||
case 'enable':
|
||||
res = await API.put('/api/models/?status_only=true', { id, status: 1 });
|
||||
break;
|
||||
case 'disable':
|
||||
res = await API.put('/api/models/?status_only=true', { id, status: 0 });
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
|
||||
const { success, message } = res.data;
|
||||
if (success) {
|
||||
showSuccess(t('操作成功完成!'));
|
||||
if (action === 'delete') {
|
||||
await refresh();
|
||||
} else {
|
||||
// Update local state for enable/disable
|
||||
setModels((prevModels) =>
|
||||
prevModels.map((model) =>
|
||||
model.id === id
|
||||
? { ...model, status: action === 'enable' ? 1 : 0 }
|
||||
: model,
|
||||
),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle page change
|
||||
const handlePageChange = (page) => {
|
||||
setActivePage(page);
|
||||
loadModels(page, pageSize, activeVendorKey);
|
||||
};
|
||||
|
||||
// Reload models when activeVendorKey changes
|
||||
useEffect(() => {
|
||||
loadModels(1, pageSize, activeVendorKey);
|
||||
}, [activeVendorKey]);
|
||||
|
||||
// Handle page size change
|
||||
const handlePageSizeChange = async (size) => {
|
||||
setPageSize(size);
|
||||
setActivePage(1);
|
||||
await loadModels(1, size, activeVendorKey);
|
||||
};
|
||||
|
||||
// Handle row click and styling
|
||||
const handleRow = (record, index) => {
|
||||
const rowStyle =
|
||||
record.status !== 1
|
||||
? {
|
||||
style: {
|
||||
background: 'var(--semi-color-disabled-border)',
|
||||
},
|
||||
}
|
||||
: {};
|
||||
|
||||
return {
|
||||
...rowStyle,
|
||||
onClick: (event) => {
|
||||
// Don't trigger row selection when clicking on buttons
|
||||
if (event.target.closest('button, .semi-button')) {
|
||||
return;
|
||||
}
|
||||
const newSelectedKeys = selectedKeys.some(
|
||||
(item) => item.id === record.id,
|
||||
)
|
||||
? selectedKeys.filter((item) => item.id !== record.id)
|
||||
: [...selectedKeys, record];
|
||||
setSelectedKeys(newSelectedKeys);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
// Batch delete models
|
||||
const batchDeleteModels = async () => {
|
||||
if (selectedKeys.length === 0) {
|
||||
showError(t('请至少选择一个模型'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const deletePromises = selectedKeys.map((model) =>
|
||||
API.delete(`/api/models/${model.id}`),
|
||||
);
|
||||
|
||||
const results = await Promise.all(deletePromises);
|
||||
let successCount = 0;
|
||||
|
||||
results.forEach((res, index) => {
|
||||
if (res.data.success) {
|
||||
successCount++;
|
||||
} else {
|
||||
showError(
|
||||
`删除模型 ${selectedKeys[index].model_name} 失败: ${res.data.message}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
if (successCount > 0) {
|
||||
showSuccess(t(`成功删除 ${successCount} 个模型`));
|
||||
setSelectedKeys([]);
|
||||
await refresh();
|
||||
}
|
||||
} catch (error) {
|
||||
showError(t('批量删除失败'));
|
||||
}
|
||||
};
|
||||
|
||||
// Copy text helper
|
||||
const copyText = async (text) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
showSuccess(t('复制成功'));
|
||||
} catch (error) {
|
||||
console.error('Copy failed:', error);
|
||||
showError(t('复制失败'));
|
||||
}
|
||||
};
|
||||
|
||||
// Initial load
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
await loadVendors();
|
||||
})();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return {
|
||||
// Data state
|
||||
models,
|
||||
loading,
|
||||
searching,
|
||||
activePage,
|
||||
pageSize,
|
||||
modelCount,
|
||||
|
||||
// Selection state
|
||||
selectedKeys,
|
||||
rowSelection,
|
||||
handleRow,
|
||||
setSelectedKeys,
|
||||
|
||||
// Modal state
|
||||
showEdit,
|
||||
editingModel,
|
||||
setEditingModel,
|
||||
setShowEdit,
|
||||
closeEdit,
|
||||
|
||||
// Form state
|
||||
formInitValues,
|
||||
setFormApi,
|
||||
|
||||
// Actions
|
||||
loadModels,
|
||||
searchModels,
|
||||
refresh,
|
||||
manageModel,
|
||||
batchDeleteModels,
|
||||
copyText,
|
||||
|
||||
// Pagination
|
||||
setActivePage,
|
||||
handlePageChange,
|
||||
handlePageSizeChange,
|
||||
|
||||
// UI state
|
||||
compactMode,
|
||||
setCompactMode,
|
||||
|
||||
// Vendor data
|
||||
vendors,
|
||||
vendorMap,
|
||||
vendorCounts,
|
||||
activeVendorKey,
|
||||
setActiveVendorKey,
|
||||
showAddVendor,
|
||||
setShowAddVendor,
|
||||
showEditVendor,
|
||||
setShowEditVendor,
|
||||
editingVendor,
|
||||
setEditingVendor,
|
||||
loadVendors,
|
||||
|
||||
// Translation
|
||||
t,
|
||||
|
||||
// Upstream sync
|
||||
syncing,
|
||||
previewing,
|
||||
syncUpstream,
|
||||
previewUpstreamDiff,
|
||||
applyUpstreamOverwrite,
|
||||
};
|
||||
};
|
||||
+555
@@ -0,0 +1,555 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import { useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { SSE } from 'sse.js';
|
||||
import {
|
||||
API_ENDPOINTS,
|
||||
MESSAGE_STATUS,
|
||||
DEBUG_TABS,
|
||||
} from '../../constants/playground.constants';
|
||||
import {
|
||||
getUserIdFromLocalStorage,
|
||||
handleApiError,
|
||||
processThinkTags,
|
||||
processIncompleteThinkTags,
|
||||
} from '../../helpers';
|
||||
|
||||
export const useApiRequest = (
|
||||
setMessage,
|
||||
setDebugData,
|
||||
setActiveDebugTab,
|
||||
sseSourceRef,
|
||||
saveMessages,
|
||||
) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// 处理消息自动关闭逻辑的公共函数
|
||||
const applyAutoCollapseLogic = useCallback(
|
||||
(message, isThinkingComplete = true) => {
|
||||
const shouldAutoCollapse =
|
||||
isThinkingComplete && !message.hasAutoCollapsed;
|
||||
return {
|
||||
isThinkingComplete,
|
||||
hasAutoCollapsed: shouldAutoCollapse || message.hasAutoCollapsed,
|
||||
isReasoningExpanded: shouldAutoCollapse
|
||||
? false
|
||||
: message.isReasoningExpanded,
|
||||
};
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// 流式消息更新
|
||||
const streamMessageUpdate = useCallback(
|
||||
(textChunk, type) => {
|
||||
setMessage((prevMessage) => {
|
||||
const lastMessage = prevMessage[prevMessage.length - 1];
|
||||
if (!lastMessage) return prevMessage;
|
||||
if (lastMessage.role !== 'assistant') return prevMessage;
|
||||
if (lastMessage.status === MESSAGE_STATUS.ERROR) {
|
||||
return prevMessage;
|
||||
}
|
||||
|
||||
if (
|
||||
lastMessage.status === MESSAGE_STATUS.LOADING ||
|
||||
lastMessage.status === MESSAGE_STATUS.INCOMPLETE
|
||||
) {
|
||||
let newMessage = { ...lastMessage };
|
||||
|
||||
if (type === 'reasoning') {
|
||||
newMessage = {
|
||||
...newMessage,
|
||||
reasoningContent:
|
||||
(lastMessage.reasoningContent || '') + textChunk,
|
||||
status: MESSAGE_STATUS.INCOMPLETE,
|
||||
isThinkingComplete: false,
|
||||
};
|
||||
} else if (type === 'content') {
|
||||
const shouldCollapseReasoning =
|
||||
!lastMessage.content && lastMessage.reasoningContent;
|
||||
const newContent = (lastMessage.content || '') + textChunk;
|
||||
|
||||
let shouldCollapseFromThinkTag = false;
|
||||
let thinkingCompleteFromTags = lastMessage.isThinkingComplete;
|
||||
|
||||
if (
|
||||
lastMessage.isReasoningExpanded &&
|
||||
newContent.includes('</think>')
|
||||
) {
|
||||
const thinkMatches = newContent.match(/<think>/g);
|
||||
const thinkCloseMatches = newContent.match(/<\/think>/g);
|
||||
if (
|
||||
thinkMatches &&
|
||||
thinkCloseMatches &&
|
||||
thinkCloseMatches.length >= thinkMatches.length
|
||||
) {
|
||||
shouldCollapseFromThinkTag = true;
|
||||
thinkingCompleteFromTags = true; // think标签闭合也标记思考完成
|
||||
}
|
||||
}
|
||||
|
||||
// 如果开始接收content内容,且之前有reasoning内容,或者think标签已闭合,则标记思考完成
|
||||
const isThinkingComplete =
|
||||
(lastMessage.reasoningContent &&
|
||||
!lastMessage.isThinkingComplete) ||
|
||||
thinkingCompleteFromTags;
|
||||
|
||||
const autoCollapseState = applyAutoCollapseLogic(
|
||||
lastMessage,
|
||||
isThinkingComplete,
|
||||
);
|
||||
|
||||
newMessage = {
|
||||
...newMessage,
|
||||
content: newContent,
|
||||
status: MESSAGE_STATUS.INCOMPLETE,
|
||||
...autoCollapseState,
|
||||
};
|
||||
}
|
||||
|
||||
return [...prevMessage.slice(0, -1), newMessage];
|
||||
}
|
||||
|
||||
return prevMessage;
|
||||
});
|
||||
},
|
||||
[setMessage, applyAutoCollapseLogic],
|
||||
);
|
||||
|
||||
// 完成消息
|
||||
const completeMessage = useCallback(
|
||||
(status = MESSAGE_STATUS.COMPLETE) => {
|
||||
setMessage((prevMessage) => {
|
||||
const lastMessage = prevMessage[prevMessage.length - 1];
|
||||
if (
|
||||
lastMessage.status === MESSAGE_STATUS.COMPLETE ||
|
||||
lastMessage.status === MESSAGE_STATUS.ERROR
|
||||
) {
|
||||
return prevMessage;
|
||||
}
|
||||
|
||||
const autoCollapseState = applyAutoCollapseLogic(lastMessage, true);
|
||||
|
||||
const updatedMessages = [
|
||||
...prevMessage.slice(0, -1),
|
||||
{
|
||||
...lastMessage,
|
||||
status: status,
|
||||
...autoCollapseState,
|
||||
},
|
||||
];
|
||||
|
||||
// 在消息完成时保存,传入更新后的消息列表
|
||||
if (
|
||||
status === MESSAGE_STATUS.COMPLETE ||
|
||||
status === MESSAGE_STATUS.ERROR
|
||||
) {
|
||||
setTimeout(() => saveMessages(updatedMessages), 0);
|
||||
}
|
||||
|
||||
return updatedMessages;
|
||||
});
|
||||
},
|
||||
[setMessage, applyAutoCollapseLogic, saveMessages],
|
||||
);
|
||||
|
||||
// 非流式请求
|
||||
const handleNonStreamRequest = useCallback(
|
||||
async (payload) => {
|
||||
setDebugData((prev) => ({
|
||||
...prev,
|
||||
request: payload,
|
||||
timestamp: new Date().toISOString(),
|
||||
response: null,
|
||||
sseMessages: null, // 非流式请求清除 SSE 消息
|
||||
isStreaming: false,
|
||||
}));
|
||||
setActiveDebugTab(DEBUG_TABS.REQUEST);
|
||||
|
||||
try {
|
||||
const response = await fetch(API_ENDPOINTS.CHAT_COMPLETIONS, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'New-Api-User': getUserIdFromLocalStorage(),
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let errorBody = '';
|
||||
let parsedError = null;
|
||||
try {
|
||||
errorBody = await response.text();
|
||||
const errorJson = JSON.parse(errorBody);
|
||||
if (errorJson?.error) {
|
||||
parsedError = errorJson.error;
|
||||
}
|
||||
} catch (e) {
|
||||
if (!errorBody) {
|
||||
errorBody = '无法读取错误响应体';
|
||||
}
|
||||
}
|
||||
|
||||
const errorInfo = handleApiError(
|
||||
new Error(
|
||||
`HTTP error! status: ${response.status}, body: ${errorBody}`,
|
||||
),
|
||||
response,
|
||||
);
|
||||
|
||||
setDebugData((prev) => ({
|
||||
...prev,
|
||||
response: JSON.stringify(errorInfo, null, 2),
|
||||
}));
|
||||
setActiveDebugTab(DEBUG_TABS.RESPONSE);
|
||||
|
||||
const err = new Error(
|
||||
parsedError?.message ||
|
||||
`HTTP error! status: ${response.status}, body: ${errorBody}`,
|
||||
);
|
||||
err.errorCode = parsedError?.code || null;
|
||||
err.errorType = parsedError?.type || null;
|
||||
throw err;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
setDebugData((prev) => ({
|
||||
...prev,
|
||||
response: JSON.stringify(data, null, 2),
|
||||
}));
|
||||
setActiveDebugTab(DEBUG_TABS.RESPONSE);
|
||||
|
||||
if (data.choices?.[0]) {
|
||||
const choice = data.choices[0];
|
||||
let content = choice.message?.content || '';
|
||||
let reasoningContent =
|
||||
choice.message?.reasoning_content ||
|
||||
choice.message?.reasoning ||
|
||||
'';
|
||||
|
||||
const processed = processThinkTags(content, reasoningContent);
|
||||
|
||||
setMessage((prevMessage) => {
|
||||
const newMessages = [...prevMessage];
|
||||
const lastMessage = newMessages[newMessages.length - 1];
|
||||
if (lastMessage?.status === MESSAGE_STATUS.LOADING) {
|
||||
const autoCollapseState = applyAutoCollapseLogic(
|
||||
lastMessage,
|
||||
true,
|
||||
);
|
||||
|
||||
newMessages[newMessages.length - 1] = {
|
||||
...lastMessage,
|
||||
content: processed.content,
|
||||
reasoningContent: processed.reasoningContent,
|
||||
status: MESSAGE_STATUS.COMPLETE,
|
||||
...autoCollapseState,
|
||||
};
|
||||
}
|
||||
return newMessages;
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Non-stream request error:', error);
|
||||
|
||||
const errorInfo = handleApiError(error);
|
||||
setDebugData((prev) => ({
|
||||
...prev,
|
||||
response: JSON.stringify(errorInfo, null, 2),
|
||||
}));
|
||||
setActiveDebugTab(DEBUG_TABS.RESPONSE);
|
||||
|
||||
setMessage((prevMessage) => {
|
||||
const newMessages = [...prevMessage];
|
||||
const lastMessage = newMessages[newMessages.length - 1];
|
||||
if (lastMessage?.status === MESSAGE_STATUS.LOADING) {
|
||||
const autoCollapseState = applyAutoCollapseLogic(lastMessage, true);
|
||||
|
||||
newMessages[newMessages.length - 1] = {
|
||||
...lastMessage,
|
||||
content: t('请求发生错误: ') + error.message,
|
||||
errorCode: error.errorCode || null,
|
||||
status: MESSAGE_STATUS.ERROR,
|
||||
...autoCollapseState,
|
||||
};
|
||||
}
|
||||
return newMessages;
|
||||
});
|
||||
}
|
||||
},
|
||||
[setDebugData, setActiveDebugTab, setMessage, t, applyAutoCollapseLogic],
|
||||
);
|
||||
|
||||
// SSE请求
|
||||
const handleSSE = useCallback(
|
||||
(payload) => {
|
||||
setDebugData((prev) => ({
|
||||
...prev,
|
||||
request: payload,
|
||||
timestamp: new Date().toISOString(),
|
||||
response: null,
|
||||
sseMessages: [], // 新增:存储 SSE 消息数组
|
||||
isStreaming: true, // 新增:标记流式状态
|
||||
}));
|
||||
setActiveDebugTab(DEBUG_TABS.REQUEST);
|
||||
|
||||
const source = new SSE(API_ENDPOINTS.CHAT_COMPLETIONS, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'New-Api-User': getUserIdFromLocalStorage(),
|
||||
},
|
||||
method: 'POST',
|
||||
payload: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
sseSourceRef.current = source;
|
||||
|
||||
let responseData = '';
|
||||
let hasReceivedFirstResponse = false;
|
||||
let isStreamComplete = false; // 添加标志位跟踪流是否正常完成
|
||||
|
||||
source.addEventListener('message', (e) => {
|
||||
if (e.data === '[DONE]') {
|
||||
isStreamComplete = true; // 标记流正常完成
|
||||
source.close();
|
||||
sseSourceRef.current = null;
|
||||
setDebugData((prev) => ({
|
||||
...prev,
|
||||
response: responseData,
|
||||
sseMessages: [...(prev.sseMessages || []), '[DONE]'], // 添加 DONE 标记
|
||||
isStreaming: false,
|
||||
}));
|
||||
completeMessage();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = JSON.parse(e.data);
|
||||
responseData += e.data + '\n';
|
||||
|
||||
if (!hasReceivedFirstResponse) {
|
||||
setActiveDebugTab(DEBUG_TABS.RESPONSE);
|
||||
hasReceivedFirstResponse = true;
|
||||
}
|
||||
|
||||
// 新增:将 SSE 消息添加到数组
|
||||
setDebugData((prev) => ({
|
||||
...prev,
|
||||
sseMessages: [...(prev.sseMessages || []), e.data],
|
||||
}));
|
||||
|
||||
const delta = payload.choices?.[0]?.delta;
|
||||
if (delta) {
|
||||
if (delta.reasoning_content) {
|
||||
streamMessageUpdate(delta.reasoning_content, 'reasoning');
|
||||
}
|
||||
if (delta.reasoning) {
|
||||
streamMessageUpdate(delta.reasoning, 'reasoning');
|
||||
}
|
||||
if (delta.content) {
|
||||
streamMessageUpdate(delta.content, 'content');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to parse SSE message:', error);
|
||||
const errorInfo = `解析错误: ${error.message}`;
|
||||
|
||||
setDebugData((prev) => ({
|
||||
...prev,
|
||||
response: responseData + `\n\nError: ${errorInfo}`,
|
||||
sseMessages: [...(prev.sseMessages || []), e.data], // 即使解析失败也保存原始数据
|
||||
isStreaming: false,
|
||||
}));
|
||||
setActiveDebugTab(DEBUG_TABS.RESPONSE);
|
||||
|
||||
streamMessageUpdate(t('解析响应数据时发生错误'), 'content');
|
||||
completeMessage(MESSAGE_STATUS.ERROR);
|
||||
}
|
||||
});
|
||||
|
||||
source.addEventListener('error', (e) => {
|
||||
// 只有在流没有正常完成且连接状态异常时才处理错误
|
||||
if (!isStreamComplete && source.readyState !== 2) {
|
||||
console.error('SSE Error:', e);
|
||||
let errorMessage = e.data || t('请求发生错误');
|
||||
let errorCode = null;
|
||||
|
||||
if (e.data) {
|
||||
try {
|
||||
const errorJson = JSON.parse(e.data);
|
||||
if (errorJson?.error) {
|
||||
errorMessage = errorJson.error.message || errorMessage;
|
||||
errorCode = errorJson.error.code || null;
|
||||
}
|
||||
} catch (_) {
|
||||
// not JSON, use raw data as error message
|
||||
}
|
||||
}
|
||||
|
||||
const errorInfo = handleApiError(new Error(errorMessage));
|
||||
errorInfo.readyState = source.readyState;
|
||||
|
||||
setDebugData((prev) => ({
|
||||
...prev,
|
||||
response:
|
||||
responseData +
|
||||
'\n\nSSE Error:\n' +
|
||||
JSON.stringify(errorInfo, null, 2),
|
||||
}));
|
||||
setActiveDebugTab(DEBUG_TABS.RESPONSE);
|
||||
|
||||
setMessage((prevMessage) => {
|
||||
const newMessages = [...prevMessage];
|
||||
const lastMessage = newMessages[newMessages.length - 1];
|
||||
if (lastMessage && lastMessage.status !== MESSAGE_STATUS.COMPLETE && lastMessage.status !== MESSAGE_STATUS.ERROR) {
|
||||
newMessages[newMessages.length - 1] = {
|
||||
...lastMessage,
|
||||
content: (lastMessage.content || '') + errorMessage,
|
||||
errorCode: errorCode,
|
||||
status: MESSAGE_STATUS.ERROR,
|
||||
};
|
||||
}
|
||||
return newMessages;
|
||||
});
|
||||
sseSourceRef.current = null;
|
||||
source.close();
|
||||
}
|
||||
});
|
||||
|
||||
source.addEventListener('readystatechange', (e) => {
|
||||
// 检查 HTTP 状态错误,但避免与正常关闭重复处理
|
||||
if (
|
||||
e.readyState >= 2 &&
|
||||
source.status !== undefined &&
|
||||
source.status !== 200 &&
|
||||
!isStreamComplete
|
||||
) {
|
||||
const errorInfo = handleApiError(new Error('HTTP状态错误'));
|
||||
errorInfo.status = source.status;
|
||||
errorInfo.readyState = source.readyState;
|
||||
|
||||
setDebugData((prev) => ({
|
||||
...prev,
|
||||
response:
|
||||
responseData +
|
||||
'\n\nHTTP Error:\n' +
|
||||
JSON.stringify(errorInfo, null, 2),
|
||||
}));
|
||||
setActiveDebugTab(DEBUG_TABS.RESPONSE);
|
||||
|
||||
source.close();
|
||||
streamMessageUpdate(t('连接已断开'), 'content');
|
||||
completeMessage(MESSAGE_STATUS.ERROR);
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
source.stream();
|
||||
} catch (error) {
|
||||
console.error('Failed to start SSE stream:', error);
|
||||
const errorInfo = handleApiError(error);
|
||||
|
||||
setDebugData((prev) => ({
|
||||
...prev,
|
||||
response: 'Stream启动失败:\n' + JSON.stringify(errorInfo, null, 2),
|
||||
}));
|
||||
setActiveDebugTab(DEBUG_TABS.RESPONSE);
|
||||
|
||||
streamMessageUpdate(t('建立连接时发生错误'), 'content');
|
||||
completeMessage(MESSAGE_STATUS.ERROR);
|
||||
}
|
||||
},
|
||||
[
|
||||
setDebugData,
|
||||
setActiveDebugTab,
|
||||
setMessage,
|
||||
streamMessageUpdate,
|
||||
completeMessage,
|
||||
t,
|
||||
applyAutoCollapseLogic,
|
||||
],
|
||||
);
|
||||
|
||||
// 停止生成
|
||||
const onStopGenerator = useCallback(() => {
|
||||
// 如果仍有活动的 SSE 连接,首先关闭
|
||||
if (sseSourceRef.current) {
|
||||
sseSourceRef.current.close();
|
||||
sseSourceRef.current = null;
|
||||
}
|
||||
|
||||
// 无论是否存在 SSE 连接,都尝试处理最后一条正在生成的消息
|
||||
setMessage((prevMessage) => {
|
||||
if (prevMessage.length === 0) return prevMessage;
|
||||
const lastMessage = prevMessage[prevMessage.length - 1];
|
||||
|
||||
if (
|
||||
lastMessage.status === MESSAGE_STATUS.LOADING ||
|
||||
lastMessage.status === MESSAGE_STATUS.INCOMPLETE
|
||||
) {
|
||||
const processed = processIncompleteThinkTags(
|
||||
lastMessage.content || '',
|
||||
lastMessage.reasoningContent || '',
|
||||
);
|
||||
|
||||
const autoCollapseState = applyAutoCollapseLogic(lastMessage, true);
|
||||
|
||||
const updatedMessages = [
|
||||
...prevMessage.slice(0, -1),
|
||||
{
|
||||
...lastMessage,
|
||||
status: MESSAGE_STATUS.COMPLETE,
|
||||
reasoningContent: processed.reasoningContent || null,
|
||||
content: processed.content,
|
||||
...autoCollapseState,
|
||||
},
|
||||
];
|
||||
|
||||
// 停止生成时也保存,传入更新后的消息列表
|
||||
setTimeout(() => saveMessages(updatedMessages), 0);
|
||||
|
||||
return updatedMessages;
|
||||
}
|
||||
return prevMessage;
|
||||
});
|
||||
}, [setMessage, applyAutoCollapseLogic, saveMessages]);
|
||||
|
||||
// 发送请求
|
||||
const sendRequest = useCallback(
|
||||
(payload, isStream) => {
|
||||
if (isStream) {
|
||||
handleSSE(payload);
|
||||
} else {
|
||||
handleNonStreamRequest(payload);
|
||||
}
|
||||
},
|
||||
[handleSSE, handleNonStreamRequest],
|
||||
);
|
||||
|
||||
return {
|
||||
sendRequest,
|
||||
onStopGenerator,
|
||||
streamMessageUpdate,
|
||||
completeMessage,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,95 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { API, processModelsData, processGroupsData } from '../../helpers';
|
||||
import { API_ENDPOINTS } from '../../constants/playground.constants';
|
||||
|
||||
export const useDataLoader = (
|
||||
userState,
|
||||
inputs,
|
||||
handleInputChange,
|
||||
setModels,
|
||||
setGroups,
|
||||
) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const loadModels = useCallback(async () => {
|
||||
try {
|
||||
const res = await API.get(API_ENDPOINTS.USER_MODELS);
|
||||
const { success, message, data } = res.data;
|
||||
|
||||
if (success) {
|
||||
const { modelOptions, selectedModel } = processModelsData(
|
||||
data,
|
||||
inputs.model,
|
||||
);
|
||||
setModels(modelOptions);
|
||||
|
||||
if (selectedModel !== inputs.model) {
|
||||
handleInputChange('model', selectedModel);
|
||||
}
|
||||
} else {
|
||||
showError(t(message));
|
||||
}
|
||||
} catch (error) {
|
||||
showError(t('加载模型失败'));
|
||||
}
|
||||
}, [inputs.model, handleInputChange, setModels, t]);
|
||||
|
||||
const loadGroups = useCallback(async () => {
|
||||
try {
|
||||
const res = await API.get(API_ENDPOINTS.USER_GROUPS);
|
||||
const { success, message, data } = res.data;
|
||||
|
||||
if (success) {
|
||||
const userGroup =
|
||||
userState?.user?.group ||
|
||||
JSON.parse(localStorage.getItem('user'))?.group;
|
||||
const groupOptions = processGroupsData(data, userGroup);
|
||||
setGroups(groupOptions);
|
||||
|
||||
const hasCurrentGroup = groupOptions.some(
|
||||
(option) => option.value === inputs.group,
|
||||
);
|
||||
if (!hasCurrentGroup) {
|
||||
handleInputChange('group', groupOptions[0]?.value || '');
|
||||
}
|
||||
} else {
|
||||
showError(t(message));
|
||||
}
|
||||
} catch (error) {
|
||||
showError(t('加载分组失败'));
|
||||
}
|
||||
}, [userState, inputs.group, handleInputChange, setGroups, t]);
|
||||
|
||||
// 自动加载数据
|
||||
useEffect(() => {
|
||||
if (userState?.user) {
|
||||
loadModels();
|
||||
loadGroups();
|
||||
}
|
||||
}, [userState?.user, loadModels, loadGroups]);
|
||||
|
||||
return {
|
||||
loadModels,
|
||||
loadGroups,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,291 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import { useCallback } from 'react';
|
||||
import { Toast, Modal } from '@douyinfe/semi-ui';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { getTextContent } from '../../helpers';
|
||||
import { ERROR_MESSAGES } from '../../constants/playground.constants';
|
||||
|
||||
export const useMessageActions = (
|
||||
message,
|
||||
setMessage,
|
||||
onMessageSend,
|
||||
saveMessages,
|
||||
) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// 复制消息
|
||||
const handleMessageCopy = useCallback(
|
||||
(targetMessage) => {
|
||||
const textToCopy = getTextContent(targetMessage);
|
||||
|
||||
if (!textToCopy) {
|
||||
Toast.warning({
|
||||
content: t(ERROR_MESSAGES.NO_TEXT_CONTENT),
|
||||
duration: 2,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const copyToClipboard = async (text) => {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
Toast.success({
|
||||
content: t('消息已复制到剪贴板'),
|
||||
duration: 2,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Clipboard API 复制失败:', err);
|
||||
fallbackCopy(text);
|
||||
}
|
||||
} else {
|
||||
fallbackCopy(text);
|
||||
}
|
||||
};
|
||||
|
||||
const fallbackCopy = (text) => {
|
||||
try {
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.value = text;
|
||||
textArea.style.cssText = `
|
||||
position: fixed;
|
||||
top: -9999px;
|
||||
left: -9999px;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
z-index: -1;
|
||||
`;
|
||||
textArea.setAttribute('readonly', '');
|
||||
|
||||
document.body.appendChild(textArea);
|
||||
textArea.select();
|
||||
textArea.setSelectionRange(0, text.length);
|
||||
|
||||
const successful = document.execCommand('copy');
|
||||
document.body.removeChild(textArea);
|
||||
|
||||
if (successful) {
|
||||
Toast.success({
|
||||
content: t('消息已复制到剪贴板'),
|
||||
duration: 2,
|
||||
});
|
||||
} else {
|
||||
throw new Error('execCommand copy failed');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('回退复制方案也失败:', err);
|
||||
|
||||
let errorMessage = t(ERROR_MESSAGES.COPY_FAILED);
|
||||
if (
|
||||
window.location.protocol === 'http:' &&
|
||||
window.location.hostname !== 'localhost'
|
||||
) {
|
||||
errorMessage = t(ERROR_MESSAGES.COPY_HTTPS_REQUIRED);
|
||||
} else if (!navigator.clipboard && !document.execCommand) {
|
||||
errorMessage = t(ERROR_MESSAGES.BROWSER_NOT_SUPPORTED);
|
||||
}
|
||||
|
||||
Toast.error({
|
||||
content: errorMessage,
|
||||
duration: 4,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
copyToClipboard(textToCopy);
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
// 重新生成消息
|
||||
const handleMessageReset = useCallback(
|
||||
(targetMessage) => {
|
||||
setMessage((prevMessages) => {
|
||||
// 使用引用查找索引,防止重复 id 造成误匹配
|
||||
let messageIndex = prevMessages.findIndex(
|
||||
(msg) => msg === targetMessage,
|
||||
);
|
||||
|
||||
// 回退到 id 匹配(兼容不同引用场景)
|
||||
if (messageIndex === -1) {
|
||||
messageIndex = prevMessages.findIndex(
|
||||
(msg) => msg.id === targetMessage.id,
|
||||
);
|
||||
}
|
||||
|
||||
if (messageIndex === -1) return prevMessages;
|
||||
|
||||
if (targetMessage.role === 'user') {
|
||||
const newMessages = prevMessages.slice(0, messageIndex);
|
||||
const contentToSend = getTextContent(targetMessage);
|
||||
|
||||
setTimeout(() => {
|
||||
onMessageSend(contentToSend);
|
||||
}, 100);
|
||||
|
||||
return newMessages;
|
||||
} else if (
|
||||
targetMessage.role === 'assistant' ||
|
||||
targetMessage.role === 'system'
|
||||
) {
|
||||
let userMessageIndex = messageIndex - 1;
|
||||
while (
|
||||
userMessageIndex >= 0 &&
|
||||
prevMessages[userMessageIndex].role !== 'user'
|
||||
) {
|
||||
userMessageIndex--;
|
||||
}
|
||||
|
||||
if (userMessageIndex >= 0) {
|
||||
const userMessage = prevMessages[userMessageIndex];
|
||||
const newMessages = prevMessages.slice(0, userMessageIndex);
|
||||
const contentToSend = getTextContent(userMessage);
|
||||
|
||||
setTimeout(() => {
|
||||
onMessageSend(contentToSend);
|
||||
}, 100);
|
||||
|
||||
return newMessages;
|
||||
}
|
||||
}
|
||||
|
||||
return prevMessages;
|
||||
});
|
||||
},
|
||||
[setMessage, onMessageSend],
|
||||
);
|
||||
|
||||
// 删除消息
|
||||
const handleMessageDelete = useCallback(
|
||||
(targetMessage) => {
|
||||
Modal.confirm({
|
||||
title: t('确认删除'),
|
||||
content: t('确定要删除这条消息吗?'),
|
||||
okText: t('确定'),
|
||||
cancelText: t('取消'),
|
||||
okButtonProps: {
|
||||
type: 'danger',
|
||||
},
|
||||
onOk: () => {
|
||||
setMessage((prevMessages) => {
|
||||
// 使用引用查找索引,防止重复 id 造成误匹配
|
||||
let messageIndex = prevMessages.findIndex(
|
||||
(msg) => msg === targetMessage,
|
||||
);
|
||||
|
||||
// 回退到 id 匹配(兼容不同引用场景)
|
||||
if (messageIndex === -1) {
|
||||
messageIndex = prevMessages.findIndex(
|
||||
(msg) => msg.id === targetMessage.id,
|
||||
);
|
||||
}
|
||||
|
||||
if (messageIndex === -1) return prevMessages;
|
||||
|
||||
let updatedMessages;
|
||||
if (
|
||||
targetMessage.role === 'user' &&
|
||||
messageIndex < prevMessages.length - 1
|
||||
) {
|
||||
const nextMessage = prevMessages[messageIndex + 1];
|
||||
if (nextMessage.role === 'assistant') {
|
||||
Toast.success({
|
||||
content: t('已删除消息及其回复'),
|
||||
duration: 2,
|
||||
});
|
||||
updatedMessages = prevMessages.filter(
|
||||
(_, index) =>
|
||||
index !== messageIndex && index !== messageIndex + 1,
|
||||
);
|
||||
} else {
|
||||
Toast.success({
|
||||
content: t('消息已删除'),
|
||||
duration: 2,
|
||||
});
|
||||
updatedMessages = prevMessages.filter(
|
||||
(msg) => msg.id !== targetMessage.id,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
Toast.success({
|
||||
content: t('消息已删除'),
|
||||
duration: 2,
|
||||
});
|
||||
updatedMessages = prevMessages.filter(
|
||||
(msg) => msg.id !== targetMessage.id,
|
||||
);
|
||||
}
|
||||
|
||||
// 删除消息后保存,传入更新后的消息列表
|
||||
setTimeout(() => saveMessages(updatedMessages), 0);
|
||||
return updatedMessages;
|
||||
});
|
||||
},
|
||||
});
|
||||
},
|
||||
[setMessage, t, saveMessages],
|
||||
);
|
||||
|
||||
// 切换角色
|
||||
const handleRoleToggle = useCallback(
|
||||
(targetMessage) => {
|
||||
if (
|
||||
!(targetMessage.role === 'assistant' || targetMessage.role === 'system')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newRole =
|
||||
targetMessage.role === 'assistant' ? 'system' : 'assistant';
|
||||
|
||||
setMessage((prevMessages) => {
|
||||
const updatedMessages = prevMessages.map((msg) => {
|
||||
if (
|
||||
msg.id === targetMessage.id &&
|
||||
(msg.role === 'assistant' || msg.role === 'system')
|
||||
) {
|
||||
return { ...msg, role: newRole };
|
||||
}
|
||||
return msg;
|
||||
});
|
||||
|
||||
// 切换角色后保存,传入更新后的消息列表
|
||||
setTimeout(() => saveMessages(updatedMessages), 0);
|
||||
return updatedMessages;
|
||||
});
|
||||
|
||||
Toast.success({
|
||||
content: t(
|
||||
`已切换为${newRole === 'system' ? 'System' : 'Assistant'}角色`,
|
||||
),
|
||||
duration: 2,
|
||||
});
|
||||
},
|
||||
[setMessage, t, saveMessages],
|
||||
);
|
||||
|
||||
return {
|
||||
handleMessageCopy,
|
||||
handleMessageReset,
|
||||
handleMessageDelete,
|
||||
handleRoleToggle,
|
||||
};
|
||||
};
|
||||
+157
@@ -0,0 +1,157 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import { useCallback, useState, useRef } from 'react';
|
||||
import { Toast, Modal } from '@douyinfe/semi-ui';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
getTextContent,
|
||||
buildApiPayload,
|
||||
createLoadingAssistantMessage,
|
||||
} from '../../helpers';
|
||||
import { MESSAGE_ROLES } from '../../constants/playground.constants';
|
||||
|
||||
export const useMessageEdit = (
|
||||
setMessage,
|
||||
inputs,
|
||||
parameterEnabled,
|
||||
sendRequest,
|
||||
saveMessages,
|
||||
) => {
|
||||
const { t } = useTranslation();
|
||||
const [editingMessageId, setEditingMessageId] = useState(null);
|
||||
const [editValue, setEditValue] = useState('');
|
||||
const editingMessageRef = useRef(null);
|
||||
|
||||
const handleMessageEdit = useCallback((targetMessage) => {
|
||||
const editableContent = getTextContent(targetMessage);
|
||||
setEditingMessageId(targetMessage.id);
|
||||
editingMessageRef.current = targetMessage;
|
||||
setEditValue(editableContent);
|
||||
}, []);
|
||||
|
||||
const handleEditSave = useCallback(() => {
|
||||
if (!editingMessageId || !editValue.trim()) return;
|
||||
|
||||
setMessage((prevMessages) => {
|
||||
let messageIndex = prevMessages.findIndex(
|
||||
(msg) => msg === editingMessageRef.current,
|
||||
);
|
||||
|
||||
if (messageIndex === -1) {
|
||||
messageIndex = prevMessages.findIndex(
|
||||
(msg) => msg.id === editingMessageId,
|
||||
);
|
||||
}
|
||||
|
||||
const targetMessage = prevMessages[messageIndex];
|
||||
let newContent;
|
||||
|
||||
if (Array.isArray(targetMessage.content)) {
|
||||
newContent = targetMessage.content.map((item) =>
|
||||
item.type === 'text' ? { ...item, text: editValue.trim() } : item,
|
||||
);
|
||||
} else {
|
||||
newContent = editValue.trim();
|
||||
}
|
||||
|
||||
const updatedMessages = prevMessages.map((msg) =>
|
||||
msg.id === editingMessageId ? { ...msg, content: newContent } : msg,
|
||||
);
|
||||
|
||||
// 处理用户消息编辑后的重新生成
|
||||
if (targetMessage.role === MESSAGE_ROLES.USER) {
|
||||
const hasSubsequentAssistantReply =
|
||||
messageIndex < prevMessages.length - 1 &&
|
||||
prevMessages[messageIndex + 1].role === MESSAGE_ROLES.ASSISTANT;
|
||||
|
||||
if (hasSubsequentAssistantReply) {
|
||||
Modal.confirm({
|
||||
title: t('消息已编辑'),
|
||||
content: t('检测到该消息后有AI回复,是否删除后续回复并重新生成?'),
|
||||
okText: t('重新生成'),
|
||||
cancelText: t('仅保存'),
|
||||
onOk: () => {
|
||||
const messagesUntilUser = updatedMessages.slice(
|
||||
0,
|
||||
messageIndex + 1,
|
||||
);
|
||||
setMessage(messagesUntilUser);
|
||||
// 编辑后保存(重新生成的情况),传入更新后的消息列表
|
||||
setTimeout(() => saveMessages(messagesUntilUser), 0);
|
||||
|
||||
setTimeout(() => {
|
||||
const payload = buildApiPayload(
|
||||
messagesUntilUser,
|
||||
null,
|
||||
inputs,
|
||||
parameterEnabled,
|
||||
);
|
||||
setMessage((prevMsg) => [
|
||||
...prevMsg,
|
||||
createLoadingAssistantMessage(),
|
||||
]);
|
||||
sendRequest(payload, inputs.stream);
|
||||
}, 100);
|
||||
},
|
||||
onCancel: () => {
|
||||
setMessage(updatedMessages);
|
||||
// 编辑后保存(仅保存的情况),传入更新后的消息列表
|
||||
setTimeout(() => saveMessages(updatedMessages), 0);
|
||||
},
|
||||
});
|
||||
return prevMessages;
|
||||
}
|
||||
}
|
||||
|
||||
// 编辑后保存(普通情况),传入更新后的消息列表
|
||||
setTimeout(() => saveMessages(updatedMessages), 0);
|
||||
return updatedMessages;
|
||||
});
|
||||
|
||||
setEditingMessageId(null);
|
||||
editingMessageRef.current = null;
|
||||
setEditValue('');
|
||||
Toast.success({ content: t('消息已更新'), duration: 2 });
|
||||
}, [
|
||||
editingMessageId,
|
||||
editValue,
|
||||
t,
|
||||
inputs,
|
||||
parameterEnabled,
|
||||
sendRequest,
|
||||
setMessage,
|
||||
saveMessages,
|
||||
]);
|
||||
|
||||
const handleEditCancel = useCallback(() => {
|
||||
setEditingMessageId(null);
|
||||
editingMessageRef.current = null;
|
||||
setEditValue('');
|
||||
}, []);
|
||||
|
||||
return {
|
||||
editingMessageId,
|
||||
editValue,
|
||||
setEditValue,
|
||||
handleMessageEdit,
|
||||
handleEditSave,
|
||||
handleEditCancel,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,313 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
DEFAULT_MESSAGES,
|
||||
getDefaultMessages,
|
||||
DEFAULT_CONFIG,
|
||||
DEBUG_TABS,
|
||||
MESSAGE_STATUS,
|
||||
} from '../../constants/playground.constants';
|
||||
import {
|
||||
loadConfig,
|
||||
saveConfig,
|
||||
loadMessages,
|
||||
saveMessages,
|
||||
} from '../../components/playground/configStorage';
|
||||
import { processIncompleteThinkTags } from '../../helpers';
|
||||
|
||||
export const usePlaygroundState = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// 使用惰性初始化,确保只在组件首次挂载时加载配置和消息
|
||||
const [savedConfig] = useState(() => loadConfig());
|
||||
const [initialMessages] = useState(() => {
|
||||
const loaded = loadMessages();
|
||||
// 检查是否是旧的中文默认消息,如果是则清除
|
||||
if (
|
||||
loaded &&
|
||||
loaded.length === 2 &&
|
||||
loaded[0].id === '2' &&
|
||||
loaded[1].id === '3'
|
||||
) {
|
||||
const hasOldChinese =
|
||||
loaded[0].content === '你好' ||
|
||||
loaded[1].content === '你好,请问有什么可以帮助您的吗?' ||
|
||||
loaded[1].content === '你好!很高兴见到你。有什么我可以帮助你的吗?';
|
||||
|
||||
if (hasOldChinese) {
|
||||
// 清除旧的默认消息
|
||||
localStorage.removeItem('playground_messages');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return loaded;
|
||||
});
|
||||
|
||||
// 基础配置状态
|
||||
const [inputs, setInputs] = useState(
|
||||
savedConfig.inputs || DEFAULT_CONFIG.inputs,
|
||||
);
|
||||
const [parameterEnabled, setParameterEnabled] = useState(
|
||||
savedConfig.parameterEnabled || DEFAULT_CONFIG.parameterEnabled,
|
||||
);
|
||||
const [showDebugPanel, setShowDebugPanel] = useState(
|
||||
savedConfig.showDebugPanel || DEFAULT_CONFIG.showDebugPanel,
|
||||
);
|
||||
const [customRequestMode, setCustomRequestMode] = useState(
|
||||
savedConfig.customRequestMode || DEFAULT_CONFIG.customRequestMode,
|
||||
);
|
||||
const [customRequestBody, setCustomRequestBody] = useState(
|
||||
savedConfig.customRequestBody || DEFAULT_CONFIG.customRequestBody,
|
||||
);
|
||||
|
||||
// UI状态
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
const [models, setModels] = useState([]);
|
||||
const [groups, setGroups] = useState([]);
|
||||
const [status, setStatus] = useState({});
|
||||
|
||||
// 消息相关状态 - 使用加载的消息或默认消息初始化
|
||||
const [message, setMessage] = useState(
|
||||
() => initialMessages || getDefaultMessages(t),
|
||||
);
|
||||
|
||||
// 当语言改变时,如果是默认消息则更新
|
||||
useEffect(() => {
|
||||
// 只在没有保存的消息时才更新默认消息
|
||||
if (!initialMessages) {
|
||||
setMessage(getDefaultMessages(t));
|
||||
}
|
||||
}, [t, initialMessages]); // 当语言改变时
|
||||
|
||||
// 调试状态
|
||||
const [debugData, setDebugData] = useState({
|
||||
request: null,
|
||||
response: null,
|
||||
timestamp: null,
|
||||
previewRequest: null,
|
||||
previewTimestamp: null,
|
||||
});
|
||||
const [activeDebugTab, setActiveDebugTab] = useState(DEBUG_TABS.PREVIEW);
|
||||
const [previewPayload, setPreviewPayload] = useState(null);
|
||||
|
||||
// 编辑状态
|
||||
const [editingMessageId, setEditingMessageId] = useState(null);
|
||||
const [editValue, setEditValue] = useState('');
|
||||
|
||||
// Refs
|
||||
const sseSourceRef = useRef(null);
|
||||
const chatRef = useRef(null);
|
||||
const saveConfigTimeoutRef = useRef(null);
|
||||
const saveMessagesTimeoutRef = useRef(null);
|
||||
|
||||
// 配置更新函数
|
||||
const handleInputChange = useCallback((name, value) => {
|
||||
setInputs((prev) => ({ ...prev, [name]: value }));
|
||||
}, []);
|
||||
|
||||
const handleParameterToggle = useCallback((paramName) => {
|
||||
setParameterEnabled((prev) => ({
|
||||
...prev,
|
||||
[paramName]: !prev[paramName],
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// 消息保存函数 - 改为立即保存,可以接受参数
|
||||
const saveMessagesImmediately = useCallback(
|
||||
(messagesToSave) => {
|
||||
// 如果提供了参数,使用参数;否则使用当前状态
|
||||
saveMessages(messagesToSave || message);
|
||||
},
|
||||
[message],
|
||||
);
|
||||
|
||||
// 配置保存
|
||||
const debouncedSaveConfig = useCallback(() => {
|
||||
if (saveConfigTimeoutRef.current) {
|
||||
clearTimeout(saveConfigTimeoutRef.current);
|
||||
}
|
||||
|
||||
saveConfigTimeoutRef.current = setTimeout(() => {
|
||||
const configToSave = {
|
||||
inputs,
|
||||
parameterEnabled,
|
||||
showDebugPanel,
|
||||
customRequestMode,
|
||||
customRequestBody,
|
||||
};
|
||||
saveConfig(configToSave);
|
||||
}, 1000);
|
||||
}, [
|
||||
inputs,
|
||||
parameterEnabled,
|
||||
showDebugPanel,
|
||||
customRequestMode,
|
||||
customRequestBody,
|
||||
]);
|
||||
|
||||
// 配置导入/重置
|
||||
const handleConfigImport = useCallback((importedConfig) => {
|
||||
if (importedConfig.inputs) {
|
||||
const parsedMaxTokens = parseInt(importedConfig.inputs.max_tokens, 10);
|
||||
setInputs((prev) => ({
|
||||
...prev,
|
||||
...importedConfig.inputs,
|
||||
max_tokens: Number.isNaN(parsedMaxTokens)
|
||||
? importedConfig.inputs.max_tokens
|
||||
: parsedMaxTokens,
|
||||
}));
|
||||
}
|
||||
if (importedConfig.parameterEnabled) {
|
||||
setParameterEnabled((prev) => ({
|
||||
...prev,
|
||||
...importedConfig.parameterEnabled,
|
||||
}));
|
||||
}
|
||||
if (typeof importedConfig.showDebugPanel === 'boolean') {
|
||||
setShowDebugPanel(importedConfig.showDebugPanel);
|
||||
}
|
||||
if (importedConfig.customRequestMode) {
|
||||
setCustomRequestMode(importedConfig.customRequestMode);
|
||||
}
|
||||
if (importedConfig.customRequestBody) {
|
||||
setCustomRequestBody(importedConfig.customRequestBody);
|
||||
}
|
||||
// 如果导入的配置包含消息,也恢复消息
|
||||
if (importedConfig.messages && Array.isArray(importedConfig.messages)) {
|
||||
setMessage(importedConfig.messages);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleConfigReset = useCallback((options = {}) => {
|
||||
const { resetMessages = false } = options;
|
||||
|
||||
setInputs(DEFAULT_CONFIG.inputs);
|
||||
setParameterEnabled(DEFAULT_CONFIG.parameterEnabled);
|
||||
setShowDebugPanel(DEFAULT_CONFIG.showDebugPanel);
|
||||
setCustomRequestMode(DEFAULT_CONFIG.customRequestMode);
|
||||
setCustomRequestBody(DEFAULT_CONFIG.customRequestBody);
|
||||
|
||||
// 只有在明确指定时才重置消息
|
||||
if (resetMessages) {
|
||||
setMessage([]);
|
||||
setTimeout(() => {
|
||||
setMessage(getDefaultMessages(t));
|
||||
}, 0);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 清理定时器
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (saveConfigTimeoutRef.current) {
|
||||
clearTimeout(saveConfigTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 页面首次加载时,若最后一条消息仍处于 LOADING/INCOMPLETE 状态,自动修复
|
||||
useEffect(() => {
|
||||
if (!Array.isArray(message) || message.length === 0) return;
|
||||
|
||||
const lastMsg = message[message.length - 1];
|
||||
if (
|
||||
lastMsg.status === MESSAGE_STATUS.LOADING ||
|
||||
lastMsg.status === MESSAGE_STATUS.INCOMPLETE
|
||||
) {
|
||||
const processed = processIncompleteThinkTags(
|
||||
lastMsg.content || '',
|
||||
lastMsg.reasoningContent || '',
|
||||
);
|
||||
|
||||
const fixedLastMsg = {
|
||||
...lastMsg,
|
||||
status: MESSAGE_STATUS.COMPLETE,
|
||||
content: processed.content,
|
||||
reasoningContent: processed.reasoningContent || null,
|
||||
isThinkingComplete: true,
|
||||
};
|
||||
|
||||
const updatedMessages = [...message.slice(0, -1), fixedLastMsg];
|
||||
setMessage(updatedMessages);
|
||||
|
||||
// 保存修复后的消息列表
|
||||
setTimeout(() => saveMessagesImmediately(updatedMessages), 0);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
// 配置状态
|
||||
inputs,
|
||||
parameterEnabled,
|
||||
showDebugPanel,
|
||||
customRequestMode,
|
||||
customRequestBody,
|
||||
|
||||
// UI状态
|
||||
showSettings,
|
||||
models,
|
||||
groups,
|
||||
status,
|
||||
|
||||
// 消息状态
|
||||
message,
|
||||
|
||||
// 调试状态
|
||||
debugData,
|
||||
activeDebugTab,
|
||||
previewPayload,
|
||||
|
||||
// 编辑状态
|
||||
editingMessageId,
|
||||
editValue,
|
||||
|
||||
// Refs
|
||||
sseSourceRef,
|
||||
chatRef,
|
||||
saveConfigTimeoutRef,
|
||||
|
||||
// 更新函数
|
||||
setInputs,
|
||||
setParameterEnabled,
|
||||
setShowDebugPanel,
|
||||
setCustomRequestMode,
|
||||
setCustomRequestBody,
|
||||
setShowSettings,
|
||||
setModels,
|
||||
setGroups,
|
||||
setStatus,
|
||||
setMessage,
|
||||
setDebugData,
|
||||
setActiveDebugTab,
|
||||
setPreviewPayload,
|
||||
setEditingMessageId,
|
||||
setEditValue,
|
||||
|
||||
// 处理函数
|
||||
handleInputChange,
|
||||
handleParameterToggle,
|
||||
debouncedSaveConfig,
|
||||
saveMessagesImmediately,
|
||||
handleConfigImport,
|
||||
handleConfigReset,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,149 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { MESSAGE_ROLES } from '../../constants/playground.constants';
|
||||
|
||||
export const useSyncMessageAndCustomBody = (
|
||||
customRequestMode,
|
||||
customRequestBody,
|
||||
message,
|
||||
inputs,
|
||||
setCustomRequestBody,
|
||||
setMessage,
|
||||
debouncedSaveConfig,
|
||||
) => {
|
||||
const isUpdatingFromMessage = useRef(false);
|
||||
const isUpdatingFromCustomBody = useRef(false);
|
||||
const lastMessageHash = useRef('');
|
||||
const lastCustomBodyHash = useRef('');
|
||||
|
||||
const getMessageHash = useCallback((messages) => {
|
||||
return JSON.stringify(
|
||||
messages.map((msg) => ({
|
||||
id: msg.id,
|
||||
role: msg.role,
|
||||
content: msg.content,
|
||||
})),
|
||||
);
|
||||
}, []);
|
||||
|
||||
const getCustomBodyHash = useCallback((customBody) => {
|
||||
try {
|
||||
const parsed = JSON.parse(customBody);
|
||||
return JSON.stringify(parsed.messages || []);
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}, []);
|
||||
|
||||
const syncMessageToCustomBody = useCallback(() => {
|
||||
if (!customRequestMode || isUpdatingFromCustomBody.current) return;
|
||||
|
||||
const currentMessageHash = getMessageHash(message);
|
||||
if (currentMessageHash === lastMessageHash.current) return;
|
||||
|
||||
try {
|
||||
isUpdatingFromMessage.current = true;
|
||||
let customPayload;
|
||||
|
||||
try {
|
||||
customPayload = JSON.parse(customRequestBody || '{}');
|
||||
} catch {
|
||||
customPayload = {
|
||||
model: inputs.model || 'gpt-4o',
|
||||
messages: [],
|
||||
temperature: inputs.temperature || 0.7,
|
||||
stream: inputs.stream !== false,
|
||||
};
|
||||
}
|
||||
|
||||
customPayload.messages = message.map((msg) => ({
|
||||
role: msg.role,
|
||||
content: msg.content,
|
||||
}));
|
||||
|
||||
const newCustomBody = JSON.stringify(customPayload, null, 2);
|
||||
setCustomRequestBody(newCustomBody);
|
||||
lastMessageHash.current = currentMessageHash;
|
||||
lastCustomBodyHash.current = getCustomBodyHash(newCustomBody);
|
||||
|
||||
setTimeout(() => {
|
||||
debouncedSaveConfig();
|
||||
}, 0);
|
||||
} finally {
|
||||
isUpdatingFromMessage.current = false;
|
||||
}
|
||||
}, [
|
||||
customRequestMode,
|
||||
customRequestBody,
|
||||
message,
|
||||
inputs.model,
|
||||
inputs.temperature,
|
||||
inputs.stream,
|
||||
getMessageHash,
|
||||
getCustomBodyHash,
|
||||
setCustomRequestBody,
|
||||
debouncedSaveConfig,
|
||||
]);
|
||||
|
||||
const syncCustomBodyToMessage = useCallback(() => {
|
||||
if (!customRequestMode || isUpdatingFromMessage.current) return;
|
||||
|
||||
const currentCustomBodyHash = getCustomBodyHash(customRequestBody);
|
||||
if (currentCustomBodyHash === lastCustomBodyHash.current) return;
|
||||
|
||||
try {
|
||||
isUpdatingFromCustomBody.current = true;
|
||||
const customPayload = JSON.parse(customRequestBody || '{}');
|
||||
|
||||
if (customPayload.messages && Array.isArray(customPayload.messages)) {
|
||||
const newMessages = customPayload.messages.map((msg, index) => ({
|
||||
id: msg.id || (index + 1).toString(),
|
||||
role: msg.role || MESSAGE_ROLES.USER,
|
||||
content: msg.content || '',
|
||||
createAt: Date.now(),
|
||||
...(msg.role === MESSAGE_ROLES.ASSISTANT && {
|
||||
reasoningContent: msg.reasoningContent || '',
|
||||
isReasoningExpanded: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
setMessage(newMessages);
|
||||
lastCustomBodyHash.current = currentCustomBodyHash;
|
||||
lastMessageHash.current = getMessageHash(newMessages);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('同步自定义请求体到消息失败:', error);
|
||||
} finally {
|
||||
isUpdatingFromCustomBody.current = false;
|
||||
}
|
||||
}, [
|
||||
customRequestMode,
|
||||
customRequestBody,
|
||||
getCustomBodyHash,
|
||||
getMessageHash,
|
||||
setMessage,
|
||||
]);
|
||||
|
||||
return {
|
||||
syncMessageToCustomBody,
|
||||
syncCustomBodyToMessage,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,360 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { API, showError, showSuccess, copy } from '../../helpers';
|
||||
import { ITEMS_PER_PAGE } from '../../constants';
|
||||
import {
|
||||
REDEMPTION_ACTIONS,
|
||||
REDEMPTION_STATUS,
|
||||
} from '../../constants/redemption.constants';
|
||||
import { Modal } from '@douyinfe/semi-ui';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useTableCompactMode } from '../common/useTableCompactMode';
|
||||
|
||||
export const useRedemptionsData = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Basic state
|
||||
const [redemptions, setRedemptions] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [activePage, setActivePage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(ITEMS_PER_PAGE);
|
||||
const [tokenCount, setTokenCount] = useState(0);
|
||||
const [selectedKeys, setSelectedKeys] = useState([]);
|
||||
|
||||
// Edit state
|
||||
const [editingRedemption, setEditingRedemption] = useState({
|
||||
id: undefined,
|
||||
});
|
||||
const [showEdit, setShowEdit] = useState(false);
|
||||
|
||||
// Form API
|
||||
const [formApi, setFormApi] = useState(null);
|
||||
|
||||
// UI state
|
||||
const [compactMode, setCompactMode] = useTableCompactMode('redemptions');
|
||||
|
||||
// Form state
|
||||
const formInitValues = {
|
||||
searchKeyword: '',
|
||||
};
|
||||
|
||||
// Get form values
|
||||
const getFormValues = () => {
|
||||
const formValues = formApi ? formApi.getValues() : {};
|
||||
return {
|
||||
searchKeyword: formValues.searchKeyword || '',
|
||||
};
|
||||
};
|
||||
|
||||
// Set redemption data format
|
||||
const setRedemptionFormat = (redemptions) => {
|
||||
setRedemptions(redemptions);
|
||||
};
|
||||
|
||||
// Load redemption list
|
||||
const loadRedemptions = async (page = 1, pageSize) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await API.get(
|
||||
`/api/redemption/?p=${page}&page_size=${pageSize}`,
|
||||
);
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
const newPageData = data.items;
|
||||
setActivePage(data.page <= 0 ? 1 : data.page);
|
||||
setTokenCount(data.total);
|
||||
setRedemptionFormat(newPageData);
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
} catch (error) {
|
||||
showError(error.message);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
// Search redemption codes
|
||||
const searchRedemptions = async () => {
|
||||
const { searchKeyword } = getFormValues();
|
||||
if (searchKeyword === '') {
|
||||
await loadRedemptions(1, pageSize);
|
||||
return;
|
||||
}
|
||||
|
||||
setSearching(true);
|
||||
try {
|
||||
const res = await API.get(
|
||||
`/api/redemption/search?keyword=${searchKeyword}&p=1&page_size=${pageSize}`,
|
||||
);
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
const newPageData = data.items;
|
||||
setActivePage(data.page || 1);
|
||||
setTokenCount(data.total);
|
||||
setRedemptionFormat(newPageData);
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
} catch (error) {
|
||||
showError(error.message);
|
||||
}
|
||||
setSearching(false);
|
||||
};
|
||||
|
||||
// Manage redemption codes (CRUD operations)
|
||||
const manageRedemption = async (id, action, record) => {
|
||||
setLoading(true);
|
||||
let data = { id };
|
||||
let res;
|
||||
|
||||
try {
|
||||
switch (action) {
|
||||
case REDEMPTION_ACTIONS.DELETE:
|
||||
res = await API.delete(`/api/redemption/${id}/`);
|
||||
break;
|
||||
case REDEMPTION_ACTIONS.ENABLE:
|
||||
data.status = REDEMPTION_STATUS.UNUSED;
|
||||
res = await API.put('/api/redemption/?status_only=true', data);
|
||||
break;
|
||||
case REDEMPTION_ACTIONS.DISABLE:
|
||||
data.status = REDEMPTION_STATUS.DISABLED;
|
||||
res = await API.put('/api/redemption/?status_only=true', data);
|
||||
break;
|
||||
default:
|
||||
throw new Error('Unknown operation type');
|
||||
}
|
||||
|
||||
const { success, message } = res.data;
|
||||
if (success) {
|
||||
showSuccess(t('操作成功完成!'));
|
||||
let redemption = res.data.data;
|
||||
let newRedemptions = [...redemptions];
|
||||
if (action !== REDEMPTION_ACTIONS.DELETE) {
|
||||
record.status = redemption.status;
|
||||
}
|
||||
setRedemptions(newRedemptions);
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
} catch (error) {
|
||||
showError(error.message);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
// Refresh data
|
||||
const refresh = async (page = activePage) => {
|
||||
const { searchKeyword } = getFormValues();
|
||||
if (searchKeyword === '') {
|
||||
await loadRedemptions(page, pageSize);
|
||||
} else {
|
||||
await searchRedemptions();
|
||||
}
|
||||
};
|
||||
|
||||
// Handle page change
|
||||
const handlePageChange = (page) => {
|
||||
setActivePage(page);
|
||||
const { searchKeyword } = getFormValues();
|
||||
if (searchKeyword === '') {
|
||||
loadRedemptions(page, pageSize);
|
||||
} else {
|
||||
searchRedemptions();
|
||||
}
|
||||
};
|
||||
|
||||
// Handle page size change
|
||||
const handlePageSizeChange = (size) => {
|
||||
setPageSize(size);
|
||||
setActivePage(1);
|
||||
const { searchKeyword } = getFormValues();
|
||||
if (searchKeyword === '') {
|
||||
loadRedemptions(1, size);
|
||||
} else {
|
||||
searchRedemptions();
|
||||
}
|
||||
};
|
||||
|
||||
// Row selection configuration
|
||||
const rowSelection = {
|
||||
onSelect: (record, selected) => {},
|
||||
onSelectAll: (selected, selectedRows) => {},
|
||||
onChange: (selectedRowKeys, selectedRows) => {
|
||||
setSelectedKeys(selectedRows);
|
||||
},
|
||||
};
|
||||
|
||||
// Row style handling - using isExpired function
|
||||
const handleRow = (record, index) => {
|
||||
// Local isExpired function
|
||||
const isExpired = (rec) => {
|
||||
return (
|
||||
rec.status === REDEMPTION_STATUS.UNUSED &&
|
||||
rec.expired_time !== 0 &&
|
||||
rec.expired_time < Math.floor(Date.now() / 1000)
|
||||
);
|
||||
};
|
||||
|
||||
if (record.status !== REDEMPTION_STATUS.UNUSED || isExpired(record)) {
|
||||
return {
|
||||
style: {
|
||||
background: 'var(--semi-color-disabled-border)',
|
||||
},
|
||||
};
|
||||
} else {
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
// Copy text
|
||||
const copyText = async (text) => {
|
||||
if (await copy(text)) {
|
||||
showSuccess('已复制到剪贴板!');
|
||||
} else {
|
||||
Modal.error({
|
||||
title: '无法复制到剪贴板,请手动复制',
|
||||
content: text,
|
||||
size: 'large',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Batch copy redemption codes
|
||||
const batchCopyRedemptions = async () => {
|
||||
if (selectedKeys.length === 0) {
|
||||
showError(t('请至少选择一个兑换码!'));
|
||||
return;
|
||||
}
|
||||
|
||||
let keys = '';
|
||||
for (let i = 0; i < selectedKeys.length; i++) {
|
||||
keys += selectedKeys[i].name + ' ' + selectedKeys[i].key + '\n';
|
||||
}
|
||||
await copyText(keys);
|
||||
};
|
||||
|
||||
// Batch delete redemption codes (clear invalid)
|
||||
const batchDeleteRedemptions = async () => {
|
||||
Modal.confirm({
|
||||
title: t('确定清除所有失效兑换码?'),
|
||||
content: t('将删除已使用、已禁用及过期的兑换码,此操作不可撤销。'),
|
||||
onOk: async () => {
|
||||
setLoading(true);
|
||||
const res = await API.delete('/api/redemption/invalid');
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
showSuccess(t('已删除 {{count}} 条失效兑换码', { count: data }));
|
||||
await refresh();
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
setLoading(false);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// Close edit modal
|
||||
const closeEdit = () => {
|
||||
setShowEdit(false);
|
||||
setTimeout(() => {
|
||||
setEditingRedemption({
|
||||
id: undefined,
|
||||
});
|
||||
}, 500);
|
||||
};
|
||||
|
||||
// Remove record (for UI update after deletion)
|
||||
const removeRecord = (key) => {
|
||||
let newDataSource = [...redemptions];
|
||||
if (key != null) {
|
||||
let idx = newDataSource.findIndex((data) => data.key === key);
|
||||
if (idx > -1) {
|
||||
newDataSource.splice(idx, 1);
|
||||
setRedemptions(newDataSource);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize data loading
|
||||
useEffect(() => {
|
||||
loadRedemptions(1, pageSize)
|
||||
.then()
|
||||
.catch((reason) => {
|
||||
showError(reason);
|
||||
});
|
||||
}, [pageSize]);
|
||||
|
||||
return {
|
||||
// Data state
|
||||
redemptions,
|
||||
loading,
|
||||
searching,
|
||||
activePage,
|
||||
pageSize,
|
||||
tokenCount,
|
||||
selectedKeys,
|
||||
|
||||
// Edit state
|
||||
editingRedemption,
|
||||
showEdit,
|
||||
|
||||
// Form state
|
||||
formApi,
|
||||
formInitValues,
|
||||
|
||||
// UI state
|
||||
compactMode,
|
||||
setCompactMode,
|
||||
|
||||
// Data operations
|
||||
loadRedemptions,
|
||||
searchRedemptions,
|
||||
manageRedemption,
|
||||
refresh,
|
||||
copyText,
|
||||
removeRecord,
|
||||
|
||||
// State updates
|
||||
setActivePage,
|
||||
setPageSize,
|
||||
setSelectedKeys,
|
||||
setEditingRedemption,
|
||||
setShowEdit,
|
||||
setFormApi,
|
||||
setLoading,
|
||||
|
||||
// Event handlers
|
||||
handlePageChange,
|
||||
handlePageSizeChange,
|
||||
rowSelection,
|
||||
handleRow,
|
||||
closeEdit,
|
||||
getFormValues,
|
||||
|
||||
// Batch operations
|
||||
batchCopyRedemptions,
|
||||
batchDeleteRedemptions,
|
||||
|
||||
// Translation function
|
||||
t,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,166 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { API, showError, showSuccess } from '../../helpers';
|
||||
import { useTableCompactMode } from '../common/useTableCompactMode';
|
||||
|
||||
export const useSubscriptionsData = () => {
|
||||
const { t } = useTranslation();
|
||||
const [compactMode, setCompactMode] = useTableCompactMode('subscriptions');
|
||||
|
||||
// State management
|
||||
const [allPlans, setAllPlans] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Pagination (client-side for now)
|
||||
const [activePage, setActivePage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
|
||||
// Drawer states
|
||||
const [showEdit, setShowEdit] = useState(false);
|
||||
const [editingPlan, setEditingPlan] = useState(null);
|
||||
const [sheetPlacement, setSheetPlacement] = useState('left'); // 'left' | 'right'
|
||||
|
||||
// Load subscription plans
|
||||
const loadPlans = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await API.get('/api/subscription/admin/plans');
|
||||
if (res.data?.success) {
|
||||
const next = res.data.data || [];
|
||||
setAllPlans(next);
|
||||
|
||||
// Keep page in range after data changes
|
||||
const totalPages = Math.max(1, Math.ceil(next.length / pageSize));
|
||||
setActivePage((p) => Math.min(p || 1, totalPages));
|
||||
} else {
|
||||
showError(res.data?.message || t('加载失败'));
|
||||
}
|
||||
} catch (e) {
|
||||
showError(t('请求失败'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Refresh data
|
||||
const refresh = async () => {
|
||||
await loadPlans();
|
||||
};
|
||||
|
||||
const handlePageChange = (page) => {
|
||||
setActivePage(page);
|
||||
};
|
||||
|
||||
const handlePageSizeChange = (size) => {
|
||||
setPageSize(size);
|
||||
setActivePage(1);
|
||||
};
|
||||
|
||||
// Update plan enabled status (single endpoint)
|
||||
const setPlanEnabled = async (planRecordOrId, enabled) => {
|
||||
const planId =
|
||||
typeof planRecordOrId === 'number'
|
||||
? planRecordOrId
|
||||
: planRecordOrId?.plan?.id;
|
||||
if (!planId) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await API.patch(`/api/subscription/admin/plans/${planId}`, {
|
||||
enabled: !!enabled,
|
||||
});
|
||||
if (res.data?.success) {
|
||||
showSuccess(enabled ? t('已启用') : t('已禁用'));
|
||||
await loadPlans();
|
||||
} else {
|
||||
showError(res.data?.message || t('操作失败'));
|
||||
}
|
||||
} catch (e) {
|
||||
showError(t('请求失败'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Modal control functions
|
||||
const closeEdit = () => {
|
||||
setShowEdit(false);
|
||||
setEditingPlan(null);
|
||||
};
|
||||
|
||||
const openCreate = () => {
|
||||
setSheetPlacement('left');
|
||||
setEditingPlan(null);
|
||||
setShowEdit(true);
|
||||
};
|
||||
|
||||
const openEdit = (planRecord) => {
|
||||
setSheetPlacement('right');
|
||||
setEditingPlan(planRecord);
|
||||
setShowEdit(true);
|
||||
};
|
||||
|
||||
// Initialize data on component mount
|
||||
useEffect(() => {
|
||||
loadPlans();
|
||||
}, []);
|
||||
|
||||
const planCount = allPlans.length;
|
||||
const plans = allPlans.slice(
|
||||
Math.max(0, (activePage - 1) * pageSize),
|
||||
Math.max(0, (activePage - 1) * pageSize) + pageSize,
|
||||
);
|
||||
|
||||
return {
|
||||
// Data state
|
||||
plans,
|
||||
planCount,
|
||||
loading,
|
||||
|
||||
// Modal state
|
||||
showEdit,
|
||||
editingPlan,
|
||||
sheetPlacement,
|
||||
setShowEdit,
|
||||
setEditingPlan,
|
||||
|
||||
// UI state
|
||||
compactMode,
|
||||
setCompactMode,
|
||||
|
||||
// Pagination
|
||||
activePage,
|
||||
pageSize,
|
||||
handlePageChange,
|
||||
handlePageSizeChange,
|
||||
|
||||
// Actions
|
||||
loadPlans,
|
||||
setPlanEnabled,
|
||||
refresh,
|
||||
closeEdit,
|
||||
openCreate,
|
||||
openEdit,
|
||||
|
||||
// Translation
|
||||
t,
|
||||
};
|
||||
};
|
||||
+376
@@ -0,0 +1,376 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Modal } from '@douyinfe/semi-ui';
|
||||
import {
|
||||
API,
|
||||
copy,
|
||||
isAdmin,
|
||||
showError,
|
||||
showSuccess,
|
||||
timestamp2string,
|
||||
} from '../../helpers';
|
||||
import { ITEMS_PER_PAGE } from '../../constants';
|
||||
import { useTableCompactMode } from '../common/useTableCompactMode';
|
||||
|
||||
export const useTaskLogsData = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Define column keys for selection
|
||||
const COLUMN_KEYS = {
|
||||
SUBMIT_TIME: 'submit_time',
|
||||
FINISH_TIME: 'finish_time',
|
||||
DURATION: 'duration',
|
||||
CHANNEL: 'channel',
|
||||
USERNAME: 'username',
|
||||
PLATFORM: 'platform',
|
||||
TYPE: 'type',
|
||||
TASK_ID: 'task_id',
|
||||
TASK_STATUS: 'task_status',
|
||||
PROGRESS: 'progress',
|
||||
FAIL_REASON: 'fail_reason',
|
||||
RESULT_URL: 'result_url',
|
||||
};
|
||||
|
||||
// Basic state
|
||||
const [logs, setLogs] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [activePage, setActivePage] = useState(1);
|
||||
const [logCount, setLogCount] = useState(0);
|
||||
const [pageSize, setPageSize] = useState(ITEMS_PER_PAGE);
|
||||
|
||||
// User and admin
|
||||
const isAdminUser = isAdmin();
|
||||
// Role-specific storage key to prevent different roles from overwriting each other
|
||||
const STORAGE_KEY = isAdminUser
|
||||
? 'task-logs-table-columns-admin'
|
||||
: 'task-logs-table-columns-user';
|
||||
|
||||
// Modal state
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [modalContent, setModalContent] = useState('');
|
||||
|
||||
// 新增:视频预览弹窗状态
|
||||
const [isVideoModalOpen, setIsVideoModalOpen] = useState(false);
|
||||
const [videoUrl, setVideoUrl] = useState('');
|
||||
|
||||
// Audio preview modal state
|
||||
const [isAudioModalOpen, setIsAudioModalOpen] = useState(false);
|
||||
const [audioClips, setAudioClips] = useState([]);
|
||||
|
||||
// User info modal state
|
||||
const [showUserInfo, setShowUserInfoModal] = useState(false);
|
||||
const [userInfoData, setUserInfoData] = useState(null);
|
||||
|
||||
// Form state
|
||||
const [formApi, setFormApi] = useState(null);
|
||||
let now = new Date();
|
||||
let zeroNow = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
|
||||
const formInitValues = {
|
||||
channel_id: '',
|
||||
task_id: '',
|
||||
dateRange: [
|
||||
timestamp2string(zeroNow.getTime() / 1000),
|
||||
timestamp2string(now.getTime() / 1000 + 3600),
|
||||
],
|
||||
};
|
||||
|
||||
// Column visibility state
|
||||
const [visibleColumns, setVisibleColumns] = useState({});
|
||||
const [showColumnSelector, setShowColumnSelector] = useState(false);
|
||||
|
||||
// Compact mode
|
||||
const [compactMode, setCompactMode] = useTableCompactMode('taskLogs');
|
||||
|
||||
// Load saved column preferences from localStorage
|
||||
useEffect(() => {
|
||||
const savedColumns = localStorage.getItem(STORAGE_KEY);
|
||||
if (savedColumns) {
|
||||
try {
|
||||
const parsed = JSON.parse(savedColumns);
|
||||
const defaults = getDefaultColumnVisibility();
|
||||
const merged = { ...defaults, ...parsed };
|
||||
|
||||
// For non-admin users, force-hide admin-only columns (does not touch admin settings)
|
||||
if (!isAdminUser) {
|
||||
merged[COLUMN_KEYS.CHANNEL] = false;
|
||||
merged[COLUMN_KEYS.USERNAME] = false;
|
||||
}
|
||||
setVisibleColumns(merged);
|
||||
} catch (e) {
|
||||
console.error('Failed to parse saved column preferences', e);
|
||||
initDefaultColumns();
|
||||
}
|
||||
} else {
|
||||
initDefaultColumns();
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Get default column visibility based on user role
|
||||
const getDefaultColumnVisibility = () => {
|
||||
return {
|
||||
[COLUMN_KEYS.SUBMIT_TIME]: true,
|
||||
[COLUMN_KEYS.FINISH_TIME]: true,
|
||||
[COLUMN_KEYS.DURATION]: true,
|
||||
[COLUMN_KEYS.CHANNEL]: isAdminUser,
|
||||
[COLUMN_KEYS.USERNAME]: isAdminUser,
|
||||
[COLUMN_KEYS.PLATFORM]: true,
|
||||
[COLUMN_KEYS.TYPE]: true,
|
||||
[COLUMN_KEYS.TASK_ID]: true,
|
||||
[COLUMN_KEYS.TASK_STATUS]: true,
|
||||
[COLUMN_KEYS.PROGRESS]: true,
|
||||
[COLUMN_KEYS.FAIL_REASON]: true,
|
||||
[COLUMN_KEYS.RESULT_URL]: true,
|
||||
};
|
||||
};
|
||||
|
||||
// Initialize default column visibility
|
||||
const initDefaultColumns = () => {
|
||||
const defaults = getDefaultColumnVisibility();
|
||||
setVisibleColumns(defaults);
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(defaults));
|
||||
};
|
||||
|
||||
// Handle column visibility change
|
||||
const handleColumnVisibilityChange = (columnKey, checked) => {
|
||||
const updatedColumns = { ...visibleColumns, [columnKey]: checked };
|
||||
setVisibleColumns(updatedColumns);
|
||||
};
|
||||
|
||||
// Handle "Select All" checkbox
|
||||
const handleSelectAll = (checked) => {
|
||||
const allKeys = Object.keys(COLUMN_KEYS).map((key) => COLUMN_KEYS[key]);
|
||||
const updatedColumns = {};
|
||||
|
||||
allKeys.forEach((key) => {
|
||||
if (
|
||||
(key === COLUMN_KEYS.CHANNEL || key === COLUMN_KEYS.USERNAME) &&
|
||||
!isAdminUser
|
||||
) {
|
||||
updatedColumns[key] = false;
|
||||
} else {
|
||||
updatedColumns[key] = checked;
|
||||
}
|
||||
});
|
||||
|
||||
setVisibleColumns(updatedColumns);
|
||||
};
|
||||
|
||||
// Persist column settings to the role-specific STORAGE_KEY
|
||||
useEffect(() => {
|
||||
if (Object.keys(visibleColumns).length > 0) {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(visibleColumns));
|
||||
}
|
||||
}, [visibleColumns]);
|
||||
|
||||
// Get form values helper function
|
||||
const getFormValues = () => {
|
||||
const formValues = formApi ? formApi.getValues() : {};
|
||||
|
||||
// 处理时间范围
|
||||
let start_timestamp = timestamp2string(zeroNow.getTime() / 1000);
|
||||
let end_timestamp = timestamp2string(now.getTime() / 1000 + 3600);
|
||||
|
||||
if (
|
||||
formValues.dateRange &&
|
||||
Array.isArray(formValues.dateRange) &&
|
||||
formValues.dateRange.length === 2
|
||||
) {
|
||||
start_timestamp = formValues.dateRange[0];
|
||||
end_timestamp = formValues.dateRange[1];
|
||||
}
|
||||
|
||||
return {
|
||||
channel_id: formValues.channel_id || '',
|
||||
task_id: formValues.task_id || '',
|
||||
start_timestamp,
|
||||
end_timestamp,
|
||||
};
|
||||
};
|
||||
|
||||
// Enrich logs data
|
||||
const enrichLogs = (items) => {
|
||||
return items.map((log) => ({
|
||||
...log,
|
||||
timestamp2string: timestamp2string(log.created_at),
|
||||
key: '' + log.id,
|
||||
}));
|
||||
};
|
||||
|
||||
// Sync page data
|
||||
const syncPageData = (payload) => {
|
||||
const items = enrichLogs(payload.items || []);
|
||||
setLogs(items);
|
||||
setLogCount(payload.total || 0);
|
||||
setActivePage(payload.page || 1);
|
||||
setPageSize(payload.page_size || pageSize);
|
||||
};
|
||||
|
||||
// Load logs function
|
||||
const loadLogs = async (page = 1, size = pageSize) => {
|
||||
setLoading(true);
|
||||
const { channel_id, task_id, start_timestamp, end_timestamp } =
|
||||
getFormValues();
|
||||
let localStartTimestamp = parseInt(Date.parse(start_timestamp) / 1000);
|
||||
let localEndTimestamp = parseInt(Date.parse(end_timestamp) / 1000);
|
||||
let url = isAdminUser
|
||||
? `/api/task/?p=${page}&page_size=${size}&channel_id=${channel_id}&task_id=${task_id}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}`
|
||||
: `/api/task/self?p=${page}&page_size=${size}&task_id=${task_id}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}`;
|
||||
const res = await API.get(url);
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
syncPageData(data);
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
// Page handlers
|
||||
const handlePageChange = (page) => {
|
||||
loadLogs(page, pageSize).then();
|
||||
};
|
||||
|
||||
const handlePageSizeChange = async (size) => {
|
||||
localStorage.setItem('task-page-size', size + '');
|
||||
await loadLogs(1, size);
|
||||
};
|
||||
|
||||
// Refresh function
|
||||
const refresh = async () => {
|
||||
await loadLogs(1, pageSize);
|
||||
};
|
||||
|
||||
// Copy text function
|
||||
const copyText = async (text) => {
|
||||
if (await copy(text)) {
|
||||
showSuccess(t('已复制:') + text);
|
||||
} else {
|
||||
Modal.error({ title: t('无法复制到剪贴板,请手动复制'), content: text });
|
||||
}
|
||||
};
|
||||
|
||||
// Modal handlers
|
||||
const openContentModal = (content) => {
|
||||
setModalContent(content);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
// 新增:打开视频预览弹窗
|
||||
const openVideoModal = (url) => {
|
||||
setVideoUrl(url);
|
||||
setIsVideoModalOpen(true);
|
||||
};
|
||||
|
||||
const openAudioModal = (clips) => {
|
||||
setAudioClips(clips);
|
||||
setIsAudioModalOpen(true);
|
||||
};
|
||||
|
||||
// User info function
|
||||
const showUserInfoFunc = async (userId) => {
|
||||
if (!isAdminUser) {
|
||||
return;
|
||||
}
|
||||
const res = await API.get(`/api/user/${userId}`);
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
setUserInfoData(data);
|
||||
setShowUserInfoModal(true);
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize data
|
||||
useEffect(() => {
|
||||
const localPageSize =
|
||||
parseInt(localStorage.getItem('task-page-size')) || ITEMS_PER_PAGE;
|
||||
setPageSize(localPageSize);
|
||||
loadLogs(1, localPageSize).then();
|
||||
}, []);
|
||||
|
||||
return {
|
||||
// Basic state
|
||||
logs,
|
||||
loading,
|
||||
activePage,
|
||||
logCount,
|
||||
pageSize,
|
||||
isAdminUser,
|
||||
|
||||
// Modal state
|
||||
isModalOpen,
|
||||
setIsModalOpen,
|
||||
modalContent,
|
||||
|
||||
// 新增:视频弹窗状态
|
||||
isVideoModalOpen,
|
||||
setIsVideoModalOpen,
|
||||
videoUrl,
|
||||
|
||||
// Audio preview modal
|
||||
isAudioModalOpen,
|
||||
setIsAudioModalOpen,
|
||||
audioClips,
|
||||
|
||||
// Form state
|
||||
formApi,
|
||||
setFormApi,
|
||||
formInitValues,
|
||||
getFormValues,
|
||||
|
||||
// Column visibility
|
||||
visibleColumns,
|
||||
showColumnSelector,
|
||||
setShowColumnSelector,
|
||||
handleColumnVisibilityChange,
|
||||
handleSelectAll,
|
||||
initDefaultColumns,
|
||||
COLUMN_KEYS,
|
||||
|
||||
// Compact mode
|
||||
compactMode,
|
||||
setCompactMode,
|
||||
|
||||
// User info modal
|
||||
showUserInfo,
|
||||
setShowUserInfoModal,
|
||||
userInfoData,
|
||||
showUserInfoFunc,
|
||||
|
||||
// Functions
|
||||
loadLogs,
|
||||
handlePageChange,
|
||||
handlePageSizeChange,
|
||||
refresh,
|
||||
copyText,
|
||||
openContentModal,
|
||||
openVideoModal,
|
||||
openAudioModal,
|
||||
enrichLogs,
|
||||
syncPageData,
|
||||
|
||||
// Translation
|
||||
t,
|
||||
};
|
||||
};
|
||||
+512
@@ -0,0 +1,512 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Modal } from '@douyinfe/semi-ui';
|
||||
import {
|
||||
API,
|
||||
copy,
|
||||
showError,
|
||||
showSuccess,
|
||||
encodeToBase64,
|
||||
} from '../../helpers';
|
||||
import { ITEMS_PER_PAGE } from '../../constants';
|
||||
import { useTableCompactMode } from '../common/useTableCompactMode';
|
||||
import {
|
||||
fetchTokenKey as fetchTokenKeyById,
|
||||
fetchTokenKeysBatch,
|
||||
getServerAddress,
|
||||
encodeChannelConnectionString,
|
||||
} from '../../helpers/token';
|
||||
|
||||
export const useTokensData = (openFluentNotification, openCCSwitchModal) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Basic state
|
||||
const [tokens, setTokens] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [groupRatios, setGroupRatios] = useState({});
|
||||
const [activePage, setActivePage] = useState(1);
|
||||
const [tokenCount, setTokenCount] = useState(0);
|
||||
const [pageSize, setPageSize] = useState(ITEMS_PER_PAGE);
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [searchMode, setSearchMode] = useState(false); // 是否处于搜索结果视图
|
||||
|
||||
// Selection state
|
||||
const [selectedKeys, setSelectedKeys] = useState([]);
|
||||
|
||||
// Edit state
|
||||
const [showEdit, setShowEdit] = useState(false);
|
||||
const [editingToken, setEditingToken] = useState({
|
||||
id: undefined,
|
||||
});
|
||||
|
||||
// UI state
|
||||
const [compactMode, setCompactMode] = useTableCompactMode('tokens');
|
||||
const [showKeys, setShowKeys] = useState({});
|
||||
const [resolvedTokenKeys, setResolvedTokenKeys] = useState({});
|
||||
const [loadingTokenKeys, setLoadingTokenKeys] = useState({});
|
||||
const keyRequestsRef = useRef({});
|
||||
|
||||
// Form state
|
||||
const [formApi, setFormApi] = useState(null);
|
||||
const formInitValues = {
|
||||
searchKeyword: '',
|
||||
searchToken: '',
|
||||
};
|
||||
|
||||
// Get form values helper function
|
||||
const getFormValues = () => {
|
||||
const formValues = formApi ? formApi.getValues() : {};
|
||||
return {
|
||||
searchKeyword: formValues.searchKeyword || '',
|
||||
searchToken: formValues.searchToken || '',
|
||||
};
|
||||
};
|
||||
|
||||
// Close edit modal
|
||||
const closeEdit = () => {
|
||||
setShowEdit(false);
|
||||
setTimeout(() => {
|
||||
setEditingToken({
|
||||
id: undefined,
|
||||
});
|
||||
}, 500);
|
||||
};
|
||||
|
||||
// Sync page data from API response
|
||||
const syncPageData = (payload) => {
|
||||
setTokens(payload.items || []);
|
||||
setTokenCount(payload.total || 0);
|
||||
setActivePage(payload.page || 1);
|
||||
setPageSize(payload.page_size || pageSize);
|
||||
setShowKeys({});
|
||||
};
|
||||
|
||||
// Load tokens function
|
||||
const loadTokens = async (page = 1, size = pageSize) => {
|
||||
setLoading(true);
|
||||
setSearchMode(false);
|
||||
const res = await API.get(`/api/token/?p=${page}&size=${size}`);
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
syncPageData(data);
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
// Refresh function
|
||||
const refresh = async (page = activePage) => {
|
||||
await loadTokens(page);
|
||||
setSelectedKeys([]);
|
||||
};
|
||||
|
||||
// Copy text function
|
||||
const copyText = async (text) => {
|
||||
if (await copy(text)) {
|
||||
showSuccess(t('已复制到剪贴板!'));
|
||||
} else {
|
||||
Modal.error({
|
||||
title: t('无法复制到剪贴板,请手动复制'),
|
||||
content: text,
|
||||
size: 'large',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const fetchTokenKey = async (tokenOrId, options = {}) => {
|
||||
const { suppressError = false } = options;
|
||||
const tokenId =
|
||||
typeof tokenOrId === 'object' ? tokenOrId?.id : Number(tokenOrId);
|
||||
|
||||
if (!tokenId) {
|
||||
const error = new Error(t('令牌不存在'));
|
||||
if (!suppressError) {
|
||||
showError(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (resolvedTokenKeys[tokenId]) {
|
||||
return resolvedTokenKeys[tokenId];
|
||||
}
|
||||
|
||||
if (keyRequestsRef.current[tokenId]) {
|
||||
return keyRequestsRef.current[tokenId];
|
||||
}
|
||||
|
||||
const request = (async () => {
|
||||
setLoadingTokenKeys((prev) => ({ ...prev, [tokenId]: true }));
|
||||
try {
|
||||
const fullKey = await fetchTokenKeyById(tokenId);
|
||||
setResolvedTokenKeys((prev) => ({ ...prev, [tokenId]: fullKey }));
|
||||
return fullKey;
|
||||
} catch (error) {
|
||||
const normalizedError = new Error(
|
||||
error?.message || t('获取令牌密钥失败'),
|
||||
);
|
||||
if (!suppressError) {
|
||||
showError(normalizedError.message);
|
||||
}
|
||||
throw normalizedError;
|
||||
} finally {
|
||||
delete keyRequestsRef.current[tokenId];
|
||||
setLoadingTokenKeys((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[tokenId];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
||||
keyRequestsRef.current[tokenId] = request;
|
||||
return request;
|
||||
};
|
||||
|
||||
const toggleTokenVisibility = async (record) => {
|
||||
const tokenId = record?.id;
|
||||
if (!tokenId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (showKeys[tokenId]) {
|
||||
setShowKeys((prev) => ({ ...prev, [tokenId]: false }));
|
||||
return;
|
||||
}
|
||||
|
||||
const fullKey = await fetchTokenKey(record);
|
||||
if (fullKey) {
|
||||
setShowKeys((prev) => ({ ...prev, [tokenId]: true }));
|
||||
}
|
||||
};
|
||||
|
||||
const copyTokenKey = async (record) => {
|
||||
const fullKey = await fetchTokenKey(record);
|
||||
await copyText(`sk-${fullKey}`);
|
||||
};
|
||||
|
||||
const copyTokenConnectionString = async (record) => {
|
||||
const fullKey = await fetchTokenKey(record);
|
||||
const serverUrl = getServerAddress();
|
||||
const connStr = encodeChannelConnectionString(`sk-${fullKey}`, serverUrl);
|
||||
await copyText(connStr);
|
||||
};
|
||||
|
||||
// Open link function for chat integrations
|
||||
const onOpenLink = async (type, url, record) => {
|
||||
const fullKey = await fetchTokenKey(record);
|
||||
if (url && url.startsWith('ccswitch')) {
|
||||
openCCSwitchModal(fullKey);
|
||||
return;
|
||||
}
|
||||
if (url && url.startsWith('fluent')) {
|
||||
openFluentNotification(fullKey);
|
||||
return;
|
||||
}
|
||||
let status = localStorage.getItem('status');
|
||||
let serverAddress = '';
|
||||
if (status) {
|
||||
status = JSON.parse(status);
|
||||
serverAddress = status.server_address;
|
||||
}
|
||||
if (serverAddress === '') {
|
||||
serverAddress = window.location.origin;
|
||||
}
|
||||
if (url.includes('{cherryConfig}') === true) {
|
||||
let cherryConfig = {
|
||||
id: 'new-api',
|
||||
baseUrl: serverAddress,
|
||||
apiKey: `sk-${fullKey}`,
|
||||
};
|
||||
let encodedConfig = encodeURIComponent(
|
||||
encodeToBase64(JSON.stringify(cherryConfig)),
|
||||
);
|
||||
url = url.replaceAll('{cherryConfig}', encodedConfig);
|
||||
} else if (url.includes('{aionuiConfig}') === true) {
|
||||
let aionuiConfig = {
|
||||
platform: 'new-api',
|
||||
baseUrl: serverAddress,
|
||||
apiKey: `sk-${fullKey}`,
|
||||
};
|
||||
let encodedConfig = encodeURIComponent(
|
||||
encodeToBase64(JSON.stringify(aionuiConfig)),
|
||||
);
|
||||
url = url.replaceAll('{aionuiConfig}', encodedConfig);
|
||||
} else {
|
||||
let encodedServerAddress = encodeURIComponent(serverAddress);
|
||||
url = url.replaceAll('{address}', encodedServerAddress);
|
||||
url = url.replaceAll('{key}', `sk-${fullKey}`);
|
||||
}
|
||||
|
||||
window.open(url, '_blank');
|
||||
};
|
||||
|
||||
// Manage token function (delete, enable, disable)
|
||||
const manageToken = async (id, action, record) => {
|
||||
setLoading(true);
|
||||
let data = { id };
|
||||
let res;
|
||||
switch (action) {
|
||||
case 'delete':
|
||||
res = await API.delete(`/api/token/${id}/`);
|
||||
break;
|
||||
case 'enable':
|
||||
data.status = 1;
|
||||
res = await API.put('/api/token/?status_only=true', data);
|
||||
break;
|
||||
case 'disable':
|
||||
data.status = 2;
|
||||
res = await API.put('/api/token/?status_only=true', data);
|
||||
break;
|
||||
}
|
||||
const { success, message } = res.data;
|
||||
if (success) {
|
||||
showSuccess(t('操作成功完成!'));
|
||||
let token = res.data.data;
|
||||
let newTokens = [...tokens];
|
||||
if (action !== 'delete') {
|
||||
record.status = token.status;
|
||||
}
|
||||
setTokens(newTokens);
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
// Search tokens function
|
||||
const searchTokens = async (page = 1, size = pageSize) => {
|
||||
const normalizedPage = Number.isInteger(page) && page > 0 ? page : 1;
|
||||
const normalizedSize =
|
||||
Number.isInteger(size) && size > 0 ? size : pageSize;
|
||||
|
||||
const { searchKeyword, searchToken } = getFormValues();
|
||||
if (searchKeyword === '' && searchToken === '') {
|
||||
setSearchMode(false);
|
||||
await loadTokens(1);
|
||||
return;
|
||||
}
|
||||
setSearching(true);
|
||||
const res = await API.get(
|
||||
`/api/token/search?keyword=${encodeURIComponent(searchKeyword)}&token=${encodeURIComponent(searchToken)}&p=${normalizedPage}&size=${normalizedSize}`,
|
||||
);
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
setSearchMode(true);
|
||||
syncPageData(data);
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
setSearching(false);
|
||||
};
|
||||
|
||||
// Sort tokens function
|
||||
const sortToken = (key) => {
|
||||
if (tokens.length === 0) return;
|
||||
setLoading(true);
|
||||
let sortedTokens = [...tokens];
|
||||
sortedTokens.sort((a, b) => {
|
||||
return ('' + a[key]).localeCompare(b[key]);
|
||||
});
|
||||
if (sortedTokens[0].id === tokens[0].id) {
|
||||
sortedTokens.reverse();
|
||||
}
|
||||
setTokens(sortedTokens);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
// Page handlers
|
||||
const handlePageChange = (page) => {
|
||||
if (searchMode) {
|
||||
searchTokens(page, pageSize).then();
|
||||
} else {
|
||||
loadTokens(page, pageSize).then();
|
||||
}
|
||||
};
|
||||
|
||||
const handlePageSizeChange = async (size) => {
|
||||
setPageSize(size);
|
||||
if (searchMode) {
|
||||
await searchTokens(1, size);
|
||||
} else {
|
||||
await loadTokens(1, size);
|
||||
}
|
||||
};
|
||||
|
||||
// Row selection handlers
|
||||
const rowSelection = {
|
||||
onSelect: (record, selected) => {},
|
||||
onSelectAll: (selected, selectedRows) => {},
|
||||
onChange: (selectedRowKeys, selectedRows) => {
|
||||
setSelectedKeys(selectedRows);
|
||||
},
|
||||
};
|
||||
|
||||
// Handle row styling
|
||||
const handleRow = (record, index) => {
|
||||
if (record.status !== 1) {
|
||||
return {
|
||||
style: {
|
||||
background: 'var(--semi-color-disabled-border)',
|
||||
},
|
||||
};
|
||||
} else {
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
// Batch delete tokens
|
||||
const batchDeleteTokens = async () => {
|
||||
if (selectedKeys.length === 0) {
|
||||
showError(t('请先选择要删除的令牌!'));
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const ids = selectedKeys.map((token) => token.id);
|
||||
const res = await API.post('/api/token/batch', { ids });
|
||||
if (res?.data?.success) {
|
||||
const count = res.data.data || 0;
|
||||
showSuccess(t('已删除 {{count}} 个令牌!', { count }));
|
||||
await refresh();
|
||||
setTimeout(() => {
|
||||
if (tokens.length === 0 && activePage > 1) {
|
||||
refresh(activePage - 1);
|
||||
}
|
||||
}, 100);
|
||||
} else {
|
||||
showError(res?.data?.message || t('删除失败'));
|
||||
}
|
||||
} catch (error) {
|
||||
showError(error.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Batch copy tokens
|
||||
const batchCopyTokens = async (copyType) => {
|
||||
if (selectedKeys.length === 0) {
|
||||
showError(t('请至少选择一个令牌!'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const ids = selectedKeys.map((token) => token.id);
|
||||
const keysMap = await fetchTokenKeysBatch(ids);
|
||||
|
||||
setResolvedTokenKeys((prev) => ({ ...prev, ...keysMap }));
|
||||
|
||||
let content = '';
|
||||
for (const token of selectedKeys) {
|
||||
const fullKey = keysMap[token.id];
|
||||
if (!fullKey) continue;
|
||||
if (copyType === 'name+key') {
|
||||
content += `${token.name} sk-${fullKey}\n`;
|
||||
} else {
|
||||
content += `sk-${fullKey}\n`;
|
||||
}
|
||||
}
|
||||
await copyText(content);
|
||||
} catch (error) {
|
||||
showError(error?.message || t('复制令牌失败'));
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize data
|
||||
useEffect(() => {
|
||||
loadTokens(1)
|
||||
.then()
|
||||
.catch((reason) => {
|
||||
showError(reason);
|
||||
});
|
||||
API.get('/api/user/self/groups')
|
||||
.then((res) => {
|
||||
if (res.data.success && res.data.data) {
|
||||
const ratios = {};
|
||||
for (const [name, info] of Object.entries(res.data.data)) {
|
||||
ratios[name] = info.ratio;
|
||||
}
|
||||
setGroupRatios(ratios);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [pageSize]);
|
||||
|
||||
return {
|
||||
// Basic state
|
||||
tokens,
|
||||
loading,
|
||||
activePage,
|
||||
tokenCount,
|
||||
pageSize,
|
||||
searching,
|
||||
groupRatios,
|
||||
|
||||
// Selection state
|
||||
selectedKeys,
|
||||
setSelectedKeys,
|
||||
|
||||
// Edit state
|
||||
showEdit,
|
||||
setShowEdit,
|
||||
editingToken,
|
||||
setEditingToken,
|
||||
closeEdit,
|
||||
|
||||
// UI state
|
||||
compactMode,
|
||||
setCompactMode,
|
||||
showKeys,
|
||||
setShowKeys,
|
||||
resolvedTokenKeys,
|
||||
loadingTokenKeys,
|
||||
|
||||
// Form state
|
||||
formApi,
|
||||
setFormApi,
|
||||
formInitValues,
|
||||
getFormValues,
|
||||
|
||||
// Functions
|
||||
loadTokens,
|
||||
refresh,
|
||||
copyText,
|
||||
fetchTokenKey,
|
||||
toggleTokenVisibility,
|
||||
copyTokenKey,
|
||||
copyTokenConnectionString,
|
||||
onOpenLink,
|
||||
manageToken,
|
||||
searchTokens,
|
||||
sortToken,
|
||||
handlePageChange,
|
||||
handlePageSizeChange,
|
||||
rowSelection,
|
||||
handleRow,
|
||||
batchDeleteTokens,
|
||||
batchCopyTokens,
|
||||
syncPageData,
|
||||
|
||||
// Translation
|
||||
t,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,900 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Modal } from '@douyinfe/semi-ui';
|
||||
import {
|
||||
API,
|
||||
getTodayStartTimestamp,
|
||||
isAdmin,
|
||||
showError,
|
||||
showSuccess,
|
||||
timestamp2string,
|
||||
renderQuota,
|
||||
renderNumber,
|
||||
getLogOther,
|
||||
copy,
|
||||
renderClaudeLogContent,
|
||||
renderLogContent,
|
||||
renderAudioModelPrice,
|
||||
renderClaudeModelPrice,
|
||||
renderModelPrice,
|
||||
renderTieredModelPrice,
|
||||
renderTaskBillingProcess,
|
||||
} from '../../helpers';
|
||||
import { ITEMS_PER_PAGE } from '../../constants';
|
||||
import { useTableCompactMode } from '../common/useTableCompactMode';
|
||||
import ParamOverrideEntry from '../../components/table/usage-logs/components/ParamOverrideEntry';
|
||||
|
||||
export const useLogsData = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Define column keys for selection
|
||||
const COLUMN_KEYS = {
|
||||
TIME: 'time',
|
||||
CHANNEL: 'channel',
|
||||
USERNAME: 'username',
|
||||
TOKEN: 'token',
|
||||
GROUP: 'group',
|
||||
TYPE: 'type',
|
||||
MODEL: 'model',
|
||||
USE_TIME: 'use_time',
|
||||
PROMPT: 'prompt',
|
||||
COMPLETION: 'completion',
|
||||
COST: 'cost',
|
||||
RETRY: 'retry',
|
||||
IP: 'ip',
|
||||
DETAILS: 'details',
|
||||
};
|
||||
|
||||
// Basic state
|
||||
const [logs, setLogs] = useState([]);
|
||||
const [expandData, setExpandData] = useState({});
|
||||
const [showStat, setShowStat] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loadingStat, setLoadingStat] = useState(false);
|
||||
const [activePage, setActivePage] = useState(1);
|
||||
const [logCount, setLogCount] = useState(0);
|
||||
const [pageSize, setPageSize] = useState(ITEMS_PER_PAGE);
|
||||
const [logType, setLogType] = useState(0);
|
||||
|
||||
// User and admin
|
||||
const isAdminUser = isAdmin();
|
||||
// Role-specific storage key to prevent different roles from overwriting each other
|
||||
const STORAGE_KEY = isAdminUser
|
||||
? 'logs-table-columns-admin'
|
||||
: 'logs-table-columns-user';
|
||||
const BILLING_DISPLAY_MODE_STORAGE_KEY = isAdminUser
|
||||
? 'logs-billing-display-mode-admin'
|
||||
: 'logs-billing-display-mode-user';
|
||||
|
||||
// Statistics state
|
||||
const [stat, setStat] = useState({
|
||||
quota: 0,
|
||||
token: 0,
|
||||
});
|
||||
|
||||
// Form state
|
||||
const [formApi, setFormApi] = useState(null);
|
||||
let now = new Date();
|
||||
const formInitValues = {
|
||||
username: '',
|
||||
token_name: '',
|
||||
model_name: '',
|
||||
channel: '',
|
||||
group: '',
|
||||
request_id: '',
|
||||
dateRange: [
|
||||
timestamp2string(getTodayStartTimestamp()),
|
||||
timestamp2string(now.getTime() / 1000 + 3600),
|
||||
],
|
||||
logType: '0',
|
||||
};
|
||||
|
||||
// Get default column visibility based on user role
|
||||
const getDefaultColumnVisibility = () => {
|
||||
return {
|
||||
[COLUMN_KEYS.TIME]: true,
|
||||
[COLUMN_KEYS.CHANNEL]: isAdminUser,
|
||||
[COLUMN_KEYS.USERNAME]: isAdminUser,
|
||||
[COLUMN_KEYS.TOKEN]: true,
|
||||
[COLUMN_KEYS.GROUP]: true,
|
||||
[COLUMN_KEYS.TYPE]: true,
|
||||
[COLUMN_KEYS.MODEL]: true,
|
||||
[COLUMN_KEYS.USE_TIME]: true,
|
||||
[COLUMN_KEYS.PROMPT]: true,
|
||||
[COLUMN_KEYS.COMPLETION]: true,
|
||||
[COLUMN_KEYS.COST]: true,
|
||||
[COLUMN_KEYS.RETRY]: isAdminUser,
|
||||
[COLUMN_KEYS.IP]: true,
|
||||
[COLUMN_KEYS.DETAILS]: true,
|
||||
};
|
||||
};
|
||||
|
||||
const getInitialVisibleColumns = () => {
|
||||
const defaults = getDefaultColumnVisibility();
|
||||
const savedColumns = localStorage.getItem(STORAGE_KEY);
|
||||
|
||||
if (!savedColumns) {
|
||||
return defaults;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(savedColumns);
|
||||
const merged = { ...defaults, ...parsed };
|
||||
|
||||
if (!isAdminUser) {
|
||||
merged[COLUMN_KEYS.CHANNEL] = false;
|
||||
merged[COLUMN_KEYS.USERNAME] = false;
|
||||
merged[COLUMN_KEYS.RETRY] = false;
|
||||
}
|
||||
|
||||
return merged;
|
||||
} catch (e) {
|
||||
console.error('Failed to parse saved column preferences', e);
|
||||
return defaults;
|
||||
}
|
||||
};
|
||||
|
||||
const getInitialBillingDisplayMode = () => {
|
||||
const savedMode = localStorage.getItem(BILLING_DISPLAY_MODE_STORAGE_KEY);
|
||||
if (savedMode === 'price' || savedMode === 'ratio') {
|
||||
return savedMode;
|
||||
}
|
||||
return localStorage.getItem('quota_display_type') === 'TOKENS'
|
||||
? 'ratio'
|
||||
: 'price';
|
||||
};
|
||||
|
||||
// Column visibility state
|
||||
const [visibleColumns, setVisibleColumns] = useState(getInitialVisibleColumns);
|
||||
const [showColumnSelector, setShowColumnSelector] = useState(false);
|
||||
const [billingDisplayMode, setBillingDisplayMode] = useState(
|
||||
getInitialBillingDisplayMode,
|
||||
);
|
||||
|
||||
// Compact mode
|
||||
const [compactMode, setCompactMode] = useTableCompactMode('logs');
|
||||
|
||||
// User info modal state
|
||||
const [showUserInfo, setShowUserInfoModal] = useState(false);
|
||||
const [userInfoData, setUserInfoData] = useState(null);
|
||||
|
||||
// Channel affinity usage cache stats modal state (admin only)
|
||||
const [
|
||||
showChannelAffinityUsageCacheModal,
|
||||
setShowChannelAffinityUsageCacheModal,
|
||||
] = useState(false);
|
||||
const [channelAffinityUsageCacheTarget, setChannelAffinityUsageCacheTarget] =
|
||||
useState(null);
|
||||
const [showParamOverrideModal, setShowParamOverrideModal] = useState(false);
|
||||
const [paramOverrideTarget, setParamOverrideTarget] = useState(null);
|
||||
|
||||
// Initialize default column visibility
|
||||
const initDefaultColumns = () => {
|
||||
const defaults = getDefaultColumnVisibility();
|
||||
setVisibleColumns(defaults);
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(defaults));
|
||||
};
|
||||
|
||||
// Handle column visibility change
|
||||
const handleColumnVisibilityChange = (columnKey, checked) => {
|
||||
const updatedColumns = { ...visibleColumns, [columnKey]: checked };
|
||||
setVisibleColumns(updatedColumns);
|
||||
};
|
||||
|
||||
// Handle "Select All" checkbox
|
||||
const handleSelectAll = (checked) => {
|
||||
const allKeys = Object.keys(COLUMN_KEYS).map((key) => COLUMN_KEYS[key]);
|
||||
const updatedColumns = {};
|
||||
|
||||
allKeys.forEach((key) => {
|
||||
if (
|
||||
(key === COLUMN_KEYS.CHANNEL ||
|
||||
key === COLUMN_KEYS.USERNAME ||
|
||||
key === COLUMN_KEYS.RETRY) &&
|
||||
!isAdminUser
|
||||
) {
|
||||
updatedColumns[key] = false;
|
||||
} else {
|
||||
updatedColumns[key] = checked;
|
||||
}
|
||||
});
|
||||
|
||||
setVisibleColumns(updatedColumns);
|
||||
};
|
||||
|
||||
// Persist column settings to the role-specific STORAGE_KEY
|
||||
useEffect(() => {
|
||||
if (Object.keys(visibleColumns).length > 0) {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(visibleColumns));
|
||||
}
|
||||
}, [visibleColumns]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem(BILLING_DISPLAY_MODE_STORAGE_KEY, billingDisplayMode);
|
||||
}, [BILLING_DISPLAY_MODE_STORAGE_KEY, billingDisplayMode]);
|
||||
|
||||
// 获取表单值的辅助函数,确保所有值都是字符串
|
||||
const getFormValues = () => {
|
||||
const formValues = formApi ? formApi.getValues() : {};
|
||||
|
||||
let start_timestamp = timestamp2string(getTodayStartTimestamp());
|
||||
let end_timestamp = timestamp2string(now.getTime() / 1000 + 3600);
|
||||
|
||||
if (
|
||||
formValues.dateRange &&
|
||||
Array.isArray(formValues.dateRange) &&
|
||||
formValues.dateRange.length === 2
|
||||
) {
|
||||
start_timestamp = formValues.dateRange[0];
|
||||
end_timestamp = formValues.dateRange[1];
|
||||
}
|
||||
|
||||
return {
|
||||
username: formValues.username || '',
|
||||
token_name: formValues.token_name || '',
|
||||
model_name: formValues.model_name || '',
|
||||
start_timestamp,
|
||||
end_timestamp,
|
||||
channel: formValues.channel || '',
|
||||
group: formValues.group || '',
|
||||
request_id: formValues.request_id || '',
|
||||
logType: formValues.logType ? parseInt(formValues.logType) : 0,
|
||||
};
|
||||
};
|
||||
|
||||
// Statistics functions
|
||||
const getLogSelfStat = async () => {
|
||||
const {
|
||||
token_name,
|
||||
model_name,
|
||||
start_timestamp,
|
||||
end_timestamp,
|
||||
group,
|
||||
logType: formLogType,
|
||||
} = getFormValues();
|
||||
const currentLogType = formLogType !== undefined ? formLogType : logType;
|
||||
let localStartTimestamp = Date.parse(start_timestamp) / 1000;
|
||||
let localEndTimestamp = Date.parse(end_timestamp) / 1000;
|
||||
let url = `/api/log/self/stat?type=${currentLogType}&token_name=${token_name}&model_name=${model_name}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}&group=${group}`;
|
||||
url = encodeURI(url);
|
||||
let res = await API.get(url);
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
setStat(data);
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
};
|
||||
|
||||
const getLogStat = async () => {
|
||||
const {
|
||||
username,
|
||||
token_name,
|
||||
model_name,
|
||||
start_timestamp,
|
||||
end_timestamp,
|
||||
channel,
|
||||
group,
|
||||
logType: formLogType,
|
||||
} = getFormValues();
|
||||
const currentLogType = formLogType !== undefined ? formLogType : logType;
|
||||
let localStartTimestamp = Date.parse(start_timestamp) / 1000;
|
||||
let localEndTimestamp = Date.parse(end_timestamp) / 1000;
|
||||
let url = `/api/log/stat?type=${currentLogType}&username=${username}&token_name=${token_name}&model_name=${model_name}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}&channel=${channel}&group=${group}`;
|
||||
url = encodeURI(url);
|
||||
let res = await API.get(url);
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
setStat(data);
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEyeClick = async () => {
|
||||
if (loadingStat) {
|
||||
return;
|
||||
}
|
||||
setLoadingStat(true);
|
||||
if (isAdminUser) {
|
||||
await getLogStat();
|
||||
} else {
|
||||
await getLogSelfStat();
|
||||
}
|
||||
setShowStat(true);
|
||||
setLoadingStat(false);
|
||||
};
|
||||
|
||||
// User info function
|
||||
const showUserInfoFunc = async (userId) => {
|
||||
if (!isAdminUser) {
|
||||
return;
|
||||
}
|
||||
const res = await API.get(`/api/user/${userId}`);
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
setUserInfoData(data);
|
||||
setShowUserInfoModal(true);
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
};
|
||||
|
||||
const openChannelAffinityUsageCacheModal = (affinity) => {
|
||||
const a = affinity || {};
|
||||
setChannelAffinityUsageCacheTarget({
|
||||
rule_name: a.rule_name || a.reason || '',
|
||||
using_group: a.using_group || '',
|
||||
key_hint: a.key_hint || '',
|
||||
key_fp: a.key_fp || '',
|
||||
});
|
||||
setShowChannelAffinityUsageCacheModal(true);
|
||||
};
|
||||
|
||||
const openParamOverrideModal = (log, other) => {
|
||||
const lines = Array.isArray(other?.po) ? other.po.filter(Boolean) : [];
|
||||
if (lines.length === 0) {
|
||||
return;
|
||||
}
|
||||
setParamOverrideTarget({
|
||||
lines,
|
||||
modelName: log?.model_name || '',
|
||||
requestId: log?.request_id || '',
|
||||
requestPath: other?.request_path || '',
|
||||
});
|
||||
setShowParamOverrideModal(true);
|
||||
};
|
||||
|
||||
// Format logs data
|
||||
const setLogsFormat = (logs) => {
|
||||
const requestConversionDisplayValue = (conversionChain) => {
|
||||
const chain = Array.isArray(conversionChain)
|
||||
? conversionChain.filter(Boolean)
|
||||
: [];
|
||||
if (chain.length <= 1) {
|
||||
return t('原生格式');
|
||||
}
|
||||
return `${chain.join(' -> ')}`;
|
||||
};
|
||||
|
||||
let expandDatesLocal = {};
|
||||
for (let i = 0; i < logs.length; i++) {
|
||||
logs[i].timestamp2string = timestamp2string(logs[i].created_at);
|
||||
logs[i].key = logs[i].id;
|
||||
let other = getLogOther(logs[i].other);
|
||||
let expandDataLocal = [];
|
||||
|
||||
if (isAdminUser && (logs[i].type === 0 || logs[i].type === 2 || logs[i].type === 6)) {
|
||||
expandDataLocal.push({
|
||||
key: t('渠道信息'),
|
||||
value: `${logs[i].channel} - ${logs[i].channel_name || '[未知]'}`,
|
||||
});
|
||||
}
|
||||
if (logs[i].request_id) {
|
||||
expandDataLocal.push({
|
||||
key: t('Request ID'),
|
||||
value: logs[i].request_id,
|
||||
});
|
||||
}
|
||||
if (other?.ws || other?.audio) {
|
||||
expandDataLocal.push({
|
||||
key: t('语音输入'),
|
||||
value: other.audio_input,
|
||||
});
|
||||
expandDataLocal.push({
|
||||
key: t('语音输出'),
|
||||
value: other.audio_output,
|
||||
});
|
||||
expandDataLocal.push({
|
||||
key: t('文字输入'),
|
||||
value: other.text_input,
|
||||
});
|
||||
expandDataLocal.push({
|
||||
key: t('文字输出'),
|
||||
value: other.text_output,
|
||||
});
|
||||
}
|
||||
if (other?.cache_tokens > 0) {
|
||||
expandDataLocal.push({
|
||||
key: t('缓存 Tokens'),
|
||||
value: other.cache_tokens,
|
||||
});
|
||||
}
|
||||
if (other?.cache_creation_tokens > 0) {
|
||||
expandDataLocal.push({
|
||||
key: t('缓存创建 Tokens'),
|
||||
value: other.cache_creation_tokens,
|
||||
});
|
||||
}
|
||||
if (logs[i].type === 2) {
|
||||
if (other?.billing_mode !== 'tiered_expr') {
|
||||
expandDataLocal.push({
|
||||
key: t('日志详情'),
|
||||
value: other?.claude
|
||||
? renderClaudeLogContent({ ...other, displayMode: billingDisplayMode })
|
||||
: renderLogContent({ ...other, displayMode: billingDisplayMode }),
|
||||
});
|
||||
}
|
||||
if (logs[i]?.content) {
|
||||
expandDataLocal.push({
|
||||
key: t('其他详情'),
|
||||
value: logs[i].content,
|
||||
});
|
||||
}
|
||||
if (isAdminUser && other?.reject_reason) {
|
||||
expandDataLocal.push({
|
||||
key: t('拦截原因'),
|
||||
value: other.reject_reason,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (logs[i].type === 2) {
|
||||
let modelMapped =
|
||||
other?.is_model_mapped &&
|
||||
other?.upstream_model_name &&
|
||||
other?.upstream_model_name !== '';
|
||||
if (modelMapped) {
|
||||
expandDataLocal.push({
|
||||
key: t('请求并计费模型'),
|
||||
value: logs[i].model_name,
|
||||
});
|
||||
expandDataLocal.push({
|
||||
key: t('实际模型'),
|
||||
value: other.upstream_model_name,
|
||||
});
|
||||
}
|
||||
|
||||
const isViolationFeeLog =
|
||||
other?.violation_fee === true ||
|
||||
Boolean(other?.violation_fee_code) ||
|
||||
Boolean(other?.violation_fee_marker);
|
||||
|
||||
let content = '';
|
||||
if (!isViolationFeeLog && other?.billing_mode !== 'tiered_expr') {
|
||||
const logOpts = {
|
||||
...other,
|
||||
prompt_tokens: logs[i].prompt_tokens,
|
||||
completion_tokens: logs[i].completion_tokens,
|
||||
displayMode: billingDisplayMode,
|
||||
};
|
||||
const isTaskLog = other?.is_task === true || other?.task_id != null;
|
||||
if (isTaskLog && other?.model_price === -1) {
|
||||
content = renderTaskBillingProcess(other, logs[i].content);
|
||||
} else if (other?.ws || other?.audio) {
|
||||
content = renderAudioModelPrice(logOpts);
|
||||
} else if (other?.claude) {
|
||||
content = renderClaudeModelPrice(logOpts);
|
||||
} else {
|
||||
content = renderModelPrice(logOpts);
|
||||
}
|
||||
expandDataLocal.push({
|
||||
key: t('计费过程'),
|
||||
value: content,
|
||||
});
|
||||
}
|
||||
if (other?.reasoning_effort) {
|
||||
expandDataLocal.push({
|
||||
key: t('Reasoning Effort'),
|
||||
value: other.reasoning_effort,
|
||||
});
|
||||
}
|
||||
if (other?.billing_mode === 'tiered_expr' && other?.expr_b64) {
|
||||
expandDataLocal.push({
|
||||
key: t('计费过程'),
|
||||
value: renderTieredModelPrice({
|
||||
...other,
|
||||
prompt_tokens: logs[i].prompt_tokens,
|
||||
completion_tokens: logs[i].completion_tokens,
|
||||
displayMode: billingDisplayMode,
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
if (logs[i].type === 6) {
|
||||
if (other?.task_id) {
|
||||
expandDataLocal.push({
|
||||
key: t('任务ID'),
|
||||
value: other.task_id,
|
||||
});
|
||||
}
|
||||
if (other?.reason) {
|
||||
expandDataLocal.push({
|
||||
key: t('失败原因'),
|
||||
value: (
|
||||
<div style={{ maxWidth: 600, whiteSpace: 'normal', wordBreak: 'break-word', lineHeight: 1.6 }}>
|
||||
{other.reason}
|
||||
</div>
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
if (other?.request_path) {
|
||||
expandDataLocal.push({
|
||||
key: t('请求路径'),
|
||||
value: other.request_path,
|
||||
});
|
||||
}
|
||||
if (isAdminUser && other?.stream_status) {
|
||||
const ss = other.stream_status;
|
||||
const isOk = ss.status === 'ok';
|
||||
const statusLabel = isOk ? '✓ ' + t('正常') : '✗ ' + t('异常');
|
||||
let streamValue = statusLabel + ' (' + (ss.end_reason || 'unknown') + ')';
|
||||
if (ss.error_count > 0) {
|
||||
streamValue += ` [${t('软错误')}: ${ss.error_count}]`;
|
||||
}
|
||||
if (ss.end_error) {
|
||||
streamValue += ` - ${ss.end_error}`;
|
||||
}
|
||||
expandDataLocal.push({
|
||||
key: t('流状态'),
|
||||
value: streamValue,
|
||||
});
|
||||
if (Array.isArray(ss.errors) && ss.errors.length > 0) {
|
||||
expandDataLocal.push({
|
||||
key: t('流错误详情'),
|
||||
value: (
|
||||
<div style={{ maxWidth: 600, whiteSpace: 'pre-line', wordBreak: 'break-word', lineHeight: 1.6 }}>
|
||||
{ss.errors.join('\n')}
|
||||
</div>
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
if (Array.isArray(other?.po) && other.po.length > 0) {
|
||||
expandDataLocal.push({
|
||||
key: t('参数覆盖'),
|
||||
value: (
|
||||
<ParamOverrideEntry
|
||||
count={other.po.length}
|
||||
t={t}
|
||||
onOpen={(event) => {
|
||||
event.stopPropagation();
|
||||
openParamOverrideModal(logs[i], other);
|
||||
}}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}
|
||||
if (other?.billing_source === 'subscription') {
|
||||
const planId = other?.subscription_plan_id;
|
||||
const planTitle = other?.subscription_plan_title || '';
|
||||
const subscriptionId = other?.subscription_id;
|
||||
const unit = t('额度');
|
||||
const pre = other?.subscription_pre_consumed ?? 0;
|
||||
const postDelta = other?.subscription_post_delta ?? 0;
|
||||
const finalConsumed = other?.subscription_consumed ?? pre + postDelta;
|
||||
const remain = other?.subscription_remain;
|
||||
const total = other?.subscription_total;
|
||||
// Use multiple Description items to avoid an overlong single line.
|
||||
if (planId) {
|
||||
expandDataLocal.push({
|
||||
key: t('订阅套餐'),
|
||||
value: `#${planId} ${planTitle}`.trim(),
|
||||
});
|
||||
}
|
||||
if (subscriptionId) {
|
||||
expandDataLocal.push({
|
||||
key: t('订阅实例'),
|
||||
value: `#${subscriptionId}`,
|
||||
});
|
||||
}
|
||||
const settlementLines = [
|
||||
`${t('预扣')}:${pre} ${unit}`,
|
||||
`${t('结算差额')}:${postDelta > 0 ? '+' : ''}${postDelta} ${unit}`,
|
||||
`${t('最终抵扣')}:${finalConsumed} ${unit}`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
expandDataLocal.push({
|
||||
key: t('订阅结算'),
|
||||
value: (
|
||||
<div style={{ whiteSpace: 'pre-line' }}>{settlementLines}</div>
|
||||
),
|
||||
});
|
||||
if (remain !== undefined && total !== undefined) {
|
||||
expandDataLocal.push({
|
||||
key: t('订阅剩余'),
|
||||
value: `${remain}/${total} ${unit}`,
|
||||
});
|
||||
}
|
||||
expandDataLocal.push({
|
||||
key: t('订阅说明'),
|
||||
value: t(
|
||||
'token 会按倍率换算成“额度/次数”,请求结束后再做差额结算(补扣/返还)。',
|
||||
),
|
||||
});
|
||||
}
|
||||
if (isAdminUser && logs[i].type !== 6 && logs[i].type !== 1) {
|
||||
expandDataLocal.push({
|
||||
key: t('请求转换'),
|
||||
value: requestConversionDisplayValue(other?.request_conversion),
|
||||
});
|
||||
}
|
||||
if (isAdminUser && logs[i].type !== 6 && logs[i].type !== 1) {
|
||||
let localCountMode = '';
|
||||
if (other?.admin_info?.local_count_tokens) {
|
||||
localCountMode = t('本地计费');
|
||||
} else {
|
||||
localCountMode = t('上游返回');
|
||||
}
|
||||
expandDataLocal.push({
|
||||
key: t('计费模式'),
|
||||
value: localCountMode,
|
||||
});
|
||||
}
|
||||
if (isAdminUser && logs[i].type === 1) {
|
||||
const adminInfo = other?.admin_info;
|
||||
if (adminInfo) {
|
||||
if (adminInfo.payment_method) {
|
||||
expandDataLocal.push({
|
||||
key: t('订单支付方式'),
|
||||
value: adminInfo.payment_method,
|
||||
});
|
||||
}
|
||||
if (adminInfo.callback_payment_method) {
|
||||
expandDataLocal.push({
|
||||
key: t('回调支付方式'),
|
||||
value: adminInfo.callback_payment_method,
|
||||
});
|
||||
}
|
||||
if (adminInfo.caller_ip) {
|
||||
expandDataLocal.push({
|
||||
key: t('回调调用者IP'),
|
||||
value: adminInfo.caller_ip,
|
||||
});
|
||||
}
|
||||
if (adminInfo.server_ip) {
|
||||
expandDataLocal.push({
|
||||
key: t('服务器IP'),
|
||||
value: adminInfo.server_ip,
|
||||
});
|
||||
}
|
||||
if (adminInfo.node_name) {
|
||||
expandDataLocal.push({
|
||||
key: t('节点名称'),
|
||||
value: adminInfo.node_name,
|
||||
});
|
||||
}
|
||||
if (adminInfo.version) {
|
||||
expandDataLocal.push({
|
||||
key: t('系统版本'),
|
||||
value: adminInfo.version,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
expandDataLocal.push({
|
||||
key: t('审计信息'),
|
||||
value: (
|
||||
<span style={{ color: 'var(--semi-color-warning)' }}>
|
||||
{t(
|
||||
'该记录由旧版本实例写入,缺少审计信息,建议将实例升级至最新版本以便记录服务器IP、回调IP、支付方式与系统版本等审计字段。',
|
||||
)}
|
||||
</span>
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
if (isAdminUser && logs[i].type === 3 && other?.admin_info) {
|
||||
const adminInfo = other.admin_info;
|
||||
const hasUsername =
|
||||
adminInfo.admin_username !== undefined &&
|
||||
adminInfo.admin_username !== null &&
|
||||
adminInfo.admin_username !== '';
|
||||
const hasId =
|
||||
adminInfo.admin_id !== undefined &&
|
||||
adminInfo.admin_id !== null &&
|
||||
adminInfo.admin_id !== '';
|
||||
if (hasUsername || hasId) {
|
||||
let operatorValue = '';
|
||||
if (hasUsername && hasId) {
|
||||
operatorValue = `${adminInfo.admin_username} (ID: ${adminInfo.admin_id})`;
|
||||
} else if (hasUsername) {
|
||||
operatorValue = String(adminInfo.admin_username);
|
||||
} else {
|
||||
operatorValue = `ID: ${adminInfo.admin_id}`;
|
||||
}
|
||||
expandDataLocal.push({
|
||||
key: t('操作管理员'),
|
||||
value: operatorValue,
|
||||
});
|
||||
}
|
||||
}
|
||||
expandDatesLocal[logs[i].key] = expandDataLocal;
|
||||
}
|
||||
|
||||
setExpandData(expandDatesLocal);
|
||||
setLogs(logs);
|
||||
};
|
||||
|
||||
// Load logs function
|
||||
const loadLogs = async (startIdx, pageSize, customLogType = null) => {
|
||||
setLoading(true);
|
||||
|
||||
let url = '';
|
||||
const {
|
||||
username,
|
||||
token_name,
|
||||
model_name,
|
||||
start_timestamp,
|
||||
end_timestamp,
|
||||
channel,
|
||||
group,
|
||||
request_id,
|
||||
logType: formLogType,
|
||||
} = getFormValues();
|
||||
|
||||
const currentLogType =
|
||||
customLogType !== null
|
||||
? customLogType
|
||||
: formLogType !== undefined
|
||||
? formLogType
|
||||
: logType;
|
||||
|
||||
let localStartTimestamp = Date.parse(start_timestamp) / 1000;
|
||||
let localEndTimestamp = Date.parse(end_timestamp) / 1000;
|
||||
if (isAdminUser) {
|
||||
url = `/api/log/?p=${startIdx}&page_size=${pageSize}&type=${currentLogType}&username=${username}&token_name=${token_name}&model_name=${model_name}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}&channel=${channel}&group=${group}&request_id=${request_id}`;
|
||||
} else {
|
||||
url = `/api/log/self/?p=${startIdx}&page_size=${pageSize}&type=${currentLogType}&token_name=${token_name}&model_name=${model_name}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}&group=${group}&request_id=${request_id}`;
|
||||
}
|
||||
url = encodeURI(url);
|
||||
const res = await API.get(url);
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
const newPageData = data.items;
|
||||
setActivePage(data.page);
|
||||
setPageSize(data.page_size);
|
||||
setLogCount(data.total);
|
||||
|
||||
setLogsFormat(newPageData);
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
// Page handlers
|
||||
const handlePageChange = (page) => {
|
||||
setActivePage(page);
|
||||
loadLogs(page, pageSize).then((r) => {});
|
||||
};
|
||||
|
||||
const handlePageSizeChange = async (size) => {
|
||||
localStorage.setItem('page-size', size + '');
|
||||
setPageSize(size);
|
||||
setActivePage(1);
|
||||
loadLogs(activePage, size)
|
||||
.then()
|
||||
.catch((reason) => {
|
||||
showError(reason);
|
||||
});
|
||||
};
|
||||
|
||||
// Refresh function
|
||||
const refresh = async () => {
|
||||
setActivePage(1);
|
||||
handleEyeClick();
|
||||
await loadLogs(1, pageSize);
|
||||
};
|
||||
|
||||
// Copy text function
|
||||
const copyText = async (e, text) => {
|
||||
e.stopPropagation();
|
||||
if (await copy(text)) {
|
||||
showSuccess('已复制:' + text);
|
||||
} else {
|
||||
Modal.error({ title: t('无法复制到剪贴板,请手动复制'), content: text });
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize data
|
||||
useEffect(() => {
|
||||
const localPageSize =
|
||||
parseInt(localStorage.getItem('page-size')) || ITEMS_PER_PAGE;
|
||||
setPageSize(localPageSize);
|
||||
loadLogs(activePage, localPageSize)
|
||||
.then()
|
||||
.catch((reason) => {
|
||||
showError(reason);
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Initialize statistics when formApi is available
|
||||
useEffect(() => {
|
||||
if (formApi) {
|
||||
handleEyeClick();
|
||||
}
|
||||
}, [formApi]);
|
||||
|
||||
// Check if any record has expandable content
|
||||
const hasExpandableRows = () => {
|
||||
return logs.some(
|
||||
(log) => expandData[log.key] && expandData[log.key].length > 0,
|
||||
);
|
||||
};
|
||||
|
||||
return {
|
||||
// Basic state
|
||||
logs,
|
||||
expandData,
|
||||
showStat,
|
||||
loading,
|
||||
loadingStat,
|
||||
activePage,
|
||||
logCount,
|
||||
pageSize,
|
||||
logType,
|
||||
stat,
|
||||
isAdminUser,
|
||||
|
||||
// Form state
|
||||
formApi,
|
||||
setFormApi,
|
||||
formInitValues,
|
||||
getFormValues,
|
||||
|
||||
// Column visibility
|
||||
visibleColumns,
|
||||
showColumnSelector,
|
||||
setShowColumnSelector,
|
||||
billingDisplayMode,
|
||||
setBillingDisplayMode,
|
||||
handleColumnVisibilityChange,
|
||||
handleSelectAll,
|
||||
initDefaultColumns,
|
||||
COLUMN_KEYS,
|
||||
|
||||
// Compact mode
|
||||
compactMode,
|
||||
setCompactMode,
|
||||
|
||||
// User info modal
|
||||
showUserInfo,
|
||||
setShowUserInfoModal,
|
||||
userInfoData,
|
||||
showUserInfoFunc,
|
||||
|
||||
// Channel affinity usage cache stats modal
|
||||
showChannelAffinityUsageCacheModal,
|
||||
setShowChannelAffinityUsageCacheModal,
|
||||
channelAffinityUsageCacheTarget,
|
||||
openChannelAffinityUsageCacheModal,
|
||||
showParamOverrideModal,
|
||||
setShowParamOverrideModal,
|
||||
paramOverrideTarget,
|
||||
|
||||
// Functions
|
||||
loadLogs,
|
||||
handlePageChange,
|
||||
handlePageSizeChange,
|
||||
refresh,
|
||||
copyText,
|
||||
handleEyeClick,
|
||||
setLogsFormat,
|
||||
hasExpandableRows,
|
||||
setLogType,
|
||||
openParamOverrideModal,
|
||||
|
||||
// Translation
|
||||
t,
|
||||
};
|
||||
};
|
||||
+321
@@ -0,0 +1,321 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { API, showError, showSuccess } from '../../helpers';
|
||||
import { ITEMS_PER_PAGE } from '../../constants';
|
||||
import { useTableCompactMode } from '../common/useTableCompactMode';
|
||||
|
||||
export const useUsersData = () => {
|
||||
const { t } = useTranslation();
|
||||
const [compactMode, setCompactMode] = useTableCompactMode('users');
|
||||
|
||||
// State management
|
||||
const [users, setUsers] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [activePage, setActivePage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(ITEMS_PER_PAGE);
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [groupOptions, setGroupOptions] = useState([]);
|
||||
const [userCount, setUserCount] = useState(0);
|
||||
|
||||
// Modal states
|
||||
const [showAddUser, setShowAddUser] = useState(false);
|
||||
const [showEditUser, setShowEditUser] = useState(false);
|
||||
const [editingUser, setEditingUser] = useState({
|
||||
id: undefined,
|
||||
});
|
||||
|
||||
// Form initial values
|
||||
const formInitValues = {
|
||||
searchKeyword: '',
|
||||
searchGroup: '',
|
||||
};
|
||||
|
||||
// Form API reference
|
||||
const [formApi, setFormApi] = useState(null);
|
||||
|
||||
// Get form values helper function
|
||||
const getFormValues = () => {
|
||||
const formValues = formApi ? formApi.getValues() : {};
|
||||
return {
|
||||
searchKeyword: formValues.searchKeyword || '',
|
||||
searchGroup: formValues.searchGroup || '',
|
||||
};
|
||||
};
|
||||
|
||||
// Set user format with key field
|
||||
const setUserFormat = (users) => {
|
||||
for (let i = 0; i < users.length; i++) {
|
||||
users[i].key = users[i].id;
|
||||
}
|
||||
setUsers(users);
|
||||
};
|
||||
|
||||
// Load users data
|
||||
const loadUsers = async (startIdx, pageSize) => {
|
||||
setLoading(true);
|
||||
const res = await API.get(`/api/user/?p=${startIdx}&page_size=${pageSize}`);
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
const newPageData = data.items;
|
||||
setActivePage(data.page);
|
||||
setUserCount(data.total);
|
||||
setUserFormat(newPageData);
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
// Search users with keyword and group
|
||||
const searchUsers = async (
|
||||
startIdx,
|
||||
pageSize,
|
||||
searchKeyword = null,
|
||||
searchGroup = null,
|
||||
) => {
|
||||
// If no parameters passed, get values from form
|
||||
if (searchKeyword === null || searchGroup === null) {
|
||||
const formValues = getFormValues();
|
||||
searchKeyword = formValues.searchKeyword;
|
||||
searchGroup = formValues.searchGroup;
|
||||
}
|
||||
|
||||
if (searchKeyword === '' && searchGroup === '') {
|
||||
// If keyword is blank, load files instead
|
||||
await loadUsers(startIdx, pageSize);
|
||||
return;
|
||||
}
|
||||
setSearching(true);
|
||||
const res = await API.get(
|
||||
`/api/user/search?keyword=${searchKeyword}&group=${searchGroup}&p=${startIdx}&page_size=${pageSize}`,
|
||||
);
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
const newPageData = data.items;
|
||||
setActivePage(data.page);
|
||||
setUserCount(data.total);
|
||||
setUserFormat(newPageData);
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
setSearching(false);
|
||||
};
|
||||
|
||||
// Manage user operations (promote, demote, enable, disable, delete)
|
||||
const manageUser = async (userId, action, record) => {
|
||||
// Trigger loading state to force table re-render
|
||||
setLoading(true);
|
||||
|
||||
const res = await API.post('/api/user/manage', {
|
||||
id: userId,
|
||||
action,
|
||||
});
|
||||
|
||||
const { success, message } = res.data;
|
||||
if (success) {
|
||||
showSuccess(t('操作成功完成!'));
|
||||
const user = res.data.data;
|
||||
|
||||
// Create a new array and new object to ensure React detects changes
|
||||
const newUsers = users.map((u) => {
|
||||
if (u.id === userId) {
|
||||
if (action === 'delete') {
|
||||
return { ...u, DeletedAt: new Date() };
|
||||
}
|
||||
return { ...u, status: user.status, role: user.role };
|
||||
}
|
||||
return u;
|
||||
});
|
||||
|
||||
setUsers(newUsers);
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const resetUserPasskey = async (user) => {
|
||||
if (!user) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await API.delete(`/api/user/${user.id}/reset_passkey`);
|
||||
const { success, message } = res.data;
|
||||
if (success) {
|
||||
showSuccess(t('Passkey 已重置'));
|
||||
} else {
|
||||
showError(message || t('操作失败,请重试'));
|
||||
}
|
||||
} catch (error) {
|
||||
showError(t('操作失败,请重试'));
|
||||
}
|
||||
};
|
||||
|
||||
const resetUserTwoFA = async (user) => {
|
||||
if (!user) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await API.delete(`/api/user/${user.id}/2fa`);
|
||||
const { success, message } = res.data;
|
||||
if (success) {
|
||||
showSuccess(t('二步验证已重置'));
|
||||
} else {
|
||||
showError(message || t('操作失败,请重试'));
|
||||
}
|
||||
} catch (error) {
|
||||
showError(t('操作失败,请重试'));
|
||||
}
|
||||
};
|
||||
|
||||
// Handle page change
|
||||
const handlePageChange = (page) => {
|
||||
setActivePage(page);
|
||||
const { searchKeyword, searchGroup } = getFormValues();
|
||||
if (searchKeyword === '' && searchGroup === '') {
|
||||
loadUsers(page, pageSize).then();
|
||||
} else {
|
||||
searchUsers(page, pageSize, searchKeyword, searchGroup).then();
|
||||
}
|
||||
};
|
||||
|
||||
// Handle page size change
|
||||
const handlePageSizeChange = async (size) => {
|
||||
localStorage.setItem('page-size', size + '');
|
||||
setPageSize(size);
|
||||
setActivePage(1);
|
||||
loadUsers(activePage, size)
|
||||
.then()
|
||||
.catch((reason) => {
|
||||
showError(reason);
|
||||
});
|
||||
};
|
||||
|
||||
// Handle table row styling for disabled/deleted users
|
||||
const handleRow = (record, index) => {
|
||||
if (record.DeletedAt !== null || record.status !== 1) {
|
||||
return {
|
||||
style: {
|
||||
background: 'var(--semi-color-disabled-border)',
|
||||
},
|
||||
};
|
||||
} else {
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
// Refresh data
|
||||
const refresh = async (page = activePage) => {
|
||||
const { searchKeyword, searchGroup } = getFormValues();
|
||||
if (searchKeyword === '' && searchGroup === '') {
|
||||
await loadUsers(page, pageSize);
|
||||
} else {
|
||||
await searchUsers(page, pageSize, searchKeyword, searchGroup);
|
||||
}
|
||||
};
|
||||
|
||||
// Fetch groups data
|
||||
const fetchGroups = async () => {
|
||||
try {
|
||||
let res = await API.get(`/api/group/`);
|
||||
if (res === undefined) {
|
||||
return;
|
||||
}
|
||||
setGroupOptions(
|
||||
res.data.data.map((group) => ({
|
||||
label: group,
|
||||
value: group,
|
||||
})),
|
||||
);
|
||||
} catch (error) {
|
||||
showError(error.message);
|
||||
}
|
||||
};
|
||||
|
||||
// Modal control functions
|
||||
const closeAddUser = () => {
|
||||
setShowAddUser(false);
|
||||
};
|
||||
|
||||
const closeEditUser = () => {
|
||||
setShowEditUser(false);
|
||||
setEditingUser({
|
||||
id: undefined,
|
||||
});
|
||||
};
|
||||
|
||||
// Initialize data on component mount
|
||||
useEffect(() => {
|
||||
loadUsers(0, pageSize)
|
||||
.then()
|
||||
.catch((reason) => {
|
||||
showError(reason);
|
||||
});
|
||||
fetchGroups().then();
|
||||
}, []);
|
||||
|
||||
return {
|
||||
// Data state
|
||||
users,
|
||||
loading,
|
||||
activePage,
|
||||
pageSize,
|
||||
userCount,
|
||||
searching,
|
||||
groupOptions,
|
||||
|
||||
// Modal state
|
||||
showAddUser,
|
||||
showEditUser,
|
||||
editingUser,
|
||||
setShowAddUser,
|
||||
setShowEditUser,
|
||||
setEditingUser,
|
||||
|
||||
// Form state
|
||||
formInitValues,
|
||||
formApi,
|
||||
setFormApi,
|
||||
|
||||
// UI state
|
||||
compactMode,
|
||||
setCompactMode,
|
||||
|
||||
// Actions
|
||||
loadUsers,
|
||||
searchUsers,
|
||||
manageUser,
|
||||
resetUserPasskey,
|
||||
resetUserTwoFA,
|
||||
handlePageChange,
|
||||
handlePageSizeChange,
|
||||
handleRow,
|
||||
refresh,
|
||||
closeAddUser,
|
||||
closeEditUser,
|
||||
getFormValues,
|
||||
|
||||
// Translation
|
||||
t,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user