Compare commits

...

135 Commits

Author SHA1 Message Date
Apple\Apple ee2679b9cc 💬 fix(PersonalSetting): improve notification message accuracy for settings save operation
- Change success message from "通知设置已更新" to "设置保存成功"
- Change error message from "更新通知设置失败" to "设置保存失败"
- Makes messages more generic since the function saves multiple types of settings (notification, pricing, IP logging) not just notification settings
2025-06-13 01:43:43 +08:00
Apple\Apple 3d3384fa69 Merge remote-tracking branch 'origin/alpha' into alpha 2025-06-13 01:34:26 +08:00
Apple\Apple 28c3aef76f 🔒 feat: Add user-configurable IP logging for consume and error logs
- Add IP field to Log model with database index and default empty value
- Implement conditional IP recording based on user setting in RecordConsumeLog and RecordErrorLog
- Add UserSettingRecordIpLog constant and update user settings API to handle record_ip_log field
- Create dedicated "IP记录" tab in personal settings under "其他设置" section
- Add IP column to logs table with help tooltip explaining recording conditions
- Make IP column visible to all users (not admin-only) with proper filtering for consume/error log types
- Restrict display of use_time and retry columns to consume and error log types only
- Update personal settings UI structure: rename "通知设置" to "其他设置" to accommodate new functionality
- Add proper translation support and maintain consistent styling across components

The IP logging feature is disabled by default and only records client IP addresses
for consume (type 2) and error (type 5) logs when explicitly enabled by users
in their personal settings.
2025-06-13 01:34:01 +08:00
Calcium-Ion 158d621fd7 Merge pull request #1207 from QuantumNous/user_group_ratio
feat: 分组特殊倍率
2025-06-13 01:25:46 +08:00
CaIon 0e97441843 🔧 refactor(LogsTable, render): remove undefined parameters for improved clarity and consistency in function signatures 2025-06-13 01:25:26 +08:00
CaIon 1358a36028 feat(render): introduce getEffectiveRatio helper for improved group ratio handling 2025-06-13 01:16:16 +08:00
CaIon fa7d18ab7f 🔒 feat(setting): add mutex for GroupGroupRatio to ensure thread safety 2025-06-13 01:08:38 +08:00
Apple\Apple b1176d0258 🔧 fix(token hooks): adapt token key fetcher to new paginated API
Changes
1. web/src/helpers/token.js
   • `fetchTokenKeys` now calls `/api/token/?p=1&size=10` (1-based paging).
   • Supports new response shape `{ items, total, page, page_size }`; falls back gracefully if array is returned.
   • Filters active tokens from `tokenItems`, not `data` directly.

`useTokenKeys` remains unchanged—its consumer code receives the same list of active keys.
2025-06-12 23:53:34 +08:00
Apple\Apple 3f67ef2d9c 🐛 fix: correct loading state for search button in TokensTable
Fix the search button loading state to be consistent with other table components.
The search button now properly shows loading animation when the table data is
being fetched.

Changes:
- Update search button loading prop from `loading={searching}` to
  `loading={loading || searching}` in TokensTable.js
- This ensures loading state is shown both when searching with keywords
  (searching=true) and when loading default data (loading=true)
- Aligns with the behavior of other table components like ChannelsTable,
  UsersTable, and RedemptionsTable

Before: Search button only showed loading when searching with keywords
After: Search button shows loading for all table data fetch operations
2025-06-12 17:48:20 +08:00
Apple\Apple f7b870c16d 🎨style: Standardize pagination text format in Dashboard components
- Replace `showTotal` with `formatPageText` in Dashboard table components
- Unify pagination text format to match table components pattern
- Update SettingsAnnouncements.js, SettingsAPIInfo.js, and SettingsFAQ.js
- Change from "共 X 条记录,显示第 Y-Z 条" to "第 Y - Z 条,共 X 条" format
- Ensure consistent user experience across all table components

This change improves UI consistency by standardizing the pagination
text format across Dashboard and table components.
2025-06-12 17:40:32 +08:00
Apple\Apple a50601295e 🚀 feat(pagination): unify backend-driven pagination & improve channel tag aggregation
SUMMARY
• Migrated Token, Task, Midjourney, Channel, Redemption tables to true server-side pagination.
• Added total / page / page_size metadata in API responses; switched all affected React tables to consume new structure.
• Implemented counting helpers:
  – model/token.go CountUserTokens
  – model/task.go TaskCountAllTasks / TaskCountAllUserTask
  – model/midjourney.go CountAllTasks / CountAllUserTask
  – model/channel.go CountAllChannels / CountAllTags
• Refactored controllers (token, task, midjourney, channel) for 1-based paging & aggregated returns.
• Redesigned `ChannelsTable.js`:
  – `loadChannels`, `syncPageData`, `enrichChannels` for tag-mode grouping without recursion.
  – Fixed runtime white-screen (maximum call-stack) by removing child duplication.
  – Pagination, search, tag-mode, idSort all hot-reload correctly.
• Removed unused `log` import in controller/midjourney.go.

BREAKING CHANGES
Front-end consumers must now expect data.items / total / page / page_size from list endpoints (`/api/channel`, `/api/task`, `/api/mj`, `/api/token`, etc.).
2025-06-12 17:25:25 +08:00
creamlike1024 2a0bf7f991 feat: 分组特殊倍率 2025-06-11 23:46:59 +08:00
Apple\Apple dede9e117a 🎨 style(dashboard): Optimize the layout of the Uptime card legend on the dashboard to resolve the issue where the last monitoring item is obscured 2025-06-11 15:07:01 +08:00
Apple\Apple 477608865a 🧶chore: remove useless web files 2025-06-11 14:45:12 +08:00
Apple\Apple 86205d73e9 🧶chore: remove redundant semantic-related dependencies and configurations 2025-06-11 12:38:51 +08:00
Apple\Apple f2fb28cfd2 feat(controller): gracefully handle missing Uptime Kuma configuration
Previously, the uptime status endpoint returned HTTP 400 with
“未配置 Uptime Kuma URL/Slug” when either option was not set, resulting in
frontend error states.

Changes:
• Treat absence of `UptimeKumaUrl` or `UptimeKumaSlug` as a valid scenario.
• Immediately respond with HTTP 200, `success: true`, and an empty `data` array.
• Preserve existing behavior when both options are provided.

This prevents unnecessary error notifications on the dashboard when
Uptime Kuma integration is not configured and improves overall UX.
2025-06-11 03:41:05 +08:00
Apple\Apple b6272de078 💄style: Align ChannelsTable column selector modal style with LogsTable
* Removed `size="middle"` and `centered` props from the column-selector
  `Modal` in `ChannelsTable.js` to match the visual style used in
  `LogsTable`.
* Re-added `size="middle"` to the main `Table` component to preserve the
  original table sizing.
* Ensures consistent UI/UX across both channel and log column settings
  modals.
2025-06-11 03:26:16 +08:00
Apple\Apple d66ed068c4 Merge remote-tracking branch 'origin/main' into alpha 2025-06-11 03:19:53 +08:00
同語 d502ac2023 Merge pull request #1199 from feitianbubu/revert-column-visiblity-settting-channel
feat: add column visibility settings for channels
2025-06-11 03:19:20 +08:00
Apple\Apple 8b2fa26250 Merge remote-tracking branch 'origin/alpha' into alpha 2025-06-11 03:16:27 +08:00
同語 f51f6fb8ef Merge pull request #1200 from RedwindA/fix/playground-sse
fix playground-sse
2025-06-11 03:15:51 +08:00
Apple\Apple 16e0c94ed0 🥳 feat(detail): unify uptime status handling & enhance availability card UI
Summary
• Centralized uptime status definition via `uptimeStatusMap`, containing color / label / text for each status.
• Generated `uptimeLegendData`, `getUptimeStatusColor`, `getUptimeStatusText` directly from the map, removing multiple switch-case blocks.

UI Improvements
1. Added statuses 2 (High Latency) & 3 (Maintenance) with dedicated colors.
2. Relocated status legend to a styled footer wrapped in a borderless sub-Card; header now only shows title + refresh button.
3. Footer (and its negative margin) renders only when `uptimeData` is present, preventing empty legend display.
4. Applied rounded, blurred badge style and always-on shadow to legend container for clearer separation.

Maintenance
• Simplified code paths, reduced duplication, and improved readability without breaking existing functionality.
2025-06-11 03:12:34 +08:00
Apple\Apple 67c9757969 Merge remote-tracking branch 'origin/alpha' into alpha 2025-06-11 02:29:08 +08:00
Apple\Apple 5f4d6648f7 ⏱️ feat: implement uptime monitoring
Introduce application uptime monitoring to improve observability and reliability.

• Add UptimeService to track process start time and expose uptime in seconds
• Create /health/uptime endpoint returning the current uptime in JSON format
• Integrate uptime metric into existing health-check middleware
• Update README with instructions for consuming the new endpoint
• Add unit tests covering UptimeService and new health route

This change enables operations teams and dashboards to programmatically
determine how long the service has been running, facilitating automated
alerts and trend analysis.
2025-06-11 02:28:36 +08:00
RedwindA b9a026ed23 🔧 fix(useApiRequest): improve playground SSE error handling and stream completion tracking 2025-06-11 02:05:16 +08:00
skynono f49a797fe7 feat: add column visibility settings for channels 2025-06-11 01:36:23 +08:00
CaIon d62769376b Merge remote-tracking branch 'origin/alpha' into alpha 2025-06-11 00:18:26 +08:00
CaIon 5511ba3670 🔧 fix(stream_scanner): improve resource management and error handling in StreamScannerHandler 2025-06-11 00:18:16 +08:00
Apple\Apple 1d28968193 🔧 fix: Update payment callback return URL path from /log to /console/log
- Modified returnUrl configuration in RequestEpay function
- Changed payment success redirect path to match updated frontend routing
- Updated controller/topup.go line 116 to use correct callback path
2025-06-10 20:41:43 +08:00
Apple\Apple 85077d6f63 Merge remote-tracking branch 'origin/alpha' into alpha 2025-06-10 20:10:35 +08:00
Apple\Apple 0f3696eef3 feat: Add console announcements and FAQ management system
- Add SettingsAnnouncements component with full CRUD operations for system announcements
  * Support multiple announcement types (default, ongoing, success, warning, error)
  * Include publish date, content, type classification and additional notes
  * Implement batch operations and pagination for better data management
  * Add real-time preview with relative time display and date formatting

- Add SettingsFAQ component for comprehensive FAQ management
  * Support question-answer pairs with rich text content
  * Include full editing, deletion and creation capabilities
  * Implement batch delete operations and paginated display
  * Add validation for complete Q&A information

- Integrate announcement and FAQ modules into DashboardSetting
  * Add unified configuration interface in admin console
  * Implement auto-refresh functionality for real-time updates
  * Add loading states and error handling for better UX

- Enhance backend API support in controller and setting modules
  * Add validation functions for console settings
  * Include time and sorting utilities for announcement management
  * Extend API endpoints for announcement and FAQ data persistence

- Improve frontend infrastructure
  * Add new translation keys for internationalization support
  * Update utility functions for date/time formatting
  * Enhance CSS styles for better component presentation
  * Add icons and visual improvements for announcements and FAQ sections

This implementation provides administrators with comprehensive tools to manage
system-wide announcements and user FAQ content through an intuitive console interface.
2025-06-10 20:10:07 +08:00
CaIon ad4d3efd2e feat(middleware): add HTTP statistics middleware 2025-06-10 19:29:32 +08:00
CaIon 7dc7805c6a 🔧 fix(token_counter): enhance token encoder caching and concurrency handling 2025-06-10 18:55:21 +08:00
CaIon 1abd77c17c 🔧 fix(token_counter): refactor token encoder initialization and retrieval logic 2025-06-10 18:51:26 +08:00
CaIon 88a801af2d 🔧 fix(token_counter): update token encoder implementation and dependencies 2025-06-10 18:04:49 +08:00
Apple\Apple 26521f42d9 🎨 refactor(ui): replace IconSearch with semantic lucide icons
- Replace IconSearch with Server icon for API info card title to better represent server/API related content
- Add Server imports from lucide-react

This change improves the semantic meaning of icons and provides better visual representation of their respective functionalities.
2025-06-10 12:43:14 +08:00
Apple\Apple 6880cca9b2 🔧 refactor(console): enhance URL validation and restructure settings module
- Refactor api_info.go to console.go for broader console settings support
- Update URL regex pattern to accept both domain names and IP addresses
- Add support for IPv4 addresses with optional port numbers
- Improve validation to handle formats like http://192.168.1.1:8080
- Add ValidateConsoleSettings function for extensible settings validation
- Maintain backward compatibility with existing ValidateApiInfo function
- Add comprehensive comments explaining supported URL formats

Fixes issue where IP-based URLs were incorrectly rejected as invalid format.
Prepares infrastructure for additional console settings validation.
2025-06-10 12:20:26 +08:00
Apple\Apple 154eaed83f 🔧 fix(api): enhance URL validation to support IP addresses and ports
- Update URL regex pattern to accept both domain names and IP addresses
- Add support for IPv4 addresses with optional port numbers
- Improve validation to handle formats like http://192.168.1.1:8080
- Add comprehensive comments explaining supported URL formats
- Maintain backward compatibility with existing domain-based URLs

Fixes issue where IP-based URLs were incorrectly rejected as invalid format.
2025-06-10 12:12:55 +08:00
CaIon b23416bbb4 🔧 fix(channel-test): ensure proper state reset to prevent deadlocks 2025-06-10 03:54:18 +08:00
CaIon 92de4d0d04 🔧 fix(api_request): enhance ping keep-alive mechanism with error handling and timeout controls 2025-06-10 03:42:23 +08:00
Apple\Apple 624fdecc6e feat: implement GET request deduplication in API layer
Add request deduplication mechanism to prevent duplicate GET requests
to the same endpoint within the same timeframe, significantly reducing
unnecessary network overhead.

**Changes:**
- Add `patchAPIInstance()` function to intercept and deduplicate GET requests
- Implement in-flight request tracking using Map with URL+params as unique keys
- Apply deduplication patch to both initial API instance and `updateAPI()` recreated instances
- Add `disableDuplicate: true` config option to bypass deduplication when needed

**Benefits:**
- Eliminates redundant API calls caused by component re-renders or rapid user interactions
- Reduces server load and improves application performance
- Provides automatic protection against accidental duplicate requests
- Maintains backward compatibility with existing code

**Technical Details:**
- Uses Promise sharing for identical concurrent requests
- Automatically cleans up completed requests from tracking map
- Preserves original axios functionality with minimal overhead
- Zero breaking changes to existing API usage

Addresses the issue observed in EditChannel.js where multiple calls
were made to the same endpoints during component lifecycle.
2025-06-10 02:32:50 +08:00
Apple\Apple df00c5c6b0 🎨 style(LogsTable): replace IconForward with Route icon for model redirection
- Remove IconForward import from @douyinfe/semi-icons
- Add Route icon import from lucide-react
- Update model redirection indicator in LogsTable component

The Route icon better represents the concept of model redirection
compared to the generic forward arrow, providing clearer visual
context for users when models are mapped to different upstream models.
2025-06-10 02:12:52 +08:00
Apple\Apple a0f89a36a3 🎨 style: change headerbar px-3 to px-2 2025-06-10 01:53:12 +08:00
Apple\Apple 5881bc7658 🎨 style: change headerbar px-4 to px-3 2025-06-10 01:51:49 +08:00
Apple\Apple 000c7c7b95 🎨 style: disable y-axis scrolling for semi-layout components
- Hide scrollbars for .semi-layout, .semi-layout-content, and .semi-sider
- Set scrollbar width and height to 0 for webkit browsers
- Add cross-browser scrollbar hiding support (webkit, firefox, IE/Edge)
- Change Content container overflow from 'auto' to 'hidden' on desktop
- Remove redundant scrollbar styling (thumb, hover, track styles)

This ensures that all semi-layout related components have no visible
scrollbars and prevents vertical scrolling functionality entirely.

Files modified:
- web/src/index.css
- web/src/components/layout/PageLayout.js
2025-06-10 01:42:38 +08:00
Apple\Apple 94df6885ae 🎨 feat(home): redesign homepage hero section with improved layout and multilingual support
- Remove system name display from homepage title
- Replace with unified gateway branding: "统一的大模型接口网关"
- Add subtitle highlighting key benefits: price, stability, no subscription
- Implement language-specific title rendering:
  - English: Two-line layout ("The Unified" / "LLMs API Gateway")
  - Chinese: Single-line layout for better readability
- Increase title font sizes for better visual hierarchy
- Adjust vertical padding for improved centering
- Enhance overall visual appeal and user experience

This update modernizes the homepage presentation and provides better
localization support for different language preferences.
2025-06-10 01:01:03 +08:00
Apple\Apple 64fe8a7f89 📱 feat(TopUp): enhance mobile UX with responsive layout and bottom fixed payment panel
- Convert copy button to Input suffix for cleaner UI design
- Add responsive grid layout for balance cards and preset amounts
  - Mobile (< md): single column layout for better readability
  - Desktop (>= md): multi-column layout for space efficiency
- Implement bottom fixed payment panel on mobile devices
  - Fixed positioning for easy access to payment options
  - Includes custom amount input and payment method buttons
  - Auto-hide on desktop to maintain original layout
- Improve mobile payment flow with sticky bottom controls
- Add proper spacing to prevent content overlap with fixed elements
- Maintain consistent functionality across all breakpoints

This update significantly improves the mobile user experience by making
payment controls easily accessible without scrolling, while preserving
the desktop layout and functionality.
2025-06-10 00:40:47 +08:00
Apple\Apple d5222743ac Merge remote-tracking branch 'origin/alpha' into alpha 2025-06-09 22:28:08 +08:00
Apple\Apple 3fd1667813 🐛 fix(theme): sync theme state between global context and local components
- Replace local isDarkMode state with global useTheme hook in TopUp component
- Replace local isDarkMode state with global useTheme hook in PersonalSetting component
- Remove redundant theme detection useEffect hooks that caused state inconsistency
- Update theme condition checks from isDarkMode to theme === 'dark'
- Fix issue where components showed dark gradients in light mode due to theme state mismatch
- Clean up trailing commas in import statements

This ensures all components stay synchronized with the global theme system managed by HeaderBar's theme toggle button.
2025-06-09 22:27:39 +08:00
IcedTangerine 6d90b2f3dc Merge pull request #1145 from RedwindA/feature/gemini_snake_case_support
feat: 支持Gemini inline_data 的蛇形命名法
2025-06-09 22:06:58 +08:00
Calcium-Ion f95feae571 Merge pull request #1180 from RedwindA/fix/gemini-tool
🐛 fix(Gemini): improve JSON parsing for tool content handling
2025-06-09 20:51:28 +08:00
CaIon b21e378fee feat(ChannelsTable): add renderQuotaWithAmount function and clean up imports 2025-06-09 20:50:37 +08:00
Calcium-Ion 5f5333ebda Merge pull request #1181 from feitianbubu/fix-balance-unit-sync
fix: balance unit sync
2025-06-09 20:48:58 +08:00
Calcium-Ion c821b0edcc Merge branch 'alpha' into fix-balance-unit-sync 2025-06-09 20:48:50 +08:00
Calcium-Ion be71bbb64f Merge pull request #1182 from RedwindA/fix/mistral-tool-content
fix(mistral): adjust condition for assistant content with tool call
2025-06-09 20:47:19 +08:00
CaIon 78b0a82cb8 Merge remote-tracking branch 'origin/alpha' into alpha 2025-06-09 19:24:45 +08:00
CaIon 158c18fe22 feat: add dark mode detection and styling enhancements to PersonalSetting and TopUp components 2025-06-09 19:24:21 +08:00
Apple\Apple e3b1d6915e ♻️ refactor(setting): move API info functions to dedicated module
Move validateApiInfo and getApiInfo functions from controller layer to
setting/api_info.go to improve code organization and separation of concerns.

Changes:
- Create setting/api_info.go with ValidateApiInfo() and GetApiInfo() functions
- Remove validateApiInfo function from controller/option.go
- Remove getApiInfo function from controller/misc.go
- Update function calls to use setting package
- Clean up unused imports (net/url, regexp, fmt) in controller/option.go

This refactoring aligns the API info configuration management with the
existing pattern used by other setting modules (chat.go, group_ratio.go,
rate_limit.go, etc.) and improves code reusability and maintainability.
2025-06-09 19:14:34 +08:00
Apple\Apple 52a665d6a4 feat: Add speed test functionality to API info display
- Add speed test tag with gauge icon for each API route
- Integrate tcptest.cn service for API endpoint performance testing
- Implement handleSpeedTest callback to open speed test in new tab
- Add Tag component import from @douyinfe/semi-ui
- Use Gauge icon with white circular tag styling
- Position speed test tag before API route for better visibility
- URL encoding handles special characters for proper test URL generation
- Remove unused IconTestScoreStroked import and clean up comments

The speed test feature allows users to quickly test API endpoint
performance by clicking a small circular tag that opens the
tcptest.cn speed testing service with the encoded API URL.
2025-06-09 19:03:04 +08:00
CaIon a5baba6b9f 💄 style(LogsTable): remove prefix icons from tags for cleaner UI 2025-06-09 19:00:28 +08:00
Apple\Apple b40f4b99fb 💄 style(ui): improve API info card layout with separate columns for avatar and text
- Restructure API info card layout to use two-column design
- Move avatar to separate left column with fixed width
- Align route name, URL, and description text to same starting position
- Remove unnecessary indentation and improve visual hierarchy
- Enhance readability and consistency of API information display
2025-06-09 18:31:49 +08:00
Apple\Apple c29ac9bb47 💄 style(LogsTable): set minimum width for log type selector
- Add min-w-[120px] class to Form.Select component for log type filtering
- Remove redundant min-width constraint from parent div container
- Ensure consistent dropdown width across different screen sizes
- Improve UI consistency and readability for log type selection
2025-06-09 18:27:01 +08:00
Apple\Apple efa807abe2 🎨 refactor: reorganize log type selector layout with responsive design
- Move Form.Select (log type selector) from grid layout to action button row
- Position log type selector on the left side of the action button area
- Keep action buttons (Query, Reset, Column Settings) aligned to the right
- Implement responsive design with sm: breakpoint (640px)
  - Mobile: vertical stacking with full-width elements
  - Desktop: horizontal layout with proper spacing
- Add min-width constraint (140px) for log type selector
- Remove extra padding-top from button area for cleaner spacing
- Maintain accessibility and usability across all screen sizes

This change improves the UI layout by better utilizing horizontal space
and providing a more intuitive grouping of form controls and actions.
2025-06-09 18:22:18 +08:00
Apple\Apple 07961bccca Merge remote-tracking branch 'origin/main' into alpha 2025-06-09 17:46:00 +08:00
Apple\Apple 49c3b9ebcc Merge remote-tracking branch 'origin/alpha' into alpha 2025-06-09 17:45:49 +08:00
Apple\Apple 4c673d2628 feat: major refactor and enhancement of Detail dashboard component & add api url display
- **Code Organization & Architecture:**
  - Restructured component with clear sections (Hooks, Constants, Helper Functions, etc.)
  - Added comprehensive code organization comments for better maintainability
  - Extracted reusable helper functions and constants for better separation of concerns

- **Performance Optimizations:**
  - Implemented extensive use of useCallback and useMemo hooks for expensive operations
  - Optimized data processing pipeline with dedicated processing functions
  - Memoized chart configurations, performance metrics, and grouped stats data
  - Cached helper functions like getTrendSpec, handleCopyUrl, and modal handlers

- **UI/UX Enhancements:**
  - Added Empty state component with construction illustrations for better UX
  - Implemented responsive grid layout with conditional API info section visibility
  - Enhanced button styling with consistent rounded design and hover effects
  - Added mini trend charts to statistics cards for visual data representation
  - Improved form field consistency with reusable createFormField helper

- **Feature Improvements:**
  - Added self-use mode detection to conditionally hide/show API information section
  - Enhanced chart configurations with centralized CHART_CONFIG constant
  - Improved time handling with dedicated helper functions (getTimeInterval, getInitialTimestamp)
  - Added comprehensive performance metrics calculation (RPM/TPM trends)
  - Implemented advanced data aggregation and processing workflows

- **Code Quality & Maintainability:**
  - Extracted complex data processing logic into dedicated functions
  - Added proper prop destructuring and state organization
  - Implemented consistent naming conventions and helper utilities
  - Enhanced error handling and loading states management
  - Added comprehensive JSDoc-style comments for better code documentation

- **Technical Debt Reduction:**
  - Replaced repetitive form field definitions with reusable components
  - Consolidated chart update logic into centralized updateChartSpec function
  - Improved data flow with better state management patterns
  - Reduced code duplication through strategic use of helper functions

This refactor significantly improves component performance, maintainability, and user experience while maintaining backward compatibility and existing functionality.
2025-06-09 17:44:23 +08:00
Calcium-Ion bc71259c2c Merge pull request #1186 from tylinux/main
feat: use bun when develop locally
2025-06-09 15:52:17 +08:00
Calcium-Ion a8b24bed00 Merge pull request #1184 from QuantumNous/refactor/message
fix: message 转 any 后,ImageUrl判断 panic
2025-06-09 15:51:53 +08:00
tylinux 8c9dfd3bb4 feat: use bun when develop locally 2025-06-09 14:57:01 +08:00
Apple\Apple 03645264c3 refactor(home): redesign homepage layout with centered content and improved responsiveness
- Remove example image and right-side image section for cleaner layout
- Center all content vertically and horizontally on the page
- Implement comprehensive responsive design using Tailwind CSS breakpoints
  - Typography scales from text-3xl to xl:text-6xl across screen sizes
  - Spacing and padding adjust dynamically (py-12 to lg:py-20)
  - Icon grid adapts from gap-3 to lg:gap-8
- Keep action buttons horizontally aligned on all screen sizes
- Add play icon to "Get Started" button for better UX
- Refactor version display logic:
  - Show version tag only in demo site mode
  - Replace GitHub button text with version number in demo mode
  - Add docs button with same logic as HeaderBar when not in demo mode
- Optimize icon layout with consistent 40px size and responsive containers
- Improve overall mobile-first responsive design from 320px to 1280px+ screens
2025-06-09 13:43:50 +08:00
Xyfacai be38aaa80d fix: message 转 any 后,ImageUrl判断 panic 2025-06-09 11:27:24 +08:00
RedwindA 07a671ec27 🐛 fix: update condition to check for empty content in assistant role messages 2025-06-09 02:15:39 +08:00
skynono d30ae8204e fix: balance unit sync 2025-06-09 01:31:39 +08:00
Apple\Apple af0f59cbc9 🎨 style: remove overly vibrant colors and simplify UI design
- Remove colorful gradient backgrounds from dashboard panel headers in Detail page
- Replace custom header styling with default Semi-UI card title styling
- Remove background images and gradient overlays from all authentication pages
- Simplify authentication page layouts with clean gray backgrounds
- Update title text colors from white to dark gray for better contrast
- Remove unnecessary z-index layering and complex positioning
- Clean up unused background image imports

This change creates a more professional and consistent visual appearance
across the application by removing distracting visual elements.
2025-06-09 00:14:35 +08:00
Apple\Apple 4c4d33318f feat(tables): add "No Results" empty state for all table components
Add consistent empty state handling across all table components to improve
user experience when search/filter results are empty.

Changes:
- Import Empty component and IllustrationNoResult/IllustrationNoResultDark from @douyinfe/semi-ui
- Add empty prop to Table components with "搜索无结果" message
- Support both light and dark theme illustrations
- Apply internationalization support for empty state text

Affected files:
- web/src/components/table/MjLogsTable.js
- web/src/components/table/LogsTable.js
- web/src/components/table/ChannelsTable.js
- web/src/components/table/RedemptionsTable.js
- web/src/components/table/TaskLogsTable.js
- web/src/components/table/TokensTable.js
- web/src/components/table/UsersTable.js
- web/src/components/table/ModelPricing.js

This ensures consistent UX across all table components when no data
matches the current search or filter criteria.
2025-06-08 23:42:39 +08:00
Apple\Apple 2fc3b39236 feat(ui): add lucide-react icons to dashboard sections
Add visual icons to improve user experience and section identification:

- Import lucide-react icons: Wallet, Activity, Zap, Gauge, PieChart
- Add Wallet icon to "Account Data" section
- Add Activity icon to "Usage Statistics" section
- Add Zap icon to "Resource Consumption" section
- Add Gauge icon to "Performance Metrics" section
- Add PieChart icon to "Model Data Analysis" card

All icons are styled with 16px size and proper flex layout with consistent spacing. Icons inherit parent text color for seamless integration with existing gradient headers.
2025-06-08 23:22:19 +08:00
Apple\Apple 2df42cea24 🎨 feat(ui): replace list icon with tags icon for channel tag aggregation
- Replace IconList with Tags icon from lucide-react for better semantic representation
- Update renderTagType function to use Tags icon instead of list icon
- Remove unused IconList import from semi-icons
- Improve visual clarity for tag aggregation feature in channels table

The Tags icon better represents the concept of multiple tags being aggregated
together, providing more intuitive user experience in the channels management
interface.
2025-06-08 23:16:34 +08:00
Apple\Apple 48599d709d feat: Add lucide-react icons to all table Tag components
- Add semantic icons to ChannelsTable.js for channel status, response time, and quota display
- Add status and quota icons to TokensTable.js for better visual distinction
- Add status and quota icons to RedemptionsTable.js for redemption code management
- Add role, status, and statistics icons to UsersTable.js for user management
- Import appropriate lucide-react icons for each table component
- Enhance UI consistency and user experience across all table interfaces

Icons added include:
- Status indicators: CheckCircle, XCircle, AlertCircle, HelpCircle
- Performance metrics: Zap, Timer, Clock, AlertTriangle, TestTube
- Financial data: Coins, DollarSign
- User roles: User, Shield, Crown
- Activity tracking: Activity, Users, UserPlus

This improves visual clarity and makes table data more intuitive to understand.
2025-06-08 23:13:45 +08:00
Apple\Apple 26d8c3aea9 🔄 fix(tables): ensure search buttons show loading state consistently across all tables
Fix inconsistent loading state behavior where search buttons in ChannelsTable,
RedemptionsTable, and UsersTable didn't display loading animation when tables
were loading data, unlike LogsTable which handled this correctly.

Changes:
- Fix ChannelsTable searchChannels function to properly manage loading state
  - Move setSearching(true) to function start and use try-finally pattern
  - Ensure loading state is set for both search and load operations
- Update search button loading prop in ChannelsTable: loading={searching} → loading={loading || searching}
- Update search button loading prop in RedemptionsTable: loading={searching} → loading={loading || searching}
- Update search button loading prop in UsersTable: loading={searching} → loading={loading || searching}

This ensures search buttons show loading state consistently when:
- Table is loading data (initial load, pagination, operations)
- Search operation is in progress

All table components now provide unified UX behavior matching LogsTable,
preventing duplicate clicks and clearly indicating system state to users.
2025-06-08 22:01:54 +08:00
CaIon 1de4af1c74 fix(main.go): correct comment formatting for embed directives 2025-06-08 20:26:14 +08:00
Apple\Apple a508eb2669 Merge remote-tracking branch 'origin/alpha' into alpha 2025-06-08 18:41:18 +08:00
Apple\Apple a62a84418a ♻️ refactor(components): migrate all table components to use Form API
- Refactor LogsTable, MjLogsTable, TokensTable, UsersTable, and ChannelsTable to use Semi-UI Form components
- Replace individual input state management with centralized Form API
- Add form validation and consistent form handling across all tables
- Implement auto-search functionality with proper state update timing
- Add reset functionality to clear all search filters
- Improve responsive layout design for better mobile experience
- Remove duplicate form initial values and consolidate form logic
- Remove column visibility feature from ChannelsTable to simplify UI
- Standardize search form structure and styling across all table components
- Fix state update timing issues in search functionality
- Add proper form submission handling with loading states

BREAKING CHANGE: Form state management has been completely rewritten.
All table components now use Form API instead of individual useState hooks.
Column visibility settings for ChannelsTable have been removed.
2025-06-08 18:41:04 +08:00
Apple\Apple d1fb841510 refactor(LogsTable): enhance Form component with auto-search and state synchronization
- Refactor Form component to use Semi Design best practices
- Remove duplicate initValues configuration for DatePicker
- Add real-time value change monitoring with onValueChange
- Implement auto-search functionality for log type selector changes
- Fix state synchronization issues causing stale values in search requests
- Optimize form layout with proper vertical layout configuration
- Enhance user experience with placeholders, clear buttons, and search icons
- Remove logType parameter passing to prevent async state update conflicts
- Ensure all form controls use latest values from formApi instead of stale state
- Add proper validation triggers and error handling configuration
- Improve reset button logic with proper timing for form state updates

The changes resolve the issue where users needed to select log type twice
for the search request to use the correct value, and ensure all form
interactions provide immediate and accurate results.
2025-06-08 17:28:28 +08:00
CaIon 2fb222b9a4 refactor(dto): change function and encoding fields to use json.RawMessage for improved flexibility 2025-06-08 16:28:47 +08:00
CaIon 2b3e64e8b6 fix(relay-channel): correct condition for mediaMessages initialization in requestOpenAI2Mistral function 2025-06-08 16:25:00 +08:00
CaIon fcb9647aeb fix(relay-gemini): remove outdated unsupported models from CovertGemini2OpenAI function 2025-06-08 16:22:39 +08:00
Calcium-Ion 5d7e688bfe Merge pull request #1174 from QuantumNous/refactor/message
refactor: message content 改成 any
2025-06-08 16:22:20 +08:00
CaIon 7469446a20 fix(relay-gemini): add unsupported models to CovertGemini2OpenAI function 2025-06-08 16:04:31 +08:00
Apple\Apple ef721bb055 Merge remote-tracking branch 'origin/main' into alpha 2025-06-08 15:08:03 +08:00
RedwindA da41545139 🐛 fix(Gemini): improve JSON parsing for tool content handling 2025-06-08 14:35:56 +08:00
Calcium-Ion 762d2c81f5 Merge pull request #1179 from QuantumNous/alpha
merge alpha to main
2025-06-08 14:34:24 +08:00
Calcium-Ion f115342784 Merge pull request #1162 from RedwindA/fix-redis-hdel
fix: Rename and refactor RedisHDelObj to RedisDelKey
2025-06-08 14:33:48 +08:00
Apple\Apple 4d9ecdb8d5 💄 style(channel): unify text link styles in EditTagModal with EditChannel
Update text link styling in EditTagModal.js to match the consistent design
pattern used in EditChannel.js. Changed className from 'text-blue-500 cursor-pointer'
to '!text-semi-color-primary cursor-pointer' for template-related action links
("填入模板", "清空重定向", "不更改").

This change ensures:
- Visual consistency across channel editing components
- Better theme adaptability using Semi Design color variables
- Adherence to established design patterns in the codebase

Files modified:
- web/src/pages/Channel/EditTagModal.js
2025-06-08 14:16:57 +08:00
Calcium-Ion 58f235af5e Merge pull request #1176 from RedwindA/feat/tagMode-channelModelList
feat: 标签聚合模式编辑渠道时复用渠道模型列表
2025-06-08 13:52:36 +08:00
RedwindA b197dfb95d 🐛 fix(EditTagModal): add info banner to clarify modelList behavior 2025-06-08 13:23:59 +08:00
Apple\Apple e0ff3b6f28 feat(token): auto-generate default token names when user input is empty
When creating tokens, if the user doesn't provide a token name (empty or whitespace-only),
the system will now automatically generate a name using the format "default-xxxxxx" where
"xxxxxx" is a 6-character random alphanumeric string.

This enhancement ensures that all created tokens have meaningful names and improves the
user experience by removing the requirement to manually input token names for quick token
creation scenarios.

Changes:
- Modified token creation logic to detect empty token names
- Added automatic fallback to "default" base name when user input is missing
- Maintained existing behavior for multiple token creation with random suffixes
- Ensured consistent naming pattern across single and batch token creation
2025-06-08 12:38:03 +08:00
Apple\Apple 5040235919 💄 style(Logs): Add rounded corners to image view button in MjLogsTable
- Add rounded-full class to "查看图片" (View Image) button for consistent UI styling
- All other buttons in both MjLogsTable.js and TaskLogsTable.js already have rounded corners applied
- Ensures uniform button styling across the log tables interface
2025-06-08 12:23:54 +08:00
Apple\Apple 07f141e8c8 🎨 fix: Import Semi UI CSS explicitly to resolve missing component styles
- Add explicit import of '@douyinfe/semi-ui/dist/css/semi.css' in index.js
- Ensures Semi Design components render with proper styling
- Resolves issue where Semi components appeared unstyled after dependency updates

This change addresses the style loading issue that occurred after adding antd
dependency and updating the build configuration. The explicit import ensures
consistent style loading regardless of plugin behavior changes.
2025-06-08 12:14:49 +08:00
CaIon e1039c67f7 chore: update CI workflows 2025-06-08 03:37:17 +08:00
CaIon 594636bace fix: update import statement for vite-plugin-semi to use default import 2025-06-08 03:35:04 +08:00
CaIon e647163969 chore: update CI workflow to use latest Bun version and adjust build environment variables 2025-06-08 03:28:36 +08:00
CaIon c3a205e792 chore: update Bun version in CI workflow to 1.2.8 2025-06-08 02:57:44 +08:00
CaIon e7289297e2 chore: update package.json to replace sse dependency and add trustedDependencies 2025-06-08 02:56:09 +08:00
CaIon 2e04d28483 chore: update CI workflows to use Bun for package management across all platforms 2025-06-08 02:45:18 +08:00
CaIon 4069b3f789 chore: update CI workflows to support manual triggers and rename Docker image workflow 2025-06-08 02:42:27 +08:00
CaIon 2cb067f485 chore: remove deprecated Docker image workflow for amd64 2025-06-08 02:39:53 +08:00
CaIon 550c068994 Merge branch 'alpha' 2025-06-08 02:38:49 +08:00
CaIon 8b55c9b917 Merge remote-tracking branch 'origin/alpha' into alpha 2025-06-08 02:34:22 +08:00
CaIon e09931558e chore: update CI workflows to use Bun for package management and build process 2025-06-08 02:34:06 +08:00
Calcium-Ion 7607da2387 Merge pull request #1177 from QuantumNous/alpha
merge alpha to main
2025-06-08 02:26:06 +08:00
Apple\Apple 09c6c2c576 🔧 fix(auth): add copy button to disabled password input in reset confirmation
- Import IconCopy from semi-icons for copy functionality
- Replace onClick handler with suffix copy button to fix disabled input issue
- Use borderless tertiary button as input suffix for better alignment
- Update notification messages formatting (colon spacing)
- Ensure password copying works even when input field is disabled
2025-06-08 02:23:47 +08:00
Apple\Apple 9f4a32d6c0 chore(PasswordResetConfirm): Improve password reset confirm UI and fix form data binding
- Replace error message div with Semi UI Banner component for better UX
- Add rounded corners to Banner component with !rounded-lg class
- Fix Form.Input not displaying values by implementing proper formApi usage
- Use getFormApi callback to obtain form API instance
- Replace manual value props with formApi.setValues() for dynamic updates
- Set proper initValues for form initialization
- Remove unused Input import and console.log statements
- Clean up debugging code and optimize form state management

This change enhances the visual consistency with Semi Design system
and resolves the issue where email field was not showing URL parameter values.
2025-06-08 01:44:38 +08:00
RedwindA 18f82f120c 🐛 fix(EditTagModal): add fetchTagModels function to retrieve models based on tag 2025-06-08 01:16:39 +08:00
RedwindA c4f664f376 🎨 feat(channel): add endpoint to retrieve models by tag 2025-06-08 01:16:27 +08:00
Apple\Apple b6dd9f96a1 🐛 fix(auth): resolve password reset confirmation display and functionality issues
- Fix input field display issues in password reset confirmation page
  * Replace `readOnly` with `disabled={true}` for proper field state
  * Improve URL parameter parsing and state management
  * Add proper null checks and fallback values

- Enhance user experience and error handling
  * Add validation for invalid reset links
  * Display appropriate error messages and placeholders
  * Add debug logging for troubleshooting
  * Improve button states and loading indicators

- Improve password reset form validation
  * Add proper email input validation with error messages
  * Enhance user feedback for empty email submissions

- Add missing English translations
  * Add i18n support for new UI text strings
  * Ensure proper internationalization coverage

The password reset confirmation page now correctly displays email addresses
from URL parameters and prevents user input as intended. Error handling
has been improved to provide better user guidance when reset links are
invalid or malformed.

Fixes: Password reset input fields showing empty and allowing user input
when they should display email/password and be read-only.
2025-06-08 01:08:03 +08:00
Apple\Apple 0377cf37c5 🔖chore(ui): Improve Loading prompt 2025-06-08 00:33:26 +08:00
Calcium-Ion 8f938d5b1a Merge pull request #1171 from RedwindA/feat/ali-rerank
feat: ali rerank
2025-06-08 00:15:21 +08:00
Calcium-Ion 258bad0a17 Merge pull request #1173 from RedwindA/fix/ali-embedding
🐛 fix(ali): Remove hardcoding of embedding model names.
2025-06-08 00:14:55 +08:00
Calcium-Ion ff6e12861a Merge pull request #1175 from RedwindA/fix/mistral-tool-id
🐛 fix: 适应Mistral的tool call格式要求
2025-06-08 00:14:23 +08:00
CaIon 28a2387dff Merge remote-tracking branch 'origin/alpha' into alpha 2025-06-08 00:11:28 +08:00
CaIon 5ace100f63 chore: update bun.lockb file 2025-06-08 00:10:54 +08:00
Apple\Apple 685cf70016 ♻️ refactor(auth): replace custom loading UI with shared Loading component and add i18n support
- Replace inline loading UI in OAuth2Callback with shared Loading component
- Add internationalization support using useTranslation hook
- Translate all hardcoded Chinese strings to support multiple languages
- Remove unused processing state variable
- Maintain consistent loading experience across the application
- Support dynamic text content for retry attempts with parameter interpolation
2025-06-08 00:07:37 +08:00
RedwindA 546d0140d3 🐛 fix(mistral): validate and generate new IDs for tool calls and tool call IDs; Correctly handle null content for assistant messages with tool_calls. 2025-06-08 00:06:56 +08:00
CaIon e7ebfd498d fix: CI 2025-06-08 00:06:53 +08:00
Xyfacai 8343fe0740 refactor: message content 改成 any
refactor: message content 改成 any
2025-06-07 23:47:22 +08:00
Apple\Apple 226ef003e5 🎨feat(ui): Improve Chat page UI and add i18n support
- Replace Banner with full-screen Spin component for better loading UX
- Add English translation for "正在跳转..." ("Redirecting...")
- Integrate i18next translation hook in Chat page component
- Remove unused useEffect import for cleaner code

The Chat page now shows a centered full-screen loading spinner instead of
a banner when redirecting, providing a more consistent and professional
user experience. The loading text is now properly internationalized and
will display "Redirecting..." in English and "正在跳转..." in Chinese.
2025-06-07 23:22:25 +08:00
Apple\Apple c46e410a0d 🔗feat(ui): Standardize link colors and update documentation URL in EditChannel component
**Changes:**
- Unify link color styling across EditChannel.js by replacing `text-blue-500` with consistent primary color scheme
- Apply `!text-semi-color-primary hover:!text-semi-color-primary-hover transition-colors` to all template fill and documentation links
- Update documentation URL from Calcium-Ion repository to QuantumNous repository
- Add smooth hover transitions and consistent visual feedback for all clickable links

**Affected Elements:**
- Model mapping template fill link
- Deployment region template fill link
- Channel settings template fill link
- Channel settings documentation link
- Status code mapping template fill link

**Benefits:**
- Consistent visual design language across the entire application
- Improved user experience with unified link styling
- Better accessibility with clear hover states and transitions
- Correct documentation references pointing to the current project repository

**Technical Details:**
- Maintains existing functionality while improving visual consistency
- Links now match the color scheme used in About page and Footer components
- Smooth color transitions enhance user interaction feedback
2025-06-07 23:15:25 +08:00
Apple\Apple 242cf8e976 🔗feat(ui): Enhance About page with interactive project links and improve external link handling 2025-06-07 22:55:12 +08:00
Apple\Apple 8122a30c8a 🔗feat(ui): Enhance About page with interactive project links and improve external link handling
**Changes:**
- Replace React Router `Link` components with native `<a>` tags for external links in About and Footer components
- Add clickable links for "NewAPI", "QuantumNous", and "One API v0.5.4" in the About page
- Link "NewAPI" to the main project repository (https://github.com/QuantumNous/new-api)
- Link "QuantumNous" to the organization page (https://github.com/QuantumNous)
- Link "One API v0.5.4" to the specific release page (https://github.com/songquanpeng/one-api/releases/tag/v0.5.4)
- Apply consistent styling with primary color theme and hover effects across all links
- Add proper security attributes (`rel="noopener noreferrer"`) to all external links

**i18n Updates:**
- Refactor i18n translation keys to support the new link structure
- Split the original copyright string into smaller, reusable translation keys
- Add new translation keys: `"© {{currentYear}}"` and `"| 基于"`
- Maintain backward compatibility for existing translations

**Benefits:**
- Improved user experience with direct access to relevant project resources
- Better SEO and link accessibility
- Consistent visual styling across all external links
- Enhanced security for external link navigation
- Proper separation of concerns between internal routing and external navigation
2025-06-07 22:50:31 +08:00
RedwindA 894f9b98d9 Merge remote-tracking branch 'upstream/main' into fix/ali-embedding 2025-06-07 22:32:02 +08:00
RedwindA 7671f82ab6 🐛 fix(ali): Remove hardcoding of embedding model names. 2025-06-07 22:28:32 +08:00
RedwindA b8cfb64a45 feat: ali rerank 2025-06-07 21:29:46 +08:00
RedwindA 098c1e8829 refactor: rename RedisHDelObj to RedisDelKey and update references 2025-06-05 21:17:57 +08:00
RedwindA 15607996e6 fix: change RedisHDelObj to use Del instead of HDel 2025-06-05 20:42:56 +08:00
RedwindA 9390cbeea8 支持Gemini inline_data的蛇形命名法 2025-06-04 02:18:54 +08:00
109 changed files with 7352 additions and 8020 deletions
+56
View File
@@ -0,0 +1,56 @@
name: Publish Docker image (Multi Registries)
on:
push:
tags:
- '*'
jobs:
push_to_registries:
name: Push Docker image to multiple registries
runs-on: ubuntu-latest
permissions:
packages: write
contents: read
steps:
- name: Check out the repo
uses: actions/checkout@v4
- name: Save version info
run: |
git describe --tags > VERSION
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Log in to the Container registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v5
with:
images: |
calciumion/new-api
ghcr.io/${{ github.repository }}
- name: Build and push Docker images
uses: docker/build-push-action@v5
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
+59
View File
@@ -0,0 +1,59 @@
name: Linux Release
permissions:
contents: write
on:
workflow_dispatch:
inputs:
name:
description: 'reason'
required: false
push:
tags:
- '*'
- '!*-alpha*'
jobs:
release:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
with:
fetch-depth: 0
- uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Build Frontend
env:
CI: ""
run: |
cd web
bun install
DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$(git describe --tags) bun run build
cd ..
- name: Set up Go
uses: actions/setup-go@v3
with:
go-version: '>=1.18.0'
- name: Build Backend (amd64)
run: |
go mod download
go build -ldflags "-s -w -X 'one-api/common.Version=$(git describe --tags)' -extldflags '-static'" -o one-api
- name: Build Backend (arm64)
run: |
sudo apt-get update
DEBIAN_FRONTEND=noninteractive sudo apt-get install -y gcc-aarch64-linux-gnu
CC=aarch64-linux-gnu-gcc CGO_ENABLED=1 GOOS=linux GOARCH=arm64 go build -ldflags "-s -w -X 'one-api/common.Version=$(git describe --tags)' -extldflags '-static'" -o one-api-arm64
- name: Release
uses: softprops/action-gh-release@v1
if: startsWith(github.ref, 'refs/tags/')
with:
files: |
one-api
one-api-arm64
draft: true
generate_release_notes: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+50
View File
@@ -0,0 +1,50 @@
name: macOS Release
permissions:
contents: write
on:
workflow_dispatch:
inputs:
name:
description: 'reason'
required: false
push:
tags:
- '*'
- '!*-alpha*'
jobs:
release:
runs-on: macos-latest
steps:
- name: Checkout
uses: actions/checkout@v3
with:
fetch-depth: 0
- uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Build Frontend
env:
CI: ""
run: |
cd web
bun install
DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$(git describe --tags) bun run build
cd ..
- name: Set up Go
uses: actions/setup-go@v3
with:
go-version: '>=1.18.0'
- name: Build Backend
run: |
go mod download
go build -ldflags "-X 'one-api/common.Version=$(git describe --tags)'" -o one-api-macos
- name: Release
uses: softprops/action-gh-release@v1
if: startsWith(github.ref, 'refs/tags/')
with:
files: one-api-macos
draft: true
generate_release_notes: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+53
View File
@@ -0,0 +1,53 @@
name: Windows Release
permissions:
contents: write
on:
workflow_dispatch:
inputs:
name:
description: 'reason'
required: false
push:
tags:
- '*'
- '!*-alpha*'
jobs:
release:
runs-on: windows-latest
defaults:
run:
shell: bash
steps:
- name: Checkout
uses: actions/checkout@v3
with:
fetch-depth: 0
- uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Build Frontend
env:
CI: ""
run: |
cd web
bun install
DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$(git describe --tags) bun run build
cd ..
- name: Set up Go
uses: actions/setup-go@v3
with:
go-version: '>=1.18.0'
- name: Build Backend
run: |
go mod download
go build -ldflags "-s -w -X 'one-api/common.Version=$(git describe --tags)'" -o one-api.exe
- name: Release
uses: softprops/action-gh-release@v1
if: startsWith(github.ref, 'refs/tags/')
with:
files: one-api.exe
draft: true
generate_release_notes: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+3 -3
View File
@@ -92,12 +92,12 @@ func RedisDel(key string) error {
return RDB.Del(ctx, key).Err()
}
func RedisHDelObj(key string) error {
func RedisDelKey(key string) error {
if DebugEnabled {
SysLog(fmt.Sprintf("Redis HDEL: key=%s", key))
SysLog(fmt.Sprintf("Redis DEL Key: key=%s", key))
}
ctx := context.Background()
return RDB.HDel(ctx, key).Err()
return RDB.Del(ctx, key).Err()
}
func RedisHSetObj(key string, obj interface{}, expiration time.Duration) error {
+1
View File
@@ -7,6 +7,7 @@ var (
UserSettingWebhookSecret = "webhook_secret" // WebhookSecret webhook密钥
UserSettingNotificationEmail = "notification_email" // NotificationEmail 通知邮箱地址
UserAcceptUnsetRatioModel = "accept_unset_model_ratio_model" // AcceptUnsetRatioModel 是否接受未设置价格的模型
UserSettingRecordIpLog = "record_ip_log" // 是否记录请求和错误日志IP
)
var (
+11 -6
View File
@@ -166,7 +166,7 @@ func testChannel(channel *model.Channel, testModel string) (err error, openAIErr
milliseconds := tok.Sub(tik).Milliseconds()
consumedTime := float64(milliseconds) / 1000.0
other := service.GenerateTextOtherInfo(c, info, priceData.ModelRatio, priceData.GroupRatio, priceData.CompletionRatio,
usage.PromptTokensDetails.CachedTokens, priceData.CacheRatio, priceData.ModelPrice)
usage.PromptTokensDetails.CachedTokens, priceData.CacheRatio, priceData.ModelPrice, priceData.UserGroupRatio)
model.RecordConsumeLog(c, 1, channel.Id, usage.PromptTokens, usage.CompletionTokens, info.OriginModelName, "模型测试",
quota, "模型测试", 0, quota, int(consumedTime), false, info.Group, other)
common.SysLog(fmt.Sprintf("testing channel #%d, response: \n%s", channel.Id, string(respBody)))
@@ -200,10 +200,10 @@ func buildTestRequest(model string) *dto.GeneralOpenAIRequest {
} else {
testRequest.MaxTokens = 10
}
content, _ := json.Marshal("hi")
testMessage := dto.Message{
Role: "user",
Content: content,
Content: "hi",
}
testRequest.Model = model
testRequest.Messages = append(testRequest.Messages, testMessage)
@@ -271,6 +271,13 @@ func testAllChannels(notify bool) error {
disableThreshold = 10000000 // a impossible value
}
gopool.Go(func() {
// 使用 defer 确保无论如何都会重置运行状态,防止死锁
defer func() {
testAllChannelsLock.Lock()
testAllChannelsRunning = false
testAllChannelsLock.Unlock()
}()
for _, channel := range channels {
isChannelEnabled := channel.Status == common.ChannelStatusEnabled
tik := time.Now()
@@ -305,9 +312,7 @@ func testAllChannels(notify bool) error {
channel.UpdateResponseTime(milliseconds)
time.Sleep(common.RequestInterval)
}
testAllChannelsLock.Lock()
testAllChannelsRunning = false
testAllChannelsLock.Unlock()
if notify {
service.NotifyRootUser(dto.NotifyTypeChannelTest, "通道测试完成", "所有通道测试已完成")
}
+62 -14
View File
@@ -43,22 +43,23 @@ type OpenAIModelsResponse struct {
func GetAllChannels(c *gin.Context) {
p, _ := strconv.Atoi(c.Query("p"))
pageSize, _ := strconv.Atoi(c.Query("page_size"))
if p < 0 {
p = 0
if p < 1 {
p = 1
}
if pageSize < 0 {
if pageSize < 1 {
pageSize = common.ItemsPerPage
}
channelData := make([]*model.Channel, 0)
idSort, _ := strconv.ParseBool(c.Query("id_sort"))
enableTagMode, _ := strconv.ParseBool(c.Query("tag_mode"))
var total int64
if enableTagMode {
tags, err := model.GetPaginatedTags(p*pageSize, pageSize)
// tag 分页:先分页 tag,再取各 tag 下 channels
tags, err := model.GetPaginatedTags((p-1)*pageSize, pageSize)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()})
return
}
for _, tag := range tags {
@@ -69,21 +70,27 @@ func GetAllChannels(c *gin.Context) {
}
}
}
// 计算 tag 总数用于分页
total, _ = model.CountAllTags()
} else {
channels, err := model.GetAllChannels(p*pageSize, pageSize, false, idSort)
channels, err := model.GetAllChannels((p-1)*pageSize, pageSize, false, idSort)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()})
return
}
channelData = channels
total, _ = model.CountAllChannels()
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": channelData,
"data": gin.H{
"items": channelData,
"total": total,
"page": p,
"page_size": pageSize,
},
})
return
}
@@ -623,3 +630,44 @@ func BatchSetChannelTag(c *gin.Context) {
})
return
}
func GetTagModels(c *gin.Context) {
tag := c.Query("tag")
if tag == "" {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "tag不能为空",
})
return
}
channels, err := model.GetChannelsByTag(tag, false) // Assuming false for idSort is fine here
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"message": err.Error(),
})
return
}
var longestModels string
maxLength := 0
// Find the longest models string among all channels with the given tag
for _, channel := range channels {
if channel.Models != "" {
currentModels := strings.Split(channel.Models, ",")
if len(currentModels) > maxLength {
maxLength = len(currentModels)
longestModels = channel.Models
}
}
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": longestModels,
})
return
}
+34 -20
View File
@@ -7,7 +7,6 @@ import (
"fmt"
"github.com/gin-gonic/gin"
"io"
"log"
"net/http"
"one-api/common"
"one-api/dto"
@@ -215,8 +214,12 @@ func checkMjTaskNeedUpdate(oldTask *model.Midjourney, newTask dto.MidjourneyDto)
func GetAllMidjourney(c *gin.Context) {
p, _ := strconv.Atoi(c.Query("p"))
if p < 0 {
p = 0
if p < 1 {
p = 1
}
pageSize, _ := strconv.Atoi(c.Query("page_size"))
if pageSize <= 0 {
pageSize = common.ItemsPerPage
}
// 解析其他查询参数
@@ -227,31 +230,38 @@ func GetAllMidjourney(c *gin.Context) {
EndTimestamp: c.Query("end_timestamp"),
}
logs := model.GetAllTasks(p*common.ItemsPerPage, common.ItemsPerPage, queryParams)
if logs == nil {
logs = make([]*model.Midjourney, 0)
}
items := model.GetAllTasks((p-1)*pageSize, pageSize, queryParams)
total := model.CountAllTasks(queryParams)
if setting.MjForwardUrlEnabled {
for i, midjourney := range logs {
for i, midjourney := range items {
midjourney.ImageUrl = setting.ServerAddress + "/mj/image/" + midjourney.MjId
logs[i] = midjourney
items[i] = midjourney
}
}
c.JSON(200, gin.H{
"success": true,
"message": "",
"data": logs,
"data": gin.H{
"items": items,
"total": total,
"page": p,
"page_size": pageSize,
},
})
}
func GetUserMidjourney(c *gin.Context) {
p, _ := strconv.Atoi(c.Query("p"))
if p < 0 {
p = 0
if p < 1 {
p = 1
}
pageSize, _ := strconv.Atoi(c.Query("page_size"))
if pageSize <= 0 {
pageSize = common.ItemsPerPage
}
userId := c.GetInt("id")
log.Printf("userId = %d \n", userId)
queryParams := model.TaskQueryParams{
MjID: c.Query("mj_id"),
@@ -259,19 +269,23 @@ func GetUserMidjourney(c *gin.Context) {
EndTimestamp: c.Query("end_timestamp"),
}
logs := model.GetAllUserTask(userId, p*common.ItemsPerPage, common.ItemsPerPage, queryParams)
if logs == nil {
logs = make([]*model.Midjourney, 0)
}
items := model.GetAllUserTask(userId, (p-1)*pageSize, pageSize, queryParams)
total := model.CountAllUserTask(userId, queryParams)
if setting.MjForwardUrlEnabled {
for i, midjourney := range logs {
for i, midjourney := range items {
midjourney.ImageUrl = setting.ServerAddress + "/mj/image/" + midjourney.MjId
logs[i] = midjourney
items[i] = midjourney
}
}
c.JSON(200, gin.H{
"success": true,
"message": "",
"data": logs,
"data": gin.H{
"items": items,
"total": total,
"page": p,
"page_size": pageSize,
},
})
}
+10 -2
View File
@@ -6,6 +6,7 @@ import (
"net/http"
"one-api/common"
"one-api/constant"
"one-api/middleware"
"one-api/model"
"one-api/setting"
"one-api/setting/operation_setting"
@@ -24,14 +25,18 @@ func TestStatus(c *gin.Context) {
})
return
}
// 获取HTTP统计信息
httpStats := middleware.GetStats()
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Server is running",
"success": true,
"message": "Server is running",
"http_stats": httpStats,
})
return
}
func GetStatus(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
@@ -74,6 +79,9 @@ func GetStatus(c *gin.Context) {
"oidc_client_id": system_setting.GetOIDCSettings().ClientId,
"oidc_authorization_endpoint": system_setting.GetOIDCSettings().AuthorizationEndpoint,
"setup": constant.Setup,
"api_info": setting.GetApiInfo(),
"announcements": setting.GetAnnouncements(),
"faq": setting.GetFAQ(),
},
})
return
+27 -1
View File
@@ -119,7 +119,33 @@ func UpdateOption(c *gin.Context) {
})
return
}
case "ApiInfo":
err = setting.ValidateApiInfo(option.Value)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
case "Announcements":
err = setting.ValidateConsoleSettings(option.Value, "Announcements")
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
case "FAQ":
err = setting.ValidateConsoleSettings(option.Value, "FAQ")
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
}
err = model.UpdateOption(option.Key, option.Value)
if err != nil {
+8 -1
View File
@@ -1,10 +1,11 @@
package controller
import (
"github.com/gin-gonic/gin"
"one-api/model"
"one-api/setting"
"one-api/setting/operation_setting"
"github.com/gin-gonic/gin"
)
func GetPricing(c *gin.Context) {
@@ -20,6 +21,12 @@ func GetPricing(c *gin.Context) {
user, err := model.GetUserCache(userId.(int))
if err == nil {
group = user.Group
for g := range groupRatio {
ratio, ok := setting.GetGroupGroupRatio(group, g)
if ok {
groupRatio[g] = ratio
}
}
}
}
+30 -14
View File
@@ -224,9 +224,14 @@ func checkTaskNeedUpdate(oldTask *model.Task, newTask dto.SunoDataResponse) bool
func GetAllTask(c *gin.Context) {
p, _ := strconv.Atoi(c.Query("p"))
if p < 0 {
p = 0
if p < 1 {
p = 1
}
pageSize, _ := strconv.Atoi(c.Query("page_size"))
if pageSize <= 0 {
pageSize = common.ItemsPerPage
}
startTimestamp, _ := strconv.ParseInt(c.Query("start_timestamp"), 10, 64)
endTimestamp, _ := strconv.ParseInt(c.Query("end_timestamp"), 10, 64)
// 解析其他查询参数
@@ -237,24 +242,32 @@ func GetAllTask(c *gin.Context) {
Action: c.Query("action"),
StartTimestamp: startTimestamp,
EndTimestamp: endTimestamp,
ChannelID: c.Query("channel_id"),
}
logs := model.TaskGetAllTasks(p*common.ItemsPerPage, common.ItemsPerPage, queryParams)
if logs == nil {
logs = make([]*model.Task, 0)
}
items := model.TaskGetAllTasks((p-1)*pageSize, pageSize, queryParams)
total := model.TaskCountAllTasks(queryParams)
c.JSON(200, gin.H{
"success": true,
"message": "",
"data": logs,
"data": gin.H{
"items": items,
"total": total,
"page": p,
"page_size": pageSize,
},
})
}
func GetUserTask(c *gin.Context) {
p, _ := strconv.Atoi(c.Query("p"))
if p < 0 {
p = 0
if p < 1 {
p = 1
}
pageSize, _ := strconv.Atoi(c.Query("page_size"))
if pageSize <= 0 {
pageSize = common.ItemsPerPage
}
userId := c.GetInt("id")
@@ -271,14 +284,17 @@ func GetUserTask(c *gin.Context) {
EndTimestamp: endTimestamp,
}
logs := model.TaskGetAllUserTask(userId, p*common.ItemsPerPage, common.ItemsPerPage, queryParams)
if logs == nil {
logs = make([]*model.Task, 0)
}
items := model.TaskGetAllUserTask(userId, (p-1)*pageSize, pageSize, queryParams)
total := model.TaskCountAllUserTask(userId, queryParams)
c.JSON(200, gin.H{
"success": true,
"message": "",
"data": logs,
"data": gin.H{
"items": items,
"total": total,
"page": p,
"page_size": pageSize,
},
})
}
+12 -4
View File
@@ -12,15 +12,15 @@ func GetAllTokens(c *gin.Context) {
userId := c.GetInt("id")
p, _ := strconv.Atoi(c.Query("p"))
size, _ := strconv.Atoi(c.Query("size"))
if p < 0 {
p = 0
if p < 1 {
p = 1
}
if size <= 0 {
size = common.ItemsPerPage
} else if size > 100 {
size = 100
}
tokens, err := model.GetAllUserTokens(userId, p*size, size)
tokens, err := model.GetAllUserTokens(userId, (p-1)*size, size)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
@@ -28,10 +28,18 @@ func GetAllTokens(c *gin.Context) {
})
return
}
// Get total count for pagination
total, _ := model.CountUserTokens(userId)
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": tokens,
"data": gin.H{
"items": tokens,
"total": total,
"page": p,
"page_size": size,
},
})
return
}
+1 -1
View File
@@ -106,7 +106,7 @@ func RequestEpay(c *gin.Context) {
payType = "wxpay"
}
callBackAddress := service.GetCallbackAddress()
returnUrl, _ := url.Parse(setting.ServerAddress + "/log")
returnUrl, _ := url.Parse(setting.ServerAddress + "/console/log")
notifyUrl, _ := url.Parse(callBackAddress + "/api/user/epay/notify")
tradeNo := fmt.Sprintf("%s%d", common.GetRandomString(6), time.Now().Unix())
tradeNo = fmt.Sprintf("USR%dNO%s", id, tradeNo)
+169
View File
@@ -0,0 +1,169 @@
package controller
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"one-api/common"
"strings"
"time"
"github.com/gin-gonic/gin"
"golang.org/x/sync/errgroup"
)
type UptimeKumaMonitor struct {
ID int `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
}
type UptimeKumaGroup struct {
ID int `json:"id"`
Name string `json:"name"`
Weight int `json:"weight"`
MonitorList []UptimeKumaMonitor `json:"monitorList"`
}
type UptimeKumaHeartbeat struct {
Status int `json:"status"`
Time string `json:"time"`
Msg string `json:"msg"`
Ping *float64 `json:"ping"`
}
type UptimeKumaStatusResponse struct {
PublicGroupList []UptimeKumaGroup `json:"publicGroupList"`
}
type UptimeKumaHeartbeatResponse struct {
HeartbeatList map[string][]UptimeKumaHeartbeat `json:"heartbeatList"`
UptimeList map[string]float64 `json:"uptimeList"`
}
type MonitorStatus struct {
Name string `json:"name"`
Uptime float64 `json:"uptime"`
Status int `json:"status"`
}
var (
ErrUpstreamNon200 = errors.New("upstream non-200")
ErrTimeout = errors.New("context deadline exceeded")
)
func getAndDecode(ctx context.Context, client *http.Client, url string, dest interface{}) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return err
}
resp, err := client.Do(req)
if err != nil {
if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
return ErrTimeout
}
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return ErrUpstreamNon200
}
return json.NewDecoder(resp.Body).Decode(dest)
}
func GetUptimeKumaStatus(c *gin.Context) {
common.OptionMapRWMutex.RLock()
uptimeKumaUrl := common.OptionMap["UptimeKumaUrl"]
slug := common.OptionMap["UptimeKumaSlug"]
common.OptionMapRWMutex.RUnlock()
if uptimeKumaUrl == "" || slug == "" {
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": []MonitorStatus{},
})
return
}
uptimeKumaUrl = strings.TrimSuffix(uptimeKumaUrl, "/")
ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second)
defer cancel()
client := &http.Client{}
statusPageUrl := fmt.Sprintf("%s/api/status-page/%s", uptimeKumaUrl, slug)
heartbeatUrl := fmt.Sprintf("%s/api/status-page/heartbeat/%s", uptimeKumaUrl, slug)
var (
statusData UptimeKumaStatusResponse
heartbeatData UptimeKumaHeartbeatResponse
)
g, gCtx := errgroup.WithContext(ctx)
g.Go(func() error {
return getAndDecode(gCtx, client, statusPageUrl, &statusData)
})
g.Go(func() error {
return getAndDecode(gCtx, client, heartbeatUrl, &heartbeatData)
})
if err := g.Wait(); err != nil {
switch err {
case ErrUpstreamNon200:
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "上游接口出现问题",
})
case ErrTimeout:
c.JSON(http.StatusRequestTimeout, gin.H{
"success": false,
"message": "请求上游接口超时",
})
default:
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": err.Error(),
})
}
return
}
var monitors []MonitorStatus
for _, group := range statusData.PublicGroupList {
for _, monitor := range group.MonitorList {
monitorStatus := MonitorStatus{
Name: monitor.Name,
Uptime: 0.0,
Status: 0,
}
uptimeKey := fmt.Sprintf("%d_24", monitor.ID)
if uptime, exists := heartbeatData.UptimeList[uptimeKey]; exists {
monitorStatus.Uptime = uptime
}
heartbeatKey := fmt.Sprintf("%d", monitor.ID)
if heartbeats, exists := heartbeatData.HeartbeatList[heartbeatKey]; exists && len(heartbeats) > 0 {
latestHeartbeat := heartbeats[0]
monitorStatus.Status = latestHeartbeat.Status
}
monitors = append(monitors, monitorStatus)
}
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": monitors,
})
}
+2
View File
@@ -943,6 +943,7 @@ type UpdateUserSettingRequest struct {
WebhookSecret string `json:"webhook_secret,omitempty"`
NotificationEmail string `json:"notification_email,omitempty"`
AcceptUnsetModelRatioModel bool `json:"accept_unset_model_ratio_model"`
RecordIpLog bool `json:"record_ip_log"`
}
func UpdateUserSetting(c *gin.Context) {
@@ -1019,6 +1020,7 @@ func UpdateUserSetting(c *gin.Context) {
constant.UserSettingNotifyType: req.QuotaWarningType,
constant.UserSettingQuotaWarningThreshold: req.QuotaWarningThreshold,
"accept_unset_model_ratio_model": req.AcceptUnsetModelRatioModel,
constant.UserSettingRecordIpLog: req.RecordIpLog,
}
// 如果是webhook类型,添加webhook相关设置
+68 -37
View File
@@ -1,6 +1,9 @@
package dto
import "encoding/json"
import (
"encoding/json"
"one-api/common"
)
type ClaudeMetadata struct {
UserId string `json:"user_id"`
@@ -20,11 +23,11 @@ type ClaudeMediaMessage struct {
Delta string `json:"delta,omitempty"`
CacheControl json.RawMessage `json:"cache_control,omitempty"`
// tool_calls
Id string `json:"id,omitempty"`
Name string `json:"name,omitempty"`
Input any `json:"input,omitempty"`
Content json.RawMessage `json:"content,omitempty"`
ToolUseId string `json:"tool_use_id,omitempty"`
Id string `json:"id,omitempty"`
Name string `json:"name,omitempty"`
Input any `json:"input,omitempty"`
Content any `json:"content,omitempty"`
ToolUseId string `json:"tool_use_id,omitempty"`
}
func (c *ClaudeMediaMessage) SetText(s string) {
@@ -39,15 +42,39 @@ func (c *ClaudeMediaMessage) GetText() string {
}
func (c *ClaudeMediaMessage) IsStringContent() bool {
var content string
return json.Unmarshal(c.Content, &content) == nil
if c.Content == nil {
return false
}
_, ok := c.Content.(string)
if ok {
return true
}
return false
}
func (c *ClaudeMediaMessage) GetStringContent() string {
var content string
if err := json.Unmarshal(c.Content, &content); err == nil {
return content
if c.Content == nil {
return ""
}
switch c.Content.(type) {
case string:
return c.Content.(string)
case []any:
var contentStr string
for _, contentItem := range c.Content.([]any) {
contentMap, ok := contentItem.(map[string]any)
if !ok {
continue
}
if contentMap["type"] == ContentTypeText {
if subStr, ok := contentMap["text"].(string); ok {
contentStr += subStr
}
}
}
return contentStr
}
return ""
}
@@ -57,16 +84,12 @@ func (c *ClaudeMediaMessage) GetJsonRowString() string {
}
func (c *ClaudeMediaMessage) SetContent(content any) {
jsonContent, _ := json.Marshal(content)
c.Content = jsonContent
c.Content = content
}
func (c *ClaudeMediaMessage) ParseMediaContent() []ClaudeMediaMessage {
var mediaContent []ClaudeMediaMessage
if err := json.Unmarshal(c.Content, &mediaContent); err == nil {
return mediaContent
}
return make([]ClaudeMediaMessage, 0)
mediaContent, _ := common.Any2Type[[]ClaudeMediaMessage](c.Content)
return mediaContent
}
type ClaudeMessageSource struct {
@@ -82,14 +105,36 @@ type ClaudeMessage struct {
}
func (c *ClaudeMessage) IsStringContent() bool {
if c.Content == nil {
return false
}
_, ok := c.Content.(string)
return ok
}
func (c *ClaudeMessage) GetStringContent() string {
if c.IsStringContent() {
return c.Content.(string)
if c.Content == nil {
return ""
}
switch c.Content.(type) {
case string:
return c.Content.(string)
case []any:
var contentStr string
for _, contentItem := range c.Content.([]any) {
contentMap, ok := contentItem.(map[string]any)
if !ok {
continue
}
if contentMap["type"] == ContentTypeText {
if subStr, ok := contentMap["text"].(string); ok {
contentStr += subStr
}
}
}
return contentStr
}
return ""
}
@@ -98,15 +143,7 @@ func (c *ClaudeMessage) SetStringContent(content string) {
}
func (c *ClaudeMessage) ParseContent() ([]ClaudeMediaMessage, error) {
// map content to []ClaudeMediaMessage
// parse to json
jsonContent, _ := json.Marshal(c.Content)
var contentList []ClaudeMediaMessage
err := json.Unmarshal(jsonContent, &contentList)
if err != nil {
return make([]ClaudeMediaMessage, 0), err
}
return contentList, nil
return common.Any2Type[[]ClaudeMediaMessage](c.Content)
}
type Tool struct {
@@ -161,14 +198,8 @@ func (c *ClaudeRequest) SetStringSystem(system string) {
}
func (c *ClaudeRequest) ParseSystem() []ClaudeMediaMessage {
// map content to []ClaudeMediaMessage
// parse to json
jsonContent, _ := json.Marshal(c.System)
var contentList []ClaudeMediaMessage
if err := json.Unmarshal(jsonContent, &contentList); err == nil {
return contentList
}
return make([]ClaudeMediaMessage, 0)
mediaContent, _ := common.Any2Type[[]ClaudeMediaMessage](c.System)
return mediaContent
}
type ClaudeError struct {
+260 -51
View File
@@ -19,43 +19,43 @@ type FormatJsonSchema struct {
}
type GeneralOpenAIRequest struct {
Model string `json:"model,omitempty"`
Messages []Message `json:"messages,omitempty"`
Prompt any `json:"prompt,omitempty"`
Prefix any `json:"prefix,omitempty"`
Suffix any `json:"suffix,omitempty"`
Stream bool `json:"stream,omitempty"`
StreamOptions *StreamOptions `json:"stream_options,omitempty"`
MaxTokens uint `json:"max_tokens,omitempty"`
MaxCompletionTokens uint `json:"max_completion_tokens,omitempty"`
ReasoningEffort string `json:"reasoning_effort,omitempty"`
Temperature *float64 `json:"temperature,omitempty"`
TopP float64 `json:"top_p,omitempty"`
TopK int `json:"top_k,omitempty"`
Stop any `json:"stop,omitempty"`
N int `json:"n,omitempty"`
Input any `json:"input,omitempty"`
Instruction string `json:"instruction,omitempty"`
Size string `json:"size,omitempty"`
Functions any `json:"functions,omitempty"`
FrequencyPenalty float64 `json:"frequency_penalty,omitempty"`
PresencePenalty float64 `json:"presence_penalty,omitempty"`
ResponseFormat *ResponseFormat `json:"response_format,omitempty"`
EncodingFormat any `json:"encoding_format,omitempty"`
Seed float64 `json:"seed,omitempty"`
ParallelTooCalls *bool `json:"parallel_tool_calls,omitempty"`
Tools []ToolCallRequest `json:"tools,omitempty"`
ToolChoice any `json:"tool_choice,omitempty"`
User string `json:"user,omitempty"`
LogProbs bool `json:"logprobs,omitempty"`
TopLogProbs int `json:"top_logprobs,omitempty"`
Dimensions int `json:"dimensions,omitempty"`
Modalities any `json:"modalities,omitempty"`
Audio any `json:"audio,omitempty"`
EnableThinking any `json:"enable_thinking,omitempty"` // ali
ExtraBody any `json:"extra_body,omitempty"`
WebSearchOptions *WebSearchOptions `json:"web_search_options,omitempty"`
// OpenRouter Params
Model string `json:"model,omitempty"`
Messages []Message `json:"messages,omitempty"`
Prompt any `json:"prompt,omitempty"`
Prefix any `json:"prefix,omitempty"`
Suffix any `json:"suffix,omitempty"`
Stream bool `json:"stream,omitempty"`
StreamOptions *StreamOptions `json:"stream_options,omitempty"`
MaxTokens uint `json:"max_tokens,omitempty"`
MaxCompletionTokens uint `json:"max_completion_tokens,omitempty"`
ReasoningEffort string `json:"reasoning_effort,omitempty"`
Temperature *float64 `json:"temperature,omitempty"`
TopP float64 `json:"top_p,omitempty"`
TopK int `json:"top_k,omitempty"`
Stop any `json:"stop,omitempty"`
N int `json:"n,omitempty"`
Input any `json:"input,omitempty"`
Instruction string `json:"instruction,omitempty"`
Size string `json:"size,omitempty"`
Functions json.RawMessage `json:"functions,omitempty"`
FrequencyPenalty float64 `json:"frequency_penalty,omitempty"`
PresencePenalty float64 `json:"presence_penalty,omitempty"`
ResponseFormat *ResponseFormat `json:"response_format,omitempty"`
EncodingFormat json.RawMessage `json:"encoding_format,omitempty"`
Seed float64 `json:"seed,omitempty"`
ParallelTooCalls *bool `json:"parallel_tool_calls,omitempty"`
Tools []ToolCallRequest `json:"tools,omitempty"`
ToolChoice any `json:"tool_choice,omitempty"`
User string `json:"user,omitempty"`
LogProbs bool `json:"logprobs,omitempty"`
TopLogProbs int `json:"top_logprobs,omitempty"`
Dimensions int `json:"dimensions,omitempty"`
Modalities json.RawMessage `json:"modalities,omitempty"`
Audio json.RawMessage `json:"audio,omitempty"`
EnableThinking any `json:"enable_thinking,omitempty"` // ali
ExtraBody json.RawMessage `json:"extra_body,omitempty"`
WebSearchOptions *WebSearchOptions `json:"web_search_options,omitempty"`
// OpenRouter Params
Reasoning json.RawMessage `json:"reasoning,omitempty"`
}
@@ -107,16 +107,16 @@ func (r *GeneralOpenAIRequest) ParseInput() []string {
}
type Message struct {
Role string `json:"role"`
Content json.RawMessage `json:"content"`
Name *string `json:"name,omitempty"`
Prefix *bool `json:"prefix,omitempty"`
ReasoningContent string `json:"reasoning_content,omitempty"`
Reasoning string `json:"reasoning,omitempty"`
ToolCalls json.RawMessage `json:"tool_calls,omitempty"`
ToolCallId string `json:"tool_call_id,omitempty"`
parsedContent []MediaContent
parsedStringContent *string
Role string `json:"role"`
Content any `json:"content"`
Name *string `json:"name,omitempty"`
Prefix *bool `json:"prefix,omitempty"`
ReasoningContent string `json:"reasoning_content,omitempty"`
Reasoning string `json:"reasoning,omitempty"`
ToolCalls json.RawMessage `json:"tool_calls,omitempty"`
ToolCallId string `json:"tool_call_id,omitempty"`
parsedContent []MediaContent
//parsedStringContent *string
}
type MediaContent struct {
@@ -132,21 +132,50 @@ type MediaContent struct {
func (m *MediaContent) GetImageMedia() *MessageImageUrl {
if m.ImageUrl != nil {
return m.ImageUrl.(*MessageImageUrl)
if _, ok := m.ImageUrl.(*MessageImageUrl); ok {
return m.ImageUrl.(*MessageImageUrl)
}
if itemMap, ok := m.ImageUrl.(map[string]any); ok {
out := &MessageImageUrl{
Url: common.Interface2String(itemMap["url"]),
Detail: common.Interface2String(itemMap["detail"]),
MimeType: common.Interface2String(itemMap["mime_type"]),
}
return out
}
}
return nil
}
func (m *MediaContent) GetInputAudio() *MessageInputAudio {
if m.InputAudio != nil {
return m.InputAudio.(*MessageInputAudio)
if _, ok := m.InputAudio.(*MessageInputAudio); ok {
return m.InputAudio.(*MessageInputAudio)
}
if itemMap, ok := m.InputAudio.(map[string]any); ok {
out := &MessageInputAudio{
Data: common.Interface2String(itemMap["data"]),
Format: common.Interface2String(itemMap["format"]),
}
return out
}
}
return nil
}
func (m *MediaContent) GetFile() *MessageFile {
if m.File != nil {
return m.File.(*MessageFile)
if _, ok := m.File.(*MessageFile); ok {
return m.File.(*MessageFile)
}
if itemMap, ok := m.File.(map[string]any); ok {
out := &MessageFile{
FileName: common.Interface2String(itemMap["file_name"]),
FileData: common.Interface2String(itemMap["file_data"]),
FileId: common.Interface2String(itemMap["file_id"]),
}
return out
}
}
return nil
}
@@ -212,6 +241,186 @@ func (m *Message) SetToolCalls(toolCalls any) {
}
func (m *Message) StringContent() string {
switch m.Content.(type) {
case string:
return m.Content.(string)
case []any:
var contentStr string
for _, contentItem := range m.Content.([]any) {
contentMap, ok := contentItem.(map[string]any)
if !ok {
continue
}
if contentMap["type"] == ContentTypeText {
if subStr, ok := contentMap["text"].(string); ok {
contentStr += subStr
}
}
}
return contentStr
}
return ""
}
func (m *Message) SetNullContent() {
m.Content = nil
m.parsedContent = nil
}
func (m *Message) SetStringContent(content string) {
m.Content = content
m.parsedContent = nil
}
func (m *Message) SetMediaContent(content []MediaContent) {
m.Content = content
m.parsedContent = content
}
func (m *Message) IsStringContent() bool {
_, ok := m.Content.(string)
if ok {
return true
}
return false
}
func (m *Message) ParseContent() []MediaContent {
if m.Content == nil {
return nil
}
if len(m.parsedContent) > 0 {
return m.parsedContent
}
var contentList []MediaContent
// 先尝试解析为字符串
content, ok := m.Content.(string)
if ok {
contentList = []MediaContent{{
Type: ContentTypeText,
Text: content,
}}
m.parsedContent = contentList
return contentList
}
// 尝试解析为数组
//var arrayContent []map[string]interface{}
arrayContent, ok := m.Content.([]any)
if !ok {
return contentList
}
for _, contentItemAny := range arrayContent {
mediaItem, ok := contentItemAny.(MediaContent)
if ok {
contentList = append(contentList, mediaItem)
continue
}
contentItem, ok := contentItemAny.(map[string]any)
if !ok {
continue
}
contentType, ok := contentItem["type"].(string)
if !ok {
continue
}
switch contentType {
case ContentTypeText:
if text, ok := contentItem["text"].(string); ok {
contentList = append(contentList, MediaContent{
Type: ContentTypeText,
Text: text,
})
}
case ContentTypeImageURL:
imageUrl := contentItem["image_url"]
temp := &MessageImageUrl{
Detail: "high",
}
switch v := imageUrl.(type) {
case string:
temp.Url = v
case map[string]interface{}:
url, ok1 := v["url"].(string)
detail, ok2 := v["detail"].(string)
if ok2 {
temp.Detail = detail
}
if ok1 {
temp.Url = url
}
}
contentList = append(contentList, MediaContent{
Type: ContentTypeImageURL,
ImageUrl: temp,
})
case ContentTypeInputAudio:
if audioData, ok := contentItem["input_audio"].(map[string]interface{}); ok {
data, ok1 := audioData["data"].(string)
format, ok2 := audioData["format"].(string)
if ok1 && ok2 {
temp := &MessageInputAudio{
Data: data,
Format: format,
}
contentList = append(contentList, MediaContent{
Type: ContentTypeInputAudio,
InputAudio: temp,
})
}
}
case ContentTypeFile:
if fileData, ok := contentItem["file"].(map[string]interface{}); ok {
fileId, ok3 := fileData["file_id"].(string)
if ok3 {
contentList = append(contentList, MediaContent{
Type: ContentTypeFile,
File: &MessageFile{
FileId: fileId,
},
})
} else {
fileName, ok1 := fileData["filename"].(string)
fileDataStr, ok2 := fileData["file_data"].(string)
if ok1 && ok2 {
contentList = append(contentList, MediaContent{
Type: ContentTypeFile,
File: &MessageFile{
FileName: fileName,
FileData: fileDataStr,
},
})
}
}
}
case ContentTypeVideoUrl:
if videoUrl, ok := contentItem["video_url"].(string); ok {
contentList = append(contentList, MediaContent{
Type: ContentTypeVideoUrl,
VideoUrl: &MessageVideoUrl{
Url: videoUrl,
},
})
}
}
}
if len(contentList) > 0 {
m.parsedContent = contentList
}
return contentList
}
// old code
/*func (m *Message) StringContent() string {
if m.parsedStringContent != nil {
return *m.parsedStringContent
}
@@ -382,7 +591,7 @@ func (m *Message) ParseContent() []MediaContent {
m.parsedContent = contentList
}
return contentList
}
}*/
type WebSearchOptions struct {
SearchContextSize string `json:"search_context_size,omitempty"`
+3 -3
View File
@@ -11,7 +11,6 @@ require (
github.com/aws/aws-sdk-go-v2/credentials v1.17.11
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.7.4
github.com/bytedance/gopkg v0.0.0-20220118071334-3db87571198b
github.com/bytedance/sonic v1.11.6
github.com/gin-contrib/cors v1.7.2
github.com/gin-contrib/gzip v0.0.6
github.com/gin-contrib/sessions v0.0.5
@@ -25,10 +24,10 @@ require (
github.com/gorilla/websocket v1.5.0
github.com/joho/godotenv v1.5.1
github.com/pkg/errors v0.9.1
github.com/pkoukk/tiktoken-go v0.1.7
github.com/samber/lo v1.39.0
github.com/shirou/gopsutil v3.21.11+incompatible
github.com/shopspring/decimal v1.4.0
github.com/tiktoken-go/tokenizer v0.6.2
golang.org/x/crypto v0.35.0
golang.org/x/image v0.23.0
golang.org/x/net v0.35.0
@@ -43,12 +42,13 @@ require (
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.5 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.5 // indirect
github.com/aws/smithy-go v1.20.2 // indirect
github.com/bytedance/sonic v1.11.6 // indirect
github.com/bytedance/sonic/loader v0.1.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudwego/base64x v0.1.4 // indirect
github.com/cloudwego/iasm v0.2.0 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/dlclark/regexp2 v1.11.0 // indirect
github.com/dlclark/regexp2 v1.11.5 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
+4 -4
View File
@@ -38,8 +38,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=
@@ -167,8 +167,6 @@ github.com/pelletier/go-toml/v2 v2.2.1/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkoukk/tiktoken-go v0.1.7 h1:qOBHXX4PHtvIvmOtyg1EeKlwFRiMKAcoMp4Q+bLQDmw=
github.com/pkoukk/tiktoken-go v0.1.7/go.mod h1:9NiV+i9mJKGj1rYOT+njbv+ZwA/zJxYdewGl6qVatpg=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
@@ -197,6 +195,8 @@ github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tiktoken-go/tokenizer v0.6.2 h1:t0GN2DvcUZSFWT/62YOgoqb10y7gSXBGs0A+4VCQK+g=
github.com/tiktoken-go/tokenizer v0.6.2/go.mod h1:6UCYI/DtOallbmL7sSy30p6YQv60qNyU/4aVigPOx6w=
github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU=
github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk=
+1 -1
View File
@@ -7,7 +7,7 @@ all: build-frontend start-backend
build-frontend:
@echo "Building frontend..."
@cd $(FRONTEND_DIR) && npm install && DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$(cat VERSION) npm run build
@cd $(FRONTEND_DIR) && bun install && DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$(cat VERSION) bun run build
start-backend:
@echo "Starting backend dev server..."
+41
View File
@@ -0,0 +1,41 @@
package middleware
import (
"sync/atomic"
"github.com/gin-gonic/gin"
)
// HTTPStats 存储HTTP统计信息
type HTTPStats struct {
activeConnections int64
}
var globalStats = &HTTPStats{}
// StatsMiddleware 统计中间件
func StatsMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
// 增加活跃连接数
atomic.AddInt64(&globalStats.activeConnections, 1)
// 确保在请求结束时减少连接数
defer func() {
atomic.AddInt64(&globalStats.activeConnections, -1)
}()
c.Next()
}
}
// StatsInfo 统计信息结构
type StatsInfo struct {
ActiveConnections int64 `json:"active_connections"`
}
// GetStats 获取统计信息
func GetStats() StatsInfo {
return StatsInfo{
ActiveConnections: atomic.LoadInt64(&globalStats.activeConnections),
}
}
+14
View File
@@ -583,3 +583,17 @@ func BatchSetChannelTag(ids []int, tag *string) error {
// 提交事务
return tx.Commit().Error
}
// CountAllChannels returns total channels in DB
func CountAllChannels() (int64, error) {
var total int64
err := DB.Model(&Channel{}).Count(&total).Error
return total, err
}
// CountAllTags returns number of non-empty distinct tags
func CountAllTags() (int64, error) {
var total int64
err := DB.Model(&Channel{}).Where("tag is not null AND tag != ''").Distinct("tag").Count(&total).Error
return total, err
}
+22
View File
@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"one-api/common"
"one-api/constant"
"os"
"strings"
"time"
@@ -32,6 +33,7 @@ type Log struct {
ChannelName string `json:"channel_name" gorm:"->"`
TokenId int `json:"token_id" gorm:"default:0;index"`
Group string `json:"group" gorm:"index"`
Ip string `json:"ip" gorm:"index;default:''"`
Other string `json:"other"`
}
@@ -95,6 +97,15 @@ func RecordErrorLog(c *gin.Context, userId int, channelId int, modelName string,
common.LogInfo(c, fmt.Sprintf("record error log: userId=%d, channelId=%d, modelName=%s, tokenName=%s, content=%s", userId, channelId, modelName, tokenName, content))
username := c.GetString("username")
otherStr := common.MapToJsonStr(other)
// 判断是否需要记录 IP
needRecordIp := false
if settingMap, err := GetUserSetting(userId, false); err == nil {
if v, ok := settingMap[constant.UserSettingRecordIpLog]; ok {
if vb, ok := v.(bool); ok && vb {
needRecordIp = true
}
}
}
log := &Log{
UserId: userId,
Username: username,
@@ -111,6 +122,7 @@ func RecordErrorLog(c *gin.Context, userId int, channelId int, modelName string,
UseTime: useTimeSeconds,
IsStream: isStream,
Group: group,
Ip: func() string { if needRecordIp { return c.ClientIP() }; return "" }(),
Other: otherStr,
}
err := LOG_DB.Create(log).Error
@@ -128,6 +140,15 @@ func RecordConsumeLog(c *gin.Context, userId int, channelId int, promptTokens in
}
username := c.GetString("username")
otherStr := common.MapToJsonStr(other)
// 判断是否需要记录 IP
needRecordIp := false
if settingMap, err := GetUserSetting(userId, false); err == nil {
if v, ok := settingMap[constant.UserSettingRecordIpLog]; ok {
if vb, ok := v.(bool); ok && vb {
needRecordIp = true
}
}
}
log := &Log{
UserId: userId,
Username: username,
@@ -144,6 +165,7 @@ func RecordConsumeLog(c *gin.Context, userId int, channelId int, promptTokens in
UseTime: useTimeSeconds,
IsStream: isStream,
Group: group,
Ip: func() string { if needRecordIp { return c.ClientIP() }; return "" }(),
Other: otherStr,
}
err := LOG_DB.Create(log).Error
+37
View File
@@ -166,3 +166,40 @@ func MjBulkUpdateByTaskIds(taskIDs []int, params map[string]any) error {
Where("id in (?)", taskIDs).
Updates(params).Error
}
// CountAllTasks returns total midjourney tasks for admin query
func CountAllTasks(queryParams TaskQueryParams) int64 {
var total int64
query := DB.Model(&Midjourney{})
if queryParams.ChannelID != "" {
query = query.Where("channel_id = ?", queryParams.ChannelID)
}
if queryParams.MjID != "" {
query = query.Where("mj_id = ?", queryParams.MjID)
}
if queryParams.StartTimestamp != "" {
query = query.Where("submit_time >= ?", queryParams.StartTimestamp)
}
if queryParams.EndTimestamp != "" {
query = query.Where("submit_time <= ?", queryParams.EndTimestamp)
}
_ = query.Count(&total).Error
return total
}
// CountAllUserTask returns total midjourney tasks for user
func CountAllUserTask(userId int, queryParams TaskQueryParams) int64 {
var total int64
query := DB.Model(&Midjourney{}).Where("user_id = ?", userId)
if queryParams.MjID != "" {
query = query.Where("mj_id = ?", queryParams.MjID)
}
if queryParams.StartTimestamp != "" {
query = query.Where("submit_time >= ?", queryParams.StartTimestamp)
}
if queryParams.EndTimestamp != "" {
query = query.Where("submit_time <= ?", queryParams.EndTimestamp)
}
_ = query.Count(&total).Error
return total
}
+6
View File
@@ -98,6 +98,7 @@ func InitOptionMap() {
common.OptionMap["ModelPrice"] = operation_setting.ModelPrice2JSONString()
common.OptionMap["CacheRatio"] = operation_setting.CacheRatio2JSONString()
common.OptionMap["GroupRatio"] = setting.GroupRatio2JSONString()
common.OptionMap["GroupGroupRatio"] = setting.GroupGroupRatio2JSONString()
common.OptionMap["UserUsableGroups"] = setting.UserUsableGroups2JSONString()
common.OptionMap["CompletionRatio"] = operation_setting.CompletionRatio2JSONString()
common.OptionMap["TopUpLink"] = common.TopUpLink
@@ -122,6 +123,9 @@ func InitOptionMap() {
common.OptionMap["SensitiveWords"] = setting.SensitiveWordsToString()
common.OptionMap["StreamCacheQueueLength"] = strconv.Itoa(setting.StreamCacheQueueLength)
common.OptionMap["AutomaticDisableKeywords"] = operation_setting.AutomaticDisableKeywordsToString()
common.OptionMap["ApiInfo"] = ""
common.OptionMap["UptimeKumaUrl"] = ""
common.OptionMap["UptimeKumaSlug"] = ""
// 自动添加所有注册的模型配置
modelConfigs := config.GlobalConfig.ExportAllConfigs()
@@ -354,6 +358,8 @@ func updateOptionMap(key string, value string) (err error) {
err = operation_setting.UpdateModelRatioByJSONString(value)
case "GroupRatio":
err = setting.UpdateGroupRatioByJSONString(value)
case "GroupGroupRatio":
err = setting.UpdateGroupGroupRatioByJSONString(value)
case "UserUsableGroups":
err = setting.UpdateUserUsableGroupsByJSONString(value)
case "CompletionRatio":
+61
View File
@@ -302,3 +302,64 @@ func SumUsedTaskQuota(queryParams SyncTaskQueryParams) (stat []TaskQuotaUsage, e
err = query.Select("mode, sum(quota) as count").Group("mode").Find(&stat).Error
return stat, err
}
// TaskCountAllTasks returns total tasks that match the given query params (admin usage)
func TaskCountAllTasks(queryParams SyncTaskQueryParams) int64 {
var total int64
query := DB.Model(&Task{})
if queryParams.ChannelID != "" {
query = query.Where("channel_id = ?", queryParams.ChannelID)
}
if queryParams.Platform != "" {
query = query.Where("platform = ?", queryParams.Platform)
}
if queryParams.UserID != "" {
query = query.Where("user_id = ?", queryParams.UserID)
}
if len(queryParams.UserIDs) != 0 {
query = query.Where("user_id in (?)", queryParams.UserIDs)
}
if queryParams.TaskID != "" {
query = query.Where("task_id = ?", queryParams.TaskID)
}
if queryParams.Action != "" {
query = query.Where("action = ?", queryParams.Action)
}
if queryParams.Status != "" {
query = query.Where("status = ?", queryParams.Status)
}
if queryParams.StartTimestamp != 0 {
query = query.Where("submit_time >= ?", queryParams.StartTimestamp)
}
if queryParams.EndTimestamp != 0 {
query = query.Where("submit_time <= ?", queryParams.EndTimestamp)
}
_ = query.Count(&total).Error
return total
}
// TaskCountAllUserTask returns total tasks for given user
func TaskCountAllUserTask(userId int, queryParams SyncTaskQueryParams) int64 {
var total int64
query := DB.Model(&Task{}).Where("user_id = ?", userId)
if queryParams.TaskID != "" {
query = query.Where("task_id = ?", queryParams.TaskID)
}
if queryParams.Action != "" {
query = query.Where("action = ?", queryParams.Action)
}
if queryParams.Status != "" {
query = query.Where("status = ?", queryParams.Status)
}
if queryParams.Platform != "" {
query = query.Where("platform = ?", queryParams.Platform)
}
if queryParams.StartTimestamp != 0 {
query = query.Where("submit_time >= ?", queryParams.StartTimestamp)
}
if queryParams.EndTimestamp != 0 {
query = query.Where("submit_time <= ?", queryParams.EndTimestamp)
}
_ = query.Count(&total).Error
return total
}
+7
View File
@@ -320,3 +320,10 @@ func decreaseTokenQuota(id int, quota int) (err error) {
).Error
return err
}
// CountUserTokens returns total number of tokens for the given user, used for pagination
func CountUserTokens(userId int) (int64, error) {
var total int64
err := DB.Model(&Token{}).Where("user_id = ?", userId).Count(&total).Error
return total, err
}
+1 -1
View File
@@ -19,7 +19,7 @@ func cacheSetToken(token Token) error {
func cacheDeleteToken(key string) error {
key = common.GenerateHMAC(key)
err := common.RedisHDelObj(fmt.Sprintf("token:%s", key))
err := common.RedisDelKey(fmt.Sprintf("token:%s", key))
if err != nil {
return err
}
+3 -2
View File
@@ -3,11 +3,12 @@ package model
import (
"encoding/json"
"fmt"
"github.com/gin-gonic/gin"
"one-api/common"
"one-api/constant"
"time"
"github.com/gin-gonic/gin"
"github.com/bytedance/gopkg/util/gopool"
)
@@ -57,7 +58,7 @@ func invalidateUserCache(userId int) error {
if !common.RedisEnabled {
return nil
}
return common.RedisHDelObj(getUserCacheKey(userId))
return common.RedisDelKey(getUserCacheKey(userId))
}
// updateUserCache updates all user cache fields using hash
+5 -1
View File
@@ -31,6 +31,8 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
switch info.RelayMode {
case constant.RelayModeEmbeddings:
fullRequestURL = fmt.Sprintf("%s/api/v1/services/embeddings/text-embedding/text-embedding", info.BaseUrl)
case constant.RelayModeRerank:
fullRequestURL = fmt.Sprintf("%s/api/v1/services/rerank/text-rerank/text-rerank", info.BaseUrl)
case constant.RelayModeImagesGenerations:
fullRequestURL = fmt.Sprintf("%s/api/v1/services/aigc/text2image/image-synthesis", info.BaseUrl)
case constant.RelayModeCompletions:
@@ -76,7 +78,7 @@ func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInf
}
func (a *Adaptor) ConvertRerankRequest(c *gin.Context, relayMode int, request dto.RerankRequest) (any, error) {
return nil, errors.New("not implemented")
return ConvertRerankRequest(request), nil
}
func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.EmbeddingRequest) (any, error) {
@@ -103,6 +105,8 @@ func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycom
err, usage = aliImageHandler(c, resp, info)
case constant.RelayModeEmbeddings:
err, usage = aliEmbeddingHandler(c, resp)
case constant.RelayModeRerank:
err, usage = RerankHandler(c, resp, info)
default:
if info.IsStream {
err, usage = openai.OaiStreamHandler(c, resp, info)
+1
View File
@@ -8,6 +8,7 @@ var ModelList = []string{
"qwq-32b",
"qwen3-235b-a22b",
"text-embedding-v1",
"gte-rerank-v2",
}
var ChannelName = "ali"
+27
View File
@@ -1,5 +1,7 @@
package ali
import "one-api/dto"
type AliMessage struct {
Content string `json:"content"`
Role string `json:"role"`
@@ -97,3 +99,28 @@ type AliImageRequest struct {
} `json:"parameters,omitempty"`
ResponseFormat string `json:"response_format,omitempty"`
}
type AliRerankParameters struct {
TopN *int `json:"top_n,omitempty"`
ReturnDocuments *bool `json:"return_documents,omitempty"`
}
type AliRerankInput struct {
Query string `json:"query"`
Documents []any `json:"documents"`
}
type AliRerankRequest struct {
Model string `json:"model"`
Input AliRerankInput `json:"input"`
Parameters AliRerankParameters `json:"parameters,omitempty"`
}
type AliRerankResponse struct {
Output struct {
Results []dto.RerankResponseResult `json:"results"`
} `json:"output"`
Usage AliUsage `json:"usage"`
RequestId string `json:"request_id"`
AliError
}
+83
View File
@@ -0,0 +1,83 @@
package ali
import (
"encoding/json"
"io"
"net/http"
"one-api/dto"
relaycommon "one-api/relay/common"
"one-api/service"
"github.com/gin-gonic/gin"
)
func ConvertRerankRequest(request dto.RerankRequest) *AliRerankRequest {
returnDocuments := request.ReturnDocuments
if returnDocuments == nil {
t := true
returnDocuments = &t
}
return &AliRerankRequest{
Model: request.Model,
Input: AliRerankInput{
Query: request.Query,
Documents: request.Documents,
},
Parameters: AliRerankParameters{
TopN: &request.TopN,
ReturnDocuments: returnDocuments,
},
}
}
func RerankHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (*dto.OpenAIErrorWithStatusCode, *dto.Usage) {
responseBody, err := io.ReadAll(resp.Body)
if err != nil {
return service.OpenAIErrorWrapper(err, "read_response_body_failed", http.StatusInternalServerError), nil
}
err = resp.Body.Close()
if err != nil {
return service.OpenAIErrorWrapper(err, "close_response_body_failed", http.StatusInternalServerError), nil
}
var aliResponse AliRerankResponse
err = json.Unmarshal(responseBody, &aliResponse)
if err != nil {
return service.OpenAIErrorWrapper(err, "unmarshal_response_body_failed", http.StatusInternalServerError), nil
}
if aliResponse.Code != "" {
return &dto.OpenAIErrorWithStatusCode{
Error: dto.OpenAIError{
Message: aliResponse.Message,
Type: aliResponse.Code,
Param: aliResponse.RequestId,
Code: aliResponse.Code,
},
StatusCode: resp.StatusCode,
}, nil
}
usage := dto.Usage{
PromptTokens: aliResponse.Usage.TotalTokens,
CompletionTokens: 0,
TotalTokens: aliResponse.Usage.TotalTokens,
}
rerankResponse := dto.RerankResponse{
Results: aliResponse.Output.Results,
Usage: usage,
}
jsonResponse, err := json.Marshal(rerankResponse)
if err != nil {
return service.OpenAIErrorWrapper(err, "marshal_response_body_failed", http.StatusInternalServerError), nil
}
c.Writer.Header().Set("Content-Type", "application/json")
c.Writer.WriteHeader(resp.StatusCode)
_, err = c.Writer.Write(jsonResponse)
if err != nil {
return service.OpenAIErrorWrapper(err, "write_response_body_failed", http.StatusInternalServerError), nil
}
return nil, &usage
}
+10 -9
View File
@@ -3,7 +3,6 @@ package ali
import (
"bufio"
"encoding/json"
"github.com/gin-gonic/gin"
"io"
"net/http"
"one-api/common"
@@ -11,6 +10,8 @@ import (
"one-api/relay/helper"
"one-api/service"
"strings"
"github.com/gin-gonic/gin"
)
// https://help.aliyun.com/document_detail/613695.html?spm=a2c4g.2399480.0.0.1adb778fAdzP9w#341800c0f8w0r
@@ -27,9 +28,6 @@ func requestOpenAI2Ali(request dto.GeneralOpenAIRequest) *dto.GeneralOpenAIReque
}
func embeddingRequestOpenAI2Ali(request dto.EmbeddingRequest) *AliEmbeddingRequest {
if request.Model == "" {
request.Model = "text-embedding-v1"
}
return &AliEmbeddingRequest{
Model: request.Model,
Input: struct {
@@ -64,7 +62,11 @@ func aliEmbeddingHandler(c *gin.Context, resp *http.Response) (*dto.OpenAIErrorW
}, nil
}
fullTextResponse := embeddingResponseAli2OpenAI(&aliResponse)
model := c.GetString("model")
if model == "" {
model = "text-embedding-v4"
}
fullTextResponse := embeddingResponseAli2OpenAI(&aliResponse, model)
jsonResponse, err := json.Marshal(fullTextResponse)
if err != nil {
return service.OpenAIErrorWrapper(err, "marshal_response_body_failed", http.StatusInternalServerError), nil
@@ -75,11 +77,11 @@ func aliEmbeddingHandler(c *gin.Context, resp *http.Response) (*dto.OpenAIErrorW
return nil, &fullTextResponse.Usage
}
func embeddingResponseAli2OpenAI(response *AliEmbeddingResponse) *dto.OpenAIEmbeddingResponse {
func embeddingResponseAli2OpenAI(response *AliEmbeddingResponse, model string) *dto.OpenAIEmbeddingResponse {
openAIEmbeddingResponse := dto.OpenAIEmbeddingResponse{
Object: "list",
Data: make([]dto.OpenAIEmbeddingResponseItem, 0, len(response.Output.Embeddings)),
Model: "text-embedding-v1",
Model: model,
Usage: dto.Usage{TotalTokens: response.Usage.TotalTokens},
}
@@ -94,12 +96,11 @@ func embeddingResponseAli2OpenAI(response *AliEmbeddingResponse) *dto.OpenAIEmbe
}
func responseAli2OpenAI(response *AliResponse) *dto.OpenAITextResponse {
content, _ := json.Marshal(response.Output.Text)
choice := dto.OpenAITextResponseChoice{
Index: 0,
Message: dto.Message{
Role: "assistant",
Content: content,
Content: response.Output.Text,
},
FinishReason: response.Output.FinishReason,
}
+63 -15
View File
@@ -109,6 +109,12 @@ func startPingKeepAlive(c *gin.Context, pingInterval time.Duration) context.Canc
gopool.Go(func() {
defer func() {
// 增加panic恢复处理
if r := recover(); r != nil {
if common2.DebugEnabled {
println("SSE ping goroutine panic recovered:", fmt.Sprintf("%v", r))
}
}
if common2.DebugEnabled {
println("SSE ping goroutine stopped.")
}
@@ -119,19 +125,32 @@ func startPingKeepAlive(c *gin.Context, pingInterval time.Duration) context.Canc
}
ticker := time.NewTicker(pingInterval)
// 退出时清理 ticker
defer ticker.Stop()
// 确保在任何情况下都清理ticker
defer func() {
ticker.Stop()
if common2.DebugEnabled {
println("SSE ping ticker stopped")
}
}()
var pingMutex sync.Mutex
if common2.DebugEnabled {
println("SSE ping goroutine started")
}
// 增加超时控制,防止goroutine长时间运行
maxPingDuration := 120 * time.Minute // 最大ping持续时间
pingTimeout := time.NewTimer(maxPingDuration)
defer pingTimeout.Stop()
for {
select {
// 发送 ping 数据
case <-ticker.C:
if err := sendPingData(c, &pingMutex); err != nil {
if common2.DebugEnabled {
println("SSE ping error, stopping goroutine:", err.Error())
}
return
}
// 收到退出信号
@@ -140,6 +159,12 @@ func startPingKeepAlive(c *gin.Context, pingInterval time.Duration) context.Canc
// request 结束
case <-c.Request.Context().Done():
return
// 超时保护,防止goroutine无限运行
case <-pingTimeout.C:
if common2.DebugEnabled {
println("SSE ping goroutine timeout, stopping")
}
return
}
}
})
@@ -148,19 +173,34 @@ func startPingKeepAlive(c *gin.Context, pingInterval time.Duration) context.Canc
}
func sendPingData(c *gin.Context, mutex *sync.Mutex) error {
mutex.Lock()
defer mutex.Unlock()
// 增加超时控制,防止锁死等待
done := make(chan error, 1)
go func() {
mutex.Lock()
defer mutex.Unlock()
err := helper.PingData(c)
if err != nil {
common2.LogError(c, "SSE ping error: "+err.Error())
err := helper.PingData(c)
if err != nil {
common2.LogError(c, "SSE ping error: "+err.Error())
done <- err
return
}
if common2.DebugEnabled {
println("SSE ping data sent.")
}
done <- nil
}()
// 设置发送ping数据的超时时间
select {
case err := <-done:
return err
case <-time.After(10 * time.Second):
return errors.New("SSE ping data send timeout")
case <-c.Request.Context().Done():
return errors.New("request context cancelled during ping")
}
if common2.DebugEnabled {
println("SSE ping data sent.")
}
return nil
}
func doRequest(c *gin.Context, req *http.Request, info *common.RelayInfo) (*http.Response, error) {
@@ -175,15 +215,23 @@ func doRequest(c *gin.Context, req *http.Request, info *common.RelayInfo) (*http
client = service.GetHttpClient()
}
var stopPinger context.CancelFunc
if info.IsStream {
helper.SetEventStreamHeaders(c)
// 处理流式请求的 ping 保活
generalSettings := operation_setting.GetGeneralSetting()
if generalSettings.PingIntervalEnabled {
pingInterval := time.Duration(generalSettings.PingIntervalSeconds) * time.Second
stopPinger := startPingKeepAlive(c, pingInterval)
defer stopPinger()
stopPinger = startPingKeepAlive(c, pingInterval)
// 使用defer确保在任何情况下都能停止ping goroutine
defer func() {
if stopPinger != nil {
stopPinger()
if common2.DebugEnabled {
println("SSE ping goroutine stopped by defer")
}
}
}()
}
}
+1 -2
View File
@@ -53,12 +53,11 @@ func requestOpenAI2Baidu(request dto.GeneralOpenAIRequest) *BaiduChatRequest {
}
func responseBaidu2OpenAI(response *BaiduChatResponse) *dto.OpenAITextResponse {
content, _ := json.Marshal(response.Result)
choice := dto.OpenAITextResponseChoice{
Index: 0,
Message: dto.Message{
Role: "assistant",
Content: content,
Content: response.Result,
},
FinishReason: "stop",
}
+5 -8
View File
@@ -48,9 +48,9 @@ func RequestOpenAI2ClaudeComplete(textRequest dto.GeneralOpenAIRequest) *dto.Cla
prompt := ""
for _, message := range textRequest.Messages {
if message.Role == "user" {
prompt += fmt.Sprintf("\n\nHuman: %s", message.Content)
prompt += fmt.Sprintf("\n\nHuman: %s", message.StringContent())
} else if message.Role == "assistant" {
prompt += fmt.Sprintf("\n\nAssistant: %s", message.Content)
prompt += fmt.Sprintf("\n\nAssistant: %s", message.StringContent())
} else if message.Role == "system" {
if prompt == "" {
prompt = message.StringContent()
@@ -155,15 +155,13 @@ func RequestOpenAI2ClaudeMessage(textRequest dto.GeneralOpenAIRequest) (*dto.Cla
}
if lastMessage.Role == message.Role && lastMessage.Role != "tool" {
if lastMessage.IsStringContent() && message.IsStringContent() {
content, _ := json.Marshal(strings.Trim(fmt.Sprintf("%s %s", lastMessage.StringContent(), message.StringContent()), "\""))
fmtMessage.Content = content
fmtMessage.SetStringContent(strings.Trim(fmt.Sprintf("%s %s", lastMessage.StringContent(), message.StringContent()), "\""))
// delete last message
formatMessages = formatMessages[:len(formatMessages)-1]
}
}
if fmtMessage.Content == nil {
content, _ := json.Marshal("...")
fmtMessage.Content = content
fmtMessage.SetStringContent("...")
}
formatMessages = append(formatMessages, fmtMessage)
lastMessage = fmtMessage
@@ -397,12 +395,11 @@ func ResponseClaude2OpenAI(reqMode int, claudeResponse *dto.ClaudeResponse) *dto
thinkingContent := ""
if reqMode == RequestModeCompletion {
content, _ := json.Marshal(strings.TrimPrefix(claudeResponse.Completion, " "))
choice := dto.OpenAITextResponseChoice{
Index: 0,
Message: dto.Message{
Role: "assistant",
Content: content,
Content: strings.TrimPrefix(claudeResponse.Completion, " "),
Name: nil,
},
FinishReason: stopReasonClaude2OpenAI(claudeResponse.StopReason),
+1 -2
View File
@@ -195,11 +195,10 @@ func cohereHandler(c *gin.Context, resp *http.Response, modelName string, prompt
openaiResp.Model = modelName
openaiResp.Usage = usage
content, _ := json.Marshal(cohereResp.Text)
openaiResp.Choices = []dto.OpenAITextResponseChoice{
{
Index: 0,
Message: dto.Message{Content: content, Role: "assistant"},
Message: dto.Message{Content: cohereResp.Text, Role: "assistant"},
FinishReason: stopReasonCohere2OpenAI(cohereResp.FinishReason),
},
}
+1 -1
View File
@@ -10,7 +10,7 @@ type CozeError struct {
type CozeEnterMessage struct {
Role string `json:"role"`
Type string `json:"type,omitempty"`
Content json.RawMessage `json:"content,omitempty"`
Content any `json:"content,omitempty"`
MetaData json.RawMessage `json:"meta_data,omitempty"`
ContentType string `json:"content_type,omitempty"`
}
+1 -2
View File
@@ -278,12 +278,11 @@ func difyHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayInf
Created: common.GetTimestamp(),
Usage: difyResponse.MetaData.Usage,
}
content, _ := json.Marshal(difyResponse.Answer)
choice := dto.OpenAITextResponseChoice{
Index: 0,
Message: dto.Message{
Role: "assistant",
Content: content,
Content: difyResponse.Answer,
},
FinishReason: "stop",
}
+53
View File
@@ -1,5 +1,7 @@
package gemini
import "encoding/json"
type GeminiChatRequest struct {
Contents []GeminiChatContent `json:"contents"`
SafetySettings []GeminiChatSafetySettings `json:"safetySettings,omitempty"`
@@ -22,6 +24,30 @@ type GeminiInlineData struct {
Data string `json:"data"`
}
// UnmarshalJSON custom unmarshaler for GeminiInlineData to support snake_case and camelCase for MimeType
func (g *GeminiInlineData) UnmarshalJSON(data []byte) error {
type Alias GeminiInlineData // Use type alias to avoid recursion
var aux struct {
Alias
MimeTypeSnake string `json:"mime_type"`
}
if err := json.Unmarshal(data, &aux); err != nil {
return err
}
*g = GeminiInlineData(aux.Alias) // Copy other fields if any in future
// Prioritize snake_case if present
if aux.MimeTypeSnake != "" {
g.MimeType = aux.MimeTypeSnake
} else if aux.MimeType != "" { // Fallback to camelCase from Alias
g.MimeType = aux.MimeType
}
// g.Data would be populated by aux.Alias.Data
return nil
}
type FunctionCall struct {
FunctionName string `json:"name"`
Arguments any `json:"args"`
@@ -58,6 +84,33 @@ type GeminiPart struct {
CodeExecutionResult *GeminiPartCodeExecutionResult `json:"codeExecutionResult,omitempty"`
}
// UnmarshalJSON custom unmarshaler for GeminiPart to support snake_case and camelCase for InlineData
func (p *GeminiPart) UnmarshalJSON(data []byte) error {
// Alias to avoid recursion during unmarshalling
type Alias GeminiPart
var aux struct {
Alias
InlineDataSnake *GeminiInlineData `json:"inline_data,omitempty"` // snake_case variant
}
if err := json.Unmarshal(data, &aux); err != nil {
return err
}
// Assign fields from alias
*p = GeminiPart(aux.Alias)
// Prioritize snake_case for InlineData if present
if aux.InlineDataSnake != nil {
p.InlineData = aux.InlineDataSnake
} else if aux.InlineData != nil { // Fallback to camelCase from Alias
p.InlineData = aux.InlineData
}
// Other fields like Text, FunctionCall etc. are already populated via aux.Alias
return nil
}
type GeminiChatContent struct {
Role string `json:"role,omitempty"`
Parts []GeminiPart `json:"parts"`
+17 -9
View File
@@ -175,12 +175,6 @@ func CovertGemini2OpenAI(textRequest dto.GeneralOpenAIRequest, info *relaycommon
// common.SysLog("tools: " + fmt.Sprintf("%+v", geminiRequest.Tools))
// json_data, _ := json.Marshal(geminiRequest.Tools)
// common.SysLog("tools_json: " + string(json_data))
} else if textRequest.Functions != nil {
//geminiRequest.Tools = []GeminiChatTool{
// {
// FunctionDeclarations: textRequest.Functions,
// },
//}
}
if textRequest.ResponseFormat != nil && (textRequest.ResponseFormat.Type == "json_schema" || textRequest.ResponseFormat.Type == "json_object") {
@@ -211,7 +205,22 @@ func CovertGemini2OpenAI(textRequest dto.GeneralOpenAIRequest, info *relaycommon
} else if val, exists := tool_call_ids[message.ToolCallId]; exists {
name = val
}
contentMap := common.StrToMap(message.StringContent())
var contentMap map[string]interface{}
contentStr := message.StringContent()
// 1. 尝试解析为 JSON 对象
if err := json.Unmarshal([]byte(contentStr), &contentMap); err != nil {
// 2. 如果失败,尝试解析为 JSON 数组
var contentSlice []interface{}
if err := json.Unmarshal([]byte(contentStr), &contentSlice); err == nil {
// 如果是数组,包装成对象
contentMap = map[string]interface{}{"result": contentSlice}
} else {
// 3. 如果再次失败,作为纯文本处理
contentMap = map[string]interface{}{"content": contentStr}
}
}
functionResp := &FunctionResponse{
Name: name,
Response: contentMap,
@@ -609,14 +618,13 @@ func responseGeminiChat2OpenAI(response *GeminiChatResponse) *dto.OpenAITextResp
Created: common.GetTimestamp(),
Choices: make([]dto.OpenAITextResponseChoice, 0, len(response.Candidates)),
}
content, _ := json.Marshal("")
isToolCall := false
for _, candidate := range response.Candidates {
choice := dto.OpenAITextResponseChoice{
Index: int(candidate.Index),
Message: dto.Message{
Role: "assistant",
Content: content,
Content: "",
},
FinishReason: constant.FinishReasonStop,
}
+42
View File
@@ -1,13 +1,55 @@
package mistral
import (
"one-api/common"
"one-api/dto"
"regexp"
)
var mistralToolCallIdRegexp = regexp.MustCompile("^[a-zA-Z0-9]{9}$")
func requestOpenAI2Mistral(request *dto.GeneralOpenAIRequest) *dto.GeneralOpenAIRequest {
messages := make([]dto.Message, 0, len(request.Messages))
idMap := make(map[string]string)
for _, message := range request.Messages {
// 1. tool_calls.id
toolCalls := message.ParseToolCalls()
if toolCalls != nil {
for i := range toolCalls {
if !mistralToolCallIdRegexp.MatchString(toolCalls[i].ID) {
if newId, ok := idMap[toolCalls[i].ID]; ok {
toolCalls[i].ID = newId
} else {
newId, err := common.GenerateRandomCharsKey(9)
if err == nil {
idMap[toolCalls[i].ID] = newId
toolCalls[i].ID = newId
}
}
}
}
message.SetToolCalls(toolCalls)
}
// 2. tool_call_id
if message.ToolCallId != "" {
if newId, ok := idMap[message.ToolCallId]; ok {
message.ToolCallId = newId
} else {
if !mistralToolCallIdRegexp.MatchString(message.ToolCallId) {
newId, err := common.GenerateRandomCharsKey(9)
if err == nil {
idMap[message.ToolCallId] = newId
message.ToolCallId = newId
}
}
}
}
mediaMessages := message.ParseContent()
if message.Role == "assistant" && message.ToolCalls != nil && message.Content == "" {
mediaMessages = []dto.MediaContent{}
}
for j, mediaMessage := range mediaMessages {
if mediaMessage.Type == dto.ContentTypeImageURL {
imageUrl := mediaMessage.GetImageMedia()
+1 -2
View File
@@ -45,12 +45,11 @@ func responsePaLM2OpenAI(response *PaLMChatResponse) *dto.OpenAITextResponse {
Choices: make([]dto.OpenAITextResponseChoice, 0, len(response.Candidates)),
}
for i, candidate := range response.Candidates {
content, _ := json.Marshal(candidate.Content)
choice := dto.OpenAITextResponseChoice{
Index: i,
Message: dto.Message{
Role: "assistant",
Content: content,
Content: candidate.Content,
},
FinishReason: "stop",
}
+1 -2
View File
@@ -56,12 +56,11 @@ func responseTencent2OpenAI(response *TencentChatResponse) *dto.OpenAITextRespon
},
}
if len(response.Choices) > 0 {
content, _ := json.Marshal(response.Choices[0].Messages.Content)
choice := dto.OpenAITextResponseChoice{
Index: 0,
Message: dto.Message{
Role: "assistant",
Content: content,
Content: response.Choices[0].Messages.Content,
},
FinishReason: response.Choices[0].FinishReason,
}
+1 -2
View File
@@ -61,12 +61,11 @@ func responseXunfei2OpenAI(response *XunfeiChatResponse) *dto.OpenAITextResponse
},
}
}
content, _ := json.Marshal(response.Payload.Choices.Text[0].Content)
choice := dto.OpenAITextResponseChoice{
Index: 0,
Message: dto.Message{
Role: "assistant",
Content: content,
Content: response.Payload.Choices.Text[0].Content,
},
FinishReason: constant.FinishReasonStop,
}
+1 -2
View File
@@ -108,12 +108,11 @@ func responseZhipu2OpenAI(response *ZhipuResponse) *dto.OpenAITextResponse {
Usage: response.Data.Usage,
}
for i, choice := range response.Data.Choices {
content, _ := json.Marshal(strings.Trim(choice.Content, "\""))
openaiChoice := dto.OpenAITextResponseChoice{
Index: i,
Message: dto.Message{
Role: choice.Role,
Content: content,
Content: strings.Trim(choice.Content, "\""),
},
FinishReason: "",
}
+2
View File
@@ -61,6 +61,7 @@ type RelayInfo struct {
TokenKey string
UserId int
Group string
UserGroup string
TokenUnlimited bool
StartTime time.Time
FirstResponseTime time.Time
@@ -204,6 +205,7 @@ func GenRelayInfo(c *gin.Context) *RelayInfo {
TokenKey: tokenKey,
UserId: userId,
Group: group,
UserGroup: c.GetString(constant.ContextKeyUserGroup),
TokenUnlimited: tokenUnlimited,
StartTime: startTime,
FirstResponseTime: startTime.Add(-time.Second),
+8 -1
View File
@@ -2,12 +2,13 @@ package helper
import (
"fmt"
"github.com/gin-gonic/gin"
"one-api/common"
constant2 "one-api/constant"
relaycommon "one-api/relay/common"
"one-api/setting"
"one-api/setting/operation_setting"
"github.com/gin-gonic/gin"
)
type PriceData struct {
@@ -18,6 +19,7 @@ type PriceData struct {
CacheCreationRatio float64
ImageRatio float64
GroupRatio float64
UserGroupRatio float64
UsePrice bool
ShouldPreConsumedQuota int
}
@@ -29,6 +31,10 @@ func (p PriceData) ToSetting() string {
func ModelPriceHelper(c *gin.Context, info *relaycommon.RelayInfo, promptTokens int, maxTokens int) (PriceData, error) {
modelPrice, usePrice := operation_setting.GetModelPrice(info.OriginModelName, false)
groupRatio := setting.GetGroupRatio(info.Group)
userGroupRatio, ok := setting.GetGroupGroupRatio(info.UserGroup, info.Group)
if ok {
groupRatio = userGroupRatio
}
var preConsumedQuota int
var modelRatio float64
var completionRatio float64
@@ -69,6 +75,7 @@ func ModelPriceHelper(c *gin.Context, info *relaycommon.RelayInfo, promptTokens
ModelRatio: modelRatio,
CompletionRatio: completionRatio,
GroupRatio: groupRatio,
UserGroupRatio: userGroupRatio,
UsePrice: usePrice,
CacheRatio: cacheRatio,
ImageRatio: imageRatio,
+128 -25
View File
@@ -3,6 +3,7 @@ package helper
import (
"bufio"
"context"
"fmt"
"io"
"net/http"
"one-api/common"
@@ -19,8 +20,8 @@ import (
)
const (
InitialScannerBufferSize = 1 << 20 // 1MB (1*1024*1024)
MaxScannerBufferSize = 10 << 20 // 10MB (10*1024*1024)
InitialScannerBufferSize = 64 << 10 // 64KB (64*1024)
MaxScannerBufferSize = 10 << 20 // 10MB (10*1024*1024)
DefaultPingInterval = 10 * time.Second
)
@@ -30,7 +31,12 @@ func StreamScannerHandler(c *gin.Context, resp *http.Response, info *relaycommon
return
}
defer resp.Body.Close()
// 确保响应体总是被关闭
defer func() {
if resp.Body != nil {
resp.Body.Close()
}
}()
streamingTimeout := time.Duration(constant.StreamingTimeout) * time.Second
if strings.HasPrefix(info.UpstreamModelName, "o") {
@@ -39,11 +45,12 @@ func StreamScannerHandler(c *gin.Context, resp *http.Response, info *relaycommon
}
var (
stopChan = make(chan bool, 2)
stopChan = make(chan bool, 3) // 增加缓冲区避免阻塞
scanner = bufio.NewScanner(resp.Body)
ticker = time.NewTicker(streamingTimeout)
pingTicker *time.Ticker
writeMutex sync.Mutex // Mutex to protect concurrent writes
wg sync.WaitGroup // 用于等待所有 goroutine 退出
)
generalSettings := operation_setting.GetGeneralSetting()
@@ -57,13 +64,32 @@ func StreamScannerHandler(c *gin.Context, resp *http.Response, info *relaycommon
pingTicker = time.NewTicker(pingInterval)
}
// 改进资源清理,确保所有 goroutine 正确退出
defer func() {
// 通知所有 goroutine 停止
common.SafeSendBool(stopChan, true)
ticker.Stop()
if pingTicker != nil {
pingTicker.Stop()
}
// 等待所有 goroutine 退出,最多等待5秒
done := make(chan struct{})
go func() {
wg.Wait()
close(done)
}()
select {
case <-done:
case <-time.After(5 * time.Second):
common.LogError(c, "timeout waiting for goroutines to exit")
}
close(stopChan)
}()
scanner.Buffer(make([]byte, InitialScannerBufferSize), MaxScannerBufferSize)
scanner.Split(bufio.ScanLines)
SetEventStreamHeaders(c)
@@ -73,35 +99,95 @@ func StreamScannerHandler(c *gin.Context, resp *http.Response, info *relaycommon
ctx = context.WithValue(ctx, "stop_chan", stopChan)
// Handle ping data sending
// Handle ping data sending with improved error handling
if pingEnabled && pingTicker != nil {
wg.Add(1)
gopool.Go(func() {
defer func() {
wg.Done()
if r := recover(); r != nil {
common.LogError(c, fmt.Sprintf("ping goroutine panic: %v", r))
common.SafeSendBool(stopChan, true)
}
if common.DebugEnabled {
println("ping goroutine exited")
}
}()
// 添加超时保护,防止 goroutine 无限运行
maxPingDuration := 30 * time.Minute // 最大 ping 持续时间
pingTimeout := time.NewTimer(maxPingDuration)
defer pingTimeout.Stop()
for {
select {
case <-pingTicker.C:
writeMutex.Lock() // Lock before writing
err := PingData(c)
writeMutex.Unlock() // Unlock after writing
if err != nil {
common.LogError(c, "ping data error: "+err.Error())
common.SafeSendBool(stopChan, true)
// 使用超时机制防止写操作阻塞
done := make(chan error, 1)
go func() {
writeMutex.Lock()
defer writeMutex.Unlock()
done <- PingData(c)
}()
select {
case err := <-done:
if err != nil {
common.LogError(c, "ping data error: "+err.Error())
return
}
if common.DebugEnabled {
println("ping data sent")
}
case <-time.After(10 * time.Second):
common.LogError(c, "ping data send timeout")
return
case <-ctx.Done():
return
case <-stopChan:
return
}
if common.DebugEnabled {
println("ping data sent")
}
case <-ctx.Done():
if common.DebugEnabled {
println("ping data goroutine stopped")
}
return
case <-stopChan:
return
case <-c.Request.Context().Done():
// 监听客户端断开连接
return
case <-pingTimeout.C:
common.LogError(c, "ping goroutine max duration reached")
return
}
}
})
}
// Scanner goroutine with improved error handling
wg.Add(1)
common.RelayCtxGo(ctx, func() {
defer func() {
wg.Done()
if r := recover(); r != nil {
common.LogError(c, fmt.Sprintf("scanner goroutine panic: %v", r))
}
common.SafeSendBool(stopChan, true)
if common.DebugEnabled {
println("scanner goroutine exited")
}
}()
for scanner.Scan() {
// 检查是否需要停止
select {
case <-stopChan:
return
case <-ctx.Done():
return
case <-c.Request.Context().Done():
return
default:
}
ticker.Reset(streamingTimeout)
data := scanner.Text()
if common.DebugEnabled {
@@ -119,11 +205,27 @@ func StreamScannerHandler(c *gin.Context, resp *http.Response, info *relaycommon
data = strings.TrimSuffix(data, "\r")
if !strings.HasPrefix(data, "[DONE]") {
info.SetFirstResponseTime()
writeMutex.Lock() // Lock before writing
success := dataHandler(data)
writeMutex.Unlock() // Unlock after writing
if !success {
break
// 使用超时机制防止写操作阻塞
done := make(chan bool, 1)
go func() {
writeMutex.Lock()
defer writeMutex.Unlock()
done <- dataHandler(data)
}()
select {
case success := <-done:
if !success {
return
}
case <-time.After(10 * time.Second):
common.LogError(c, "data handler timeout")
return
case <-ctx.Done():
return
case <-stopChan:
return
}
}
}
@@ -133,17 +235,18 @@ func StreamScannerHandler(c *gin.Context, resp *http.Response, info *relaycommon
common.LogError(c, "scanner error: "+err.Error())
}
}
common.SafeSendBool(stopChan, true)
})
// 主循环等待完成或超时
select {
case <-ticker.C:
// 超时处理逻辑
common.LogError(c, "streaming timeout")
common.SafeSendBool(stopChan, true)
case <-stopChan:
// 正常结束
common.LogInfo(c, "streaming finished")
case <-c.Request.Context().Done():
// 客户端断开连接
common.LogInfo(c, "client disconnected")
}
}
+2 -1
View File
@@ -363,6 +363,7 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo,
modelRatio := priceData.ModelRatio
groupRatio := priceData.GroupRatio
modelPrice := priceData.ModelPrice
userGroupRatio := priceData.UserGroupRatio
// Convert values to decimal for precise calculation
dPromptTokens := decimal.NewFromInt(int64(promptTokens))
@@ -510,7 +511,7 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo,
if extraContent != "" {
logContent += ", " + extraContent
}
other := service.GenerateTextOtherInfo(ctx, relayInfo, modelRatio, groupRatio, completionRatio, cacheTokens, cacheRatio, modelPrice)
other := service.GenerateTextOtherInfo(ctx, relayInfo, modelRatio, groupRatio, completionRatio, cacheTokens, cacheRatio, modelPrice, userGroupRatio)
if imageTokens != 0 {
other["image"] = true
other["image_ratio"] = imageRatio
+2
View File
@@ -16,6 +16,7 @@ func SetApiRouter(router *gin.Engine) {
apiRouter.GET("/setup", controller.GetSetup)
apiRouter.POST("/setup", controller.PostSetup)
apiRouter.GET("/status", controller.GetStatus)
apiRouter.GET("/uptime/status", controller.GetUptimeKumaStatus)
apiRouter.GET("/models", middleware.UserAuth(), controller.DashboardListModels)
apiRouter.GET("/status/test", middleware.AdminAuth(), controller.TestStatus)
apiRouter.GET("/notice", controller.GetNotice)
@@ -105,6 +106,7 @@ func SetApiRouter(router *gin.Engine) {
channelRoute.GET("/fetch_models/:id", controller.FetchUpstreamModels)
channelRoute.POST("/fetch_models", controller.FetchModels)
channelRoute.POST("/batch/tag", controller.BatchSetChannelTag)
channelRoute.GET("/tag/models", controller.GetTagModels)
}
tokenRoute := apiRouter.Group("/token")
tokenRoute.Use(middleware.UserAuth())
+1
View File
@@ -11,6 +11,7 @@ import (
func SetRelayRouter(router *gin.Engine) {
router.Use(middleware.CORS())
router.Use(middleware.DecompressRequestMiddleware())
router.Use(middleware.StatsMiddleware())
// https://platform.openai.com/docs/api-reference/introduction
modelsRouter := router.Group("/v1/models")
modelsRouter.Use(middleware.TokenAuth())
+8 -7
View File
@@ -8,7 +8,7 @@ import (
)
func GenerateTextOtherInfo(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, modelRatio, groupRatio, completionRatio float64,
cacheTokens int, cacheRatio float64, modelPrice float64) map[string]interface{} {
cacheTokens int, cacheRatio float64, modelPrice float64, userGroupRatio float64) map[string]interface{} {
other := make(map[string]interface{})
other["model_ratio"] = modelRatio
other["group_ratio"] = groupRatio
@@ -16,6 +16,7 @@ func GenerateTextOtherInfo(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, m
other["cache_tokens"] = cacheTokens
other["cache_ratio"] = cacheRatio
other["model_price"] = modelPrice
other["user_group_ratio"] = userGroupRatio
other["frt"] = float64(relayInfo.FirstResponseTime.UnixMilli() - relayInfo.StartTime.UnixMilli())
if relayInfo.ReasoningEffort != "" {
other["reasoning_effort"] = relayInfo.ReasoningEffort
@@ -30,8 +31,8 @@ func GenerateTextOtherInfo(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, m
return other
}
func GenerateWssOtherInfo(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage *dto.RealtimeUsage, modelRatio, groupRatio, completionRatio, audioRatio, audioCompletionRatio, modelPrice float64) map[string]interface{} {
info := GenerateTextOtherInfo(ctx, relayInfo, modelRatio, groupRatio, completionRatio, 0, 0.0, modelPrice)
func GenerateWssOtherInfo(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage *dto.RealtimeUsage, modelRatio, groupRatio, completionRatio, audioRatio, audioCompletionRatio, modelPrice, userGroupRatio float64) map[string]interface{} {
info := GenerateTextOtherInfo(ctx, relayInfo, modelRatio, groupRatio, completionRatio, 0, 0.0, modelPrice, userGroupRatio)
info["ws"] = true
info["audio_input"] = usage.InputTokenDetails.AudioTokens
info["audio_output"] = usage.OutputTokenDetails.AudioTokens
@@ -42,8 +43,8 @@ func GenerateWssOtherInfo(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, us
return info
}
func GenerateAudioOtherInfo(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage *dto.Usage, modelRatio, groupRatio, completionRatio, audioRatio, audioCompletionRatio, modelPrice float64) map[string]interface{} {
info := GenerateTextOtherInfo(ctx, relayInfo, modelRatio, groupRatio, completionRatio, 0, 0.0, modelPrice)
func GenerateAudioOtherInfo(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage *dto.Usage, modelRatio, groupRatio, completionRatio, audioRatio, audioCompletionRatio, modelPrice, userGroupRatio float64) map[string]interface{} {
info := GenerateTextOtherInfo(ctx, relayInfo, modelRatio, groupRatio, completionRatio, 0, 0.0, modelPrice, userGroupRatio)
info["audio"] = true
info["audio_input"] = usage.PromptTokensDetails.AudioTokens
info["audio_output"] = usage.CompletionTokenDetails.AudioTokens
@@ -55,8 +56,8 @@ func GenerateAudioOtherInfo(ctx *gin.Context, relayInfo *relaycommon.RelayInfo,
}
func GenerateClaudeOtherInfo(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, modelRatio, groupRatio, completionRatio float64,
cacheTokens int, cacheRatio float64, cacheCreationTokens int, cacheCreationRatio float64, modelPrice float64) map[string]interface{} {
info := GenerateTextOtherInfo(ctx, relayInfo, modelRatio, groupRatio, completionRatio, cacheTokens, cacheRatio, modelPrice)
cacheTokens int, cacheRatio float64, cacheCreationTokens int, cacheCreationRatio float64, modelPrice float64, userGroupRatio float64) map[string]interface{} {
info := GenerateTextOtherInfo(ctx, relayInfo, modelRatio, groupRatio, completionRatio, cacheTokens, cacheRatio, modelPrice, userGroupRatio)
info["claude"] = true
info["cache_creation_tokens"] = cacheCreationTokens
info["cache_creation_ratio"] = cacheCreationRatio
+21 -6
View File
@@ -94,6 +94,10 @@ func PreWssConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usag
audioInputTokens := usage.InputTokenDetails.AudioTokens
audioOutTokens := usage.OutputTokenDetails.AudioTokens
groupRatio := setting.GetGroupRatio(relayInfo.Group)
userGroupRatio, ok := setting.GetGroupGroupRatio(relayInfo.UserGroup, relayInfo.Group)
if ok {
groupRatio = userGroupRatio
}
modelRatio, _ := operation_setting.GetModelRatio(modelName)
quotaInfo := QuotaInfo{
@@ -145,6 +149,11 @@ func PostWssConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, mod
audioRatio := decimal.NewFromFloat(operation_setting.GetAudioRatio(relayInfo.OriginModelName))
audioCompletionRatio := decimal.NewFromFloat(operation_setting.GetAudioCompletionRatio(modelName))
actualGroupRatio := groupRatio
userGroupRatio, ok := setting.GetGroupGroupRatio(relayInfo.UserGroup, relayInfo.Group)
if ok {
actualGroupRatio = userGroupRatio
}
quotaInfo := QuotaInfo{
InputDetails: TokenDetails{
TextTokens: textInputTokens,
@@ -157,7 +166,7 @@ func PostWssConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, mod
ModelName: modelName,
UsePrice: usePrice,
ModelRatio: modelRatio,
GroupRatio: groupRatio,
GroupRatio: actualGroupRatio,
}
quota := calculateAudioQuota(quotaInfo)
@@ -189,7 +198,7 @@ func PostWssConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, mod
logContent += ", " + extraContent
}
other := GenerateWssOtherInfo(ctx, relayInfo, usage, modelRatio, groupRatio,
completionRatio.InexactFloat64(), audioRatio.InexactFloat64(), audioCompletionRatio.InexactFloat64(), modelPrice)
completionRatio.InexactFloat64(), audioRatio.InexactFloat64(), audioCompletionRatio.InexactFloat64(), modelPrice, userGroupRatio)
model.RecordConsumeLog(ctx, relayInfo.UserId, relayInfo.ChannelId, usage.InputTokens, usage.OutputTokens, logModel,
tokenName, quota, logContent, relayInfo.TokenId, userQuota, int(useTimeSeconds), relayInfo.IsStream, relayInfo.Group, other)
}
@@ -207,7 +216,7 @@ func PostClaudeConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo,
modelRatio := priceData.ModelRatio
groupRatio := priceData.GroupRatio
modelPrice := priceData.ModelPrice
userGroupRatio := priceData.UserGroupRatio
cacheRatio := priceData.CacheRatio
cacheTokens := usage.PromptTokensDetails.CachedTokens
@@ -256,7 +265,7 @@ func PostClaudeConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo,
}
other := GenerateClaudeOtherInfo(ctx, relayInfo, modelRatio, groupRatio, completionRatio,
cacheTokens, cacheRatio, cacheCreationTokens, cacheCreationRatio, modelPrice)
cacheTokens, cacheRatio, cacheCreationTokens, cacheCreationRatio, modelPrice, userGroupRatio)
model.RecordConsumeLog(ctx, relayInfo.UserId, relayInfo.ChannelId, promptTokens, completionTokens, modelName,
tokenName, quota, logContent, relayInfo.TokenId, userQuota, int(useTimeSeconds), relayInfo.IsStream, relayInfo.Group, other)
}
@@ -281,6 +290,12 @@ func PostAudioConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo,
modelPrice := priceData.ModelPrice
usePrice := priceData.UsePrice
actualGroupRatio := groupRatio
userGroupRatio, ok := setting.GetGroupGroupRatio(relayInfo.UserGroup, relayInfo.Group)
if ok {
actualGroupRatio = userGroupRatio
}
quotaInfo := QuotaInfo{
InputDetails: TokenDetails{
TextTokens: textInputTokens,
@@ -293,7 +308,7 @@ func PostAudioConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo,
ModelName: relayInfo.OriginModelName,
UsePrice: usePrice,
ModelRatio: modelRatio,
GroupRatio: groupRatio,
GroupRatio: actualGroupRatio,
}
quota := calculateAudioQuota(quotaInfo)
@@ -333,7 +348,7 @@ func PostAudioConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo,
logContent += ", " + extraContent
}
other := GenerateAudioOtherInfo(ctx, relayInfo, usage, modelRatio, groupRatio,
completionRatio.InexactFloat64(), audioRatio.InexactFloat64(), audioCompletionRatio.InexactFloat64(), modelPrice)
completionRatio.InexactFloat64(), audioRatio.InexactFloat64(), audioCompletionRatio.InexactFloat64(), modelPrice, userGroupRatio)
model.RecordConsumeLog(ctx, relayInfo.UserId, relayInfo.ChannelId, usage.PromptTokens, usage.CompletionTokens, logModel,
tokenName, quota, logContent, relayInfo.TokenId, userQuota, int(useTimeSeconds), relayInfo.IsStream, relayInfo.Group, other)
}
+52 -61
View File
@@ -4,6 +4,8 @@ import (
"encoding/json"
"errors"
"fmt"
"github.com/tiktoken-go/tokenizer"
"github.com/tiktoken-go/tokenizer/codec"
"image"
"log"
"math"
@@ -11,78 +13,63 @@ import (
"one-api/constant"
"one-api/dto"
relaycommon "one-api/relay/common"
"one-api/setting/operation_setting"
"strings"
"sync"
"unicode/utf8"
"github.com/pkoukk/tiktoken-go"
)
// tokenEncoderMap won't grow after initialization
var tokenEncoderMap = map[string]*tiktoken.Tiktoken{}
var defaultTokenEncoder *tiktoken.Tiktoken
var o200kTokenEncoder *tiktoken.Tiktoken
var defaultTokenEncoder tokenizer.Codec
// tokenEncoderMap is used to store token encoders for different models
var tokenEncoderMap = make(map[string]tokenizer.Codec)
// tokenEncoderMutex protects tokenEncoderMap for concurrent access
var tokenEncoderMutex sync.RWMutex
func InitTokenEncoders() {
common.SysLog("initializing token encoders")
cl100TokenEncoder, err := tiktoken.GetEncoding(tiktoken.MODEL_CL100K_BASE)
if err != nil {
common.FatalLog(fmt.Sprintf("failed to get gpt-3.5-turbo token encoder: %s", err.Error()))
}
defaultTokenEncoder = cl100TokenEncoder
o200kTokenEncoder, err = tiktoken.GetEncoding(tiktoken.MODEL_O200K_BASE)
if err != nil {
common.FatalLog(fmt.Sprintf("failed to get gpt-4o token encoder: %s", err.Error()))
}
for model, _ := range operation_setting.GetDefaultModelRatioMap() {
if strings.HasPrefix(model, "gpt-3.5") {
tokenEncoderMap[model] = cl100TokenEncoder
} else if strings.HasPrefix(model, "gpt-4") {
if strings.HasPrefix(model, "gpt-4o") {
tokenEncoderMap[model] = o200kTokenEncoder
} else {
tokenEncoderMap[model] = defaultTokenEncoder
}
} else if strings.HasPrefix(model, "o") {
tokenEncoderMap[model] = o200kTokenEncoder
} else {
tokenEncoderMap[model] = defaultTokenEncoder
}
}
defaultTokenEncoder = codec.NewCl100kBase()
common.SysLog("token encoders initialized")
}
func getModelDefaultTokenEncoder(model string) *tiktoken.Tiktoken {
if strings.HasPrefix(model, "gpt-4o") || strings.HasPrefix(model, "chatgpt-4o") || strings.HasPrefix(model, "o1") {
return o200kTokenEncoder
func getTokenEncoder(model string) tokenizer.Codec {
// First, try to get the encoder from cache with read lock
tokenEncoderMutex.RLock()
if encoder, exists := tokenEncoderMap[model]; exists {
tokenEncoderMutex.RUnlock()
return encoder
}
return defaultTokenEncoder
tokenEncoderMutex.RUnlock()
// If not in cache, create new encoder with write lock
tokenEncoderMutex.Lock()
defer tokenEncoderMutex.Unlock()
// Double-check if another goroutine already created the encoder
if encoder, exists := tokenEncoderMap[model]; exists {
return encoder
}
// Create new encoder
modelCodec, err := tokenizer.ForModel(tokenizer.Model(model))
if err != nil {
// Cache the default encoder for this model to avoid repeated failures
tokenEncoderMap[model] = defaultTokenEncoder
return defaultTokenEncoder
}
// Cache the new encoder
tokenEncoderMap[model] = modelCodec
return modelCodec
}
func getTokenEncoder(model string) *tiktoken.Tiktoken {
tokenEncoder, ok := tokenEncoderMap[model]
if ok && tokenEncoder != nil {
return tokenEncoder
}
// 如果ok(即model在tokenEncoderMap中),但是tokenEncoder为nil,说明可能是自定义模型
if ok {
tokenEncoder, err := tiktoken.EncodingForModel(model)
if err != nil {
common.SysError(fmt.Sprintf("failed to get token encoder for model %s: %s, using encoder for gpt-3.5-turbo", model, err.Error()))
tokenEncoder = getModelDefaultTokenEncoder(model)
}
tokenEncoderMap[model] = tokenEncoder
return tokenEncoder
}
// 如果model不在tokenEncoderMap中,直接返回默认的tokenEncoder
return getModelDefaultTokenEncoder(model)
}
func getTokenNum(tokenEncoder *tiktoken.Tiktoken, text string) int {
func getTokenNum(tokenEncoder tokenizer.Codec, text string) int {
if text == "" {
return 0
}
return len(tokenEncoder.Encode(text, nil, nil))
tkm, _ := tokenEncoder.Count(text)
return tkm
}
func getImageToken(info *relaycommon.RelayInfo, imageUrl *dto.MessageImageUrl, model string, stream bool) (int, error) {
@@ -261,12 +248,16 @@ func CountTokenClaudeMessages(messages []dto.ClaudeMessage, model string, stream
//}
tokenNum += 1000
case "tool_use":
tokenNum += getTokenNum(tokenEncoder, mediaMessage.Name)
inputJSON, _ := json.Marshal(mediaMessage.Input)
tokenNum += getTokenNum(tokenEncoder, string(inputJSON))
if mediaMessage.Input != nil {
tokenNum += getTokenNum(tokenEncoder, mediaMessage.Name)
inputJSON, _ := json.Marshal(mediaMessage.Input)
tokenNum += getTokenNum(tokenEncoder, string(inputJSON))
}
case "tool_result":
contentJSON, _ := json.Marshal(mediaMessage.Content)
tokenNum += getTokenNum(tokenEncoder, string(contentJSON))
if mediaMessage.Content != nil {
contentJSON, _ := json.Marshal(mediaMessage.Content)
tokenNum += getTokenNum(tokenEncoder, string(contentJSON))
}
}
}
}
@@ -386,7 +377,7 @@ func CountTokenMessages(info *relaycommon.RelayInfo, messages []dto.Message, mod
for _, message := range messages {
tokenNum += tokensPerMessage
tokenNum += getTokenNum(tokenEncoder, message.Role)
if len(message.Content) > 0 {
if message.Content != nil {
if message.Name != nil {
tokenNum += tokensPerName
tokenNum += getTokenNum(tokenEncoder, *message.Name)
+327
View File
@@ -0,0 +1,327 @@
package setting
import (
"encoding/json"
"fmt"
"net/url"
"one-api/common"
"regexp"
"sort"
"strings"
"time"
)
// ValidateConsoleSettings 验证控制台设置信息格式
func ValidateConsoleSettings(settingsStr string, settingType string) error {
if settingsStr == "" {
return nil // 空字符串是合法的
}
switch settingType {
case "ApiInfo":
return validateApiInfo(settingsStr)
case "Announcements":
return validateAnnouncements(settingsStr)
case "FAQ":
return validateFAQ(settingsStr)
default:
return fmt.Errorf("未知的设置类型:%s", settingType)
}
}
// validateApiInfo 验证API信息格式
func validateApiInfo(apiInfoStr string) error {
var apiInfoList []map[string]interface{}
if err := json.Unmarshal([]byte(apiInfoStr), &apiInfoList); err != nil {
return fmt.Errorf("API信息格式错误:%s", err.Error())
}
// 验证数组长度
if len(apiInfoList) > 50 {
return fmt.Errorf("API信息数量不能超过50个")
}
// 允许的颜色值
validColors := map[string]bool{
"blue": true, "green": true, "cyan": true, "purple": true, "pink": true,
"red": true, "orange": true, "amber": true, "yellow": true, "lime": true,
"light-green": true, "teal": true, "light-blue": true, "indigo": true,
"violet": true, "grey": true,
}
// URL正则表达式,支持域名和IP地址格式
// 域名格式:https://example.com 或 https://sub.example.com:8080
// IP地址格式:https://192.168.1.1 或 https://192.168.1.1:8080
urlRegex := regexp.MustCompile(`^https?://(?:(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)*[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?|(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?))(?::[0-9]{1,5})?(?:/.*)?$`)
for i, apiInfo := range apiInfoList {
// 检查必填字段
urlStr, ok := apiInfo["url"].(string)
if !ok || urlStr == "" {
return fmt.Errorf("第%d个API信息缺少URL字段", i+1)
}
route, ok := apiInfo["route"].(string)
if !ok || route == "" {
return fmt.Errorf("第%d个API信息缺少线路描述字段", i+1)
}
description, ok := apiInfo["description"].(string)
if !ok || description == "" {
return fmt.Errorf("第%d个API信息缺少说明字段", i+1)
}
color, ok := apiInfo["color"].(string)
if !ok || color == "" {
return fmt.Errorf("第%d个API信息缺少颜色字段", i+1)
}
// 验证URL格式
if !urlRegex.MatchString(urlStr) {
return fmt.Errorf("第%d个API信息的URL格式不正确", i+1)
}
// 验证URL可解析性
if _, err := url.Parse(urlStr); err != nil {
return fmt.Errorf("第%d个API信息的URL无法解析:%s", i+1, err.Error())
}
// 验证字段长度
if len(urlStr) > 500 {
return fmt.Errorf("第%d个API信息的URL长度不能超过500字符", i+1)
}
if len(route) > 100 {
return fmt.Errorf("第%d个API信息的线路描述长度不能超过100字符", i+1)
}
if len(description) > 200 {
return fmt.Errorf("第%d个API信息的说明长度不能超过200字符", i+1)
}
// 验证颜色值
if !validColors[color] {
return fmt.Errorf("第%d个API信息的颜色值不合法", i+1)
}
// 检查并过滤危险字符(防止XSS
dangerousChars := []string{"<script", "<iframe", "javascript:", "onload=", "onerror=", "onclick="}
for _, dangerous := range dangerousChars {
if strings.Contains(strings.ToLower(description), dangerous) {
return fmt.Errorf("第%d个API信息的说明包含不允许的内容", i+1)
}
if strings.Contains(strings.ToLower(route), dangerous) {
return fmt.Errorf("第%d个API信息的线路描述包含不允许的内容", i+1)
}
}
}
return nil
}
// ValidateApiInfo 保持向后兼容的函数
func ValidateApiInfo(apiInfoStr string) error {
return validateApiInfo(apiInfoStr)
}
// GetApiInfo 获取API信息列表
func GetApiInfo() []map[string]interface{} {
// 从OptionMap中获取API信息,如果不存在则返回空数组
common.OptionMapRWMutex.RLock()
apiInfoStr, exists := common.OptionMap["ApiInfo"]
common.OptionMapRWMutex.RUnlock()
if !exists || apiInfoStr == "" {
// 如果没有配置,返回空数组
return []map[string]interface{}{}
}
// 解析存储的API信息
var apiInfo []map[string]interface{}
if err := json.Unmarshal([]byte(apiInfoStr), &apiInfo); err != nil {
// 如果解析失败,返回空数组
return []map[string]interface{}{}
}
return apiInfo
}
// validateAnnouncements 验证系统公告格式
func validateAnnouncements(announcementsStr string) error {
var announcementsList []map[string]interface{}
if err := json.Unmarshal([]byte(announcementsStr), &announcementsList); err != nil {
return fmt.Errorf("系统公告格式错误:%s", err.Error())
}
// 验证数组长度
if len(announcementsList) > 100 {
return fmt.Errorf("系统公告数量不能超过100个")
}
// 允许的类型值
validTypes := map[string]bool{
"default": true, "ongoing": true, "success": true, "warning": true, "error": true,
}
for i, announcement := range announcementsList {
// 检查必填字段
content, ok := announcement["content"].(string)
if !ok || content == "" {
return fmt.Errorf("第%d个公告缺少内容字段", i+1)
}
// 检查发布日期字段
publishDate, exists := announcement["publishDate"]
if !exists {
return fmt.Errorf("第%d个公告缺少发布日期字段", i+1)
}
publishDateStr, ok := publishDate.(string)
if !ok || publishDateStr == "" {
return fmt.Errorf("第%d个公告的发布日期不能为空", i+1)
}
// 验证ISO日期格式
if _, err := time.Parse(time.RFC3339, publishDateStr); err != nil {
return fmt.Errorf("第%d个公告的发布日期格式错误", i+1)
}
// 验证可选字段
if announcementType, exists := announcement["type"]; exists {
if typeStr, ok := announcementType.(string); ok {
if !validTypes[typeStr] {
return fmt.Errorf("第%d个公告的类型值不合法", i+1)
}
}
}
// 验证字段长度
if len(content) > 500 {
return fmt.Errorf("第%d个公告的内容长度不能超过500字符", i+1)
}
if extra, exists := announcement["extra"]; exists {
if extraStr, ok := extra.(string); ok && len(extraStr) > 200 {
return fmt.Errorf("第%d个公告的说明长度不能超过200字符", i+1)
}
}
// 检查并过滤危险字符(防止XSS
dangerousChars := []string{"<script", "<iframe", "javascript:", "onload=", "onerror=", "onclick="}
for _, dangerous := range dangerousChars {
if strings.Contains(strings.ToLower(content), dangerous) {
return fmt.Errorf("第%d个公告的内容包含不允许的内容", i+1)
}
}
}
return nil
}
// validateFAQ 验证常见问答格式
func validateFAQ(faqStr string) error {
var faqList []map[string]interface{}
if err := json.Unmarshal([]byte(faqStr), &faqList); err != nil {
return fmt.Errorf("常见问答格式错误:%s", err.Error())
}
// 验证数组长度
if len(faqList) > 100 {
return fmt.Errorf("常见问答数量不能超过100个")
}
for i, faq := range faqList {
// 检查必填字段
title, ok := faq["title"].(string)
if !ok || title == "" {
return fmt.Errorf("第%d个问答缺少标题字段", i+1)
}
content, ok := faq["content"].(string)
if !ok || content == "" {
return fmt.Errorf("第%d个问答缺少内容字段", i+1)
}
// 验证字段长度
if len(title) > 200 {
return fmt.Errorf("第%d个问答的标题长度不能超过200字符", i+1)
}
if len(content) > 1000 {
return fmt.Errorf("第%d个问答的内容长度不能超过1000字符", i+1)
}
// 检查并过滤危险字符(防止XSS
dangerousChars := []string{"<script", "<iframe", "javascript:", "onload=", "onerror=", "onclick="}
for _, dangerous := range dangerousChars {
if strings.Contains(strings.ToLower(title), dangerous) {
return fmt.Errorf("第%d个问答的标题包含不允许的内容", i+1)
}
if strings.Contains(strings.ToLower(content), dangerous) {
return fmt.Errorf("第%d个问答的内容包含不允许的内容", i+1)
}
}
}
return nil
}
// GetAnnouncements 获取系统公告列表(返回最新的前20条)
func GetAnnouncements() []map[string]interface{} {
common.OptionMapRWMutex.RLock()
announcementsStr, exists := common.OptionMap["Announcements"]
common.OptionMapRWMutex.RUnlock()
if !exists || announcementsStr == "" {
return []map[string]interface{}{}
}
var announcements []map[string]interface{}
if err := json.Unmarshal([]byte(announcementsStr), &announcements); err != nil {
return []map[string]interface{}{}
}
// 按发布日期降序排序(最新的在前)
sort.Slice(announcements, func(i, j int) bool {
dateI, okI := announcements[i]["publishDate"].(string)
dateJ, okJ := announcements[j]["publishDate"].(string)
if !okI || !okJ {
return false
}
timeI, errI := time.Parse(time.RFC3339, dateI)
timeJ, errJ := time.Parse(time.RFC3339, dateJ)
if errI != nil || errJ != nil {
return false
}
return timeI.After(timeJ)
})
// 限制返回前20条
if len(announcements) > 20 {
announcements = announcements[:20]
}
return announcements
}
// GetFAQ 获取常见问答列表
func GetFAQ() []map[string]interface{} {
common.OptionMapRWMutex.RLock()
faqStr, exists := common.OptionMap["FAQ"]
common.OptionMapRWMutex.RUnlock()
if !exists || faqStr == "" {
return []map[string]interface{}{}
}
var faq []map[string]interface{}
if err := json.Unmarshal([]byte(faqStr), &faq); err != nil {
return []map[string]interface{}{}
}
return faq
}
+48 -5
View File
@@ -14,10 +14,19 @@ var groupRatio = map[string]float64{
}
var groupRatioMutex sync.RWMutex
var (
GroupGroupRatio = map[string]map[string]float64{
"vip": {
"edit_this": 0.9,
},
}
groupGroupRatioMutex sync.RWMutex
)
func GetGroupRatioCopy() map[string]float64 {
groupRatioMutex.RLock()
defer groupRatioMutex.RUnlock()
groupRatioCopy := make(map[string]float64)
for k, v := range groupRatio {
groupRatioCopy[k] = v
@@ -28,7 +37,7 @@ func GetGroupRatioCopy() map[string]float64 {
func ContainsGroupRatio(name string) bool {
groupRatioMutex.RLock()
defer groupRatioMutex.RUnlock()
_, ok := groupRatio[name]
return ok
}
@@ -36,7 +45,7 @@ func ContainsGroupRatio(name string) bool {
func GroupRatio2JSONString() string {
groupRatioMutex.RLock()
defer groupRatioMutex.RUnlock()
jsonBytes, err := json.Marshal(groupRatio)
if err != nil {
common.SysError("error marshalling model ratio: " + err.Error())
@@ -47,7 +56,7 @@ func GroupRatio2JSONString() string {
func UpdateGroupRatioByJSONString(jsonStr string) error {
groupRatioMutex.Lock()
defer groupRatioMutex.Unlock()
groupRatio = make(map[string]float64)
return json.Unmarshal([]byte(jsonStr), &groupRatio)
}
@@ -55,7 +64,7 @@ func UpdateGroupRatioByJSONString(jsonStr string) error {
func GetGroupRatio(name string) float64 {
groupRatioMutex.RLock()
defer groupRatioMutex.RUnlock()
ratio, ok := groupRatio[name]
if !ok {
common.SysError("group ratio not found: " + name)
@@ -64,6 +73,40 @@ func GetGroupRatio(name string) float64 {
return ratio
}
func GetGroupGroupRatio(group, name string) (float64, bool) {
groupGroupRatioMutex.RLock()
defer groupGroupRatioMutex.RUnlock()
gp, ok := GroupGroupRatio[group]
if !ok {
return -1, false
}
ratio, ok := gp[name]
if !ok {
return -1, false
}
return ratio, true
}
func GroupGroupRatio2JSONString() string {
groupGroupRatioMutex.RLock()
defer groupGroupRatioMutex.RUnlock()
jsonBytes, err := json.Marshal(GroupGroupRatio)
if err != nil {
common.SysError("error marshalling group-group ratio: " + err.Error())
}
return string(jsonBytes)
}
func UpdateGroupGroupRatioByJSONString(jsonStr string) error {
groupGroupRatioMutex.Lock()
defer groupGroupRatioMutex.Unlock()
GroupGroupRatio = make(map[string]map[string]float64)
return json.Unmarshal([]byte(jsonStr), &GroupGroupRatio)
}
func CheckGroupRatio(jsonStr string) error {
checkGroupRatio := make(map[string]float64)
err := json.Unmarshal([]byte(jsonStr), &checkGroupRatio)
-21
View File
@@ -1,21 +0,0 @@
# React Template
## Basic Usages
```shell
# Runs the app in the development mode
npm start
# Builds the app for production to the `build` folder
npm run build
```
If you want to change the default server, please set `REACT_APP_SERVER` environment variables before build,
for example: `REACT_APP_SERVER=http://your.domain.com`.
Before you start editing, make sure your `Actions on Save` options have `Optimize imports` & `Run Prettier` enabled.
## Reference
1. https://github.com/OIerDb-ng/OIerDb
2. https://github.com/cornflourblue/react-hooks-redux-registration-login-example
BIN
View File
Binary file not shown.
+2 -4
View File
@@ -37,9 +37,7 @@
"remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.1",
"remark-math": "^6.0.0",
"semantic-ui-offline": "^2.5.0",
"semantic-ui-react": "^2.1.3",
"sse": "https://github.com/mpetazzoni/sse.js",
"sse.js": "^2.6.0",
"unist-util-visit": "^5.0.0",
"use-debounce": "^10.0.4"
},
@@ -69,7 +67,7 @@
]
},
"devDependencies": {
"@douyinfe/semi-webpack-plugin": "^2.78.0",
"@douyinfe/vite-plugin-semi": "^2.74.0-alpha.6",
"@so1ve/prettier-config": "^3.1.0",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.21",
-5584
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.

Before

Width:  |  Height:  |  Size: 550 KiB

+3 -15
View File
@@ -32,7 +32,6 @@ import OIDCIcon from '../common/logo/OIDCIcon.js';
import WeChatIcon from '../common/logo/WeChatIcon.js';
import LinuxDoIcon from '../common/logo/LinuxDoIcon.js';
import { useTranslation } from 'react-i18next';
import Background from '/example.png';
const LoginForm = () => {
const [inputs, setInputs] = useState({
@@ -266,7 +265,7 @@ const LoginForm = () => {
<div className="w-full max-w-md">
<div className="flex items-center justify-center mb-6 gap-2">
<img src={logo} alt="Logo" className="h-10 rounded-full" />
<Title heading={3} className='!text-white'>{systemName}</Title>
<Title heading={3} className='!text-gray-800'>{systemName}</Title>
</div>
<Card className="shadow-xl border-0 !rounded-2xl overflow-hidden">
@@ -500,19 +499,8 @@ const LoginForm = () => {
};
return (
<div className="min-h-screen relative flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8 overflow-hidden">
{/* 背景图片容器 - 放大并保持居中 */}
<div
className="absolute inset-0 z-0 bg-cover bg-center scale-125 opacity-100"
style={{
backgroundImage: `url(${Background})`
}}
></div>
{/* 半透明遮罩层 */}
<div className="absolute inset-0 bg-gradient-to-br from-teal-500/30 via-blue-500/30 to-purple-500/30 backdrop-blur-sm z-0"></div>
<div className="w-full max-w-sm relative z-10">
<div className="bg-gray-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
<div className="w-full max-w-sm">
{showEmailLogin || !(status.github_oauth || status.oidc_enabled || status.wechat_login || status.linuxdo_oauth || status.telegram_oauth)
? renderEmailLoginForm()
: renderOAuthOptions()}
+12 -21
View File
@@ -1,15 +1,16 @@
import React, { useContext, useEffect, useState } from 'react';
import { Spin, Typography, Space } from '@douyinfe/semi-ui';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { API, showError, showSuccess, updateAPI, setUserData } from '../../helpers';
import { UserContext } from '../../context/User';
import Loading from '../common/Loading';
const OAuth2Callback = (props) => {
const { t } = useTranslation();
const [searchParams, setSearchParams] = useSearchParams();
const [userState, userDispatch] = useContext(UserContext);
const [prompt, setPrompt] = useState('处理中...');
const [processing, setProcessing] = useState(true);
const [prompt, setPrompt] = useState(t('处理中...'));
let navigate = useNavigate();
@@ -20,25 +21,25 @@ const OAuth2Callback = (props) => {
const { success, message, data } = res.data;
if (success) {
if (message === 'bind') {
showSuccess('绑定成功!');
navigate('/setting');
showSuccess(t('绑定成功!'));
navigate('/console/setting');
} else {
userDispatch({ type: 'login', payload: data });
localStorage.setItem('user', JSON.stringify(data));
setUserData(data);
updateAPI();
showSuccess('登录成功!');
navigate('/token');
showSuccess(t('登录成功!'));
navigate('/console/token');
}
} else {
showError(message);
if (count === 0) {
setPrompt(`操作失败,重定向至登录界面中...`);
navigate('/setting'); // in case this is failed to bind GitHub
setPrompt(t('操作失败,重定向至登录界面中...'));
navigate('/console/setting'); // in case this is failed to bind GitHub
return;
}
count++;
setPrompt(`出现错误,第 ${count} 次重试中...`);
setPrompt(t('出现错误,第 ${count} 次重试中...', { count }));
await new Promise((resolve) => setTimeout(resolve, count * 2000));
await sendCode(code, state, count);
}
@@ -50,17 +51,7 @@ const OAuth2Callback = (props) => {
sendCode(code, state, 0).then();
}, []);
return (
<div className="flex items-center justify-center min-h-[300px] w-full bg-white rounded-lg shadow p-6">
<Space vertical align="center">
<Spin size="large" spinning={processing}>
<div className="min-h-[200px] min-w-[200px] flex items-center justify-center">
<Typography.Text type="secondary">{prompt}</Typography.Text>
</div>
</Spin>
</Space>
</div>
);
return <Loading prompt={prompt} />;
};
export default OAuth2Callback;
+52 -35
View File
@@ -1,10 +1,9 @@
import React, { useEffect, useState } from 'react';
import { API, copy, showError, showNotice, getLogo, getSystemName } from '../../helpers';
import { useSearchParams, Link } from 'react-router-dom';
import { Button, Card, Form, Typography } from '@douyinfe/semi-ui';
import { IconMail, IconLock } from '@douyinfe/semi-icons';
import { Button, Card, Form, Typography, Banner } from '@douyinfe/semi-ui';
import { IconMail, IconLock, IconCopy } from '@douyinfe/semi-icons';
import { useTranslation } from 'react-i18next';
import Background from '/example.png';
const { Text, Title } = Typography;
@@ -15,13 +14,14 @@ const PasswordResetConfirm = () => {
token: '',
});
const { email, token } = inputs;
const isValidResetLink = email && token;
const [loading, setLoading] = useState(false);
const [disableButton, setDisableButton] = useState(false);
const [countdown, setCountdown] = useState(30);
const [newPassword, setNewPassword] = useState('');
const [searchParams, setSearchParams] = useSearchParams();
const [formApi, setFormApi] = useState(null);
const logo = getLogo();
const systemName = getSystemName();
@@ -30,10 +30,16 @@ const PasswordResetConfirm = () => {
let token = searchParams.get('token');
let email = searchParams.get('email');
setInputs({
token,
email,
token: token || '',
email: email || '',
});
}, []);
if (formApi) {
formApi.setValues({
email: email || '',
newPassword: newPassword || ''
});
}
}, [searchParams, newPassword, formApi]);
useEffect(() => {
let countdownInterval = null;
@@ -49,7 +55,10 @@ const PasswordResetConfirm = () => {
}, [disableButton, countdown]);
async function handleSubmit(e) {
if (!email || !token) return;
if (!email || !token) {
showError(t('无效的重置链接,请重新发起密码重置请求'));
return;
}
setDisableButton(true);
setLoading(true);
const res = await API.post(`/api/user/reset`, {
@@ -61,7 +70,7 @@ const PasswordResetConfirm = () => {
let password = res.data.data;
setNewPassword(password);
await copy(password);
showNotice(`${t('密码已重置并已复制到剪贴板')}: ${password}`);
showNotice(`${t('密码已重置并已复制到剪贴板')} ${password}`);
} else {
showError(message);
}
@@ -69,24 +78,13 @@ const PasswordResetConfirm = () => {
}
return (
<div className="min-h-screen relative flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8 overflow-hidden">
{/* 背景图片容器 - 放大并保持居中 */}
<div
className="absolute inset-0 z-0 bg-cover bg-center scale-125 opacity-100"
style={{
backgroundImage: `url(${Background})`
}}
></div>
{/* 半透明遮罩层 */}
<div className="absolute inset-0 bg-gradient-to-br from-teal-500/30 via-blue-500/30 to-purple-500/30 backdrop-blur-sm z-0"></div>
<div className="w-full max-w-sm relative z-10">
<div className="bg-gray-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
<div className="w-full max-w-sm">
<div className="flex flex-col items-center">
<div className="w-full max-w-md">
<div className="flex items-center justify-center mb-6 gap-2">
<img src={logo} alt="Logo" className="h-10 rounded-full" />
<Title heading={3} className='!text-white'>{systemName}</Title>
<Title heading={3} className='!text-gray-800'>{systemName}</Title>
</div>
<Card className="shadow-xl border-0 !rounded-2xl overflow-hidden">
@@ -94,16 +92,28 @@ const PasswordResetConfirm = () => {
<Title heading={3} className="text-gray-800 dark:text-gray-200">{t('密码重置确认')}</Title>
</div>
<div className="px-2 py-8">
<Form className="space-y-3">
{!isValidResetLink && (
<Banner
type="danger"
description={t('无效的重置链接,请重新发起密码重置请求')}
className="mb-4 !rounded-lg"
closeIcon={null}
/>
)}
<Form
getFormApi={(api) => setFormApi(api)}
initValues={{ email: email || '', newPassword: newPassword || '' }}
className="space-y-4"
>
<Form.Input
field="email"
label={t('邮箱')}
name="email"
size="large"
className="!rounded-md"
value={email}
readOnly
disabled={true}
prefix={<IconMail />}
placeholder={email ? '' : t('等待获取邮箱信息...')}
/>
{newPassword && (
@@ -113,14 +123,21 @@ const PasswordResetConfirm = () => {
name="newPassword"
size="large"
className="!rounded-md"
value={newPassword}
readOnly
disabled={true}
prefix={<IconLock />}
onClick={(e) => {
e.target.select();
navigator.clipboard.writeText(newPassword);
showNotice(`${t('密码已复制到剪贴板')}: ${newPassword}`);
}}
suffix={
<Button
icon={<IconCopy />}
type="tertiary"
theme="borderless"
onClick={async () => {
await copy(newPassword);
showNotice(`${t('密码已复制到剪贴板:')} ${newPassword}`);
}}
>
{t('复制')}
</Button>
}
/>
)}
@@ -133,9 +150,9 @@ const PasswordResetConfirm = () => {
size="large"
onClick={handleSubmit}
loading={loading}
disabled={disableButton || newPassword}
disabled={disableButton || newPassword || !isValidResetLink}
>
{newPassword ? t('密码重置完成') : t('提交')}
{newPassword ? t('密码重置完成') : t('确认重置密码')}
</Button>
</div>
</Form>
+7 -16
View File
@@ -5,7 +5,6 @@ import { Button, Card, Form, Typography } from '@douyinfe/semi-ui';
import { IconMail } from '@douyinfe/semi-icons';
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import Background from '/example.png';
const { Text, Title } = Typography;
@@ -55,7 +54,10 @@ const PasswordResetForm = () => {
}
async function handleSubmit(e) {
if (!email) return;
if (!email) {
showError(t('请输入邮箱地址'));
return;
}
if (turnstileEnabled && turnstileToken === '') {
showInfo(t('请稍后几秒重试,Turnstile 正在检查用户环境!'));
return;
@@ -76,24 +78,13 @@ const PasswordResetForm = () => {
}
return (
<div className="min-h-screen relative flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8 overflow-hidden">
{/* 背景图片容器 - 放大并保持居中 */}
<div
className="absolute inset-0 z-0 bg-cover bg-center scale-125 opacity-100"
style={{
backgroundImage: `url(${Background})`
}}
></div>
{/* 半透明遮罩层 */}
<div className="absolute inset-0 bg-gradient-to-br from-teal-500/30 via-blue-500/30 to-purple-500/30 backdrop-blur-sm z-0"></div>
<div className="w-full max-w-sm relative z-10">
<div className="bg-gray-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
<div className="w-full max-w-sm">
<div className="flex flex-col items-center">
<div className="w-full max-w-md">
<div className="flex items-center justify-center mb-6 gap-2">
<img src={logo} alt="Logo" className="h-10 rounded-full" />
<Title heading={3} className='!text-white'>{systemName}</Title>
<Title heading={3} className='!text-gray-800'>{systemName}</Title>
</div>
<Card className="shadow-xl border-0 !rounded-2xl overflow-hidden">
+4 -14
View File
@@ -33,7 +33,6 @@ import WeChatIcon from '../common/logo/WeChatIcon.js';
import TelegramLoginButton from 'react-telegram-login/src';
import { UserContext } from '../../context/User/index.js';
import { useTranslation } from 'react-i18next';
import Background from '/example.png';
const RegisterForm = () => {
const { t } = useTranslation();
@@ -272,7 +271,7 @@ const RegisterForm = () => {
<div className="w-full max-w-md">
<div className="flex items-center justify-center mb-6 gap-2">
<img src={logo} alt="Logo" className="h-10 rounded-full" />
<Title heading={3} className='!text-white'>{systemName}</Title>
<Title heading={3} className='!text-gray-800'>{systemName}</Title>
</div>
<Card className="shadow-xl border-0 !rounded-2xl overflow-hidden">
@@ -379,7 +378,7 @@ const RegisterForm = () => {
<div className="w-full max-w-md">
<div className="flex items-center justify-center mb-6 gap-2">
<img src={logo} alt="Logo" className="h-10 rounded-full" />
<Title heading={3} className='!text-white'>{systemName}</Title>
<Title heading={3} className='!text-gray-800'>{systemName}</Title>
</div>
<Card className="shadow-xl border-0 !rounded-2xl overflow-hidden">
@@ -542,17 +541,8 @@ const RegisterForm = () => {
};
return (
<div className="min-h-screen relative flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8 overflow-hidden">
<div
className="absolute inset-0 z-0 bg-cover bg-center scale-125 opacity-100"
style={{
backgroundImage: `url(${Background})`
}}
></div>
<div className="absolute inset-0 bg-gradient-to-br from-teal-500/30 via-blue-500/30 to-purple-500/30 backdrop-blur-sm z-0"></div>
<div className="w-full max-w-sm relative z-10">
<div className="bg-gray-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
<div className="w-full max-w-sm">
{showEmailRegister || !(status.github_oauth || status.oidc_enabled || status.wechat_login || status.linuxdo_oauth || status.telegram_oauth)
? renderEmailRegisterForm()
: renderOAuthOptions()}
+1 -1
View File
@@ -14,7 +14,7 @@ const Loading = ({ prompt: name = '', size = 'large' }) => {
tip={null}
/>
<span className="whitespace-nowrap mt-2 text-center" style={{ color: 'var(--semi-color-primary)' }}>
{name ? t('加载{{name}}中...', { name }) : t('加载中...')}
{name ? t('{{name}}', { name }) : t('加载中...')}
</span>
</div>
</div>
+18 -20
View File
@@ -40,36 +40,36 @@ const FooterBar = () => {
<div className="text-left">
<p className="!text-semi-color-text-0 font-semibold mb-5">{t('关于我们')}</p>
<div className="flex flex-col gap-4">
<a href="https://docs.newapi.pro/wiki/project-introduction/" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1 hover:!text-semi-color-primary transition-colors">{t('关于项目')}</a>
<a href="https://docs.newapi.pro/support/community-interaction/" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1 hover:!text-semi-color-primary transition-colors">{t('联系我们')}</a>
<a href="https://docs.newapi.pro/wiki/features-introduction/" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1 hover:!text-semi-color-primary transition-colors">{t('功能特性')}</a>
<a href="https://docs.newapi.pro/wiki/project-introduction/" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1">{t('关于项目')}</a>
<a href="https://docs.newapi.pro/support/community-interaction/" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1">{t('联系我们')}</a>
<a href="https://docs.newapi.pro/wiki/features-introduction/" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1">{t('功能特性')}</a>
</div>
</div>
<div className="text-left">
<p className="!text-semi-color-text-0 font-semibold mb-5">{t('文档')}</p>
<div className="flex flex-col gap-4">
<a href="https://docs.newapi.pro/getting-started/" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1 hover:!text-semi-color-primary transition-colors">{t('快速开始')}</a>
<a href="https://docs.newapi.pro/installation/" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1 hover:!text-semi-color-primary transition-colors">{t('安装指南')}</a>
<a href="https://docs.newapi.pro/api/" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1 hover:!text-semi-color-primary transition-colors">{t('API 文档')}</a>
<a href="https://docs.newapi.pro/getting-started/" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1">{t('快速开始')}</a>
<a href="https://docs.newapi.pro/installation/" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1">{t('安装指南')}</a>
<a href="https://docs.newapi.pro/api/" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1">{t('API 文档')}</a>
</div>
</div>
<div className="text-left">
<p className="!text-semi-color-text-0 font-semibold mb-5">{t('相关项目')}</p>
<div className="flex flex-col gap-4">
<a href="https://github.com/songquanpeng/one-api" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1 hover:!text-semi-color-primary transition-colors">One API</a>
<a href="https://github.com/novicezk/midjourney-proxy" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1 hover:!text-semi-color-primary transition-colors">Midjourney-Proxy</a>
<a href="https://github.com/Deeptrain-Community/chatnio" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1 hover:!text-semi-color-primary transition-colors">chatnio</a>
<a href="https://github.com/Calcium-Ion/neko-api-key-tool" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1 hover:!text-semi-color-primary transition-colors">neko-api-key-tool</a>
<a href="https://github.com/songquanpeng/one-api" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1">One API</a>
<a href="https://github.com/novicezk/midjourney-proxy" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1">Midjourney-Proxy</a>
<a href="https://github.com/Deeptrain-Community/chatnio" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1">chatnio</a>
<a href="https://github.com/Calcium-Ion/neko-api-key-tool" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1">neko-api-key-tool</a>
</div>
</div>
<div className="text-left">
<p className="!text-semi-color-text-0 font-semibold mb-5">{t('基于New API的项目')}</p>
<div className="flex flex-col gap-4">
<a href="https://github.com/Calcium-Ion/new-api-horizon" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1 hover:!text-semi-color-primary transition-colors">new-api-horizon</a>
{/* <a href="https://github.com/VoAPI/VoAPI" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1 hover:!text-semi-color-primary transition-colors">VoAPI</a> */}
<a href="https://github.com/Calcium-Ion/new-api-horizon" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1">new-api-horizon</a>
{/* <a href="https://github.com/VoAPI/VoAPI" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1">VoAPI</a> */}
</div>
</div>
</div>
@@ -81,14 +81,12 @@ const FooterBar = () => {
<Typography.Text className="text-sm !text-semi-color-text-1">© {currentYear} {systemName}. {t('版权所有')}</Typography.Text>
</div>
{isDemoSiteMode && (
<div className="text-sm">
<span className="!text-semi-color-text-1">{t('设计与开发由')} </span>
<span className="!text-semi-color-primary">Douyin FE</span>
<span className="!text-semi-color-text-1"> & </span>
<a href="https://github.com/QuantumNous" target="_blank" rel="noreferrer" className="!text-semi-color-primary hover:!text-semi-color-primary-hover transition-colors">QuantumNous</a>
</div>
)}
<div className="text-sm">
<span className="!text-semi-color-text-1">{t('设计与开发由')} </span>
<a href="https://github.com/QuantumNous/new-api" target="_blank" rel="noopener noreferrer" className="!text-semi-color-primary font-medium">New API</a>
<span className="!text-semi-color-text-1"> & </span>
<a href="https://github.com/songquanpeng/one-api" target="_blank" rel="noopener noreferrer" className="!text-semi-color-primary font-medium">One API</a>
</div>
</div>
</footer>
), [logo, systemName, t, currentYear, isDemoSiteMode]);
+1 -1
View File
@@ -363,7 +363,7 @@ const HeaderBar = () => {
onClose={() => setNoticeVisible(false)}
isMobile={styleState.isMobile}
/>
<div className="w-full px-4">
<div className="w-full px-2">
<div className="flex items-center justify-between h-16">
<div className="flex items-center">
<div className="md:hidden">
+2 -3
View File
@@ -134,11 +134,10 @@ const PageLayout = () => {
<Content
style={{
flex: '1 0 auto',
overflowY: styleState.isMobile ? 'visible' : 'auto',
overflowY: styleState.isMobile ? 'visible' : 'hidden',
WebkitOverflowScrolling: 'touch',
padding: shouldInnerPadding ? '24px' : '0',
padding: shouldInnerPadding ? (styleState.isMobile ? '5px' : '24px') : '0',
position: 'relative',
marginTop: styleState.isMobile ? '2px' : '0',
}}
>
<App />
@@ -0,0 +1,79 @@
import React, { useEffect, useState } from 'react';
import { Card, Spin } from '@douyinfe/semi-ui';
import { API, showError } from '../../helpers';
import SettingsAPIInfo from '../../pages/Setting/Dashboard/SettingsAPIInfo.js';
import SettingsAnnouncements from '../../pages/Setting/Dashboard/SettingsAnnouncements.js';
import SettingsFAQ from '../../pages/Setting/Dashboard/SettingsFAQ.js';
import SettingsUptimeKuma from '../../pages/Setting/Dashboard/SettingsUptimeKuma.js';
const DashboardSetting = () => {
let [inputs, setInputs] = useState({
ApiInfo: '',
Announcements: '',
FAQ: '',
UptimeKumaUrl: '',
UptimeKumaSlug: '',
});
let [loading, setLoading] = useState(false);
const getOptions = async () => {
const res = await API.get('/api/option/');
const { success, message, data } = res.data;
if (success) {
let newInputs = {};
data.forEach((item) => {
if (item.key in inputs) {
newInputs[item.key] = item.value;
}
});
setInputs(newInputs);
} else {
showError(message);
}
};
async function onRefresh() {
try {
setLoading(true);
await getOptions();
} catch (error) {
showError('刷新失败');
console.error(error);
} finally {
setLoading(false);
}
}
useEffect(() => {
onRefresh();
}, []);
return (
<>
<Spin spinning={loading} size='large'>
{/* API信息管理 */}
<Card style={{ marginTop: '10px' }}>
<SettingsAPIInfo options={inputs} refresh={onRefresh} />
</Card>
{/* 系统公告管理 */}
<Card style={{ marginTop: '10px' }}>
<SettingsAnnouncements options={inputs} refresh={onRefresh} />
</Card>
{/* 常见问答管理 */}
<Card style={{ marginTop: '10px' }}>
<SettingsFAQ options={inputs} refresh={onRefresh} />
</Card>
{/* Uptime Kuma 监控设置 */}
<Card style={{ marginTop: '10px' }}>
<SettingsUptimeKuma options={inputs} refresh={onRefresh} />
</Card>
</Spin>
</>
);
};
export default DashboardSetting;
@@ -30,6 +30,7 @@ const OperationSetting = () => {
CompletionRatio: '',
ModelPrice: '',
GroupRatio: '',
GroupGroupRatio: '',
UserUsableGroups: '',
TopUpLink: '',
'general_setting.docs_link': '',
@@ -74,6 +75,7 @@ const OperationSetting = () => {
if (
item.key === 'ModelRatio' ||
item.key === 'GroupRatio' ||
item.key === 'GroupGroupRatio' ||
item.key === 'UserUsableGroups' ||
item.key === 'CompletionRatio' ||
item.key === 'ModelPrice' ||
+119 -100
View File
@@ -19,6 +19,7 @@ import {
} from '../../helpers';
import Turnstile from 'react-turnstile';
import { UserContext } from '../../context/User';
import { useTheme } from '../../context/Theme';
import {
Avatar,
Banner,
@@ -39,7 +40,7 @@ import {
AutoComplete,
Checkbox,
Tabs,
TabPane,
TabPane
} from '@douyinfe/semi-ui';
import { IllustrationNoContent, IllustrationNoContentDark } from '@douyinfe/semi-illustrations';
import {
@@ -53,7 +54,7 @@ import {
IconKey,
IconDelete,
IconChevronDown,
IconChevronUp,
IconChevronUp
} from '@douyinfe/semi-icons';
import { SiTelegram, SiWechat, SiLinux } from 'react-icons/si';
import { Bell, Shield, Webhook, Globe, Settings, UserPlus, ShieldCheck } from 'lucide-react';
@@ -64,6 +65,7 @@ const PersonalSetting = () => {
const [userState, userDispatch] = useContext(UserContext);
let navigate = useNavigate();
const { t } = useTranslation();
const theme = useTheme();
const [inputs, setInputs] = useState({
wechat_verification_code: '',
@@ -101,6 +103,7 @@ const PersonalSetting = () => {
webhookSecret: '',
notificationEmail: '',
acceptUnsetModelRatioModel: false,
recordIpLog: false,
});
const [modelsLoading, setModelsLoading] = useState(true);
const [showWebhookDocs, setShowWebhookDocs] = useState(true);
@@ -145,6 +148,7 @@ const PersonalSetting = () => {
notificationEmail: settings.notification_email || '',
acceptUnsetModelRatioModel:
settings.accept_unset_model_ratio_model || false,
recordIpLog: settings.record_ip_log || false,
});
}
}, [userState?.user?.setting]);
@@ -344,7 +348,7 @@ const PersonalSetting = () => {
const handleNotificationSettingChange = (type, value) => {
setNotificationSettings((prev) => ({
...prev,
[type]: value.target ? value.target.value : value, // 处理 Radio 事件对象
[type]: value.target ? value.target.value !== undefined ? value.target.value : value.target.checked : value, // handle checkbox properly
}));
};
@@ -360,16 +364,17 @@ const PersonalSetting = () => {
notification_email: notificationSettings.notificationEmail,
accept_unset_model_ratio_model:
notificationSettings.acceptUnsetModelRatioModel,
record_ip_log: notificationSettings.recordIpLog,
});
if (res.data.success) {
showSuccess(t('通知设置已更新'));
showSuccess(t('设置保存成功'));
await getUserData();
} else {
showError(res.data.message);
}
} catch (error) {
showError(t('更新通知设置失败'));
showError(t('设置保存失败'));
}
};
@@ -384,107 +389,81 @@ const PersonalSetting = () => {
<Card className="!rounded-2xl shadow-lg border-0">
{/* 顶部用户信息区域 */}
<Card
className="!rounded-2xl !border-0 !shadow-2xl overflow-hidden"
className="!rounded-2xl !border-0 !shadow-lg overflow-hidden"
style={{
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 25%, #a855f7 50%, #c084fc 75%, #d8b4fe 100%)',
background: theme === 'dark'
? 'linear-gradient(135deg, #1e293b 0%, #334155 50%, #475569 100%)'
: 'linear-gradient(135deg, #f8fafc 0%, #e2e8f0 50%, #cbd5e1 100%)',
position: 'relative'
}}
bodyStyle={{ padding: 0 }}
>
{/* 装饰性背景元素 */}
<div className="absolute inset-0 overflow-hidden">
<div className="absolute -top-10 -right-10 w-40 h-40 bg-white opacity-5 rounded-full"></div>
<div className="absolute -bottom-16 -left-16 w-48 h-48 bg-white opacity-3 rounded-full"></div>
<div className="absolute top-1/2 right-1/4 w-24 h-24 bg-yellow-400 opacity-10 rounded-full"></div>
<div className="absolute -top-10 -right-10 w-40 h-40 bg-slate-400 dark:bg-slate-500 opacity-5 rounded-full"></div>
<div className="absolute -bottom-16 -left-16 w-48 h-48 bg-slate-300 dark:bg-slate-400 opacity-8 rounded-full"></div>
<div className="absolute top-1/2 right-1/4 w-24 h-24 bg-slate-400 dark:bg-slate-500 opacity-6 rounded-full"></div>
</div>
<div className="relative p-4 sm:p-6 md:p-8" style={{ color: 'white' }}>
<div className="relative p-4 sm:p-6 md:p-8 text-gray-600 dark:text-gray-300">
<div className="flex justify-between items-start mb-4 sm:mb-6">
<div className="flex items-center flex-1 min-w-0">
<Avatar
size='large'
color={stringToColor(getUsername())}
border={{ motion: true }}
contentMotion={true}
className="mr-3 sm:mr-4 shadow-lg flex-shrink-0"
className="mr-3 sm:mr-4 shadow-md flex-shrink-0 bg-slate-500 dark:bg-slate-400"
>
{getAvatarText()}
</Avatar>
<div className="flex-1 min-w-0">
<div className="text-base sm:text-lg font-semibold truncate" style={{ color: 'white' }}>
<div className="text-base sm:text-lg font-semibold truncate text-gray-800 dark:text-gray-100">
{getUsername()}
</div>
<div className="mt-1 flex flex-wrap gap-1 sm:gap-2">
{isRoot() ? (
<Tag
color='red'
size='small'
style={{
backgroundColor: 'rgba(255, 255, 255, 0.95)',
color: '#dc2626',
fontWeight: '600'
}}
className="!rounded-full"
className="!rounded-full bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300"
style={{ fontWeight: '500' }}
>
{t('超级管理员')}
</Tag>
) : isAdmin() ? (
<Tag
color='orange'
size='small'
style={{
backgroundColor: 'rgba(255, 255, 255, 0.95)',
color: '#ea580c',
fontWeight: '600'
}}
className="!rounded-full"
className="!rounded-full bg-gray-50 dark:bg-gray-700 text-gray-600 dark:text-gray-300"
style={{ fontWeight: '500' }}
>
{t('管理员')}
</Tag>
) : (
<Tag
color='blue'
size='small'
style={{
backgroundColor: 'rgba(255, 255, 255, 0.95)',
color: '#2563eb',
fontWeight: '600'
}}
className="!rounded-full"
className="!rounded-full bg-slate-50 dark:bg-slate-700 text-slate-600 dark:text-slate-300"
style={{ fontWeight: '500' }}
>
{t('普通用户')}
</Tag>
)}
<Tag
color='green'
size='small'
className="!rounded-full"
style={{
backgroundColor: 'rgba(255, 255, 255, 0.95)',
color: '#16a34a',
fontWeight: '600'
}}
className="!rounded-full bg-slate-100 dark:bg-slate-700 text-slate-600 dark:text-slate-300"
style={{ fontWeight: '500' }}
>
ID: {userState?.user?.id}
</Tag>
</div>
</div>
</div>
<div
className="w-10 h-10 sm:w-12 sm:h-12 rounded-lg flex items-center justify-center shadow-lg flex-shrink-0 ml-2"
style={{
background: `linear-gradient(135deg, ${stringToColor(getUsername())} 0%, #f59e0b 100%)`
}}
>
<IconUser size="default" style={{ color: 'white' }} />
<div className="w-10 h-10 sm:w-12 sm:h-12 rounded-lg flex items-center justify-center shadow-md flex-shrink-0 ml-2 bg-slate-400 dark:bg-slate-500">
<IconUser size="default" className="text-white" />
</div>
</div>
<div className="mb-4 sm:mb-6">
<div className="text-xs sm:text-sm mb-1 sm:mb-2" style={{ color: 'rgba(255, 255, 255, 0.7)' }}>
<div className="text-xs sm:text-sm mb-1 sm:mb-2 text-gray-500 dark:text-gray-400">
{t('当前余额')}
</div>
<div className="text-2xl sm:text-3xl md:text-4xl font-bold tracking-wide" style={{ color: 'white' }}>
<div className="text-2xl sm:text-3xl md:text-4xl font-bold tracking-wide text-gray-900 dark:text-gray-100">
{renderQuota(userState?.user?.quota)}
</div>
</div>
@@ -492,33 +471,33 @@ const PersonalSetting = () => {
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-end">
<div className="grid grid-cols-3 gap-2 sm:flex sm:space-x-6 lg:space-x-8 mb-3 sm:mb-0">
<div className="text-center sm:text-left">
<div className="text-xs" style={{ color: 'rgba(255, 255, 255, 0.6)' }}>
<div className="text-xs text-gray-400 dark:text-gray-500">
{t('历史消耗')}
</div>
<div className="text-xs sm:text-sm font-medium truncate" style={{ color: 'white' }}>
<div className="text-xs sm:text-sm font-medium truncate text-gray-600 dark:text-gray-300">
{renderQuota(userState?.user?.used_quota)}
</div>
</div>
<div className="text-center sm:text-left">
<div className="text-xs" style={{ color: 'rgba(255, 255, 255, 0.6)' }}>
<div className="text-xs text-gray-400 dark:text-gray-500">
{t('请求次数')}
</div>
<div className="text-xs sm:text-sm font-medium truncate" style={{ color: 'white' }}>
<div className="text-xs sm:text-sm font-medium truncate text-gray-600 dark:text-gray-300">
{userState.user?.request_count || 0}
</div>
</div>
<div className="text-center sm:text-left">
<div className="text-xs" style={{ color: 'rgba(255, 255, 255, 0.6)' }}>
<div className="text-xs text-gray-400 dark:text-gray-500">
{t('用户分组')}
</div>
<div className="text-xs sm:text-sm font-medium truncate" style={{ color: 'white' }}>
<div className="text-xs sm:text-sm font-medium truncate text-gray-600 dark:text-gray-300">
{userState?.user?.group || t('默认')}
</div>
</div>
</div>
</div>
<div className="absolute top-0 left-0 w-full h-2 bg-gradient-to-r from-yellow-400 via-orange-400 to-red-400" style={{ opacity: 0.6 }}></div>
<div className="absolute top-0 left-0 w-full h-2 bg-gradient-to-r from-slate-300 via-slate-400 to-slate-500 dark:from-slate-600 dark:via-slate-500 dark:to-slate-400 opacity-40"></div>
</div>
</Card>
@@ -537,10 +516,10 @@ const PersonalSetting = () => {
>
<div className="gap-6 py-4">
{/* 可用模型部分 */}
<div className="bg-gray-50 rounded-xl">
<div className="bg-gray-50 dark:bg-gray-800 rounded-xl">
<div className="flex items-center mb-4">
<div className="w-10 h-10 rounded-full bg-purple-50 flex items-center justify-center mr-3">
<Settings size={20} className="text-purple-500" />
<div className="w-10 h-10 rounded-full bg-slate-100 dark:bg-slate-700 flex items-center justify-center mr-3">
<Settings size={20} className="text-slate-600 dark:text-slate-300" />
</div>
<div>
<Typography.Title heading={6} className="mb-0">{t('模型列表')}</Typography.Title>
@@ -629,7 +608,7 @@ const PersonalSetting = () => {
</Tabs>
</div>
<div className="bg-white rounded-lg p-3">
<div className="bg-white dark:bg-gray-700 rounded-lg p-3">
{(() => {
// 根据当前选中的分类过滤模型
const categories = getModelCategories(t);
@@ -737,8 +716,8 @@ const PersonalSetting = () => {
>
<div className="flex items-center justify-between">
<div className="flex items-center flex-1">
<div className="w-10 h-10 rounded-full bg-red-50 flex items-center justify-center mr-3">
<IconMail size="default" className="text-red-500" />
<div className="w-10 h-10 rounded-full bg-slate-100 dark:bg-slate-700 flex items-center justify-center mr-3">
<IconMail size="default" className="text-slate-600 dark:text-slate-300" />
</div>
<div className="flex-1 min-w-0">
<div className="font-medium text-gray-900">{t('邮箱')}</div>
@@ -771,8 +750,8 @@ const PersonalSetting = () => {
>
<div className="flex items-center justify-between">
<div className="flex items-center flex-1">
<div className="w-10 h-10 rounded-full bg-green-50 flex items-center justify-center mr-3">
<SiWechat size={20} className="text-green-500" />
<div className="w-10 h-10 rounded-full bg-slate-100 dark:bg-slate-700 flex items-center justify-center mr-3">
<SiWechat size={20} className="text-slate-600 dark:text-slate-300" />
</div>
<div className="flex-1 min-w-0">
<div className="font-medium text-gray-900">{t('微信')}</div>
@@ -808,8 +787,8 @@ const PersonalSetting = () => {
>
<div className="flex items-center justify-between">
<div className="flex items-center flex-1">
<div className="w-10 h-10 rounded-full bg-gray-100 flex items-center justify-center mr-3">
<IconGithubLogo size="default" className="text-gray-700" />
<div className="w-10 h-10 rounded-full bg-slate-100 dark:bg-slate-700 flex items-center justify-center mr-3">
<IconGithubLogo size="default" className="text-slate-600 dark:text-slate-300" />
</div>
<div className="flex-1 min-w-0">
<div className="font-medium text-gray-900">{t('GitHub')}</div>
@@ -844,8 +823,8 @@ const PersonalSetting = () => {
>
<div className="flex items-center justify-between">
<div className="flex items-center flex-1">
<div className="w-10 h-10 rounded-full bg-indigo-50 flex items-center justify-center mr-3">
<IconShield size="default" className="text-indigo-500" />
<div className="w-10 h-10 rounded-full bg-slate-100 dark:bg-slate-700 flex items-center justify-center mr-3">
<IconShield size="default" className="text-slate-600 dark:text-slate-300" />
</div>
<div className="flex-1 min-w-0">
<div className="font-medium text-gray-900">{t('OIDC')}</div>
@@ -883,8 +862,8 @@ const PersonalSetting = () => {
>
<div className="flex items-center justify-between">
<div className="flex items-center flex-1">
<div className="w-10 h-10 rounded-full bg-blue-50 flex items-center justify-center mr-3">
<SiTelegram size={20} className="text-blue-500" />
<div className="w-10 h-10 rounded-full bg-slate-100 dark:bg-slate-700 flex items-center justify-center mr-3">
<SiTelegram size={20} className="text-slate-600 dark:text-slate-300" />
</div>
<div className="flex-1 min-w-0">
<div className="font-medium text-gray-900">{t('Telegram')}</div>
@@ -926,8 +905,8 @@ const PersonalSetting = () => {
>
<div className="flex items-center justify-between">
<div className="flex items-center flex-1">
<div className="w-10 h-10 rounded-full bg-orange-50 flex items-center justify-center mr-3">
<SiLinux size={20} className="text-orange-500" />
<div className="w-10 h-10 rounded-full bg-slate-100 dark:bg-slate-700 flex items-center justify-center mr-3">
<SiLinux size={20} className="text-slate-600 dark:text-slate-300" />
</div>
<div className="flex-1 min-w-0">
<div className="font-medium text-gray-900">{t('LinuxDO')}</div>
@@ -978,8 +957,8 @@ const PersonalSetting = () => {
>
<div className="flex flex-col sm:flex-row items-start sm:justify-between gap-4">
<div className="flex items-start w-full sm:w-auto">
<div className="w-12 h-12 rounded-full bg-blue-50 flex items-center justify-center mr-4 flex-shrink-0">
<IconKey size="large" className="text-blue-500" />
<div className="w-12 h-12 rounded-full bg-slate-100 flex items-center justify-center mr-4 flex-shrink-0">
<IconKey size="large" className="text-slate-600" />
</div>
<div className="flex-1">
<Typography.Title heading={6} className="mb-1">
@@ -1006,7 +985,7 @@ const PersonalSetting = () => {
type="primary"
theme="solid"
onClick={generateAccessToken}
className="!rounded-lg !bg-blue-500 hover:!bg-blue-600 w-full sm:w-auto"
className="!rounded-lg !bg-slate-600 hover:!bg-slate-700 w-full sm:w-auto"
icon={<IconKey />}
>
{systemToken ? t('重新生成') : t('生成令牌')}
@@ -1022,8 +1001,8 @@ const PersonalSetting = () => {
>
<div className="flex flex-col sm:flex-row items-start sm:justify-between gap-4">
<div className="flex items-start w-full sm:w-auto">
<div className="w-12 h-12 rounded-full bg-orange-50 flex items-center justify-center mr-4 flex-shrink-0">
<IconLock size="large" className="text-orange-500" />
<div className="w-12 h-12 rounded-full bg-slate-100 flex items-center justify-center mr-4 flex-shrink-0">
<IconLock size="large" className="text-slate-600" />
</div>
<div>
<Typography.Title heading={6} className="mb-1">
@@ -1038,7 +1017,7 @@ const PersonalSetting = () => {
type="primary"
theme="solid"
onClick={() => setShowChangePasswordModal(true)}
className="!rounded-lg !bg-orange-500 hover:!bg-orange-600 w-full sm:w-auto"
className="!rounded-lg !bg-slate-600 hover:!bg-slate-700 w-full sm:w-auto"
icon={<IconLock />}
>
{t('修改密码')}
@@ -1054,11 +1033,11 @@ const PersonalSetting = () => {
>
<div className="flex flex-col sm:flex-row items-start sm:justify-between gap-4">
<div className="flex items-start w-full sm:w-auto">
<div className="w-12 h-12 rounded-full bg-red-50 flex items-center justify-center mr-4 flex-shrink-0">
<IconDelete size="large" className="text-red-500" />
<div className="w-12 h-12 rounded-full bg-slate-100 flex items-center justify-center mr-4 flex-shrink-0">
<IconDelete size="large" className="text-slate-600" />
</div>
<div>
<Typography.Title heading={6} className="mb-1 text-red-600">
<Typography.Title heading={6} className="mb-1 text-slate-700">
{t('删除账户')}
</Typography.Title>
<Typography.Text type="tertiary" className="text-sm">
@@ -1070,7 +1049,7 @@ const PersonalSetting = () => {
type="danger"
theme="solid"
onClick={() => setShowAccountDeleteModal(true)}
className="!rounded-lg w-full sm:w-auto"
className="!rounded-lg w-full sm:w-auto !bg-slate-500 hover:!bg-slate-600"
icon={<IconDelete />}
>
{t('删除账户')}
@@ -1087,7 +1066,7 @@ const PersonalSetting = () => {
tab={
<div className="flex items-center">
<Bell size={16} className="mr-2" />
{t('通知设置')}
{t('其他设置')}
</div>
}
itemKey='notification'
@@ -1111,7 +1090,7 @@ const PersonalSetting = () => {
>
<Radio value='email' className="!p-4 !rounded-lg">
<div className="flex items-center">
<IconMail className="mr-2 text-blue-500" />
<IconMail className="mr-2 text-slate-600" />
<div>
<div className="font-medium">{t('邮件通知')}</div>
<div className="text-sm text-gray-500">{t('通过邮件接收通知')}</div>
@@ -1120,7 +1099,7 @@ const PersonalSetting = () => {
</Radio>
<Radio value='webhook' className="!p-4 !rounded-lg">
<div className="flex items-center">
<Webhook size={16} className="mr-2 text-green-500" />
<Webhook size={16} className="mr-2 text-slate-600" />
<div>
<div className="font-medium">{t('Webhook通知')}</div>
<div className="text-sm text-gray-500">{t('通过HTTP请求接收通知')}</div>
@@ -1167,11 +1146,11 @@ const PersonalSetting = () => {
</div>
</div>
<div className="bg-yellow-50 rounded-xl">
<div className="bg-slate-50 rounded-xl">
<div className="flex items-center justify-between cursor-pointer" onClick={() => setShowWebhookDocs(!showWebhookDocs)}>
<div className="flex items-center">
<Globe size={16} className="mr-2 text-yellow-600" />
<Typography.Text strong className="text-yellow-800">
<Globe size={16} className="mr-2 text-slate-600" />
<Typography.Text strong className="text-slate-700">
{t('Webhook请求结构')}
</Typography.Text>
</div>
@@ -1252,28 +1231,68 @@ const PersonalSetting = () => {
<TabPane
tab={t('价格设置')}
itemKey='price'
>
<div className="py-4">
<div className="space-y-4">
{/* 接受未设置价格模型 */}
<div className="bg-white rounded-xl">
<div className="flex items-start">
<div className="w-10 h-10 rounded-full bg-slate-100 flex items-center justify-center mt-1">
<Shield size={20} className="text-slate-600" />
</div>
<div className="flex-1">
<div className="flex items-center justify-between">
<div>
<Typography.Text strong className="block mb-2">
{t('接受未设置价格模型')}
</Typography.Text>
<div className="text-gray-500 text-sm">
{t('当模型没有设置价格时仍接受调用,仅当您信任该网站时使用,可能会产生高额费用')}
</div>
</div>
<Checkbox
checked={notificationSettings.acceptUnsetModelRatioModel}
onChange={(e) =>
handleNotificationSettingChange(
'acceptUnsetModelRatioModel',
e.target.checked,
)
}
className="ml-4"
/>
</div>
</div>
</div>
</div>
</div>
</div>
</TabPane>
<TabPane
tab={t('IP记录')}
itemKey='ip'
>
<div className="py-4">
<div className="bg-white rounded-xl">
<div className="flex items-start">
<div className="w-10 h-10 rounded-full bg-orange-50 flex items-center justify-center mt-1">
<Shield size={20} className="text-orange-500" />
<div className="w-10 h-10 rounded-full bg-slate-100 flex items-center justify-center mt-1">
<ShieldCheck size={20} className="text-slate-600" />
</div>
<div className="flex-1">
<div className="flex items-center justify-between">
<div>
<Typography.Text strong className="block mb-2">
{t('接受未设置价格模型')}
{t('记录请求与错误日志 IP')}
</Typography.Text>
<div className="text-gray-500 text-sm">
{t('当模型没有设置价格时仍接受调用,仅当您信任该网站时使用,可能会产生高额费用')}
{t('开启后,仅“消费”和“错误”日志将记录您的客户端 IP 地址')}
</div>
</div>
<Checkbox
checked={notificationSettings.acceptUnsetModelRatioModel}
checked={notificationSettings.recordIpLog}
onChange={(e) =>
handleNotificationSettingChange(
'acceptUnsetModelRatioModel',
'recordIpLog',
e.target.checked,
)
}
@@ -1292,7 +1311,7 @@ const PersonalSetting = () => {
type='primary'
onClick={saveNotificationSettings}
size="large"
className="!rounded-lg !bg-purple-500 hover:!bg-purple-600"
className="!rounded-lg !bg-slate-600 hover:!bg-slate-700"
icon={<IconSetting />}
>
{t('保存设置')}
@@ -1408,7 +1427,7 @@ const PersonalSetting = () => {
theme="solid"
size='large'
onClick={bindWeChat}
className="!rounded-lg w-full !bg-green-500 hover:!bg-green-600"
className="!rounded-lg w-full !bg-slate-600 hover:!bg-slate-700"
icon={<SiWechat size={16} />}
>
{t('绑定')}
+210 -165
View File
@@ -6,15 +6,31 @@ import {
showSuccess,
timestamp2string,
renderGroup,
renderNumberWithPoint,
renderQuota
renderQuota,
getChannelIcon,
renderQuotaWithAmount
} from '../../helpers/index.js';
import {
CheckCircle,
XCircle,
AlertCircle,
HelpCircle,
TestTube,
Zap,
Timer,
Clock,
AlertTriangle,
Coins,
Tags
} from 'lucide-react';
import { CHANNEL_OPTIONS, ITEMS_PER_PAGE } from '../../constants/index.js';
import {
Button,
Divider,
Dropdown,
Empty,
Input,
InputNumber,
Modal,
@@ -27,13 +43,15 @@ import {
Typography,
Checkbox,
Card,
Select
Form
} from '@douyinfe/semi-ui';
import {
IllustrationNoResult,
IllustrationNoResultDark
} from '@douyinfe/semi-illustrations';
import EditChannel from '../../pages/Channel/EditChannel.js';
import {
IconList,
IconTreeTriangleDown,
IconFilter,
IconPlus,
IconRefresh,
IconSetting,
@@ -64,7 +82,12 @@ const ChannelsTable = () => {
type2label[0] = { value: 0, label: t('未知类型'), color: 'grey' };
}
return (
<Tag size='large' color={type2label[type]?.color} shape='circle'>
<Tag
size='large'
color={type2label[type]?.color}
shape='circle'
prefixIcon={getChannelIcon(type)}
>
{type2label[type]?.label}
</Tag>
);
@@ -74,7 +97,7 @@ const ChannelsTable = () => {
return (
<Tag
color='light-blue'
prefixIcon={<IconList />}
prefixIcon={<Tags size={14} />}
size='large'
shape='circle'
type='light'
@@ -88,25 +111,25 @@ const ChannelsTable = () => {
switch (status) {
case 1:
return (
<Tag size='large' color='green' shape='circle'>
<Tag size='large' color='green' shape='circle' prefixIcon={<CheckCircle size={14} />}>
{t('已启用')}
</Tag>
);
case 2:
return (
<Tag size='large' color='yellow' shape='circle'>
<Tag size='large' color='yellow' shape='circle' prefixIcon={<XCircle size={14} />}>
{t('已禁用')}
</Tag>
);
case 3:
return (
<Tag size='large' color='yellow' shape='circle'>
<Tag size='large' color='yellow' shape='circle' prefixIcon={<AlertCircle size={14} />}>
{t('自动禁用')}
</Tag>
);
default:
return (
<Tag size='large' color='grey' shape='circle'>
<Tag size='large' color='grey' shape='circle' prefixIcon={<HelpCircle size={14} />}>
{t('未知状态')}
</Tag>
);
@@ -118,31 +141,31 @@ const ChannelsTable = () => {
time = time.toFixed(2) + t(' 秒');
if (responseTime === 0) {
return (
<Tag size='large' color='grey' shape='circle'>
<Tag size='large' color='grey' shape='circle' prefixIcon={<TestTube size={14} />}>
{t('未测试')}
</Tag>
);
} else if (responseTime <= 1000) {
return (
<Tag size='large' color='green' shape='circle'>
<Tag size='large' color='green' shape='circle' prefixIcon={<Zap size={14} />}>
{time}
</Tag>
);
} else if (responseTime <= 3000) {
return (
<Tag size='large' color='lime' shape='circle'>
<Tag size='large' color='lime' shape='circle' prefixIcon={<Timer size={14} />}>
{time}
</Tag>
);
} else if (responseTime <= 5000) {
return (
<Tag size='large' color='yellow' shape='circle'>
<Tag size='large' color='yellow' shape='circle' prefixIcon={<Clock size={14} />}>
{time}
</Tag>
);
} else {
return (
<Tag size='large' color='red' shape='circle'>
<Tag size='large' color='red' shape='circle' prefixIcon={<AlertTriangle size={14} />}>
{time}
</Tag>
);
@@ -324,19 +347,20 @@ const ChannelsTable = () => {
<div>
<Space spacing={1}>
<Tooltip content={t('已用额度')}>
<Tag color='white' type='ghost' size='large' shape='circle'>
<Tag color='white' type='ghost' size='large' shape='circle' prefixIcon={<Coins size={14} />}>
{renderQuota(record.used_quota)}
</Tag>
</Tooltip>
<Tooltip content={t('剩余额度') + record.balance + t(',点击更新')}>
<Tooltip content={t('剩余额度$') + record.balance + t(',点击更新')}>
<Tag
color='white'
type='ghost'
size='large'
shape='circle'
prefixIcon={<Coins size={14} />}
onClick={() => updateChannelBalance(record)}
>
${renderNumberWithPoint(record.balance)}
{renderQuotaWithAmount(record.balance)}
</Tag>
</Tooltip>
</Space>
@@ -345,7 +369,7 @@ const ChannelsTable = () => {
} else {
return (
<Tooltip content={t('已用额度')}>
<Tag color='white' type='ghost' size='large' shape='circle'>
<Tag color='white' type='ghost' size='large' shape='circle' prefixIcon={<Coins size={14} />}>
{renderQuota(record.used_quota)}
</Tag>
</Tooltip>
@@ -631,6 +655,44 @@ const ChannelsTable = () => {
},
];
const [channels, setChannels] = useState([]);
const [loading, setLoading] = useState(true);
const [activePage, setActivePage] = useState(1);
const [idSort, setIdSort] = useState(false);
const [searching, setSearching] = useState(false);
const [pageSize, setPageSize] = useState(ITEMS_PER_PAGE);
const [channelCount, setChannelCount] = useState(pageSize);
const [groupOptions, setGroupOptions] = useState([]);
const [showEdit, setShowEdit] = useState(false);
const [enableBatchDelete, setEnableBatchDelete] = useState(false);
const [editingChannel, setEditingChannel] = useState({
id: undefined,
});
const [showEditTag, setShowEditTag] = useState(false);
const [editingTag, setEditingTag] = useState('');
const [selectedChannels, setSelectedChannels] = useState([]);
const [enableTagMode, setEnableTagMode] = useState(false);
const [showBatchSetTag, setShowBatchSetTag] = useState(false);
const [batchSetTagValue, setBatchSetTagValue] = useState('');
const [showModelTestModal, setShowModelTestModal] = useState(false);
const [currentTestChannel, setCurrentTestChannel] = useState(null);
const [modelSearchKeyword, setModelSearchKeyword] = useState('');
const [modelTestResults, setModelTestResults] = useState({});
const [testingModels, setTestingModels] = useState(new Set());
const [isBatchTesting, setIsBatchTesting] = useState(false);
const [testQueue, setTestQueue] = useState([]);
const [isProcessingQueue, setIsProcessingQueue] = useState(false);
// Form API 引用
const [formApi, setFormApi] = useState(null);
// Form 初始值
const formInitValues = {
searchKeyword: '',
searchGroup: '',
searchModel: '',
};
// Filter columns based on visibility settings
const getVisibleColumns = () => {
return allColumns.filter((column) => visibleColumns[column.key]);
@@ -668,8 +730,6 @@ const ChannelsTable = () => {
</Button>
</div>
}
size="middle"
centered={true}
>
<div style={{ marginBottom: 20 }}>
<Checkbox
@@ -714,37 +774,6 @@ const ChannelsTable = () => {
);
};
const [channels, setChannels] = useState([]);
const [loading, setLoading] = useState(true);
const [activePage, setActivePage] = useState(1);
const [idSort, setIdSort] = useState(false);
const [searchKeyword, setSearchKeyword] = useState('');
const [searchGroup, setSearchGroup] = useState('');
const [searchModel, setSearchModel] = useState('');
const [searching, setSearching] = useState(false);
const [pageSize, setPageSize] = useState(ITEMS_PER_PAGE);
const [channelCount, setChannelCount] = useState(pageSize);
const [groupOptions, setGroupOptions] = useState([]);
const [showEdit, setShowEdit] = useState(false);
const [enableBatchDelete, setEnableBatchDelete] = useState(false);
const [editingChannel, setEditingChannel] = useState({
id: undefined,
});
const [showEditTag, setShowEditTag] = useState(false);
const [editingTag, setEditingTag] = useState('');
const [selectedChannels, setSelectedChannels] = useState([]);
const [enableTagMode, setEnableTagMode] = useState(false);
const [showBatchSetTag, setShowBatchSetTag] = useState(false);
const [batchSetTagValue, setBatchSetTagValue] = useState('');
const [showModelTestModal, setShowModelTestModal] = useState(false);
const [currentTestChannel, setCurrentTestChannel] = useState(null);
const [modelSearchKeyword, setModelSearchKeyword] = useState('');
const [modelTestResults, setModelTestResults] = useState({});
const [testingModels, setTestingModels] = useState(new Set());
const [isBatchTesting, setIsBatchTesting] = useState(false);
const [testQueue, setTestQueue] = useState([]);
const [isProcessingQueue, setIsProcessingQueue] = useState(false);
const removeRecord = (record) => {
let newDataSource = [...channels];
if (record.id != null) {
@@ -836,32 +865,22 @@ const ChannelsTable = () => {
tagChannelDates.response_time = tagChannelDates.response_time / 2;
}
}
// data.key = '' + data.id
setChannels(channelDates);
if (channelDates.length >= pageSize) {
setChannelCount(channelDates.length + pageSize);
} else {
setChannelCount(channelDates.length);
}
};
const loadChannels = async (startIdx, pageSize, idSort, enableTagMode) => {
const loadChannels = async (page, pageSize, idSort, enableTagMode) => {
setLoading(true);
const res = await API.get(
`/api/channel/?p=${startIdx}&page_size=${pageSize}&id_sort=${idSort}&tag_mode=${enableTagMode}`,
`/api/channel/?p=${page}&page_size=${pageSize}&id_sort=${idSort}&tag_mode=${enableTagMode}`,
);
if (res === undefined) {
return;
}
const { success, message, data } = res.data;
if (success) {
if (startIdx === 0) {
setChannelFormat(data, enableTagMode);
} else {
let newChannels = [...channels];
newChannels.splice(startIdx * pageSize, data.length, ...data);
setChannelFormat(newChannels, enableTagMode);
}
const { items, total } = data;
setChannelFormat(items, enableTagMode);
setChannelCount(total);
} else {
showError(message);
}
@@ -874,7 +893,6 @@ const ChannelsTable = () => {
channelToCopy.created_time = null;
channelToCopy.balance = 0;
channelToCopy.used_quota = 0;
// 删除可能导致类型不匹配的字段
delete channelToCopy.test_time;
delete channelToCopy.response_time;
if (!channelToCopy) {
@@ -896,15 +914,11 @@ const ChannelsTable = () => {
};
const refresh = async () => {
const { searchKeyword, searchGroup, searchModel } = getFormValues();
if (searchKeyword === '' && searchGroup === '' && searchModel === '') {
await loadChannels(activePage - 1, pageSize, idSort, enableTagMode);
await loadChannels(activePage, pageSize, idSort, enableTagMode);
} else {
await searchChannels(
searchKeyword,
searchGroup,
searchModel,
enableTagMode,
);
await searchChannels(enableTagMode);
}
};
@@ -919,7 +933,7 @@ const ChannelsTable = () => {
setPageSize(localPageSize);
setEnableTagMode(localEnableTagMode);
setEnableBatchDelete(localEnableBatchDelete);
loadChannels(0, localPageSize, localIdSort, localEnableTagMode)
loadChannels(1, localPageSize, localIdSort, localEnableTagMode)
.then()
.catch((reason) => {
showError(reason);
@@ -1010,29 +1024,39 @@ const ChannelsTable = () => {
}
};
const searchChannels = async (
searchKeyword,
searchGroup,
searchModel,
enableTagMode,
) => {
if (searchKeyword === '' && searchGroup === '' && searchModel === '') {
await loadChannels(activePage - 1, pageSize, idSort, enableTagMode);
// setActivePage(1);
return;
}
// 获取表单值的辅助函数,确保所有值都是字符串
const getFormValues = () => {
const formValues = formApi ? formApi.getValues() : {};
return {
searchKeyword: formValues.searchKeyword || '',
searchGroup: formValues.searchGroup || '',
searchModel: formValues.searchModel || '',
};
};
const searchChannels = async (enableTagMode) => {
const { searchKeyword, searchGroup, searchModel } = getFormValues();
setSearching(true);
const res = await API.get(
`/api/channel/search?keyword=${searchKeyword}&group=${searchGroup}&model=${searchModel}&id_sort=${idSort}&tag_mode=${enableTagMode}`,
);
const { success, message, data } = res.data;
if (success) {
setChannelFormat(data, enableTagMode);
setActivePage(1);
} else {
showError(message);
try {
if (searchKeyword === '' && searchGroup === '' && searchModel === '') {
await loadChannels(activePage - 1, pageSize, idSort, enableTagMode);
return;
}
const res = await API.get(
`/api/channel/search?keyword=${searchKeyword}&group=${searchGroup}&model=${searchModel}&id_sort=${idSort}&tag_mode=${enableTagMode}`,
);
const { success, message, data } = res.data;
if (success) {
setChannelFormat(data, enableTagMode);
setActivePage(1);
} else {
showError(message);
}
} finally {
setSearching(false);
}
setSearching(false);
};
const updateChannelProperty = (channelId, updateFn) => {
@@ -1155,24 +1179,18 @@ const ChannelsTable = () => {
}
};
let pageData = channels.slice(
(activePage - 1) * pageSize,
activePage * pageSize,
);
let pageData = channels;
const handlePageChange = (page) => {
setActivePage(page);
if (page === Math.ceil(channels.length / pageSize) + 1) {
// In this case we have to load more data and then append them.
loadChannels(page - 1, pageSize, idSort, enableTagMode).then((r) => { });
}
loadChannels(page, pageSize, idSort, enableTagMode).then(() => { });
};
const handlePageSizeChange = async (size) => {
localStorage.setItem('page-size', size + '');
setPageSize(size);
setActivePage(1);
loadChannels(0, size, idSort, enableTagMode)
loadChannels(1, size, idSort, enableTagMode)
.then()
.catch((reason) => {
showError(reason);
@@ -1182,8 +1200,6 @@ const ChannelsTable = () => {
const fetchGroups = async () => {
try {
let res = await API.get(`/api/group/`);
// add 'all' option
// res.data.data.unshift('all');
if (res === undefined) {
return;
}
@@ -1478,7 +1494,7 @@ const ChannelsTable = () => {
onChange={(v) => {
localStorage.setItem('id-sort', v + '');
setIdSort(v);
loadChannels(0, pageSize, v, enableTagMode);
loadChannels(activePage, pageSize, v, enableTagMode);
}}
/>
</div>
@@ -1505,7 +1521,8 @@ const ChannelsTable = () => {
onChange={(v) => {
localStorage.setItem('enable-tag-mode', v + '');
setEnableTagMode(v);
loadChannels(0, pageSize, idSort, v);
setActivePage(1);
loadChannels(1, pageSize, idSort, v);
}}
/>
</div>
@@ -1553,58 +1570,80 @@ const ChannelsTable = () => {
</div>
<div className="flex flex-col md:flex-row items-center gap-4 w-full md:w-auto order-1 md:order-2">
<div className="relative w-full md:w-64">
<Input
prefix={<IconSearch />}
placeholder={t('搜索渠道的 ID,名称,密钥和API地址 ...')}
value={searchKeyword}
loading={searching}
onChange={(v) => {
setSearchKeyword(v.trim());
}}
className="!rounded-full"
showClear
/>
</div>
<div className="w-full md:w-48">
<Input
prefix={<IconFilter />}
placeholder={t('模型关键字')}
value={searchModel}
loading={searching}
onChange={(v) => {
setSearchModel(v.trim());
}}
className="!rounded-full"
showClear
/>
</div>
<div className="w-full md:w-48">
<Select
placeholder={t('选择分组')}
optionList={[
{ label: t('选择分组'), value: null },
...groupOptions,
]}
value={searchGroup}
onChange={(v) => {
setSearchGroup(v);
searchChannels(searchKeyword, v, searchModel, enableTagMode);
}}
className="!rounded-full w-full"
showClear
/>
</div>
<Button
type="primary"
onClick={() => {
searchChannels(searchKeyword, searchGroup, searchModel, enableTagMode);
}}
loading={searching}
className="!rounded-full w-full md:w-auto"
<Form
initValues={formInitValues}
getFormApi={(api) => setFormApi(api)}
onSubmit={() => searchChannels(enableTagMode)}
allowEmpty={true}
autoComplete="off"
layout="horizontal"
trigger="change"
stopValidateWithError={false}
className="flex flex-col md:flex-row items-center gap-4 w-full"
>
{t('查询')}
</Button>
<div className="relative w-full md:w-64">
<Form.Input
field="searchKeyword"
prefix={<IconSearch />}
placeholder={t('搜索渠道的 ID,名称,密钥和API地址 ...')}
className="!rounded-full"
showClear
pure
/>
</div>
<div className="w-full md:w-48">
<Form.Input
field="searchModel"
prefix={<IconSearch />}
placeholder={t('模型关键字')}
className="!rounded-full"
showClear
pure
/>
</div>
<div className="w-full md:w-48">
<Form.Select
field="searchGroup"
placeholder={t('选择分组')}
optionList={[
{ label: t('选择分组'), value: null },
...groupOptions,
]}
className="!rounded-full w-full"
showClear
pure
onChange={() => {
// 延迟执行搜索,让表单值先更新
setTimeout(() => {
searchChannels(enableTagMode);
}, 0);
}}
/>
</div>
<Button
type="primary"
htmlType="submit"
loading={loading || searching}
className="!rounded-full w-full md:w-auto"
>
{t('查询')}
</Button>
<Button
theme='light'
onClick={() => {
if (formApi) {
formApi.reset();
// 重置后立即查询,使用setTimeout确保表单重置完成
setTimeout(() => {
refresh();
}, 100);
}
}}
className="!rounded-full w-full md:w-auto"
>
{t('重置')}
</Button>
</Form>
</div>
</div>
</div>
@@ -1645,7 +1684,7 @@ const ChannelsTable = () => {
formatPageText: (page) => t('第 {{start}} - {{end}} 条,共 {{total}} 条', {
start: page.currentStart,
end: page.currentEnd,
total: channels.length,
total: channelCount,
}),
onPageSizeChange: (size) => {
handlePageSizeChange(size);
@@ -1663,6 +1702,14 @@ const ChannelsTable = () => {
}
: null
}
empty={
<Empty
image={<IllustrationNoResult style={{ width: 150, height: 150 }} />}
darkModeImage={<IllustrationNoResultDark style={{ width: 150, height: 150 }} />}
description={t('搜索无结果')}
style={{ padding: 30 }}
/>
}
className="rounded-xl overflow-hidden"
size="middle"
loading={loading}
@@ -1756,7 +1803,6 @@ const ChannelsTable = () => {
</div>
}
maskClosable={!isBatchTesting}
centered={true}
className="!rounded-lg"
size="large"
>
@@ -1857,7 +1903,6 @@ const ChannelsTable = () => {
key: model
}))}
pagination={false}
size="middle"
/>
</div>
)}
+339 -179
View File
@@ -27,9 +27,9 @@ import {
Avatar,
Button,
Descriptions,
Empty,
Modal,
Popover,
Select,
Space,
Spin,
Table,
@@ -39,24 +39,19 @@ import {
Card,
Typography,
Divider,
Input,
DatePicker,
Form,
} from '@douyinfe/semi-ui';
import {
IllustrationNoResult,
IllustrationNoResultDark,
} from '@douyinfe/semi-illustrations';
import { ITEMS_PER_PAGE } from '../../constants';
import Paragraph from '@douyinfe/semi-ui/lib/es/typography/paragraph';
import { IconSetting, IconSearch, IconForward } from '@douyinfe/semi-icons';
import { IconSetting, IconSearch, IconHelpCircle } from '@douyinfe/semi-icons';
import { Route } from 'lucide-react';
const { Text } = Typography;
function renderTimestamp(timestamp) {
return <>{timestamp2string(timestamp)}</>;
}
const MODE_OPTIONS = [
{ key: 'all', text: 'all', value: 'all' },
{ key: 'self', text: 'current user', value: 'self' },
];
const colors = [
'amber',
'blue',
@@ -197,7 +192,7 @@ const LogsTable = () => {
if (!modelMapped) {
return renderModelTag(record.model_name, {
onClick: (event) => {
copyText(event, record.model_name).then((r) => { });
copyText(event, record.model_name).then((r) => {});
},
});
} else {
@@ -214,7 +209,7 @@ const LogsTable = () => {
</Text>
{renderModelTag(record.model_name, {
onClick: (event) => {
copyText(event, record.model_name).then((r) => { });
copyText(event, record.model_name).then((r) => {});
},
})}
</div>
@@ -225,7 +220,7 @@ const LogsTable = () => {
{renderModelTag(other.upstream_model_name, {
onClick: (event) => {
copyText(event, other.upstream_model_name).then(
(r) => { },
(r) => {},
);
},
})}
@@ -236,10 +231,10 @@ const LogsTable = () => {
>
{renderModelTag(record.model_name, {
onClick: (event) => {
copyText(event, record.model_name).then((r) => { });
copyText(event, record.model_name).then((r) => {});
},
suffixIcon: (
<IconForward
<Route
style={{ width: '0.9em', height: '0.9em', opacity: 0.75 }}
/>
),
@@ -265,6 +260,7 @@ const LogsTable = () => {
COMPLETION: 'completion',
COST: 'cost',
RETRY: 'retry',
IP: 'ip',
DETAILS: 'details',
};
@@ -306,6 +302,7 @@ const LogsTable = () => {
[COLUMN_KEYS.COMPLETION]: true,
[COLUMN_KEYS.COST]: true,
[COLUMN_KEYS.RETRY]: isAdminUser,
[COLUMN_KEYS.IP]: true,
[COLUMN_KEYS.DETAILS]: true,
};
};
@@ -490,6 +487,9 @@ const LogsTable = () => {
title: t('用时/首字'),
dataIndex: 'use_time',
render: (text, record, index) => {
if (!(record.type === 2 || record.type === 5)) {
return <></>;
}
if (record.is_stream) {
let other = getLogOther(record.other);
return (
@@ -550,12 +550,45 @@ const LogsTable = () => {
);
},
},
{
key: COLUMN_KEYS.IP,
title: (
<div className="flex items-center gap-1">
{t('IP')}
<Tooltip content={t('只有当用户设置开启IP记录时,才会进行请求和错误类型日志的IP记录')}>
<IconHelpCircle className="text-gray-400 cursor-help" />
</Tooltip>
</div>
),
dataIndex: 'ip',
render: (text, record, index) => {
return (record.type === 2 || record.type === 5) && text ? (
<Tooltip content={text}>
<Tag
color='orange'
size='large'
shape='circle'
onClick={(event) => {
copyText(event, text);
}}
>
{text}
</Tag>
</Tooltip>
) : (
<></>
);
},
},
{
key: COLUMN_KEYS.RETRY,
title: t('重试'),
dataIndex: 'retry',
className: isAdmin() ? 'tableShow' : 'tableHiddle',
render: (text, record, index) => {
if (!(record.type === 2 || record.type === 5)) {
return <></>;
}
let content = t('渠道') + `${record.channel}`;
if (record.other !== '') {
let other = JSON.parse(record.other);
@@ -603,21 +636,23 @@ const LogsTable = () => {
}
let content = other?.claude
? renderClaudeModelPriceSimple(
other.model_ratio,
other.model_price,
other.group_ratio,
other.cache_tokens || 0,
other.cache_ratio || 1.0,
other.cache_creation_tokens || 0,
other.cache_creation_ratio || 1.0,
)
other.model_ratio,
other.model_price,
other.group_ratio,
other?.user_group_ratio,
other.cache_tokens || 0,
other.cache_ratio || 1.0,
other.cache_creation_tokens || 0,
other.cache_creation_ratio || 1.0,
)
: renderModelPriceSimple(
other.model_ratio,
other.model_price,
other.group_ratio,
other.cache_tokens || 0,
other.cache_ratio || 1.0,
);
other.model_ratio,
other.model_price,
other.group_ratio,
other?.user_group_ratio,
other.cache_tokens || 0,
other.cache_ratio || 1.0,
);
return (
<Paragraph
ellipsis={{
@@ -737,39 +772,71 @@ const LogsTable = () => {
const [logType, setLogType] = useState(0);
const isAdminUser = isAdmin();
let now = new Date();
// 初始化start_timestamp为今天0点
const [inputs, setInputs] = useState({
// Form 初始值
const formInitValues = {
username: '',
token_name: '',
model_name: '',
start_timestamp: timestamp2string(getTodayStartTimestamp()),
end_timestamp: timestamp2string(now.getTime() / 1000 + 3600),
channel: '',
group: '',
});
const {
username,
token_name,
model_name,
start_timestamp,
end_timestamp,
channel,
group,
} = inputs;
dateRange: [
timestamp2string(getTodayStartTimestamp()),
timestamp2string(now.getTime() / 1000 + 3600),
],
logType: '0',
};
const [stat, setStat] = useState({
quota: 0,
token: 0,
});
const handleInputChange = (value, name) => {
setInputs((inputs) => ({ ...inputs, [name]: value }));
// Form API 引用
const [formApi, setFormApi] = useState(null);
// 获取表单值的辅助函数,确保所有值都是字符串
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 || '',
logType: formValues.logType ? parseInt(formValues.logType) : 0,
};
};
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=${logType}&token_name=${token_name}&model_name=${model_name}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}&group=${group}`;
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;
@@ -781,9 +848,20 @@ const LogsTable = () => {
};
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=${logType}&username=${username}&token_name=${token_name}&model_name=${model_name}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}&channel=${channel}&group=${group}`;
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;
@@ -907,27 +985,27 @@ const LogsTable = () => {
key: t('日志详情'),
value: other?.claude
? renderClaudeLogContent(
other?.model_ratio,
other.completion_ratio,
other.model_price,
other.group_ratio,
other.cache_ratio || 1.0,
other.cache_creation_ratio || 1.0,
)
other?.model_ratio,
other.completion_ratio,
other.model_price,
other.group_ratio,
other?.user_group_ratio,
other.cache_ratio || 1.0,
other.cache_creation_ratio || 1.0,
)
: renderLogContent(
other?.model_ratio,
other.completion_ratio,
other.model_price,
other.group_ratio,
other?.user_group_ratio,
false,
1.0,
undefined,
other.web_search || false,
other.web_search_call_count || 0,
other.file_search || false,
other.file_search_call_count || 0,
),
other?.model_ratio,
other.completion_ratio,
other.model_price,
other.group_ratio,
other?.user_group_ratio,
false,
1.0,
other.web_search || false,
other.web_search_call_count || 0,
other.file_search || false,
other.file_search_call_count || 0,
),
});
}
if (logs[i].type === 2) {
@@ -958,6 +1036,7 @@ const LogsTable = () => {
other?.audio_ratio,
other?.audio_completion_ratio,
other?.group_ratio,
other?.user_group_ratio,
other?.cache_tokens || 0,
other?.cache_ratio || 1.0,
);
@@ -969,6 +1048,7 @@ const LogsTable = () => {
other.model_price,
other.completion_ratio,
other.group_ratio,
other?.user_group_ratio,
other.cache_tokens || 0,
other.cache_ratio || 1.0,
other.cache_creation_tokens || 0,
@@ -982,6 +1062,7 @@ const LogsTable = () => {
other?.model_price,
other?.completion_ratio,
other?.group_ratio,
other?.user_group_ratio,
other?.cache_tokens || 0,
other?.cache_ratio || 1.0,
other?.image || false,
@@ -1016,16 +1097,35 @@ const LogsTable = () => {
setLogs(logs);
};
const loadLogs = async (startIdx, pageSize, logType = 0) => {
const loadLogs = async (startIdx, pageSize, customLogType = null) => {
setLoading(true);
let url = '';
const {
username,
token_name,
model_name,
start_timestamp,
end_timestamp,
channel,
group,
logType: formLogType,
} = getFormValues();
// 使用传入的 logType 或者表单中的 logType 或者状态中的 logType
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=${logType}&username=${username}&token_name=${token_name}&model_name=${model_name}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}&channel=${channel}&group=${group}`;
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}`;
} else {
url = `/api/log/self/?p=${startIdx}&page_size=${pageSize}&type=${logType}&token_name=${token_name}&model_name=${model_name}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}&group=${group}`;
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}`;
}
url = encodeURI(url);
const res = await API.get(url);
@@ -1045,7 +1145,7 @@ const LogsTable = () => {
const handlePageChange = (page) => {
setActivePage(page);
loadLogs(page, pageSize, logType).then((r) => { });
loadLogs(page, pageSize).then((r) => {}); // 不传入logType,让其从表单获取最新值
};
const handlePageSizeChange = async (size) => {
@@ -1062,7 +1162,7 @@ const LogsTable = () => {
const refresh = async () => {
setActivePage(1);
handleEyeClick();
await loadLogs(activePage, pageSize, logType);
await loadLogs(1, pageSize); // 不传入logType,让其从表单获取最新值
};
const copyText = async (e, text) => {
@@ -1083,9 +1183,15 @@ const LogsTable = () => {
.catch((reason) => {
showError(reason);
});
handleEyeClick();
}, []);
// 当 formApi 可用时,初始化统计
useEffect(() => {
if (formApi) {
handleEyeClick();
}
}, [formApi]);
const expandRowRender = (record, index) => {
return <Descriptions data={expandData[record.key]} />;
};
@@ -1149,115 +1255,156 @@ const LogsTable = () => {
<Divider margin='12px' />
{/* 搜索表单区域 */}
<div className='flex flex-col gap-4'>
<div className='grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4'>
{/* 时间选择器 */}
<div className='col-span-1 lg:col-span-2'>
<DatePicker
className='w-full'
value={[start_timestamp, end_timestamp]}
type='dateTimeRange'
onChange={(value) => {
if (Array.isArray(value) && value.length === 2) {
handleInputChange(value[0], 'start_timestamp');
handleInputChange(value[1], 'end_timestamp');
}
}}
<Form
initValues={formInitValues}
getFormApi={(api) => setFormApi(api)}
onSubmit={refresh}
allowEmpty={true}
autoComplete='off'
layout='vertical'
trigger='change'
stopValidateWithError={false}
>
<div className='flex flex-col gap-4'>
<div className='grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4'>
{/* 时间选择器 */}
<div className='col-span-1 lg:col-span-2'>
<Form.DatePicker
field='dateRange'
className='w-full'
type='dateTimeRange'
placeholder={[t('开始时间'), t('结束时间')]}
showClear
pure
/>
</div>
{/* 其他搜索字段 */}
<Form.Input
field='token_name'
prefix={<IconSearch />}
placeholder={t('令牌名称')}
className='!rounded-full'
showClear
pure
/>
<Form.Input
field='model_name'
prefix={<IconSearch />}
placeholder={t('模型名称')}
className='!rounded-full'
showClear
pure
/>
<Form.Input
field='group'
prefix={<IconSearch />}
placeholder={t('分组')}
className='!rounded-full'
showClear
pure
/>
{isAdminUser && (
<>
<Form.Input
field='channel'
prefix={<IconSearch />}
placeholder={t('渠道 ID')}
className='!rounded-full'
showClear
pure
/>
<Form.Input
field='username'
prefix={<IconSearch />}
placeholder={t('用户名称')}
className='!rounded-full'
showClear
pure
/>
</>
)}
</div>
{/* 日志类型选择器 */}
<Select
value={logType.toString()}
placeholder={t('日志类型')}
className='!rounded-full'
onChange={(value) => {
setLogType(parseInt(value));
loadLogs(0, pageSize, parseInt(value));
}}
>
<Select.Option value='0'>{t('全部')}</Select.Option>
<Select.Option value='1'>{t('充值')}</Select.Option>
<Select.Option value='2'>{t('消费')}</Select.Option>
<Select.Option value='3'>{t('管理')}</Select.Option>
<Select.Option value='4'>{t('系统')}</Select.Option>
<Select.Option value='5'>{t('错误')}</Select.Option>
</Select>
{/* 其他搜索字段 */}
<Input
prefix={<IconSearch />}
placeholder={t('令牌名称')}
value={token_name}
onChange={(value) => handleInputChange(value, 'token_name')}
className='!rounded-full'
showClear
/>
<Input
prefix={<IconSearch />}
placeholder={t('模型名称')}
value={model_name}
onChange={(value) => handleInputChange(value, 'model_name')}
className='!rounded-full'
showClear
/>
<Input
prefix={<IconSearch />}
placeholder={t('分组')}
value={group}
onChange={(value) => handleInputChange(value, 'group')}
className='!rounded-full'
showClear
/>
{isAdminUser && (
<>
<Input
prefix={<IconSearch />}
placeholder={t('渠道 ID')}
value={channel}
onChange={(value) => handleInputChange(value, 'channel')}
className='!rounded-full'
{/* 操作按钮区域 */}
<div className='flex flex-col sm:flex-row justify-between items-start sm:items-center gap-3'>
{/* 日志类型选择器 */}
<div className='w-full sm:w-auto'>
<Form.Select
field='logType'
placeholder={t('日志类型')}
className='!rounded-full w-full sm:w-auto min-w-[120px]'
showClear
/>
<Input
prefix={<IconSearch />}
placeholder={t('用户名称')}
value={username}
onChange={(value) => handleInputChange(value, 'username')}
className='!rounded-full'
showClear
/>
</>
)}
</div>
pure
onChange={() => {
// 延迟执行搜索,让表单值先更新
setTimeout(() => {
refresh();
}, 0);
}}
>
<Form.Select.Option value='0'>
{t('全部')}
</Form.Select.Option>
<Form.Select.Option value='1'>
{t('充值')}
</Form.Select.Option>
<Form.Select.Option value='2'>
{t('消费')}
</Form.Select.Option>
<Form.Select.Option value='3'>
{t('管理')}
</Form.Select.Option>
<Form.Select.Option value='4'>
{t('系统')}
</Form.Select.Option>
<Form.Select.Option value='5'>
{t('错误')}
</Form.Select.Option>
</Form.Select>
</div>
{/* 操作按钮区域 */}
<div className='flex justify-between items-center pt-2'>
<div></div>
<div className='flex gap-2'>
<Button
type='primary'
onClick={refresh}
loading={loading}
className='!rounded-full'
>
{t('查询')}
</Button>
<Button
theme='light'
type='tertiary'
icon={<IconSetting />}
onClick={() => setShowColumnSelector(true)}
className='!rounded-full'
>
{t('列设置')}
</Button>
<div className='flex gap-2 w-full sm:w-auto justify-end'>
<Button
type='primary'
htmlType='submit'
loading={loading}
className='!rounded-full'
>
{t('查询')}
</Button>
<Button
theme='light'
onClick={() => {
if (formApi) {
formApi.reset();
setLogType(0);
// 重置后立即查询,使用setTimeout确保表单重置完成
setTimeout(() => {
refresh();
}, 100);
}
}}
className='!rounded-full'
>
{t('重置')}
</Button>
<Button
theme='light'
type='tertiary'
icon={<IconSetting />}
onClick={() => setShowColumnSelector(true)}
className='!rounded-full'
>
{t('列设置')}
</Button>
</div>
</div>
</div>
</div>
</Form>
</div>
}
shadows='always'
@@ -1268,7 +1415,8 @@ const LogsTable = () => {
{...(hasExpandableRows() && {
expandedRowRender: expandRowRender,
expandRowByClick: true,
rowExpandable: (record) => expandData[record.key] && expandData[record.key].length > 0
rowExpandable: (record) =>
expandData[record.key] && expandData[record.key].length > 0,
})}
dataSource={logs}
rowKey='key'
@@ -1276,6 +1424,18 @@ const LogsTable = () => {
scroll={{ x: 'max-content' }}
className='rounded-xl overflow-hidden'
size='middle'
empty={
<Empty
image={
<IllustrationNoResult style={{ width: 150, height: 150 }} />
}
darkModeImage={
<IllustrationNoResultDark style={{ width: 150, height: 150 }} />
}
description={t('搜索无结果')}
style={{ padding: 30 }}
/>
}
pagination={{
formatPageText: (page) =>
t('第 {{start}} - {{end}} 条,共 {{total}} 条', {
+214 -147
View File
@@ -1,35 +1,65 @@
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Palette,
ZoomIn,
Shuffle,
Move,
FileText,
Blend,
Upload,
Minimize2,
RotateCcw,
PaintBucket,
Focus,
Move3D,
Monitor,
UserCheck,
HelpCircle,
CheckCircle,
Clock,
Copy,
FileX,
Pause,
XCircle,
Loader,
AlertCircle,
Hash
} from 'lucide-react';
import {
API,
copy,
isAdmin,
showError,
showSuccess,
timestamp2string,
timestamp2string
} from '../../helpers';
import {
Button,
Card,
Checkbox,
DatePicker,
Divider,
Empty,
Form,
ImagePreview,
Input,
Layout,
Modal,
Progress,
Skeleton,
Table,
Tag,
Typography,
Typography
} from '@douyinfe/semi-ui';
import {
IllustrationNoResult,
IllustrationNoResultDark
} from '@douyinfe/semi-illustrations';
import { ITEMS_PER_PAGE } from '../../constants';
import {
IconEyeOpened,
IconSearch,
IconSetting,
IconSetting
} from '@douyinfe/semi-icons';
const { Text } = Typography;
@@ -154,103 +184,103 @@ const LogsTable = () => {
switch (type) {
case 'IMAGINE':
return (
<Tag color='blue' size='large' shape='circle'>
<Tag color='blue' size='large' shape='circle' prefixIcon={<Palette size={14} />}>
{t('绘图')}
</Tag>
);
case 'UPSCALE':
return (
<Tag color='orange' size='large' shape='circle'>
<Tag color='orange' size='large' shape='circle' prefixIcon={<ZoomIn size={14} />}>
{t('放大')}
</Tag>
);
case 'VARIATION':
return (
<Tag color='purple' size='large' shape='circle'>
<Tag color='purple' size='large' shape='circle' prefixIcon={<Shuffle size={14} />}>
{t('变换')}
</Tag>
);
case 'HIGH_VARIATION':
return (
<Tag color='purple' size='large' shape='circle'>
<Tag color='purple' size='large' shape='circle' prefixIcon={<Shuffle size={14} />}>
{t('强变换')}
</Tag>
);
case 'LOW_VARIATION':
return (
<Tag color='purple' size='large' shape='circle'>
<Tag color='purple' size='large' shape='circle' prefixIcon={<Shuffle size={14} />}>
{t('弱变换')}
</Tag>
);
case 'PAN':
return (
<Tag color='cyan' size='large' shape='circle'>
<Tag color='cyan' size='large' shape='circle' prefixIcon={<Move size={14} />}>
{t('平移')}
</Tag>
);
case 'DESCRIBE':
return (
<Tag color='yellow' size='large' shape='circle'>
<Tag color='yellow' size='large' shape='circle' prefixIcon={<FileText size={14} />}>
{t('图生文')}
</Tag>
);
case 'BLEND':
return (
<Tag color='lime' size='large' shape='circle'>
<Tag color='lime' size='large' shape='circle' prefixIcon={<Blend size={14} />}>
{t('图混合')}
</Tag>
);
case 'UPLOAD':
return (
<Tag color='blue' size='large' shape='circle'>
<Tag color='blue' size='large' shape='circle' prefixIcon={<Upload size={14} />}>
上传文件
</Tag>
);
case 'SHORTEN':
return (
<Tag color='pink' size='large' shape='circle'>
<Tag color='pink' size='large' shape='circle' prefixIcon={<Minimize2 size={14} />}>
{t('缩词')}
</Tag>
);
case 'REROLL':
return (
<Tag color='indigo' size='large' shape='circle'>
<Tag color='indigo' size='large' shape='circle' prefixIcon={<RotateCcw size={14} />}>
{t('重绘')}
</Tag>
);
case 'INPAINT':
return (
<Tag color='violet' size='large' shape='circle'>
<Tag color='violet' size='large' shape='circle' prefixIcon={<PaintBucket size={14} />}>
{t('局部重绘-提交')}
</Tag>
);
case 'ZOOM':
return (
<Tag color='teal' size='large' shape='circle'>
<Tag color='teal' size='large' shape='circle' prefixIcon={<Focus size={14} />}>
{t('变焦')}
</Tag>
);
case 'CUSTOM_ZOOM':
return (
<Tag color='teal' size='large' shape='circle'>
<Tag color='teal' size='large' shape='circle' prefixIcon={<Move3D size={14} />}>
{t('自定义变焦-提交')}
</Tag>
);
case 'MODAL':
return (
<Tag color='green' size='large' shape='circle'>
<Tag color='green' size='large' shape='circle' prefixIcon={<Monitor size={14} />}>
{t('窗口处理')}
</Tag>
);
case 'SWAP_FACE':
return (
<Tag color='light-green' size='large' shape='circle'>
<Tag color='light-green' size='large' shape='circle' prefixIcon={<UserCheck size={14} />}>
{t('换脸')}
</Tag>
);
default:
return (
<Tag color='white' size='large' shape='circle'>
<Tag color='white' size='large' shape='circle' prefixIcon={<HelpCircle size={14} />}>
{t('未知')}
</Tag>
);
@@ -261,31 +291,31 @@ const LogsTable = () => {
switch (code) {
case 1:
return (
<Tag color='green' size='large' shape='circle'>
<Tag color='green' size='large' shape='circle' prefixIcon={<CheckCircle size={14} />}>
{t('已提交')}
</Tag>
);
case 21:
return (
<Tag color='lime' size='large' shape='circle'>
<Tag color='lime' size='large' shape='circle' prefixIcon={<Clock size={14} />}>
{t('等待中')}
</Tag>
);
case 22:
return (
<Tag color='orange' size='large' shape='circle'>
<Tag color='orange' size='large' shape='circle' prefixIcon={<Copy size={14} />}>
{t('重复提交')}
</Tag>
);
case 0:
return (
<Tag color='yellow' size='large' shape='circle'>
<Tag color='yellow' size='large' shape='circle' prefixIcon={<FileX size={14} />}>
{t('未提交')}
</Tag>
);
default:
return (
<Tag color='white' size='large' shape='circle'>
<Tag color='white' size='large' shape='circle' prefixIcon={<HelpCircle size={14} />}>
{t('未知')}
</Tag>
);
@@ -296,43 +326,43 @@ const LogsTable = () => {
switch (type) {
case 'SUCCESS':
return (
<Tag color='green' size='large' shape='circle'>
<Tag color='green' size='large' shape='circle' prefixIcon={<CheckCircle size={14} />}>
{t('成功')}
</Tag>
);
case 'NOT_START':
return (
<Tag color='grey' size='large' shape='circle'>
<Tag color='grey' size='large' shape='circle' prefixIcon={<Pause size={14} />}>
{t('未启动')}
</Tag>
);
case 'SUBMITTED':
return (
<Tag color='yellow' size='large' shape='circle'>
<Tag color='yellow' size='large' shape='circle' prefixIcon={<Clock size={14} />}>
{t('队列中')}
</Tag>
);
case 'IN_PROGRESS':
return (
<Tag color='blue' size='large' shape='circle'>
<Tag color='blue' size='large' shape='circle' prefixIcon={<Loader size={14} />}>
{t('执行中')}
</Tag>
);
case 'FAILURE':
return (
<Tag color='red' size='large' shape='circle'>
<Tag color='red' size='large' shape='circle' prefixIcon={<XCircle size={14} />}>
{t('失败')}
</Tag>
);
case 'MODAL':
return (
<Tag color='yellow' size='large' shape='circle'>
<Tag color='yellow' size='large' shape='circle' prefixIcon={<AlertCircle size={14} />}>
{t('窗口等待')}
</Tag>
);
default:
return (
<Tag color='white' size='large' shape='circle'>
<Tag color='white' size='large' shape='circle' prefixIcon={<HelpCircle size={14} />}>
{t('未知')}
</Tag>
);
@@ -362,7 +392,7 @@ const LogsTable = () => {
const color = durationSec > 60 ? 'red' : 'green';
return (
<Tag color={color} size='large' shape='circle'>
<Tag color={color} size='large' shape='circle' prefixIcon={<Clock size={14} />}>
{durationSec} {t('秒')}
</Tag>
);
@@ -398,6 +428,7 @@ const LogsTable = () => {
color={colors[parseInt(text) % colors.length]}
size='large'
shape='circle'
prefixIcon={<Hash size={14} />}
onClick={() => {
copyText(text);
}}
@@ -462,7 +493,7 @@ const LogsTable = () => {
percent={text ? parseInt(text.replace('%', '')) : 0}
showInfo={true}
aria-label='drawing progress'
style={{ minWidth: '200px' }}
style={{ minWidth: '160px' }}
/>
}
</div>
@@ -483,6 +514,7 @@ const LogsTable = () => {
setModalImageUrl(text);
setIsModalOpenurl(true);
}}
className="!rounded-full"
>
{t('查看图片')}
</Button>
@@ -569,8 +601,7 @@ const LogsTable = () => {
const [logs, setLogs] = useState([]);
const [loading, setLoading] = useState(true);
const [activePage, setActivePage] = useState(1);
const [logCount, setLogCount] = useState(ITEMS_PER_PAGE);
const [logType, setLogType] = useState(0);
const [logCount, setLogCount] = useState(0);
const [pageSize, setPageSize] = useState(ITEMS_PER_PAGE);
const [isModalOpenurl, setIsModalOpenurl] = useState(false);
const [showBanner, setShowBanner] = useState(false);
@@ -578,86 +609,93 @@ const LogsTable = () => {
// 定义模态框图片URL的状态和更新函数
const [modalImageUrl, setModalImageUrl] = useState('');
let now = new Date();
// 初始化start_timestamp为前一天
const [inputs, setInputs] = useState({
// Form 初始值
const formInitValues = {
channel_id: '',
mj_id: '',
start_timestamp: timestamp2string(now.getTime() / 1000 - 2592000),
end_timestamp: timestamp2string(now.getTime() / 1000 + 3600),
});
const { channel_id, mj_id, start_timestamp, end_timestamp } = inputs;
dateRange: [
timestamp2string(now.getTime() / 1000 - 2592000),
timestamp2string(now.getTime() / 1000 + 3600)
],
};
// Form API 引用
const [formApi, setFormApi] = useState(null);
const [stat, setStat] = useState({
quota: 0,
token: 0,
});
const handleInputChange = (value, name) => {
setInputs((inputs) => ({ ...inputs, [name]: value }));
};
// 获取表单值的辅助函数
const getFormValues = () => {
const formValues = formApi ? formApi.getValues() : {};
const setLogsFormat = (logs) => {
for (let i = 0; i < logs.length; i++) {
logs[i].timestamp2string = timestamp2string(logs[i].created_at);
logs[i].key = '' + logs[i].id;
// 处理时间范围
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];
}
// data.key = '' + data.id
setLogs(logs);
setLogCount(logs.length + pageSize);
// console.log(logCount);
return {
channel_id: formValues.channel_id || '',
mj_id: formValues.mj_id || '',
start_timestamp,
end_timestamp,
};
};
const loadLogs = async (startIdx, pageSize = ITEMS_PER_PAGE) => {
setLoading(true);
const enrichLogs = (items) => {
return items.map((log) => ({
...log,
timestamp2string: timestamp2string(log.created_at),
key: '' + log.id,
}));
};
let url = '';
const syncPageData = (payload) => {
const items = enrichLogs(payload.items || []);
setLogs(items);
setLogCount(payload.total || 0);
setActivePage(payload.page || 1);
setPageSize(payload.page_size || pageSize);
};
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);
if (isAdminUser) {
url = `/api/mj/?p=${startIdx}&page_size=${pageSize}&channel_id=${channel_id}&mj_id=${mj_id}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}`;
} else {
url = `/api/mj/self/?p=${startIdx}&page_size=${pageSize}&mj_id=${mj_id}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}`;
}
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) {
if (startIdx === 0) {
setLogsFormat(data);
} else {
let newLogs = [...logs];
newLogs.splice(startIdx * pageSize, data.length, ...data);
setLogsFormat(newLogs);
}
syncPageData(data);
} else {
showError(message);
}
setLoading(false);
};
const pageData = logs.slice(
(activePage - 1) * pageSize,
activePage * pageSize,
);
const pageData = logs;
const handlePageChange = (page) => {
setActivePage(page);
if (page === Math.ceil(logs.length / pageSize) + 1) {
// In this case we have to load more data and then append them.
loadLogs(page - 1, pageSize).then((r) => { });
}
loadLogs(page, pageSize).then();
};
const handlePageSizeChange = async (size) => {
localStorage.setItem('mj-page-size', size + '');
setPageSize(size);
setActivePage(1);
await loadLogs(0, size);
await loadLogs(1, size);
};
const refresh = async () => {
// setLoading(true);
setActivePage(1);
await loadLogs(0, pageSize);
await loadLogs(1, pageSize);
};
const copyText = async (text) => {
@@ -672,8 +710,8 @@ const LogsTable = () => {
useEffect(() => {
const localPageSize = parseInt(localStorage.getItem('mj-page-size')) || ITEMS_PER_PAGE;
setPageSize(localPageSize);
loadLogs(0, localPageSize).then();
}, [logType]);
loadLogs(1, localPageSize).then();
}, []);
useEffect(() => {
const mjNotifyEnabled = localStorage.getItem('mj_notify_enabled');
@@ -788,70 +826,93 @@ const LogsTable = () => {
<Divider margin="12px" />
{/* 搜索表单区域 */}
<div className="flex flex-col gap-4">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{/* 时间选择器 */}
<div className="col-span-1 lg:col-span-2">
<DatePicker
className="w-full"
value={[start_timestamp, end_timestamp]}
type='dateTimeRange'
onChange={(value) => {
if (Array.isArray(value) && value.length === 2) {
handleInputChange(value[0], 'start_timestamp');
handleInputChange(value[1], 'end_timestamp');
}
}}
/>
</div>
<Form
initValues={formInitValues}
getFormApi={(api) => setFormApi(api)}
onSubmit={refresh}
allowEmpty={true}
autoComplete="off"
layout="vertical"
trigger="change"
stopValidateWithError={false}
>
<div className="flex flex-col gap-4">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{/* 时间选择器 */}
<div className="col-span-1 lg:col-span-2">
<Form.DatePicker
field='dateRange'
className="w-full"
type='dateTimeRange'
placeholder={[t('开始时间'), t('结束时间')]}
showClear
pure
/>
</div>
{/* 任务 ID */}
<Input
prefix={<IconSearch />}
placeholder={t('任务 ID')}
value={mj_id}
onChange={(value) => handleInputChange(value, 'mj_id')}
className="!rounded-full"
showClear
/>
{/* 渠道 ID - 仅管理员可见 */}
{isAdminUser && (
<Input
{/* 任务 ID */}
<Form.Input
field='mj_id'
prefix={<IconSearch />}
placeholder={t('渠道 ID')}
value={channel_id}
onChange={(value) => handleInputChange(value, 'channel_id')}
placeholder={t('任务 ID')}
className="!rounded-full"
showClear
pure
/>
)}
</div>
{/* 操作按钮区域 */}
<div className="flex justify-between items-center pt-2">
<div></div>
<div className="flex gap-2">
<Button
type='primary'
onClick={refresh}
loading={loading}
className="!rounded-full"
>
{t('查询')}
</Button>
<Button
theme='light'
type='tertiary'
icon={<IconSetting />}
onClick={() => setShowColumnSelector(true)}
className="!rounded-full"
>
{t('列设置')}
</Button>
{/* 渠道 ID - 仅管理员可见 */}
{isAdminUser && (
<Form.Input
field='channel_id'
prefix={<IconSearch />}
placeholder={t('渠道 ID')}
className="!rounded-full"
showClear
pure
/>
)}
</div>
{/* 操作按钮区域 */}
<div className="flex justify-between items-center">
<div></div>
<div className="flex gap-2">
<Button
type='primary'
htmlType='submit'
loading={loading}
className="!rounded-full"
>
{t('查询')}
</Button>
<Button
theme='light'
onClick={() => {
if (formApi) {
formApi.reset();
// 重置后立即查询,使用setTimeout确保表单重置完成
setTimeout(() => {
refresh();
}, 100);
}
}}
className="!rounded-full"
>
{t('重置')}
</Button>
<Button
theme='light'
type='tertiary'
icon={<IconSetting />}
onClick={() => setShowColumnSelector(true)}
className="!rounded-full"
>
{t('列设置')}
</Button>
</div>
</div>
</div>
</div>
</Form>
</div>
}
shadows='always'
@@ -859,12 +920,20 @@ const LogsTable = () => {
>
<Table
columns={getVisibleColumns()}
dataSource={pageData}
dataSource={logs}
rowKey='key'
loading={loading}
scroll={{ x: 'max-content' }}
className="rounded-xl overflow-hidden"
size="middle"
empty={
<Empty
image={<IllustrationNoResult style={{ width: 150, height: 150 }} />}
darkModeImage={<IllustrationNoResultDark style={{ width: 150, height: 150 }} />}
description={t('搜索无结果')}
style={{ padding: 30 }}
/>
}
pagination={{
formatPageText: (page) =>
t('第 {{start}} - {{end}} 条,共 {{total}} 条', {
@@ -877,9 +946,7 @@ const LogsTable = () => {
total: logCount,
pageSizeOptions: [10, 20, 50, 100],
showSizeChanger: true,
onPageSizeChange: (size) => {
handlePageSizeChange(size);
},
onPageSizeChange: handlePageSizeChange,
onPageChange: handlePageChange,
}}
/>
+14 -1
View File
@@ -17,14 +17,19 @@ import {
Tabs,
TabPane,
Dropdown,
Empty
} from '@douyinfe/semi-ui';
import {
IllustrationNoResult,
IllustrationNoResultDark
} from '@douyinfe/semi-illustrations';
import {
IconVerify,
IconHelpCircle,
IconSearch,
IconCopy,
IconInfoCircle,
IconLayers,
IconLayers
} from '@douyinfe/semi-icons';
import { UserContext } from '../../context/User/index.js';
import { AlertCircle } from 'lucide-react';
@@ -489,6 +494,14 @@ const ModelPricing = () => {
loading={loading}
rowSelection={rowSelection}
className="custom-table"
empty={
<Empty
image={<IllustrationNoResult style={{ width: 150, height: 150 }} />}
darkModeImage={<IllustrationNoResultDark style={{ width: 150, height: 150 }} />}
description={t('搜索无结果')}
style={{ padding: 30 }}
/>
}
pagination={{
defaultPageSize: 10,
pageSize: pageSize,
+113 -36
View File
@@ -8,20 +8,33 @@ import {
renderQuota
} from '../../helpers';
import {
CheckCircle,
XCircle,
Minus,
HelpCircle,
Coins
} from 'lucide-react';
import { ITEMS_PER_PAGE } from '../../constants';
import {
Button,
Card,
Divider,
Dropdown,
Input,
Empty,
Form,
Modal,
Popover,
Space,
Table,
Tag,
Typography,
Typography
} from '@douyinfe/semi-ui';
import {
IllustrationNoResult,
IllustrationNoResultDark
} from '@douyinfe/semi-illustrations';
import {
IconPlus,
IconCopy,
@@ -31,7 +44,7 @@ import {
IconDelete,
IconStop,
IconPlay,
IconMore,
IconMore
} from '@douyinfe/semi-icons';
import EditRedemption from '../../pages/Redemption/EditRedemption';
import { useTranslation } from 'react-i18next';
@@ -49,25 +62,25 @@ const RedemptionsTable = () => {
switch (status) {
case 1:
return (
<Tag color='green' size='large' shape='circle'>
<Tag color='green' size='large' shape='circle' prefixIcon={<CheckCircle size={14} />}>
{t('未使用')}
</Tag>
);
case 2:
return (
<Tag color='red' size='large' shape='circle'>
<Tag color='red' size='large' shape='circle' prefixIcon={<XCircle size={14} />}>
{t('已禁用')}
</Tag>
);
case 3:
return (
<Tag color='grey' size='large' shape='circle'>
<Tag color='grey' size='large' shape='circle' prefixIcon={<Minus size={14} />}>
{t('已使用')}
</Tag>
);
default:
return (
<Tag color='black' size='large' shape='circle'>
<Tag color='black' size='large' shape='circle' prefixIcon={<HelpCircle size={14} />}>
{t('未知状态')}
</Tag>
);
@@ -95,7 +108,13 @@ const RedemptionsTable = () => {
title: t('额度'),
dataIndex: 'quota',
render: (text, record, index) => {
return <div>{renderQuota(parseInt(text))}</div>;
return (
<div>
<Tag size={'large'} color={'grey'} shape='circle' prefixIcon={<Coins size={14} />}>
{renderQuota(parseInt(text))}
</Tag>
</div>
);
},
},
{
@@ -223,7 +242,6 @@ const RedemptionsTable = () => {
const [redemptions, setRedemptions] = useState([]);
const [loading, setLoading] = useState(true);
const [activePage, setActivePage] = useState(1);
const [searchKeyword, setSearchKeyword] = useState('');
const [searching, setSearching] = useState(false);
const [tokenCount, setTokenCount] = useState(ITEMS_PER_PAGE);
const [selectedKeys, setSelectedKeys] = useState([]);
@@ -233,6 +251,22 @@ const RedemptionsTable = () => {
});
const [showEdit, setShowEdit] = useState(false);
// Form 初始值
const formInitValues = {
searchKeyword: '',
};
// Form API 引用
const [formApi, setFormApi] = useState(null);
// 获取表单值的辅助函数
const getFormValues = () => {
const formValues = formApi ? formApi.getValues() : {};
return {
searchKeyword: formValues.searchKeyword || '',
};
};
const closeEdit = () => {
setShowEdit(false);
setTimeout(() => {
@@ -340,8 +374,14 @@ const RedemptionsTable = () => {
setLoading(false);
};
const searchRedemptions = async (keyword, page, pageSize) => {
if (searchKeyword === '') {
const searchRedemptions = async (keyword = null, page, pageSize) => {
// 如果没有传递keyword参数,从表单获取值
if (keyword === null) {
const formValues = getFormValues();
keyword = formValues.searchKeyword;
}
if (keyword === '') {
await loadRedemptions(page, pageSize);
return;
}
@@ -361,10 +401,6 @@ const RedemptionsTable = () => {
setSearching(false);
};
const handleKeywordChange = async (value) => {
setSearchKeyword(value.trim());
};
const sortRedemption = (key) => {
if (redemptions.length === 0) return;
setLoading(true);
@@ -381,6 +417,7 @@ const RedemptionsTable = () => {
const handlePageChange = (page) => {
setActivePage(page);
const { searchKeyword } = getFormValues();
if (searchKeyword === '') {
loadRedemptions(page, pageSize).then();
} else {
@@ -457,28 +494,59 @@ const RedemptionsTable = () => {
</Button>
</div>
<div className="flex flex-col md:flex-row items-center gap-4 w-full md:w-auto order-1 md:order-2">
<div className="relative w-full md:w-64">
<Input
prefix={<IconSearch />}
placeholder={t('关键字(id或者名称)')}
value={searchKeyword}
onChange={handleKeywordChange}
className="!rounded-full"
showClear
/>
<Form
initValues={formInitValues}
getFormApi={(api) => setFormApi(api)}
onSubmit={() => {
setActivePage(1);
searchRedemptions(null, 1, pageSize);
}}
allowEmpty={true}
autoComplete="off"
layout="horizontal"
trigger="change"
stopValidateWithError={false}
className="w-full md:w-auto order-1 md:order-2"
>
<div className="flex flex-col md:flex-row items-center gap-4 w-full md:w-auto">
<div className="relative w-full md:w-64">
<Form.Input
field="searchKeyword"
prefix={<IconSearch />}
placeholder={t('关键字(id或者名称)')}
className="!rounded-full"
showClear
pure
/>
</div>
<div className="flex gap-2 w-full md:w-auto">
<Button
type="primary"
htmlType="submit"
loading={loading || searching}
className="!rounded-full flex-1 md:flex-initial md:w-auto"
>
{t('查询')}
</Button>
<Button
theme="light"
onClick={() => {
if (formApi) {
formApi.reset();
// 重置后立即查询,使用setTimeout确保表单重置完成
setTimeout(() => {
setActivePage(1);
loadRedemptions(1, pageSize);
}, 100);
}
}}
className="!rounded-full flex-1 md:flex-initial md:w-auto"
>
{t('重置')}
</Button>
</div>
</div>
<Button
type="primary"
onClick={() => {
searchRedemptions(searchKeyword, 1, pageSize).then();
}}
loading={searching}
className="!rounded-full w-full md:w-auto"
>
{t('查询')}
</Button>
</div>
</Form>
</div>
</div>
);
@@ -517,6 +585,7 @@ const RedemptionsTable = () => {
onPageSizeChange: (size) => {
setPageSize(size);
setActivePage(1);
const { searchKeyword } = getFormValues();
if (searchKeyword === '') {
loadRedemptions(1, size).then();
} else {
@@ -528,6 +597,14 @@ const RedemptionsTable = () => {
loading={loading}
rowSelection={rowSelection}
onRow={handleRow}
empty={
<Empty
image={<IllustrationNoResult style={{ width: 150, height: 150 }} />}
darkModeImage={<IllustrationNoResultDark style={{ width: 150, height: 150 }} />}
description={t('搜索无结果')}
style={{ padding: 30 }}
/>
}
className="rounded-xl overflow-hidden"
size="middle"
></Table>
+192 -136
View File
@@ -1,34 +1,51 @@
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Music,
FileText,
HelpCircle,
CheckCircle,
Pause,
Clock,
Play,
XCircle,
Loader,
List,
Hash
} from 'lucide-react';
import {
API,
copy,
isAdmin,
showError,
showSuccess,
timestamp2string,
timestamp2string
} from '../../helpers';
import {
Button,
Card,
Checkbox,
DatePicker,
Divider,
Input,
Empty,
Form,
Layout,
Modal,
Progress,
Skeleton,
Table,
Tag,
Typography,
Typography
} from '@douyinfe/semi-ui';
import {
IllustrationNoResult,
IllustrationNoResultDark
} from '@douyinfe/semi-illustrations';
import { ITEMS_PER_PAGE } from '../../constants';
import {
IconEyeOpened,
IconSearch,
IconSetting,
IconSetting
} from '@douyinfe/semi-icons';
const { Text } = Typography;
@@ -97,7 +114,7 @@ function renderDuration(submit_time, finishTime) {
// 返回带有样式的颜色标签
return (
<Tag color={color} size='large'>
<Tag color={color} size='large' prefixIcon={<Clock size={14} />}>
{durationSec}
</Tag>
);
@@ -188,19 +205,19 @@ const LogsTable = () => {
switch (type) {
case 'MUSIC':
return (
<Tag color='grey' size='large' shape='circle'>
<Tag color='grey' size='large' shape='circle' prefixIcon={<Music size={14} />}>
{t('生成音乐')}
</Tag>
);
case 'LYRICS':
return (
<Tag color='pink' size='large' shape='circle'>
<Tag color='pink' size='large' shape='circle' prefixIcon={<FileText size={14} />}>
{t('生成歌词')}
</Tag>
);
default:
return (
<Tag color='white' size='large' shape='circle'>
<Tag color='white' size='large' shape='circle' prefixIcon={<HelpCircle size={14} />}>
{t('未知')}
</Tag>
);
@@ -211,13 +228,13 @@ const LogsTable = () => {
switch (type) {
case 'suno':
return (
<Tag color='green' size='large' shape='circle'>
<Tag color='green' size='large' shape='circle' prefixIcon={<Music size={14} />}>
Suno
</Tag>
);
default:
return (
<Tag color='white' size='large' shape='circle'>
<Tag color='white' size='large' shape='circle' prefixIcon={<HelpCircle size={14} />}>
{t('未知')}
</Tag>
);
@@ -228,55 +245,55 @@ const LogsTable = () => {
switch (type) {
case 'SUCCESS':
return (
<Tag color='green' size='large' shape='circle'>
<Tag color='green' size='large' shape='circle' prefixIcon={<CheckCircle size={14} />}>
{t('成功')}
</Tag>
);
case 'NOT_START':
return (
<Tag color='grey' size='large' shape='circle'>
<Tag color='grey' size='large' shape='circle' prefixIcon={<Pause size={14} />}>
{t('未启动')}
</Tag>
);
case 'SUBMITTED':
return (
<Tag color='yellow' size='large' shape='circle'>
<Tag color='yellow' size='large' shape='circle' prefixIcon={<Clock size={14} />}>
{t('队列中')}
</Tag>
);
case 'IN_PROGRESS':
return (
<Tag color='blue' size='large' shape='circle'>
<Tag color='blue' size='large' shape='circle' prefixIcon={<Play size={14} />}>
{t('执行中')}
</Tag>
);
case 'FAILURE':
return (
<Tag color='red' size='large' shape='circle'>
<Tag color='red' size='large' shape='circle' prefixIcon={<XCircle size={14} />}>
{t('失败')}
</Tag>
);
case 'QUEUED':
return (
<Tag color='orange' size='large' shape='circle'>
<Tag color='orange' size='large' shape='circle' prefixIcon={<List size={14} />}>
{t('排队中')}
</Tag>
);
case 'UNKNOWN':
return (
<Tag color='white' size='large' shape='circle'>
<Tag color='white' size='large' shape='circle' prefixIcon={<HelpCircle size={14} />}>
{t('未知')}
</Tag>
);
case '':
return (
<Tag color='grey' size='large' shape='circle'>
<Tag color='grey' size='large' shape='circle' prefixIcon={<Loader size={14} />}>
{t('正在提交')}
</Tag>
);
default:
return (
<Tag color='white' size='large' shape='circle'>
<Tag color='white' size='large' shape='circle' prefixIcon={<HelpCircle size={14} />}>
{t('未知')}
</Tag>
);
@@ -321,6 +338,7 @@ const LogsTable = () => {
color={colors[parseInt(text) % colors.length]}
size='large'
shape='circle'
prefixIcon={<Hash size={14} />}
onClick={() => {
copyText(text);
}}
@@ -395,7 +413,7 @@ const LogsTable = () => {
percent={text ? parseInt(text.replace('%', '')) : 0}
showInfo={true}
aria-label='task progress'
style={{ minWidth: '200px' }}
style={{ minWidth: '160px' }}
/>
)
}
@@ -433,87 +451,102 @@ const LogsTable = () => {
return allColumns.filter((column) => visibleColumns[column.key]);
};
const [logs, setLogs] = useState([]);
const [loading, setLoading] = useState(true);
const [activePage, setActivePage] = useState(1);
const [logCount, setLogCount] = useState(ITEMS_PER_PAGE);
const [logType] = useState(0);
const [logCount, setLogCount] = useState(0);
const [logs, setLogs] = useState([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
const localPageSize = parseInt(localStorage.getItem('task-page-size')) || ITEMS_PER_PAGE;
setPageSize(localPageSize);
loadLogs(1, localPageSize).then();
}, []);
let now = new Date();
// 初始化start_timestamp为前一天
let zeroNow = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const [inputs, setInputs] = useState({
// Form 初始值
const formInitValues = {
channel_id: '',
task_id: '',
start_timestamp: timestamp2string(zeroNow.getTime() / 1000),
end_timestamp: '',
});
const { channel_id, task_id, start_timestamp, end_timestamp } = inputs;
const handleInputChange = (value, name) => {
setInputs((inputs) => ({ ...inputs, [name]: value }));
dateRange: [
timestamp2string(zeroNow.getTime() / 1000),
timestamp2string(now.getTime() / 1000 + 3600)
],
};
const setLogsFormat = (logs) => {
for (let i = 0; i < logs.length; i++) {
logs[i].timestamp2string = timestamp2string(logs[i].created_at);
logs[i].key = '' + logs[i].id;
// Form API 引用
const [formApi, setFormApi] = useState(null);
// 获取表单值的辅助函数
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];
}
// data.key = '' + data.id
setLogs(logs);
setLogCount(logs.length + ITEMS_PER_PAGE);
// console.log(logCount);
return {
channel_id: formValues.channel_id || '',
task_id: formValues.task_id || '',
start_timestamp,
end_timestamp,
};
};
const loadLogs = async (startIdx, pageSize = ITEMS_PER_PAGE) => {
setLoading(true);
const enrichLogs = (items) => {
return items.map((log) => ({
...log,
timestamp2string: timestamp2string(log.created_at),
key: '' + log.id,
}));
};
let url = '';
const syncPageData = (payload) => {
const items = enrichLogs(payload.items || []);
setLogs(items);
setLogCount(payload.total || 0);
setActivePage(payload.page || 1);
setPageSize(payload.page_size || pageSize);
};
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);
if (isAdminUser) {
url = `/api/task/?p=${startIdx}&page_size=${pageSize}&channel_id=${channel_id}&task_id=${task_id}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}`;
} else {
url = `/api/task/self?p=${startIdx}&page_size=${pageSize}&task_id=${task_id}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}`;
}
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);
let { success, message, data } = res.data;
const { success, message, data } = res.data;
if (success) {
if (startIdx === 0) {
setLogsFormat(data);
} else {
let newLogs = [...logs];
newLogs.splice(startIdx * pageSize, data.length, ...data);
setLogsFormat(newLogs);
}
syncPageData(data);
} else {
showError(message);
}
setLoading(false);
};
const pageData = logs.slice(
(activePage - 1) * pageSize,
activePage * pageSize,
);
const pageData = logs;
const handlePageChange = (page) => {
setActivePage(page);
if (page === Math.ceil(logs.length / pageSize) + 1) {
loadLogs(page - 1, pageSize).then((r) => { });
}
loadLogs(page, pageSize).then();
};
const handlePageSizeChange = async (size) => {
localStorage.setItem('task-page-size', size + '');
setPageSize(size);
setActivePage(1);
await loadLogs(0, size);
await loadLogs(1, size);
};
const refresh = async () => {
setActivePage(1);
await loadLogs(0, pageSize);
await loadLogs(1, pageSize);
};
const copyText = async (text) => {
@@ -524,12 +557,6 @@ const LogsTable = () => {
}
};
useEffect(() => {
const localPageSize = parseInt(localStorage.getItem('task-page-size')) || ITEMS_PER_PAGE;
setPageSize(localPageSize);
loadLogs(0, localPageSize).then();
}, [logType]);
// 列选择器模态框
const renderColumnSelector = () => {
return (
@@ -628,70 +655,93 @@ const LogsTable = () => {
<Divider margin="12px" />
{/* 搜索表单区域 */}
<div className="flex flex-col gap-4">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{/* 时间选择器 */}
<div className="col-span-1 lg:col-span-2">
<DatePicker
className="w-full"
value={[start_timestamp, end_timestamp]}
type='dateTimeRange'
onChange={(value) => {
if (Array.isArray(value) && value.length === 2) {
handleInputChange(value[0], 'start_timestamp');
handleInputChange(value[1], 'end_timestamp');
}
}}
/>
</div>
<Form
initValues={formInitValues}
getFormApi={(api) => setFormApi(api)}
onSubmit={refresh}
allowEmpty={true}
autoComplete="off"
layout="vertical"
trigger="change"
stopValidateWithError={false}
>
<div className="flex flex-col gap-4">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{/* 时间选择器 */}
<div className="col-span-1 lg:col-span-2">
<Form.DatePicker
field='dateRange'
className="w-full"
type='dateTimeRange'
placeholder={[t('开始时间'), t('结束时间')]}
showClear
pure
/>
</div>
{/* 任务 ID */}
<Input
prefix={<IconSearch />}
placeholder={t('任务 ID')}
value={task_id}
onChange={(value) => handleInputChange(value, 'task_id')}
className="!rounded-full"
showClear
/>
{/* 渠道 ID - 仅管理员可见 */}
{isAdminUser && (
<Input
{/* 任务 ID */}
<Form.Input
field='task_id'
prefix={<IconSearch />}
placeholder={t('渠道 ID')}
value={channel_id}
onChange={(value) => handleInputChange(value, 'channel_id')}
placeholder={t('任务 ID')}
className="!rounded-full"
showClear
pure
/>
)}
</div>
{/* 操作按钮区域 */}
<div className="flex justify-between items-center pt-2">
<div></div>
<div className="flex gap-2">
<Button
type='primary'
onClick={refresh}
loading={loading}
className="!rounded-full"
>
{t('查询')}
</Button>
<Button
theme='light'
type='tertiary'
icon={<IconSetting />}
onClick={() => setShowColumnSelector(true)}
className="!rounded-full"
>
{t('列设置')}
</Button>
{/* 渠道 ID - 仅管理员可见 */}
{isAdminUser && (
<Form.Input
field='channel_id'
prefix={<IconSearch />}
placeholder={t('渠道 ID')}
className="!rounded-full"
showClear
pure
/>
)}
</div>
{/* 操作按钮区域 */}
<div className="flex justify-between items-center">
<div></div>
<div className="flex gap-2">
<Button
type='primary'
htmlType='submit'
loading={loading}
className="!rounded-full"
>
{t('查询')}
</Button>
<Button
theme='light'
onClick={() => {
if (formApi) {
formApi.reset();
// 重置后立即查询,使用setTimeout确保表单重置完成
setTimeout(() => {
refresh();
}, 100);
}
}}
className="!rounded-full"
>
{t('重置')}
</Button>
<Button
theme='light'
type='tertiary'
icon={<IconSetting />}
onClick={() => setShowColumnSelector(true)}
className="!rounded-full"
>
{t('列设置')}
</Button>
</div>
</div>
</div>
</div>
</Form>
</div>
}
shadows='always'
@@ -699,12 +749,20 @@ const LogsTable = () => {
>
<Table
columns={getVisibleColumns()}
dataSource={pageData}
dataSource={logs}
rowKey='key'
loading={loading}
scroll={{ x: 'max-content' }}
className="rounded-xl overflow-hidden"
size="middle"
empty={
<Empty
image={<IllustrationNoResult style={{ width: 150, height: 150 }} />}
darkModeImage={<IllustrationNoResultDark style={{ width: 150, height: 150 }} />}
description={t('搜索无结果')}
style={{ padding: 30 }}
/>
}
pagination={{
formatPageText: (page) =>
t('第 {{start}} - {{end}} 条,共 {{total}} 条', {
@@ -717,9 +775,7 @@ const LogsTable = () => {
total: logCount,
pageSizeOptions: [10, 20, 50, 100],
showSizeChanger: true,
onPageSizeChange: (size) => {
handlePageSizeChange(size);
},
onPageSizeChange: handlePageSizeChange,
onPageChange: handlePageChange,
}}
/>
+164 -92
View File
@@ -6,7 +6,8 @@ import {
showSuccess,
timestamp2string,
renderGroup,
renderQuota
renderQuota,
getQuotaPerUnit
} from '../../helpers';
import { ITEMS_PER_PAGE } from '../../constants';
@@ -14,13 +15,29 @@ import {
Button,
Card,
Dropdown,
Empty,
Form,
Modal,
Space,
SplitButtonGroup,
Table,
Tag,
Input,
Tag
} from '@douyinfe/semi-ui';
import {
IllustrationNoResult,
IllustrationNoResultDark
} from '@douyinfe/semi-illustrations';
import {
CheckCircle,
Shield,
XCircle,
Clock,
Gauge,
HelpCircle,
Infinity,
Coins
} from 'lucide-react';
import {
IconPlus,
@@ -32,7 +49,7 @@ import {
IconDelete,
IconStop,
IconPlay,
IconMore,
IconMore
} from '@douyinfe/semi-icons';
import EditToken from '../../pages/Token/EditToken';
import { useTranslation } from 'react-i18next';
@@ -49,38 +66,38 @@ const TokensTable = () => {
case 1:
if (model_limits_enabled) {
return (
<Tag color='green' size='large' shape='circle'>
<Tag color='green' size='large' shape='circle' prefixIcon={<Shield size={14} />}>
{t('已启用:限制模型')}
</Tag>
);
} else {
return (
<Tag color='green' size='large' shape='circle'>
<Tag color='green' size='large' shape='circle' prefixIcon={<CheckCircle size={14} />}>
{t('已启用')}
</Tag>
);
}
case 2:
return (
<Tag color='red' size='large' shape='circle'>
<Tag color='red' size='large' shape='circle' prefixIcon={<XCircle size={14} />}>
{t('已禁用')}
</Tag>
);
case 3:
return (
<Tag color='yellow' size='large' shape='circle'>
<Tag color='yellow' size='large' shape='circle' prefixIcon={<Clock size={14} />}>
{t('已过期')}
</Tag>
);
case 4:
return (
<Tag color='grey' size='large' shape='circle'>
<Tag color='grey' size='large' shape='circle' prefixIcon={<Gauge size={14} />}>
{t('已耗尽')}
</Tag>
);
default:
return (
<Tag color='black' size='large' shape='circle'>
<Tag color='black' size='large' shape='circle' prefixIcon={<HelpCircle size={14} />}>
{t('未知状态')}
</Tag>
);
@@ -111,21 +128,45 @@ const TokensTable = () => {
title: t('已用额度'),
dataIndex: 'used_quota',
render: (text, record, index) => {
return <div>{renderQuota(parseInt(text))}</div>;
return (
<div>
<Tag size={'large'} color={'grey'} shape='circle' prefixIcon={<Coins size={14} />}>
{renderQuota(parseInt(text))}
</Tag>
</div>
);
},
},
{
title: t('剩余额度'),
dataIndex: 'remain_quota',
render: (text, record, index) => {
const getQuotaColor = (quotaValue) => {
const quotaPerUnit = getQuotaPerUnit();
const dollarAmount = quotaValue / quotaPerUnit;
if (dollarAmount <= 0) {
return 'red';
} else if (dollarAmount <= 100) {
return 'yellow';
} else {
return 'green';
}
};
return (
<div>
{record.unlimited_quota ? (
<Tag size={'large'} color={'white'} shape='circle'>
<Tag size={'large'} color={'white'} shape='circle' prefixIcon={<Infinity size={14} />}>
{t('无限制')}
</Tag>
) : (
<Tag size={'large'} color={'light-blue'} shape='circle'>
<Tag
size={'large'}
color={getQuotaColor(parseInt(text))}
shape='circle'
prefixIcon={<Coins size={14} />}
>
{renderQuota(parseInt(text))}
</Tag>
)}
@@ -335,14 +376,29 @@ const TokensTable = () => {
const [tokenCount, setTokenCount] = useState(pageSize);
const [loading, setLoading] = useState(true);
const [activePage, setActivePage] = useState(1);
const [searchKeyword, setSearchKeyword] = useState('');
const [searchToken, setSearchToken] = useState('');
const [searching, setSearching] = useState(false);
const [chats, setChats] = useState([]);
const [editingToken, setEditingToken] = useState({
id: undefined,
});
// Form 初始值
const formInitValues = {
searchKeyword: '',
searchToken: '',
};
// Form API 引用
const [formApi, setFormApi] = useState(null);
// 获取表单值的辅助函数
const getFormValues = () => {
const formValues = formApi ? formApi.getValues() : {};
return {
searchKeyword: formValues.searchKeyword || '',
searchToken: formValues.searchToken || '',
};
};
const closeEdit = () => {
setShowEdit(false);
setTimeout(() => {
@@ -352,31 +408,20 @@ const TokensTable = () => {
}, 500);
};
const setTokensFormat = (tokens) => {
setTokens(tokens);
if (tokens.length >= pageSize) {
setTokenCount(tokens.length + pageSize);
} else {
setTokenCount(tokens.length);
}
// 将后端返回的数据写入状态
const syncPageData = (payload) => {
setTokens(payload.items || []);
setTokenCount(payload.total || 0);
setActivePage(payload.page || 1);
setPageSize(payload.page_size || pageSize);
};
let pageData = tokens.slice(
(activePage - 1) * pageSize,
activePage * pageSize,
);
const loadTokens = async (startIdx) => {
const loadTokens = async (page = 1, size = pageSize) => {
setLoading(true);
const res = await API.get(`/api/token/?p=${startIdx}&size=${pageSize}`);
const res = await API.get(`/api/token/?p=${page}&size=${size}`);
const { success, message, data } = res.data;
if (success) {
if (startIdx === 0) {
setTokensFormat(data);
} else {
let newTokens = [...tokens];
newTokens.splice(startIdx * pageSize, data.length, ...data);
setTokensFormat(newTokens);
}
syncPageData(data);
} else {
showError(message);
}
@@ -384,7 +429,7 @@ const TokensTable = () => {
};
const refresh = async () => {
await loadTokens(activePage - 1);
await loadTokens(1);
};
const copyText = async (text) => {
@@ -416,10 +461,8 @@ const TokensTable = () => {
window.open(url, '_blank');
};
useEffect(() => {
loadTokens(0)
loadTokens(1)
.then()
.catch((reason) => {
showError(reason);
@@ -433,7 +476,7 @@ const TokensTable = () => {
if (idx > -1) {
newDataSource.splice(idx, 1);
setTokensFormat(newDataSource);
setTokens(newDataSource);
}
}
};
@@ -464,7 +507,7 @@ const TokensTable = () => {
} else {
record.status = token.status;
}
setTokensFormat(newTokens);
setTokens(newTokens);
} else {
showError(message);
}
@@ -472,9 +515,9 @@ const TokensTable = () => {
};
const searchTokens = async () => {
const { searchKeyword, searchToken } = getFormValues();
if (searchKeyword === '' && searchToken === '') {
await loadTokens(0);
setActivePage(1);
await loadTokens(1);
return;
}
setSearching(true);
@@ -483,7 +526,8 @@ const TokensTable = () => {
);
const { success, message, data } = res.data;
if (success) {
setTokensFormat(data);
setTokens(data);
setTokenCount(data.length);
setActivePage(1);
} else {
showError(message);
@@ -491,14 +535,6 @@ const TokensTable = () => {
setSearching(false);
};
const handleKeywordChange = async (value) => {
setSearchKeyword(value.trim());
};
const handleSearchTokenChange = async (value) => {
setSearchToken(value.trim());
};
const sortToken = (key) => {
if (tokens.length === 0) return;
setLoading(true);
@@ -514,10 +550,12 @@ const TokensTable = () => {
};
const handlePageChange = (page) => {
setActivePage(page);
if (page === Math.ceil(tokens.length / pageSize) + 1) {
loadTokens(page - 1).then((r) => { });
}
loadTokens(page, pageSize).then();
};
const handlePageSizeChange = async (size) => {
setPageSize(size);
await loadTokens(1, size);
};
const rowSelection = {
@@ -580,36 +618,65 @@ const TokensTable = () => {
</Button>
</div>
<div className="flex flex-col md:flex-row items-center gap-4 w-full md:w-auto order-1 md:order-2">
<div className="relative w-full md:w-56">
<Input
prefix={<IconSearch />}
placeholder={t('搜索关键字')}
value={searchKeyword}
onChange={handleKeywordChange}
className="!rounded-full"
showClear
/>
<Form
initValues={formInitValues}
getFormApi={(api) => setFormApi(api)}
onSubmit={searchTokens}
allowEmpty={true}
autoComplete="off"
layout="horizontal"
trigger="change"
stopValidateWithError={false}
className="w-full md:w-auto order-1 md:order-2"
>
<div className="flex flex-col md:flex-row items-center gap-4 w-full md:w-auto">
<div className="relative w-full md:w-56">
<Form.Input
field="searchKeyword"
prefix={<IconSearch />}
placeholder={t('搜索关键字')}
className="!rounded-full"
showClear
pure
/>
</div>
<div className="relative w-full md:w-56">
<Form.Input
field="searchToken"
prefix={<IconSearch />}
placeholder={t('密钥')}
className="!rounded-full"
showClear
pure
/>
</div>
<div className="flex gap-2 w-full md:w-auto">
<Button
type="primary"
htmlType="submit"
loading={loading || searching}
className="!rounded-full flex-1 md:flex-initial md:w-auto"
>
{t('查询')}
</Button>
<Button
theme="light"
onClick={() => {
if (formApi) {
formApi.reset();
// 重置后立即查询,使用setTimeout确保表单重置完成
setTimeout(() => {
searchTokens();
}, 100);
}
}}
className="!rounded-full flex-1 md:flex-initial md:w-auto"
>
{t('重置')}
</Button>
</div>
</div>
<div className="relative w-full md:w-56">
<Input
prefix={<IconSearch />}
placeholder={t('密钥')}
value={searchToken}
onChange={handleSearchTokenChange}
className="!rounded-full"
showClear
/>
</div>
<Button
type="primary"
onClick={searchTokens}
loading={searching}
className="!rounded-full w-full md:w-auto"
>
{t('查询')}
</Button>
</div>
</Form>
</div>
</div>
);
@@ -631,7 +698,7 @@ const TokensTable = () => {
>
<Table
columns={columns}
dataSource={pageData}
dataSource={tokens}
scroll={{ x: 'max-content' }}
pagination={{
currentPage: activePage,
@@ -643,17 +710,22 @@ const TokensTable = () => {
t('第 {{start}} - {{end}} 条,共 {{total}} 条', {
start: page.currentStart,
end: page.currentEnd,
total: tokens.length,
total: tokenCount,
}),
onPageSizeChange: (size) => {
setPageSize(size);
setActivePage(1);
},
onPageSizeChange: handlePageSizeChange,
onPageChange: handlePageChange,
}}
loading={loading}
rowSelection={rowSelection}
onRow={handleRow}
empty={
<Empty
image={<IllustrationNoResult style={{ width: 150, height: 150 }} />}
darkModeImage={<IllustrationNoResultDark style={{ width: 150, height: 150 }} />}
description={t('搜索无结果')}
style={{ padding: 30 }}
/>
}
className="rounded-xl overflow-hidden"
size="middle"
></Table>
+146 -63
View File
@@ -1,18 +1,37 @@
import React, { useEffect, useState } from 'react';
import { API, showError, showSuccess, renderGroup, renderNumber, renderQuota } from '../../helpers';
import {
User,
Shield,
Crown,
HelpCircle,
CheckCircle,
XCircle,
Minus,
Coins,
Activity,
Users,
DollarSign,
UserPlus
} from 'lucide-react';
import {
Button,
Card,
Divider,
Dropdown,
Input,
Empty,
Form,
Modal,
Select,
Space,
Table,
Tag,
Typography,
Typography
} from '@douyinfe/semi-ui';
import {
IllustrationNoResult,
IllustrationNoResultDark
} from '@douyinfe/semi-illustrations';
import {
IconPlus,
IconSearch,
@@ -23,7 +42,7 @@ import {
IconMore,
IconUserAdd,
IconArrowUp,
IconArrowDown,
IconArrowDown
} from '@douyinfe/semi-icons';
import { ITEMS_PER_PAGE } from '../../constants';
import AddUser from '../../pages/User/AddUser';
@@ -39,25 +58,25 @@ const UsersTable = () => {
switch (role) {
case 1:
return (
<Tag size='large' color='blue' shape='circle'>
<Tag size='large' color='blue' shape='circle' prefixIcon={<User size={14} />}>
{t('普通用户')}
</Tag>
);
case 10:
return (
<Tag color='yellow' size='large' shape='circle'>
<Tag color='yellow' size='large' shape='circle' prefixIcon={<Shield size={14} />}>
{t('管理员')}
</Tag>
);
case 100:
return (
<Tag color='orange' size='large' shape='circle'>
<Tag color='orange' size='large' shape='circle' prefixIcon={<Crown size={14} />}>
{t('超级管理员')}
</Tag>
);
default:
return (
<Tag color='red' size='large' shape='circle'>
<Tag color='red' size='large' shape='circle' prefixIcon={<HelpCircle size={14} />}>
{t('未知身份')}
</Tag>
);
@@ -67,16 +86,16 @@ const UsersTable = () => {
const renderStatus = (status) => {
switch (status) {
case 1:
return <Tag size='large' color='green' shape='circle'>{t('已激活')}</Tag>;
return <Tag size='large' color='green' shape='circle' prefixIcon={<CheckCircle size={14} />}>{t('已激活')}</Tag>;
case 2:
return (
<Tag size='large' color='red' shape='circle'>
<Tag size='large' color='red' shape='circle' prefixIcon={<XCircle size={14} />}>
{t('已封禁')}
</Tag>
);
default:
return (
<Tag size='large' color='grey' shape='circle'>
<Tag size='large' color='grey' shape='circle' prefixIcon={<HelpCircle size={14} />}>
{t('未知状态')}
</Tag>
);
@@ -106,13 +125,13 @@ const UsersTable = () => {
return (
<div>
<Space spacing={1}>
<Tag color='white' size='large' shape='circle' className="!text-xs">
<Tag color='white' size='large' shape='circle' className="!text-xs" prefixIcon={<Coins size={14} />}>
{t('剩余')}: {renderQuota(record.quota)}
</Tag>
<Tag color='white' size='large' shape='circle' className="!text-xs">
<Tag color='white' size='large' shape='circle' className="!text-xs" prefixIcon={<Coins size={14} />}>
{t('已用')}: {renderQuota(record.used_quota)}
</Tag>
<Tag color='white' size='large' shape='circle' className="!text-xs">
<Tag color='white' size='large' shape='circle' className="!text-xs" prefixIcon={<Activity size={14} />}>
{t('调用')}: {renderNumber(record.request_count)}
</Tag>
</Space>
@@ -127,13 +146,13 @@ const UsersTable = () => {
return (
<div>
<Space spacing={1}>
<Tag color='white' size='large' shape='circle' className="!text-xs">
<Tag color='white' size='large' shape='circle' className="!text-xs" prefixIcon={<Users size={14} />}>
{t('邀请')}: {renderNumber(record.aff_count)}
</Tag>
<Tag color='white' size='large' shape='circle' className="!text-xs">
<Tag color='white' size='large' shape='circle' className="!text-xs" prefixIcon={<DollarSign size={14} />}>
{t('收益')}: {renderQuota(record.aff_history_quota)}
</Tag>
<Tag color='white' size='large' shape='circle' className="!text-xs">
<Tag color='white' size='large' shape='circle' className="!text-xs" prefixIcon={<UserPlus size={14} />}>
{record.inviter_id === 0 ? t('无邀请人') : `邀请人: ${record.inviter_id}`}
</Tag>
</Space>
@@ -155,7 +174,7 @@ const UsersTable = () => {
return (
<div>
{record.DeletedAt !== null ? (
<Tag color='red' shape='circle'>{t('已注销')}</Tag>
<Tag color='red' shape='circle' prefixIcon={<Minus size={14} />}>{t('已注销')}</Tag>
) : (
renderStatus(text)
)}
@@ -285,9 +304,7 @@ const UsersTable = () => {
const [loading, setLoading] = useState(true);
const [activePage, setActivePage] = useState(1);
const [pageSize, setPageSize] = useState(ITEMS_PER_PAGE);
const [searchKeyword, setSearchKeyword] = useState('');
const [searching, setSearching] = useState(false);
const [searchGroup, setSearchGroup] = useState('');
const [groupOptions, setGroupOptions] = useState([]);
const [userCount, setUserCount] = useState(ITEMS_PER_PAGE);
const [showAddUser, setShowAddUser] = useState(false);
@@ -296,6 +313,24 @@ const UsersTable = () => {
id: undefined,
});
// Form 初始值
const formInitValues = {
searchKeyword: '',
searchGroup: '',
};
// Form API 引用
const [formApi, setFormApi] = useState(null);
// 获取表单值的辅助函数
const getFormValues = () => {
const formValues = formApi ? formApi.getValues() : {};
return {
searchKeyword: formValues.searchKeyword || '',
searchGroup: formValues.searchGroup || '',
};
};
const removeRecord = (key) => {
let newDataSource = [...users];
if (key != null) {
@@ -363,9 +398,16 @@ const UsersTable = () => {
const searchUsers = async (
startIdx,
pageSize,
searchKeyword,
searchGroup,
searchKeyword = null,
searchGroup = null,
) => {
// 如果没有传递参数,从表单获取值
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);
@@ -387,12 +429,9 @@ const UsersTable = () => {
setSearching(false);
};
const handleKeywordChange = async (value) => {
setSearchKeyword(value.trim());
};
const handlePageChange = (page) => {
setActivePage(page);
const { searchKeyword, searchGroup } = getFormValues();
if (searchKeyword === '' && searchGroup === '') {
loadUsers(page, pageSize).then();
} else {
@@ -413,10 +452,11 @@ const UsersTable = () => {
const refresh = async () => {
setActivePage(1);
if (searchKeyword === '') {
await loadUsers(activePage, pageSize);
const { searchKeyword, searchGroup } = getFormValues();
if (searchKeyword === '' && searchGroup === '') {
await loadUsers(1, pageSize);
} else {
await searchUsers(activePage, pageSize, searchKeyword, searchGroup);
await searchUsers(1, pageSize, searchKeyword, searchGroup);
}
};
@@ -488,41 +528,76 @@ const UsersTable = () => {
</Button>
</div>
<div className="flex flex-col md:flex-row items-center gap-4 w-full md:w-auto order-1 md:order-2">
<div className="relative w-full md:w-64">
<Input
prefix={<IconSearch />}
placeholder={t('支持搜索用户的 ID、用户名、显示名称和邮箱地址')}
value={searchKeyword}
onChange={handleKeywordChange}
className="!rounded-full"
showClear
/>
<Form
initValues={formInitValues}
getFormApi={(api) => setFormApi(api)}
onSubmit={() => {
setActivePage(1);
searchUsers(1, pageSize);
}}
allowEmpty={true}
autoComplete="off"
layout="horizontal"
trigger="change"
stopValidateWithError={false}
className="w-full md:w-auto order-1 md:order-2"
>
<div className="flex flex-col md:flex-row items-center gap-4 w-full md:w-auto">
<div className="relative w-full md:w-64">
<Form.Input
field="searchKeyword"
prefix={<IconSearch />}
placeholder={t('支持搜索用户的 ID、用户名、显示名称和邮箱地址')}
className="!rounded-full"
showClear
pure
/>
</div>
<div className="w-full md:w-48">
<Form.Select
field="searchGroup"
placeholder={t('选择分组')}
optionList={groupOptions}
onChange={(value) => {
// 分组变化时自动搜索
setTimeout(() => {
setActivePage(1);
searchUsers(1, pageSize);
}, 100);
}}
className="!rounded-full w-full"
showClear
pure
/>
</div>
<div className="flex gap-2 w-full md:w-auto">
<Button
type="primary"
htmlType="submit"
loading={loading || searching}
className="!rounded-full flex-1 md:flex-initial md:w-auto"
>
{t('查询')}
</Button>
<Button
theme="light"
onClick={() => {
if (formApi) {
formApi.reset();
// 重置后立即查询,使用setTimeout确保表单重置完成
setTimeout(() => {
setActivePage(1);
loadUsers(1, pageSize);
}, 100);
}
}}
className="!rounded-full flex-1 md:flex-initial md:w-auto"
>
{t('重置')}
</Button>
</div>
</div>
<div className="w-full md:w-48">
<Select
placeholder={t('选择分组')}
optionList={groupOptions}
value={searchGroup}
onChange={(value) => {
setSearchGroup(value);
searchUsers(activePage, pageSize, searchKeyword, value);
}}
className="!rounded-full w-full"
showClear
/>
</div>
<Button
type="primary"
onClick={() => {
searchUsers(activePage, pageSize, searchKeyword, searchGroup);
}}
loading={searching}
className="!rounded-full w-full md:w-auto"
>
{t('查询')}
</Button>
</div>
</Form>
</div>
</div>
);
@@ -570,6 +645,14 @@ const UsersTable = () => {
}}
loading={loading}
onRow={handleRow}
empty={
<Empty
image={<IllustrationNoResult style={{ width: 150, height: 150 }} />}
darkModeImage={<IllustrationNoResultDark style={{ width: 150, height: 150 }} />}
description={t('搜索无结果')}
style={{ padding: 30 }}
/>
}
className="rounded-xl overflow-hidden"
size="middle"
/>
+32
View File
@@ -12,6 +12,36 @@ export let API = axios.create({
},
});
function patchAPIInstance(instance) {
const originalGet = instance.get.bind(instance);
const inFlightGetRequests = new Map();
const genKey = (url, config = {}) => {
const params = config.params ? JSON.stringify(config.params) : '{}';
return `${url}?${params}`;
};
instance.get = (url, config = {}) => {
if (config?.disableDuplicate) {
return originalGet(url, config);
}
const key = genKey(url, config);
if (inFlightGetRequests.has(key)) {
return inFlightGetRequests.get(key);
}
const reqPromise = originalGet(url, config).finally(() => {
inFlightGetRequests.delete(key);
});
inFlightGetRequests.set(key, reqPromise);
return reqPromise;
};
}
patchAPIInstance(API);
export function updateAPI() {
API = axios.create({
baseURL: import.meta.env.VITE_REACT_APP_SERVER_URL
@@ -22,6 +52,8 @@ export function updateAPI() {
'Cache-Control': 'no-store',
},
});
patchAPIInstance(API);
}
API.interceptors.response.use(
+152 -19
View File
@@ -24,6 +24,13 @@ import {
XAI,
Ollama,
Doubao,
Suno,
Xinference,
OpenRouter,
Dify,
Coze,
SiliconCloud,
FastGPT,
} from '@lobehub/icons';
import {
@@ -308,6 +315,87 @@ export const getModelCategories = (() => {
};
})();
/**
* 根据渠道类型返回对应的厂商图标
* @param {number} channelType - 渠道类型值
* @returns {JSX.Element|null} - 对应的厂商图标组件
*/
export function getChannelIcon(channelType) {
const iconSize = 14;
switch (channelType) {
case 1: // OpenAI
case 3: // Azure OpenAI
return <OpenAI size={iconSize} />;
case 2: // Midjourney Proxy
case 5: // Midjourney Proxy Plus
return <Midjourney size={iconSize} />;
case 36: // Suno API
return <Suno size={iconSize} />;
case 4: // Ollama
return <Ollama size={iconSize} />;
case 14: // Anthropic Claude
case 33: // AWS Claude
return <Claude.Color size={iconSize} />;
case 41: // Vertex AI
return <Gemini.Color size={iconSize} />;
case 34: // Cohere
return <Cohere.Color size={iconSize} />;
case 39: // Cloudflare
return <Cloudflare.Color size={iconSize} />;
case 43: // DeepSeek
return <DeepSeek.Color size={iconSize} />;
case 15: // 百度文心千帆
case 46: // 百度文心千帆V2
return <Wenxin.Color size={iconSize} />;
case 17: // 阿里通义千问
return <Qwen.Color size={iconSize} />;
case 18: // 讯飞星火认知
return <Spark.Color size={iconSize} />;
case 16: // 智谱 ChatGLM
case 26: // 智谱 GLM-4V
return <Zhipu.Color size={iconSize} />;
case 24: // Google Gemini
case 11: // Google PaLM2
return <Gemini.Color size={iconSize} />;
case 47: // Xinference
return <Xinference.Color size={iconSize} />;
case 25: // Moonshot
return <Moonshot size={iconSize} />;
case 20: // OpenRouter
return <OpenRouter size={iconSize} />;
case 19: // 360 智脑
return <Ai360.Color size={iconSize} />;
case 23: // 腾讯混元
return <Hunyuan.Color size={iconSize} />;
case 31: // 零一万物
return <Yi.Color size={iconSize} />;
case 35: // MiniMax
return <Minimax.Color size={iconSize} />;
case 37: // Dify
return <Dify.Color size={iconSize} />;
case 38: // Jina
return <Jina size={iconSize} />;
case 40: // SiliconCloud
return <SiliconCloud.Color size={iconSize} />;
case 42: // Mistral AI
return <Mistral.Color size={iconSize} />;
case 45: // 字节火山方舟、豆包通用
return <Doubao.Color size={iconSize} />;
case 48: // xAI
return <XAI size={iconSize} />;
case 49: // Coze
return <Coze size={iconSize} />;
case 8: // 自定义渠道
case 22: // 知识库:FastGPT
return <FastGPT.Color size={iconSize} />;
case 21: // 知识库:AI Proxy
case 44: // 嵌入模型:MokaAI M3E
default:
return null; // 未知类型或自定义渠道不显示图标
}
}
// 颜色列表
const colors = [
'amber',
@@ -519,7 +607,7 @@ export function renderGroup(group) {
showSuccess(i18next.t('已复制:') + group);
} else {
Modal.error({
title: t('无法复制到剪贴板,请手动复制'),
title: i18next.t('无法复制到剪贴板,请手动复制'),
content: group,
});
}
@@ -764,7 +852,7 @@ export function renderQuotaWithAmount(amount) {
if (displayInCurrency) {
return '$' + amount;
} else {
return renderUnitWithQuota(amount);
return renderNumber(renderUnitWithQuota(amount));
}
}
@@ -779,6 +867,30 @@ export function renderQuota(quota, digits = 2) {
return renderNumber(quota);
}
function isValidGroupRatio(ratio) {
return Number.isFinite(ratio) && ratio !== -1;
}
/**
* Helper function to get effective ratio and label
* @param {number} groupRatio - The default group ratio
* @param {number} user_group_ratio - The user-specific group ratio
* @returns {Object} - Object containing { ratio, label, useUserGroupRatio }
*/
function getEffectiveRatio(groupRatio, user_group_ratio) {
const useUserGroupRatio = isValidGroupRatio(user_group_ratio);
const ratioLabel = useUserGroupRatio
? i18next.t('专属倍率')
: i18next.t('分组倍率');
const effectiveRatio = useUserGroupRatio ? user_group_ratio : groupRatio;
return {
ratio: effectiveRatio,
label: ratioLabel,
useUserGroupRatio: useUserGroupRatio
};
}
export function renderModelPrice(
inputTokens,
completionTokens,
@@ -786,6 +898,7 @@ export function renderModelPrice(
modelPrice = -1,
completionRatio,
groupRatio,
user_group_ratio,
cacheTokens = 0,
cacheRatio = 1.0,
image = false,
@@ -801,13 +914,17 @@ export function renderModelPrice(
audioInputTokens = 0,
audioInputPrice = 0,
) {
const { ratio: effectiveGroupRatio, label: ratioLabel } = getEffectiveRatio(groupRatio, user_group_ratio);
groupRatio = effectiveGroupRatio;
if (modelPrice !== -1) {
return i18next.t(
'模型价格:${{price}} * 分组倍率{{ratio}} = ${{total}}',
'模型价格:${{price}} * {{ratioType}}{{ratio}} = ${{total}}',
{
price: modelPrice,
ratio: groupRatio,
total: modelPrice * groupRatio,
ratioType: ratioLabel,
},
);
} else {
@@ -944,11 +1061,12 @@ export function renderModelPrice(
// 构建输出部分描述
const outputDesc = i18next.t(
'输出 {{completion}} tokens / 1M tokens * ${{compPrice}}) * 分组倍率 {{ratio}}',
'输出 {{completion}} tokens / 1M tokens * ${{compPrice}}) * {{ratioType}} {{ratio}}',
{
completion: completionTokens,
compPrice: completionRatioPrice,
ratio: groupRatio,
ratioType: ratioLabel,
},
);
@@ -956,21 +1074,23 @@ export function renderModelPrice(
const extraServices = [
webSearch && webSearchCallCount > 0
? i18next.t(
' + Web搜索 {{count}}次 / 1K 次 * ${{price}} * 分组倍率 {{ratio}}',
' + Web搜索 {{count}}次 / 1K 次 * ${{price}} * {{ratioType}} {{ratio}}',
{
count: webSearchCallCount,
price: webSearchPrice,
ratio: groupRatio,
ratioType: ratioLabel,
},
)
: '',
fileSearch && fileSearchCallCount > 0
? i18next.t(
' + 文件搜索 {{count}}次 / 1K 次 * ${{price}} * 分组倍率 {{ratio}}',
' + 文件搜索 {{count}}次 / 1K 次 * ${{price}} * {{ratioType}} {{ratio}}',
{
count: fileSearchCallCount,
price: fileSearchPrice,
ratio: groupRatio,
ratioType: ratioLabel,
},
)
: '',
@@ -1002,16 +1122,12 @@ export function renderLogContent(
user_group_ratio,
image = false,
imageRatio = 1.0,
useUserGroupRatio = undefined,
webSearch = false,
webSearchCallCount = 0,
fileSearch = false,
fileSearchCallCount = 0,
) {
const ratioLabel = useUserGroupRatio
? i18next.t('专属倍率')
: i18next.t('分组倍率');
const ratio = useUserGroupRatio ? user_group_ratio : groupRatio;
const { ratio, label: ratioLabel, useUserGroupRatio: useUserGroupRatio } = getEffectiveRatio(groupRatio, user_group_ratio);
if (modelPrice !== -1) {
return i18next.t('模型价格 ${{price}}{{ratioType}} {{ratio}}', {
@@ -1060,14 +1176,18 @@ export function renderModelPriceSimple(
modelRatio,
modelPrice = -1,
groupRatio,
user_group_ratio,
cacheTokens = 0,
cacheRatio = 1.0,
image = false,
imageRatio = 1.0,
) {
const { ratio: effectiveGroupRatio, label: ratioLabel } = getEffectiveRatio(groupRatio, user_group_ratio);
groupRatio = effectiveGroupRatio;
if (modelPrice !== -1) {
return i18next.t('价格:${{price}} * 分组{{ratio}}', {
return i18next.t('价格:${{price}} * {{ratioType}}{{ratio}}', {
price: modelPrice,
ratioType: ratioLabel,
ratio: groupRatio,
});
} else {
@@ -1102,8 +1222,9 @@ export function renderModelPriceSimple(
},
);
} else {
return i18next.t('模型: {{ratio}} * 分组: {{groupRatio}}', {
return i18next.t('模型: {{ratio}} * {{ratioType}}{{groupRatio}}', {
ratio: modelRatio,
ratioType: ratioLabel,
groupRatio: groupRatio,
});
}
@@ -1121,17 +1242,21 @@ export function renderAudioModelPrice(
audioRatio,
audioCompletionRatio,
groupRatio,
user_group_ratio,
cacheTokens = 0,
cacheRatio = 1.0,
) {
const { ratio: effectiveGroupRatio, label: ratioLabel } = getEffectiveRatio(groupRatio, user_group_ratio);
groupRatio = effectiveGroupRatio;
// 1 ratio = $0.002 / 1K tokens
if (modelPrice !== -1) {
return i18next.t(
'模型价格:${{price}} * 分组倍率{{ratio}} = ${{total}}',
'模型价格:${{price}} * {{ratioType}}{{ratio}} = ${{total}}',
{
price: modelPrice,
ratio: groupRatio,
total: modelPrice * groupRatio,
ratioType: ratioLabel,
},
);
} else {
@@ -1285,12 +1410,14 @@ export function renderClaudeModelPrice(
modelPrice = -1,
completionRatio,
groupRatio,
user_group_ratio,
cacheTokens = 0,
cacheRatio = 1.0,
cacheCreationTokens = 0,
cacheCreationRatio = 1.0,
) {
const ratioLabel = false ? i18next.t('专属倍率') : i18next.t('分组倍率');
const { ratio: effectiveGroupRatio, label: ratioLabel } = getEffectiveRatio(groupRatio, user_group_ratio);
groupRatio = effectiveGroupRatio;
if (modelPrice !== -1) {
return i18next.t(
@@ -1372,7 +1499,7 @@ export function renderClaudeModelPrice(
<p>
{cacheTokens > 0 || cacheCreationTokens > 0
? i18next.t(
'提示 {{nonCacheInput}} tokens / 1M tokens * ${{price}} + 缓存 {{cacheInput}} tokens / 1M tokens * ${{cachePrice}} + 缓存创建 {{cacheCreationInput}} tokens / 1M tokens * ${{cacheCreationPrice}} + 补全 {{completion}} tokens / 1M tokens * ${{compPrice}} * 分组 {{ratio}} = ${{total}}',
'提示 {{nonCacheInput}} tokens / 1M tokens * ${{price}} + 缓存 {{cacheInput}} tokens / 1M tokens * ${{cachePrice}} + 缓存创建 {{cacheCreationInput}} tokens / 1M tokens * ${{cacheCreationPrice}} + 补全 {{completion}} tokens / 1M tokens * ${{compPrice}} * {{ratioType}} {{ratio}} = ${{total}}',
{
nonCacheInput: nonCachedTokens,
cacheInput: cacheTokens,
@@ -1385,17 +1512,19 @@ export function renderClaudeModelPrice(
completion: completionTokens,
compPrice: completionRatioPrice,
ratio: groupRatio,
ratioType: ratioLabel,
total: price.toFixed(6),
},
)
: i18next.t(
'提示 {{input}} tokens / 1M tokens * ${{price}} + 补全 {{completion}} tokens / 1M tokens * ${{compPrice}} * 分组 {{ratio}} = ${{total}}',
'提示 {{input}} tokens / 1M tokens * ${{price}} + 补全 {{completion}} tokens / 1M tokens * ${{compPrice}} * {{ratioType}} {{ratio}} = ${{total}}',
{
input: inputTokens,
price: inputRatioPrice,
completion: completionTokens,
compPrice: completionRatioPrice,
ratio: groupRatio,
ratioType: ratioLabel,
total: price.toFixed(6),
},
)}
@@ -1412,10 +1541,12 @@ export function renderClaudeLogContent(
completionRatio,
modelPrice = -1,
groupRatio,
user_group_ratio,
cacheRatio = 1.0,
cacheCreationRatio = 1.0,
) {
const ratioLabel = false ? i18next.t('专属倍率') : i18next.t('分组倍率');
const { ratio: effectiveGroupRatio, label: ratioLabel } = getEffectiveRatio(groupRatio, user_group_ratio);
groupRatio = effectiveGroupRatio;
if (modelPrice !== -1) {
return i18next.t('模型价格 ${{price}}{{ratioType}} {{ratio}}', {
@@ -1442,12 +1573,14 @@ export function renderClaudeModelPriceSimple(
modelRatio,
modelPrice = -1,
groupRatio,
user_group_ratio,
cacheTokens = 0,
cacheRatio = 1.0,
cacheCreationTokens = 0,
cacheCreationRatio = 1.0,
) {
const ratioLabel = false ? i18next.t('专属倍率') : i18next.t('分组');
const { ratio: effectiveGroupRatio, label: ratioLabel } = getEffectiveRatio(groupRatio, user_group_ratio);
groupRatio = effectiveGroupRatio;
if (modelPrice !== -1) {
return i18next.t('价格:${{price}} * {{ratioType}}{{ratio}}', {
+6 -7
View File
@@ -6,14 +6,13 @@ import { API } from './api';
*/
export async function fetchTokenKeys() {
try {
const response = await API.get('/api/token/?p=0&size=100');
const response = await API.get('/api/token/?p=1&size=10');
const { success, data } = response.data;
if (success) {
const activeTokens = data.filter((token) => token.status === 1);
return activeTokens.map((token) => token.key);
} else {
throw new Error('Failed to fetch token keys');
}
if (!success) throw new Error('Failed to fetch token keys');
const tokenItems = Array.isArray(data) ? data : data.items || [];
const activeTokens = tokenItems.filter((token) => token.status === 1);
return activeTokens.map((token) => token.key);
} catch (error) {
console.error('Error fetching token keys:', error);
return [];
+63
View File
@@ -446,3 +446,66 @@ export const getLastAssistantMessage = (messages) => {
}
return null;
};
// 计算相对时间(几天前、几小时前等)
export const getRelativeTime = (publishDate) => {
if (!publishDate) return '';
const now = new Date();
const pubDate = new Date(publishDate);
// 如果日期无效,返回原始字符串
if (isNaN(pubDate.getTime())) return publishDate;
const diffMs = now.getTime() - pubDate.getTime();
const diffSeconds = Math.floor(diffMs / 1000);
const diffMinutes = Math.floor(diffSeconds / 60);
const diffHours = Math.floor(diffMinutes / 60);
const diffDays = Math.floor(diffHours / 24);
const diffWeeks = Math.floor(diffDays / 7);
const diffMonths = Math.floor(diffDays / 30);
const diffYears = Math.floor(diffDays / 365);
// 如果是未来时间,显示具体日期
if (diffMs < 0) {
return formatDateString(pubDate);
}
// 根据时间差返回相应的描述
if (diffSeconds < 60) {
return '刚刚';
} else if (diffMinutes < 60) {
return `${diffMinutes} 分钟前`;
} else if (diffHours < 24) {
return `${diffHours} 小时前`;
} else if (diffDays < 7) {
return `${diffDays} 天前`;
} else if (diffWeeks < 4) {
return `${diffWeeks} 周前`;
} else if (diffMonths < 12) {
return `${diffMonths} 个月前`;
} else if (diffYears < 2) {
return '1 年前';
} else {
// 超过2年显示具体日期
return formatDateString(pubDate);
}
};
// 格式化日期字符串
export const formatDateString = (date) => {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
};
// 格式化日期时间字符串(包含时间)
export const formatDateTimeString = (date) => {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
return `${year}-${month}-${day} ${hours}:${minutes}`;
};
+22 -16
View File
@@ -1,6 +1,6 @@
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { SSE } from 'sse';
import { SSE } from 'sse.js';
import {
API_ENDPOINTS,
MESSAGE_STATUS,
@@ -246,9 +246,11 @@ export const useApiRequest = (
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 }));
@@ -290,26 +292,30 @@ export const useApiRequest = (
});
source.addEventListener('error', (e) => {
console.error('SSE Error:', e);
const errorMessage = e.data || t('请求发生错误');
// 只有在流没有正常完成且连接状态异常时才处理错误
if (!isStreamComplete && source.readyState !== 2) {
console.error('SSE Error:', e);
const errorMessage = e.data || t('请求发生错误');
const errorInfo = handleApiError(new Error(errorMessage));
errorInfo.readyState = source.readyState;
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);
setDebugData(prev => ({
...prev,
response: responseData + '\n\nSSE Error:\n' + JSON.stringify(errorInfo, null, 2)
}));
setActiveDebugTab(DEBUG_TABS.RESPONSE);
streamMessageUpdate(errorMessage, 'content');
completeMessage(MESSAGE_STATUS.ERROR);
sseSourceRef.current = null;
source.close();
streamMessageUpdate(errorMessage, 'content');
completeMessage(MESSAGE_STATUS.ERROR);
sseSourceRef.current = null;
source.close();
}
});
source.addEventListener('readystatechange', (e) => {
if (e.readyState >= 2 && source.status !== undefined && source.status !== 200) {
// 检查 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;
@@ -401,4 +407,4 @@ export const useApiRequest = (
streamMessageUpdate,
completeMessage,
};
};
};
+107 -11
View File
@@ -265,10 +265,15 @@
"设置页脚": "Set Footer",
"新版本": "New Version",
"关闭": "Close",
"密码已重置并已复制到剪贴板": "Password has been reset and copied to clipboard",
"密码已重置并已复制到剪贴板": "Password has been reset and copied to clipboard: ",
"密码已复制到剪贴板:": "Password has been copied to clipboard: ",
"密码重置确认": "Password Reset Confirmation",
"邮箱地址": "Email address",
"提交": "Submit",
"等待获取邮箱信息...": "Waiting to get email information...",
"确认重置密码": "Confirm Password Reset",
"无效的重置链接,请重新发起密码重置请求": "Invalid reset link, please initiate a new password reset request",
"请输入邮箱地址": "Please enter the email address",
"请稍后几秒重试": "Please retry in a few seconds",
"正在检查用户环境": "Checking user environment",
"重置邮件发送成功": "Reset mail sent successfully",
@@ -505,7 +510,7 @@
"此项可选,输入镜像站地址,格式为:": "This is optional, enter the mirror site address, the format is:",
"模型映射": "Model mapping",
"请输入默认 API 版本,例如:2023-03-15-preview,该配置可以被实际的请求查询参数所覆盖": "Please enter the default API version, for example: 2023-03-15-preview, this configuration can be overridden by the actual request query parameters",
"默认": "default",
"默认": "Default",
"图片演示": "Image demo",
"注意,系统请求的时模型名称中的点会被剔除,例如:gpt-4.1会请求为gpt-41,所以在Azure部署的时候,部署模型名称需要手动改为gpt-41": "Note that the dot in the model name requested by the system will be removed, for example: gpt-4.1 will be requested as gpt-41, so when deploying on Azure, the deployment model name needs to be manually changed to gpt-41",
"2025年5月10日后添加的渠道,不需要再在部署的时候移除模型名称中的\".\"": "After May 10, 2025, channels added do not need to remove the dot in the model name during deployment",
@@ -832,7 +837,18 @@
"支付宝": "Alipay",
"待使用收益": "Proceeds to be used",
"邀请人数": "Number of people invited",
"兑换余额": "Exchange balance",
"兑换码充值": "Redemption code recharge",
"使用兑换码快速充值": "Use redemption code to quickly recharge",
"支付方式": "Payment method",
"邀请奖励": "Invite reward",
"或输入自定义金额": "Or enter a custom amount",
"选择充值额度": "Select recharge amount",
"实付": "Actual payment",
"快速方便的充值方式": "Quick and convenient recharge method",
"邀请好友获得额外奖励": "Invite friends to get additional rewards",
"邀请好友注册,好友充值后您可获得相应奖励": "Invite friends to register, and you can get the corresponding reward after the friend recharges",
"通过划转功能将奖励额度转入到您的账户余额中": "Transfer the reward amount to your account balance through the transfer function",
"邀请的好友越多,获得的奖励越多": "The more friends you invite, the more rewards you will get",
"在线充值": "Online recharge",
"充值数量,最低 ": "Recharge quantity, minimum",
"请选择充值金额": "Please select the recharge amount",
@@ -865,8 +881,7 @@
"你好,": "Hello,",
"线路监控": "line monitoring",
"查看全部": "View all",
"高延迟": "high latency",
"异常": "abnormal",
"异常": "Abnormal",
"的未命名令牌": "unnamed token",
"令牌更新成功!": "Token updated successfully!",
"(origin) Discord原链接": "(origin) Discord original link",
@@ -925,7 +940,7 @@
"支付中..": "Paying",
"查看图片": "View pictures",
"并发限制": "Concurrency limit",
"正常": "normal",
"正常": "Normal",
"周期": "cycle",
"同步频率10-20分钟": "Synchronization frequency 10-20 minutes",
"模型调用占比": "Model call proportion",
@@ -949,12 +964,14 @@
"任务ID": "Task ID",
"周": "week",
"总计:": "Total:",
"划转": "transfer",
"划转到余额": "Transfer to balance",
"可用额度": "Available credit",
"邀请码:": "Invitation code:",
"最低": "lowest",
"划转额度": "Transfer amount",
"邀请链接": "Invitation link",
"划转邀请额度": "Transfer invitation quota",
"可用邀请额度": "Available invitation quota",
"更多优惠": "More offers",
"企业微信": "Enterprise WeChat",
"点击解绑WxPusher": "Click to unbind WxPusher",
@@ -1388,7 +1405,8 @@
"可在初始化后修改": "Can be modified after initialization",
"初始化系统": "Initialize system",
"支持众多的大模型供应商": "Supporting various LLM providers",
"新一代大模型网关与AI资产管理系统,一键接入主流大模型,轻松管理您的AI资产": "Next-generation LLM gateway and AI asset management system, one-click integration with mainstream models, easily manage your AI assets",
"统一的大模型接口网关": "The Unified LLMs API Gateway",
"更好的价格,更好的稳定性,无需订阅": "Better price, better stability, no subscription required",
"开始使用": "Get Started",
"关于我们": "About Us",
"关于项目": "About Project",
@@ -1404,8 +1422,13 @@
"演示站点": "Demo Site",
"页面未找到,请检查您的浏览器地址是否正确": "Page not found, please check if your browser address is correct",
"New API项目仓库地址:": "New API project repository address: ",
"NewAPI © {{currentYear}} QuantumNous | 基于 One API v0.5.4 © 2023 JustSong。": "NewAPI © {{currentYear}} QuantumNous | Based on One API v0.5.4 © 2023 JustSong.",
"本项目根据MIT许可证授权,需在遵守Apache-2.0协议的前提下使用。": "This project is licensed under the MIT License and must be used in compliance with the Apache-2.0 License.",
"© {{currentYear}}": "© {{currentYear}}",
"| 基于": " | Based on ",
"MIT许可证": "MIT License",
"Apache-2.0协议": "Apache-2.0 License",
"本项目根据": "This project is licensed under the ",
"授权,需在遵守": " and must be used in compliance with the ",
"的前提下使用。": ".",
"管理员暂时未设置任何关于内容": "The administrator has not set any custom About content yet",
"早上好": "Good morning",
"中午好": "Good afternoon",
@@ -1531,6 +1554,7 @@
"关闭公告": "Close Notice",
"搜索条件": "Search Conditions",
"加载中...": "Loading...",
"正在跳转...": "Redirecting...",
"暂无公告": "No Notice",
"操练场": "Playground",
"欢迎使用,请完成以下设置以开始使用系统": "Welcome to use, please complete the following settings to start using the system",
@@ -1556,5 +1580,77 @@
"使用统计": "Usage Statistics",
"资源消耗": "Resource Consumption",
"性能指标": "Performance Indicators",
"模型数据分析": "Model Data Analysis"
"模型数据分析": "Model Data Analysis",
"搜索无结果": "No results found",
"仪表盘配置": "Dashboard Configuration",
"API信息管理,可以配置多个API地址用于状态展示和负载均衡(最多50个)": "API information management, you can configure multiple API addresses for status display and load balancing (maximum 50)",
"线路描述": "Route description",
"颜色": "Color",
"标识颜色": "Identifier color",
"添加API": "Add API",
"API信息": "API Information",
"暂无API信息": "No API information",
"请输入API地址": "Please enter the API address",
"请输入线路描述": "Please enter the route description",
"如:大带宽批量分析图片推荐": "e.g. Large bandwidth batch analysis of image recommendations",
"请输入说明": "Please enter the description",
"如:香港线路": "e.g. Hong Kong line",
"请联系管理员在系统设置中配置API信息": "Please contact the administrator to configure API information in the system settings.",
"请联系管理员在系统设置中配置公告信息": "Please contact the administrator to configure notice information in the system settings.",
"请联系管理员在系统设置中配置常见问答": "Please contact the administrator to configure FAQ information in the system settings.",
"确定要删除此API信息吗?": "Are you sure you want to delete this API information?",
"测速": "Speed Test",
"批量删除": "Batch Delete",
"常见问答": "FAQ",
"进行中": "Ongoing",
"警告": "Warning",
"添加公告": "Add Notice",
"编辑公告": "Edit Notice",
"公告内容": "Notice Content",
"请输入公告内容": "Please enter the notice content",
"发布日期": "Publish Date",
"请选择发布日期": "Please select the publish date",
"发布时间": "Publish Time",
"公告类型": "Notice Type",
"说明信息": "Description",
"可选,公告的补充说明": "Optional, additional information for the notice",
"确定要删除此公告吗?": "Are you sure you want to delete this notice?",
"系统公告管理,可以发布系统通知和重要消息": "System notice management, you can publish system notices and important messages",
"暂无系统公告": "No system notice",
"添加问答": "Add FAQ",
"编辑问答": "Edit FAQ",
"问题标题": "Question Title",
"请输入问题标题": "Please enter the question title",
"回答内容": "Answer Content",
"请输入回答内容": "Please enter the answer content",
"确定要删除此问答吗?": "Are you sure you want to delete this FAQ?",
"系统公告管理,可以发布系统通知和重要消息(最多100个,前端显示最新20条)": "System notice management, you can publish system notices and important messages (maximum 100, display latest 20 on the front end)",
"常见问答管理,为用户提供常见问题的答案(最多50个,前端显示最新20条)": "FAQ management, providing answers to common questions for users (maximum 50, display latest 20 on the front end)",
"暂无常见问答": "No FAQ",
"显示最新20条": "Display latest 20",
"Uptime Kuma 服务地址": "Uptime Kuma service address",
"状态页面 Slug": "Status page slug",
"请输入 Uptime Kuma 服务的完整地址,例如:https://uptime.example.com": "Please enter the complete address of Uptime Kuma, for example: https://uptime.example.com",
"请输入状态页面的 slug 标识符,例如:my-status": "Please enter the slug identifier for the status page, for example: my-status",
"Uptime Kuma 服务地址不能为空": "Uptime Kuma service address cannot be empty",
"请输入有效的 URL 地址": "Please enter a valid URL address",
"状态页面 Slug 不能为空": "Status page slug cannot be empty",
"Slug 只能包含字母、数字、下划线和连字符": "Slug can only contain letters, numbers, underscores, and hyphens",
"请输入 Uptime Kuma 服务地址": "Please enter the Uptime Kuma service address",
"请输入状态页面 Slug": "Please enter the status page slug",
"配置": "Configure",
"服务监控地址,用于展示服务状态信息": "service monitoring address for displaying status information",
"服务可用性": "Service Status",
"可用率": "Availability",
"有异常": "Abnormal",
"高延迟": "High latency",
"维护中": "Maintenance",
"暂无监控数据": "No monitoring data",
"请联系管理员在系统设置中配置Uptime": "Please contact the administrator to configure Uptime in the system settings.",
"IP记录": "IP Record",
"记录请求与错误日志 IP": "Record request and error log IP",
"开启后,仅“消费”和“错误”日志将记录您的客户端 IP 地址": "After enabling, only \"consumption\" and \"error\" logs will record your client IP address",
"只有当用户设置开启IP记录时,才会进行请求和错误类型日志的IP记录": "Only when the user sets IP recording, the IP recording of request and error type logs will be performed",
"设置保存成功": "Settings saved successfully",
"设置保存失败": "Settings save failed"
}
+37 -73
View File
@@ -15,20 +15,9 @@
/* ==================== 全局基础样式 ==================== */
body {
margin: 0;
padding-top: 0;
font-family:
Lato, 'Helvetica Neue', Arial, Helvetica, 'Microsoft YaHei', sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
scrollbar-width: none;
color: var(--semi-color-text-0) !important;
background-color: var(--semi-color-bg-0) !important;
height: 100vh;
}
body::-webkit-scrollbar {
display: none;
font-family: Lato, 'Helvetica Neue', Arial, Helvetica, 'Microsoft YaHei', sans-serif;
color: var(--semi-color-text-0);
background-color: var(--semi-color-bg-0);
}
code {
@@ -36,34 +25,20 @@ code {
source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace;
}
#root {
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* ==================== 布局相关样式 ==================== */
.semi-layout::-webkit-scrollbar,
.semi-layout-content::-webkit-scrollbar,
.semi-sider::-webkit-scrollbar {
width: 6px;
height: 6px;
display: none;
width: 0;
height: 0;
}
.semi-layout-content::-webkit-scrollbar-thumb,
.semi-sider::-webkit-scrollbar-thumb {
background: var(--semi-color-tertiary-light-default);
border-radius: 3px;
}
.semi-layout-content::-webkit-scrollbar-thumb:hover,
.semi-sider::-webkit-scrollbar-thumb:hover {
background: var(--semi-color-tertiary);
}
.semi-layout-content::-webkit-scrollbar-track,
.semi-sider::-webkit-scrollbar-track {
background: transparent;
.semi-layout,
.semi-layout-content,
.semi-sider {
-ms-overflow-style: none;
scrollbar-width: none;
}
/* ==================== 导航和侧边栏样式 ==================== */
@@ -73,6 +48,10 @@ code {
.semi-page-item,
.semi-navigation-item,
.semi-tag-closable,
.semi-input-wrapper,
.semi-tabs-tab-button,
.semi-select,
.semi-button,
.semi-datepicker-range-input {
border-radius: 9999px !important;
}
@@ -322,6 +301,24 @@ code {
font-size: 1.1em;
}
/* 卡片内容容器通用样式 */
.card-content-container {
position: relative;
}
.card-content-fade-indicator {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 30px;
background: linear-gradient(transparent, var(--semi-color-bg-1));
pointer-events: none;
z-index: 1;
opacity: 0;
transition: opacity 0.3s ease;
}
/* ==================== 调试面板特定样式 ==================== */
.debug-panel .semi-tabs {
height: 100% !important;
@@ -377,7 +374,8 @@ code {
background: transparent;
}
/* 隐藏模型设置区域的滚动条 */
/* 隐藏卡片内容区域的滚动条 */
.card-content-scroll::-webkit-scrollbar,
.model-settings-scroll::-webkit-scrollbar,
.thinking-content-scroll::-webkit-scrollbar,
.custom-request-textarea .semi-input::-webkit-scrollbar,
@@ -385,6 +383,7 @@ code {
display: none;
}
.card-content-scroll,
.model-settings-scroll,
.thinking-content-scroll,
.custom-request-textarea .semi-input,
@@ -414,41 +413,6 @@ code {
/* ==================== 响应式/移动端样式 ==================== */
@media only screen and (max-width: 767px) {
#root>section>header>section>div>div>div>div.semi-navigation-footer>div>a>li {
padding: 0 0;
}
#root>section>header>section>div>div>div>div.semi-navigation-header-list-outer>div.semi-navigation-list-wrapper>ul>div>a>li {
padding: 0 5px;
}
#root>section>header>section>div>div>div>div.semi-navigation-footer>div:nth-child(1)>a>li {
padding: 0 5px;
}
.semi-navigation-horizontal .semi-navigation-header {
margin-right: 0;
}
/* 确保移动端内容可滚动 */
.semi-layout-content {
-webkit-overflow-scrolling: touch !important;
overscroll-behavior-y: auto !important;
}
/* 修复移动端下拉刷新 */
body {
overflow: visible !important;
overscroll-behavior-y: auto !important;
position: static !important;
height: 100% !important;
}
/* 确保内容区域在移动端可以正常滚动 */
#root {
overflow: visible !important;
height: 100% !important;
}
/* 移动端表格样式调整 */
.semi-table-tbody,
+1
View File
@@ -1,6 +1,7 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import '@douyinfe/semi-ui/dist/css/semi.css';
import { UserProvider } from './context/User';
import 'react-toastify/dist/ReactToastify.css';
import { StatusProvider } from './context/Status';
+55 -5
View File
@@ -3,7 +3,6 @@ import { API, showError } from '../../helpers';
import { marked } from 'marked';
import { Empty } from '@douyinfe/semi-ui';
import { IllustrationConstruction, IllustrationConstructionDark } from '@douyinfe/semi-illustrations';
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
const About = () => {
@@ -42,14 +41,65 @@ const About = () => {
<div style={{ textAlign: 'center' }}>
<p>{t('可在设置页面设置关于内容,支持 HTML & Markdown')}</p>
{t('New API项目仓库地址:')}
<Link to='https://github.com/QuantumNous/new-api' target="_blank">
<a
href='https://github.com/QuantumNous/new-api'
target="_blank"
rel="noopener noreferrer"
className="!text-semi-color-primary"
>
https://github.com/QuantumNous/new-api
</Link>
</a>
<p>
{t('NewAPI © {{currentYear}} QuantumNous | 基于 One API v0.5.4 © 2023 JustSong。', { currentYear })}
<a
href="https://github.com/QuantumNous/new-api"
target="_blank"
rel="noopener noreferrer"
className="!text-semi-color-primary"
>
NewAPI
</a> {t('© {{currentYear}}', { currentYear })} <a
href="https://github.com/QuantumNous"
target="_blank"
rel="noopener noreferrer"
className="!text-semi-color-primary"
>
QuantumNous
</a> {t('| ')} <a
href="https://github.com/songquanpeng/one-api/releases/tag/v0.5.4"
target="_blank"
rel="noopener noreferrer"
className="!text-semi-color-primary"
>
One API v0.5.4
</a> © 2023 <a
href="https://github.com/songquanpeng"
target="_blank"
rel="noopener noreferrer"
className="!text-semi-color-primary"
>
JustSong
</a>
</p>
<p>
{t('本项目根据MIT许可证授权,需在遵守Apache-2.0协议的前提下使用。')}
{t('本项目根据')}
<a
href="https://github.com/songquanpeng/one-api/blob/v0.5.4/LICENSE"
target="_blank"
rel="noopener noreferrer"
className="!text-semi-color-primary"
>
{t('MIT许可证')}
</a>
{t('授权,需在遵守')}
<a
href="https://github.com/QuantumNous/new-api/blob/main/LICENSE"
target="_blank"
rel="noopener noreferrer"
className="!text-semi-color-primary"
>
{t('Apache-2.0协议')}
</a>
{t('的前提下使用。')}
</p>
</div>
);
+6 -6
View File
@@ -846,7 +846,7 @@ const EditChannel = (props) => {
className="!rounded-lg font-mono"
/>
<Text
className="text-blue-500 cursor-pointer mt-1 block"
className="!text-semi-color-primary cursor-pointer mt-1 block"
onClick={() => handleInputChange('model_mapping', JSON.stringify(MODEL_MAPPING_EXAMPLE, null, 2))}
>
{t('填入模板')}
@@ -940,7 +940,7 @@ const EditChannel = (props) => {
className="!rounded-lg font-mono"
/>
<Text
className="text-blue-500 cursor-pointer mt-1 block"
className="!text-semi-color-primary cursor-pointer mt-1 block"
onClick={() => handleInputChange('other', JSON.stringify(REGION_EXAMPLE, null, 2))}
>
{t('填入模板')}
@@ -1062,7 +1062,7 @@ const EditChannel = (props) => {
/>
<div className="flex gap-2 mt-1">
<Text
className="text-blue-500 cursor-pointer"
className="!text-semi-color-primary cursor-pointer"
onClick={() => {
handleInputChange(
'setting',
@@ -1073,10 +1073,10 @@ const EditChannel = (props) => {
{t('填入模板')}
</Text>
<Text
className="text-blue-500 cursor-pointer"
className="!text-semi-color-primary cursor-pointer"
onClick={() => {
window.open(
'https://github.com/Calcium-Ion/new-api/blob/main/docs/channel/other_setting.md',
'https://github.com/QuantumNous/new-api/blob/main/docs/channel/other_setting.md',
);
}}
>
@@ -1146,7 +1146,7 @@ const EditChannel = (props) => {
className="!rounded-lg font-mono"
/>
<Text
className="text-blue-500 cursor-pointer mt-1 block"
className="!text-semi-color-primary cursor-pointer mt-1 block"
onClick={() => {
handleInputChange(
'status_code_mapping',
+28 -4
View File
@@ -194,6 +194,24 @@ const EditTagModal = (props) => {
}, [originModelOptions, inputs.models]);
useEffect(() => {
const fetchTagModels = async () => {
if (!tag) return;
setLoading(true);
try {
const res = await API.get(`/api/channel/tag/models?tag=${tag}`);
if (res?.data?.success) {
const models = res.data.data ? res.data.data.split(',') : [];
setInputs((inputs) => ({ ...inputs, models: models }));
} else {
showError(res.data.message);
}
} catch (error) {
showError(error.message);
} finally {
setLoading(false);
}
};
setInputs({
...originInputs,
tag: tag,
@@ -201,7 +219,8 @@ const EditTagModal = (props) => {
});
fetchModels().then();
fetchGroups().then();
}, [visible]);
fetchTagModels().then(); // Call the new function
}, [visible, tag]); // Add tag to dependency array
const addCustomModels = () => {
if (customModel.trim() === '') return;
@@ -347,6 +366,11 @@ const EditTagModal = (props) => {
<div className="space-y-4">
<div>
<Text strong className="block mb-2">{t('模型')}</Text>
<Banner
type="info"
description={t('当前模型列表为该标签下所有渠道模型列表最长的一个,并非所有渠道的并集,请注意可能导致某些渠道模型丢失。')}
className="!rounded-lg mb-4"
/>
<Select
placeholder={t('请选择该渠道所支持的模型,留空则不更改')}
name='models'
@@ -388,19 +412,19 @@ const EditTagModal = (props) => {
/>
<Space className="mt-2">
<Text
className="text-blue-500 cursor-pointer"
className="!text-semi-color-primary cursor-pointer"
onClick={() => handleInputChange('model_mapping', JSON.stringify(MODEL_MAPPING_EXAMPLE, null, 2))}
>
{t('填入模板')}
</Text>
<Text
className="text-blue-500 cursor-pointer"
className="!text-semi-color-primary cursor-pointer"
onClick={() => handleInputChange('model_mapping', JSON.stringify({}, null, 2))}
>
{t('清空重定向')}
</Text>
<Text
className="text-blue-500 cursor-pointer"
className="!text-semi-color-primary cursor-pointer"
onClick={() => handleInputChange('model_mapping', '')}
>
{t('不更改')}
+15 -8
View File
@@ -1,9 +1,11 @@
import React, { useEffect } from 'react';
import React from 'react';
import { useTokenKeys } from '../../hooks/useTokenKeys';
import { Banner, Layout } from '@douyinfe/semi-ui';
import { Spin } from '@douyinfe/semi-ui';
import { useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
const ChatPage = () => {
const { t } = useTranslation();
const { id } = useParams();
const { keys, serverAddress, isLoading } = useTokenKeys(id);
@@ -40,12 +42,17 @@ const ChatPage = () => {
allow='camera;microphone'
/>
) : (
<div>
<Layout>
<Layout.Header>
<Banner description={'正在跳转......'} type={'warning'} />
</Layout.Header>
</Layout>
<div className="fixed inset-0 w-screen h-screen flex items-center justify-center bg-white/80 z-[1000]">
<div className="flex flex-col items-center">
<Spin
size="large"
spinning={true}
tip={null}
/>
<span className="whitespace-nowrap mt-2 text-center" style={{ color: 'var(--semi-color-primary)' }}>
{t('正在跳转...')}
</span>
</div>
</div>
);
};
File diff suppressed because it is too large Load Diff
+64 -65
View File
@@ -4,8 +4,7 @@ import { API, showError, isMobile } from '../../helpers';
import { StatusContext } from '../../context/Status';
import { marked } from 'marked';
import { useTranslation } from 'react-i18next';
import { IconGithubLogo } from '@douyinfe/semi-icons';
import exampleImage from '/example.png';
import { IconGithubLogo, IconPlay, IconFile } from '@douyinfe/semi-icons';
import { Link } from 'react-router-dom';
import NoticeModal from '../../components/layout/NoticeModal';
import { Moonshot, OpenAI, XAI, Zhipu, Volcengine, Cohere, Claude, Gemini, Suno, Minimax, Wenxin, Spark, Qingyan, DeepSeek, Qwen, Midjourney, Grok, AzureAI, Hunyuan, Xinference } from '@lobehub/icons';
@@ -20,6 +19,7 @@ const Home = () => {
const [noticeVisible, setNoticeVisible] = useState(false);
const isDemoSiteMode = statusState?.status?.demo_site_enabled || false;
const docsLink = statusState?.status?.docs_link || '';
useEffect(() => {
const checkNoticeAndShow = async () => {
@@ -85,132 +85,130 @@ const Home = () => {
{homePageContentLoaded && homePageContent === '' ? (
<div className="w-full overflow-x-hidden">
{/* Banner 部分 */}
<div className="w-full border-b border-semi-color-border min-h-[500px] md:h-[650px] lg:h-[750px] relative overflow-x-hidden">
<div className="flex flex-col md:flex-row items-center justify-center h-full px-4 py-8 md:py-0">
{/* 左侧内容区 */}
<div className="flex-shrink-0 w-full md:w-[480px] md:mr-[60px] lg:mr-[120px] mb-8 md:mb-0">
<div className="flex items-center gap-2 justify-center md:justify-start">
<h1 className="text-3xl md:text-4xl lg:text-5xl font-semibold text-semi-color-text-0 w-auto leading-normal md:leading-[67px]">
{statusState?.status?.system_name || 'New API'}
<div className="w-full border-b border-semi-color-border min-h-[500px] md:min-h-[600px] lg:min-h-[700px] relative overflow-x-hidden">
<div className="flex items-center justify-center h-full px-4 py-20 md:py-24 lg:py-32">
{/* 居中内容区 */}
<div className="flex flex-col items-center justify-center text-center max-w-4xl mx-auto">
<div className="flex flex-col items-center justify-center mb-6 md:mb-8">
<h1 className="text-4xl md:text-5xl lg:text-6xl xl:text-7xl font-semibold text-semi-color-text-0 leading-tight">
{i18n.language === 'en' ? (
<>
The Unified<br />
LLMs API Gateway
</>
) : (
t('统一的大模型接口网关')
)}
</h1>
{statusState?.status?.version && (
<Tag color='light-blue' size='large' shape='circle' className="ml-1">
{statusState.status.version}
</Tag>
)}
<p className="text-lg md:text-xl lg:text-2xl text-semi-color-text-1 mt-4 md:mt-6">
{t('更好的价格,更好的稳定性,无需订阅')}
</p>
</div>
<p className="text-base md:text-lg text-semi-color-text-0 mt-4 md:mt-8 w-full md:w-[480px] leading-7 md:leading-8 text-center md:text-left">
{t('新一代大模型网关与AI资产管理系统,一键接入主流大模型,轻松管理您的AI资产')}
</p>
{/* 操作按钮 */}
<div className="mt-6 md:mt-10 flex flex-wrap gap-4 justify-center md:justify-start">
<div className="flex flex-row gap-4 justify-center items-center">
<Link to="/console">
<Button theme="solid" type="primary" size="large" className="!rounded-3xl">
<Button theme="solid" type="primary" size={isMobile() ? "default" : "large"} className="!rounded-3xl px-8 py-2" icon={<IconPlay />}>
{t('开始使用')}
</Button>
</Link>
{isDemoSiteMode && (
{isDemoSiteMode && statusState?.status?.version ? (
<Button
size="large"
className="flex items-center !rounded-3xl"
size={isMobile() ? "default" : "large"}
className="flex items-center !rounded-3xl px-6 py-2"
icon={<IconGithubLogo />}
onClick={() => window.open('https://github.com/QuantumNous/new-api', '_blank')}
>
GitHub
{statusState.status.version}
</Button>
) : (
docsLink && (
<Button
size={isMobile() ? "default" : "large"}
className="flex items-center !rounded-3xl px-6 py-2"
icon={<IconFile />}
onClick={() => window.open(docsLink, '_blank')}
>
{t('文档')}
</Button>
)
)}
</div>
{/* 框架兼容性图标 */}
<div className="mt-8 md:mt-16">
<div className="flex items-center mb-3 justify-center md:justify-start">
<Text type="tertiary" className="text-lg md:text-xl font-light">
<div className="mt-12 md:mt-16 lg:mt-20 w-full">
<div className="flex items-center mb-6 md:mb-8 justify-center">
<Text type="tertiary" className="text-lg md:text-xl lg:text-2xl font-light">
{t('支持众多的大模型供应商')}
</Text>
</div>
<div className="flex flex-wrap items-center relative mt-6 md:mt-8 gap-6 md:gap-8 justify-center md:justify-start">
<div className="relative w-8 md:w-10 h-8 md:h-10 flex items-center justify-center">
<div className="flex flex-wrap items-center justify-center gap-3 sm:gap-4 md:gap-6 lg:gap-8 max-w-5xl mx-auto px-4">
<div className="w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center">
<Moonshot size={40} />
</div>
<div className="relative w-8 md:w-10 h-8 md:h-10 flex items-center justify-center">
<div className="w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center">
<OpenAI size={40} />
</div>
<div className="relative w-8 md:w-10 h-8 md:h-10 flex items-center justify-center">
<div className="w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center">
<XAI size={40} />
</div>
<div className="relative w-8 md:w-10 h-8 md:h-10 flex items-center justify-center">
<div className="w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center">
<Zhipu.Color size={40} />
</div>
<div className="relative w-8 md:w-10 h-8 md:h-10 flex items-center justify-center">
<div className="w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center">
<Volcengine.Color size={40} />
</div>
<div className="relative w-8 md:w-10 h-8 md:h-10 flex items-center justify-center">
<div className="w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center">
<Cohere.Color size={40} />
</div>
<div className="relative w-8 md:w-10 h-8 md:h-10 flex items-center justify-center">
<div className="w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center">
<Claude.Color size={40} />
</div>
<div className="relative w-8 md:w-10 h-8 md:h-10 flex items-center justify-center">
<div className="w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center">
<Gemini.Color size={40} />
</div>
<div className="relative w-8 md:w-10 h-8 md:h-10 flex items-center justify-center">
<div className="w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center">
<Suno size={40} />
</div>
<div className="relative w-8 md:w-10 h-8 md:h-10 flex items-center justify-center">
<div className="w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center">
<Minimax.Color size={40} />
</div>
<div className="relative w-8 md:w-10 h-8 md:h-10 flex items-center justify-center">
<div className="w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center">
<Wenxin.Color size={40} />
</div>
<div className="relative w-8 md:w-10 h-8 md:h-10 flex items-center justify-center">
<div className="w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center">
<Spark.Color size={40} />
</div>
<div className="relative w-8 md:w-10 h-8 md:h-10 flex items-center justify-center">
<div className="w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center">
<Qingyan.Color size={40} />
</div>
<div className="relative w-8 md:w-10 h-8 md:h-10 flex items-center justify-center">
<div className="w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center">
<DeepSeek.Color size={40} />
</div>
<div className="relative w-8 md:w-10 h-8 md:h-10 flex items-center justify-center">
<div className="w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center">
<Qwen.Color size={40} />
</div>
<div className="relative w-8 md:w-10 h-8 md:h-10 flex items-center justify-center">
<div className="w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center">
<Midjourney size={40} />
</div>
<div className="relative w-8 md:w-10 h-8 md:h-10 flex items-center justify-center">
<div className="w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center">
<Grok size={40} />
</div>
<div className="relative w-8 md:w-10 h-8 md:h-10 flex items-center justify-center">
<div className="w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center">
<AzureAI.Color size={40} />
</div>
<div className="relative w-8 md:w-10 h-8 md:h-10 flex items-center justify-center">
<div className="w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center">
<Hunyuan.Color size={40} />
</div>
<div className="relative w-8 md:w-10 h-8 md:h-10 flex items-center justify-center">
<div className="w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center">
<Xinference.Color size={40} />
</div>
<div className="relative w-8 md:w-10 h-8 md:h-10 flex items-center justify-center">
<Typography.Text className="!text-2xl font-bold">30+</Typography.Text>
<div className="w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center">
<Typography.Text className="!text-lg sm:!text-xl md:!text-2xl lg:!text-3xl font-bold">30+</Typography.Text>
</div>
</div>
</div>
</div>
{/* 右侧图片区域 - 在小屏幕上隐藏或调整位置 */}
<div className="flex-shrink-0 relative md:mr-[-200px] lg:mr-[-400px] hidden md:block lg:min-w-[1100px]">
<div className="absolute w-[320px] md:w-[500px] lg:w-[640px] h-[320px] md:h-[500px] lg:h-[640px] left-[-25px] md:left-[-40px] lg:left-[-50px] top-[-10px] md:top-[-15px] lg:top-[-20px] opacity-60"
style={{ filter: 'blur(120px)' }}>
<div className="absolute w-[320px] md:w-[400px] lg:w-[474px] h-[320px] md:h-[400px] lg:h-[474px] top-[80px] md:top-[100px] lg:top-[132px] bg-semi-color-primary rounded-full opacity-30"></div>
<div className="absolute w-[320px] md:w-[400px] lg:w-[474px] h-[320px] md:h-[400px] lg:h-[474px] left-[80px] md:left-[120px] lg:left-[166px] bg-semi-color-tertiary rounded-full opacity-30"></div>
</div>
<img
src={exampleImage}
alt="application demo"
className="relative h-[400px] md:h-[600px] lg:h-[721px] ml-[-15px] md:ml-[-20px] lg:ml-[-30px] mt-[-15px] md:mt-[-20px] lg:mt-[-30px]"
/>
</div>
</div>
</div>
</div>
@@ -223,7 +221,7 @@ const Home = () => {
/>
) : (
<div
className="text-base md:text-lg p-4 md:p-6 overflow-x-hidden"
className="text-base md:text-lg p-4 md:p-6 lg:p-8 overflow-x-hidden max-w-6xl mx-auto"
dangerouslySetInnerHTML={{ __html: homePageContent }}
></div>
)}
@@ -234,3 +232,4 @@ const Home = () => {
};
export default Home;
@@ -0,0 +1,472 @@
import React, { useEffect, useState } from 'react';
import {
Button,
Space,
Table,
Form,
Typography,
Empty,
Divider,
Avatar,
Modal,
Tag
} from '@douyinfe/semi-ui';
import {
IllustrationNoResult,
IllustrationNoResultDark
} from '@douyinfe/semi-illustrations';
import {
Plus,
Edit,
Trash2,
Save,
Settings
} from 'lucide-react';
import { API, showError, showSuccess } from '../../../helpers';
import { useTranslation } from 'react-i18next';
const { Text } = Typography;
const SettingsAPIInfo = ({ options, refresh }) => {
const { t } = useTranslation();
const [apiInfoList, setApiInfoList] = useState([]);
const [showApiModal, setShowApiModal] = useState(false);
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [deletingApi, setDeletingApi] = useState(null);
const [editingApi, setEditingApi] = useState(null);
const [modalLoading, setModalLoading] = useState(false);
const [loading, setLoading] = useState(false);
const [hasChanges, setHasChanges] = useState(false);
const [apiForm, setApiForm] = useState({
url: '',
description: '',
route: '',
color: 'blue'
});
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [selectedRowKeys, setSelectedRowKeys] = useState([]);
const colorOptions = [
{ value: 'blue', label: 'blue' },
{ value: 'green', label: 'green' },
{ value: 'cyan', label: 'cyan' },
{ value: 'purple', label: 'purple' },
{ value: 'pink', label: 'pink' },
{ value: 'red', label: 'red' },
{ value: 'orange', label: 'orange' },
{ value: 'amber', label: 'amber' },
{ value: 'yellow', label: 'yellow' },
{ value: 'lime', label: 'lime' },
{ value: 'light-green', label: 'light-green' },
{ value: 'teal', label: 'teal' },
{ value: 'light-blue', label: 'light-blue' },
{ value: 'indigo', label: 'indigo' },
{ value: 'violet', label: 'violet' },
{ value: 'grey', label: 'grey' }
];
const updateOption = async (key, value) => {
const res = await API.put('/api/option/', {
key,
value,
});
const { success, message } = res.data;
if (success) {
showSuccess('API信息已更新');
if (refresh) refresh();
} else {
showError(message);
}
};
const submitApiInfo = async () => {
try {
setLoading(true);
const apiInfoJson = JSON.stringify(apiInfoList);
await updateOption('ApiInfo', apiInfoJson);
setHasChanges(false);
} catch (error) {
console.error('API信息更新失败', error);
showError('API信息更新失败');
} finally {
setLoading(false);
}
};
const handleAddApi = () => {
setEditingApi(null);
setApiForm({
url: '',
description: '',
route: '',
color: 'blue'
});
setShowApiModal(true);
};
const handleEditApi = (api) => {
setEditingApi(api);
setApiForm({
url: api.url,
description: api.description,
route: api.route,
color: api.color
});
setShowApiModal(true);
};
const handleDeleteApi = (api) => {
setDeletingApi(api);
setShowDeleteModal(true);
};
const confirmDeleteApi = () => {
if (deletingApi) {
const newList = apiInfoList.filter(api => api.id !== deletingApi.id);
setApiInfoList(newList);
setHasChanges(true);
showSuccess('API信息已删除,请及时点击“保存设置”进行保存');
}
setShowDeleteModal(false);
setDeletingApi(null);
};
const handleSaveApi = async () => {
if (!apiForm.url || !apiForm.route || !apiForm.description) {
showError('请填写完整的API信息');
return;
}
try {
setModalLoading(true);
let newList;
if (editingApi) {
newList = apiInfoList.map(api =>
api.id === editingApi.id
? { ...api, ...apiForm }
: api
);
} else {
const newId = Math.max(...apiInfoList.map(api => api.id), 0) + 1;
const newApi = {
id: newId,
...apiForm
};
newList = [...apiInfoList, newApi];
}
setApiInfoList(newList);
setHasChanges(true);
setShowApiModal(false);
showSuccess(editingApi ? 'API信息已更新,请及时点击“保存设置”进行保存' : 'API信息已添加,请及时点击“保存设置”进行保存');
} catch (error) {
showError('操作失败: ' + error.message);
} finally {
setModalLoading(false);
}
};
const parseApiInfo = (apiInfoStr) => {
if (!apiInfoStr) {
setApiInfoList([]);
return;
}
try {
const parsed = JSON.parse(apiInfoStr);
setApiInfoList(Array.isArray(parsed) ? parsed : []);
} catch (error) {
console.error('解析API信息失败:', error);
setApiInfoList([]);
}
};
useEffect(() => {
if (options.ApiInfo !== undefined) {
parseApiInfo(options.ApiInfo);
}
}, [options.ApiInfo]);
const columns = [
{
title: 'ID',
dataIndex: 'id',
},
{
title: t('API地址'),
dataIndex: 'url',
render: (text, record) => (
<Tag
color={record.color}
className="!rounded-full"
style={{ maxWidth: '280px' }}
>
{text}
</Tag>
),
},
{
title: t('线路描述'),
dataIndex: 'route',
render: (text, record) => (
<Tag shape='circle'>
{text}
</Tag>
),
},
{
title: t('说明'),
dataIndex: 'description',
ellipsis: true,
render: (text, record) => (
<Tag shape='circle'>
{text || '-'}
</Tag>
),
},
{
title: t('颜色'),
dataIndex: 'color',
render: (color) => (
<Avatar
size="extra-extra-small"
color={color}
/>
),
},
{
title: t('操作'),
fixed: 'right',
width: 150,
render: (_, record) => (
<Space>
<Button
icon={<Edit size={14} />}
theme='light'
type='tertiary'
size='small'
className="!rounded-full"
onClick={() => handleEditApi(record)}
>
{t('编辑')}
</Button>
<Button
icon={<Trash2 size={14} />}
type='danger'
theme='light'
size='small'
className="!rounded-full"
onClick={() => handleDeleteApi(record)}
>
{t('删除')}
</Button>
</Space>
),
},
];
const handleBatchDelete = () => {
if (selectedRowKeys.length === 0) {
showError('请先选择要删除的API信息');
return;
}
const newList = apiInfoList.filter(api => !selectedRowKeys.includes(api.id));
setApiInfoList(newList);
setSelectedRowKeys([]);
setHasChanges(true);
showSuccess(`已删除 ${selectedRowKeys.length} 个API信息,请及时点击“保存设置”进行保存`);
};
const renderHeader = () => (
<div className="flex flex-col w-full">
<div className="mb-2">
<div className="flex items-center text-blue-500">
<Settings size={16} className="mr-2" />
<Text>{t('API信息管理,可以配置多个API地址用于状态展示和负载均衡(最多50个)')}</Text>
</div>
</div>
<Divider margin="12px" />
<div className="flex flex-col md:flex-row justify-between items-center gap-4 w-full">
<div className="flex gap-2 w-full md:w-auto order-2 md:order-1">
<Button
theme='light'
type='primary'
icon={<Plus size={14} />}
className="!rounded-full w-full md:w-auto"
onClick={handleAddApi}
>
{t('添加API')}
</Button>
<Button
icon={<Trash2 size={14} />}
type='danger'
theme='light'
onClick={handleBatchDelete}
disabled={selectedRowKeys.length === 0}
className="!rounded-full w-full md:w-auto"
>
{t('批量删除')} {selectedRowKeys.length > 0 && `(${selectedRowKeys.length})`}
</Button>
<Button
icon={<Save size={14} />}
onClick={submitApiInfo}
loading={loading}
disabled={!hasChanges}
type='secondary'
className="!rounded-full w-full md:w-auto"
>
{t('保存设置')}
</Button>
</div>
</div>
</div>
);
// 计算当前页显示的数据
const getCurrentPageData = () => {
const startIndex = (currentPage - 1) * pageSize;
const endIndex = startIndex + pageSize;
return apiInfoList.slice(startIndex, endIndex);
};
const rowSelection = {
selectedRowKeys,
onChange: (selectedRowKeys, selectedRows) => {
setSelectedRowKeys(selectedRowKeys);
},
onSelect: (record, selected, selectedRows) => {
console.log(`选择行: ${selected}`, record);
},
onSelectAll: (selected, selectedRows) => {
console.log(`全选: ${selected}`, selectedRows);
},
getCheckboxProps: (record) => ({
disabled: false,
name: record.id,
}),
};
return (
<>
<Form.Section text={renderHeader()}>
<Table
columns={columns}
dataSource={getCurrentPageData()}
rowSelection={rowSelection}
rowKey="id"
scroll={{ x: 'max-content' }}
pagination={{
currentPage: currentPage,
pageSize: pageSize,
total: apiInfoList.length,
showSizeChanger: true,
showQuickJumper: true,
formatPageText: (page) => t('第 {{start}} - {{end}} 条,共 {{total}} 条', {
start: page.currentStart,
end: page.currentEnd,
total: apiInfoList.length,
}),
pageSizeOptions: ['5', '10', '20', '50'],
onChange: (page, size) => {
setCurrentPage(page);
setPageSize(size);
},
onShowSizeChange: (current, size) => {
setCurrentPage(1);
setPageSize(size);
}
}}
size='middle'
loading={loading}
empty={
<Empty
image={<IllustrationNoResult style={{ width: 150, height: 150 }} />}
darkModeImage={<IllustrationNoResultDark style={{ width: 150, height: 150 }} />}
description={t('暂无API信息')}
style={{ padding: 30 }}
/>
}
className="rounded-xl overflow-hidden"
/>
</Form.Section>
<Modal
title={editingApi ? t('编辑API') : t('添加API')}
visible={showApiModal}
onOk={handleSaveApi}
onCancel={() => setShowApiModal(false)}
okText={t('保存')}
cancelText={t('取消')}
className="rounded-xl"
confirmLoading={modalLoading}
>
<Form layout='vertical' initValues={apiForm} key={editingApi ? editingApi.id : 'new'}>
<Form.Input
field='url'
label={t('API地址')}
placeholder='https://api.example.com'
rules={[{ required: true, message: t('请输入API地址') }]}
onChange={(value) => setApiForm({ ...apiForm, url: value })}
/>
<Form.Input
field='route'
label={t('线路描述')}
placeholder={t('如:香港线路')}
rules={[{ required: true, message: t('请输入线路描述') }]}
onChange={(value) => setApiForm({ ...apiForm, route: value })}
/>
<Form.Input
field='description'
label={t('说明')}
placeholder={t('如:大带宽批量分析图片推荐')}
rules={[{ required: true, message: t('请输入说明') }]}
onChange={(value) => setApiForm({ ...apiForm, description: value })}
/>
<Form.Select
field='color'
label={t('标识颜色')}
optionList={colorOptions}
onChange={(value) => setApiForm({ ...apiForm, color: value })}
render={(option) => (
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Avatar
size="extra-extra-small"
color={option.value}
/>
{option.label}
</div>
)}
/>
</Form>
</Modal>
<Modal
title={t('确认删除')}
visible={showDeleteModal}
onOk={confirmDeleteApi}
onCancel={() => {
setShowDeleteModal(false);
setDeletingApi(null);
}}
okText={t('确认删除')}
cancelText={t('取消')}
type="warning"
className="rounded-xl"
okButtonProps={{
type: 'danger',
theme: 'solid'
}}
>
<Text>{t('确定要删除此API信息吗?')}</Text>
</Modal>
</>
);
};
export default SettingsAPIInfo;

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