Compare commits

...

101 Commits

Author SHA1 Message Date
Calcium-Ion 0b245ff4ee Merge pull request #1268 from QuantumNous/alpha
fix: gemini relay empty response
2025-06-20 02:31:11 +08:00
CaIon 2b2e0a4777 feat: enhance error handling in GeminiHelper and streamline response processing
- Added status code mapping handling in GeminiHelper to reset status codes based on response.
- Removed redundant candidate check in GeminiTextGenerationHandler to simplify response processing.
2025-06-20 01:42:19 +08:00
CaIon 2aae048295 Merge remote-tracking branch 'origin/alpha' into alpha 2025-06-20 01:07:52 +08:00
CaIon f9a72212e6 Merge branch 'main' into alpha 2025-06-20 01:07:44 +08:00
Apple\Apple cfeb0c2828 💄 style(TopUp): Optimize payment method buttons layout based on quantity
Enhance the UI of payment method selection area with responsive layouts:
- Use 2-column grid when exactly 2 payment methods are present
- Use 3-column grid for 3 payment methods
- Use compact card layout for more than 3 payment methods
- Full-width button for single payment method

This improves the visual balance across different device sizes and payment provider configurations, ensuring buttons fill their grid cells appropriately with the w-full class.
2025-06-20 00:52:45 +08:00
CaIon 94a1aeb5c7 feat: add data presence check before batch update in utils 2025-06-19 19:34:57 +08:00
CaIon 2cd9fd4c45 refactor: streamline JSON response structure in channel API endpoints 2025-06-19 19:03:35 +08:00
creamlike1024 4a725f8bb0 fix: 使用日志分组查询 2025-06-19 17:17:32 +08:00
CaIon 05aaf63337 Merge branch 'alpha' 2025-06-19 16:17:56 +08:00
CaIon e685279207 fix: ratio render 2025-06-19 15:36:06 +08:00
creamlike1024 bc1381ea5b Merge branch 'xqx121-main' 2025-06-19 14:51:15 +08:00
creamlike1024 1271f8f648 update relay-gemini-native.go 2025-06-19 14:50:50 +08:00
creamlike1024 24dda1f6fa Merge branch 'main' of github.com:xqx121/new-api into xqx121-main 2025-06-19 14:45:41 +08:00
CaIon fd943659c0 Merge remote-tracking branch 'origin/alpha' into alpha 2025-06-19 14:36:55 +08:00
CaIon bf30adcde0 Merge branch 'main' into alpha 2025-06-19 14:36:17 +08:00
Calcium-Ion b33b3c4f85 Merge pull request #1253 from TopickAI/main
Fix Vertex channel global region format for claude models
2025-06-19 14:35:18 +08:00
Calcium-Ion 0d51f07d8f Merge pull request #1260 from tbphp/fix-gemini-empty-content-error
fix: Gemini & Vertex empty content error
2025-06-19 14:34:27 +08:00
Calcium-Ion 93761f9948 Merge pull request #1261 from KamiPasi/new-api-pr
透传thinking参数, 豆包模型用来控制是否思考
2025-06-19 14:33:41 +08:00
Calcium-Ion 4ba5558094 Merge pull request #1262 from wans10/main
fix: 修复渠道界面模型选择下拉框模型重复显示
2025-06-19 14:32:49 +08:00
Calcium-Ion 4d5e79c9d3 Merge pull request #1257 from QuantumNous/pay_custom
feat: 自定义充值方式
2025-06-19 14:31:45 +08:00
wans10 e4a7f0c779 修复渠道界面模型选择下拉框模型重复显示 2025-06-19 13:34:11 +08:00
KamiPasi 3247d7a341 透传thinking参数, 豆包模型用来控制是否思考 2025-06-19 12:06:42 +08:00
tbphp 173594446e fix: Gemini & Vertex empty content error 2025-06-19 11:25:59 +08:00
Calcium-Ion 1ea674f3ff Merge pull request #1258 from feitianbubu/pr/fix-task-cost-time
fix: task cost time
2025-06-18 23:15:28 +08:00
skynono 6b01438ba5 fix: task cost time 2025-06-18 21:57:46 +08:00
creamlike1024 ef6c390a83 feat: 充值方式设置 2025-06-18 21:23:06 +08:00
CaIon 8d05e44f61 Merge remote-tracking branch 'origin/alpha' into alpha 2025-06-18 20:51:06 +08:00
CaIon 17f3832487 feat(relay): add debug logging for Gemini request body and introduce flexible speech configuration 2025-06-18 20:50:13 +08:00
Apple\Apple 175ea56fdd 🚚 Refactor(ratio_setting): refactor ratio management into standalone ratio_setting package
Summary
• Migrated all ratio-related sources into `setting/ratio_setting/`
  – `model_ratio.go` (renamed from model-ratio.go)
  – `cache_ratio.go`
  – `group_ratio.go`
• Changed package name to `ratio_setting` and relocated initialization (`ratio_setting.InitRatioSettings()` in main).
• Updated every import & call site:
  – Model / cache / completion / image ratio helpers
  – Group ratio helpers (`GetGroupRatio*`, `ContainsGroupRatio`, `CheckGroupRatio`, etc.)
  – JSON-serialization & update helpers (`*Ratio2JSONString`, `Update*RatioByJSONString`)
• Adjusted controllers, middleware, relay helpers, services and models to reference the new package.
• Removed obsolete `setting` / `operation_setting` imports; added missing `ratio_setting` imports.
• Adopted idiomatic map iteration (`for key := range m`) where value is unused.
• Ran static checks to ensure clean build.

This commit centralises all ratio configuration (model, cache and group) in one cohesive module, simplifying future maintenance and improving code clarity.
2025-06-18 18:00:49 +08:00
xqx121 2541defbf4 Update relay-gemini-native.go 2025-06-18 14:26:23 +08:00
sgyy 302ca0b847 fix: Vertex channel global region format 2025-06-18 11:21:56 +08:00
Apple\Apple ee793087de 🐛 fix(detail): explicitly set preventScroll={true} on Tabs to stop page jump
Problem
Semi UI’s Tabs calls `focus()` on the active tab during mount, causing the browser to scroll the page to that element.
Using the bare `preventScroll` shorthand was not picked up reliably, so the page still jumped to the Tabs’ position on first render.

Changes
• Updated both Tabs instances in `web/src/pages/Detail/index.js` to `preventScroll={true}` instead of the shorthand prop.
• Ensures the prop is explicitly interpreted as boolean `true`, converting the internal call to `focus({ preventScroll: true })`.

Result
The `Detail` page now stays at its original scroll position after load, eliminating the unexpected auto-scroll behavior.
2025-06-18 05:10:32 +08:00
Apple\Apple 1d16d3288d 🛠️ fix(detail): disable automatic page scroll caused by Tabs focus
The initial render of the `Detail` page was jumping to the first `Tabs` component because Semi UI calls `focus()` on the active tab, which triggers the browser’s default scroll-into-view behavior.

Changes made
• Added `preventScroll` to the chart-selector `Tabs` (type="button").
• Added `preventScroll` to the uptime-monitor `Tabs` (type="card").

These flags convert the internal `focus()` call to `focus({ preventScroll: true })`, allowing the page to stay at its current position after load.

No functional logic is changed other than disabling the unwanted scroll; UI and user interactions remain the same.
2025-06-18 04:36:12 +08:00
Apple\Apple cd94cc200b 🏷️ chore(ui): Hide Type Tabs in Tag-Aggregation Mode & Refine Query Logic
frontend(ChannelsTable):
• Do not render type-filter Tabs when `enableTagMode` is true, preventing UI/logic conflicts in tag aggregation view.
• Adjust API query construction:
  – Append `type=` param only when NOT in tag mode and selected tab ≠ 'all'.
  – Applies to both `loadChannels` and `searchChannels`.
• Result: UI stays clean in tag view, and backend receives correct parameters across modes.

No other functionality affected.
2025-06-18 02:59:34 +08:00
Apple\Apple 1800cfd1f6 🎨 style(EditChannel): replace fixed-height TextArea with autosize to eliminate unwanted scrollbar
The “Batch Create” secret-key input in Channel Edit previously used a TextArea
with a hard-coded `minHeight`, which caused an extra scrollbar and blank space
on the right side of the field.
This change:

• Removes the fixed `minHeight` in favour of `autosize={{ minRows: 6, maxRows: 6 }}`
• Keeps the field’s rounded appearance while letting it grow/shrink with
  content, improving usability on both desktop and mobile

No other components or global styles are affected.
2025-06-18 02:41:06 +08:00
Apple\Apple 44688fe6cc 🚀 feat(Channels): Enhance Channel Filtering & Performance
feat(api):
• Add optional `type` query param to `/api/channel` endpoint for type-specific pagination
• Return `type_counts` map with counts for each channel type
• Implement `GetChannelsByType`, `CountChannelsByType`, `CountChannelsGroupByType` in `model/channel.go`

feat(frontend):
• Introduce type Tabs in `ChannelsTable` to switch between channel types
• Tabs show dynamic counts using backend `type_counts`; “All” is computed from sum
• Persist active type, reload data on tab change (with proper query params)

perf(frontend):
• Use a request counter (`useRef`) to discard stale responses when tabs switch quickly
• Move all `useMemo` hooks to top level to satisfy React Hook rules
• Remove redundant local type counting fallback when backend data present

ui:
• Remove icons from response-time tags for cleaner look
• Use Semi-UI native arrow controls for Tabs; custom arrow code deleted

chore:
• Minor refactor & comments for clarity
• Ensure ESLint Hook rules pass

Result: Channel list now supports fast, accurate type filtering with correct counts, improved concurrency safety, and cleaner UI.
2025-06-18 02:33:18 +08:00
Apple\Apple 997d9901aa Merge remote-tracking branch 'origin/main' into alpha 2025-06-18 01:30:12 +08:00
Apple\Apple 19c3cb1248 🚀 feat(ui): isolate ratio configurations into dedicated “Ratio” tab and refactor settings components
Summary
• Added new Ratio tab in Settings for managing all ratio-related configurations (group & model multipliers).
• Created `RatioSetting` component to host GroupRatio, ModelRatio, Visual Editor and Unset-Models panels.
• Moved ratio components to `web/src/pages/Setting/Ratio/` directory:
  – `GroupRatioSettings.js`
  – `ModelRatioSettings.js`
  – `ModelSettingsVisualEditor.js`
  – `ModelRationNotSetEditor.js`
• Updated imports in `RatioSetting.js` to use the new path.
• Updated main Settings router (`web/src/pages/Setting/index.js`) to include the new “Ratio Settings” tab.
• Pruned `OperationSetting.js`:
  – Removed ratio-specific cards, tabs and unused imports.
  – Reduced state to only the keys required by its child components.
  – Deleted obsolete fields (`StreamCacheQueueLength`, `CheckSensitiveOnCompletionEnabled`, `StopOnSensitiveEnabled`).
• Added boolean handling simplification in `OperationSetting.js`.
• Adjusted helper import list and removed unused translation hook.

Why
Separating ratio-related settings improves UX clarity, reduces cognitive load in the Operation Settings panel and keeps the codebase modular and easier to maintain.

BREAKING CHANGE
The file paths for ratio components have changed. Any external imports referencing the old `Operation` directory must update to the new `Ratio` path.
2025-06-18 01:29:35 +08:00
Calcium-Ion 423796e790 Merge pull request #1247 from RedwindA/feat/25lite-thinking
feat: improve gemini thinking budget adaption
2025-06-18 01:00:08 +08:00
RedwindA a004db93c9 feat(Gemini): enhance budget clamping logic for Gemini models 2025-06-18 00:49:35 +08:00
CaIon 36a0d4d7ae fix(relay): ensure consistent setting of web search context size in TextHelper function 2025-06-18 00:37:22 +08:00
CaIon d4e20df7a6 fix(relay): refine error message for unsupported MIME types and enhance error handling in OpenAI wrapper 2025-06-17 22:44:57 +08:00
CaIon 8d0b54bf6e fix(relay): improve error handling for unsupported MIME types by sanitizing URLs 2025-06-17 22:40:41 +08:00
CaIon 5506e4feed feat(file_decoder): expand MIME type detection to include additional file extensions 2025-06-17 22:20:19 +08:00
CaIon 16bffe05d4 feat(file_decoder): add debug logging for MIME type detection when handling application/octet-stream 2025-06-17 22:18:51 +08:00
CaIon 680ff8e8eb feat(file_decoder): enhance MIME type detection based on URL and Content-Disposition header 2025-06-17 21:49:13 +08:00
Calcium-Ion 129d1b081f Merge pull request #1239 from QuantumNous/auto_group
feat: auto分组
2025-06-17 21:14:09 +08:00
CaIon 4ec7012974 feat: enhance group ratio handling in pricing calculations 2025-06-17 21:05:35 +08:00
CaIon 26c6087d80 feat(GroupRatioSettings): enhance JSON validation for group ratios 2025-06-17 21:05:24 +08:00
CaIon 4238068c65 feat(channel): enhance Claude response handling with new Done flag and improved usage tracking 2025-06-17 20:08:25 +08:00
CaIon 822ed681de feat(channel): add handling for pre_consume_token_quota_failed error type 2025-06-17 16:46:52 +08:00
CaIon 2a1ed60738 Merge branch 'alpha' 2025-06-17 14:49:13 +08:00
Calcium-Ion a13a63549b Merge pull request #1243 from cjm0810151/main
fix(audio): :bugs: fix webm audio strconv.ParseFloat: parsing "N/A"
2025-06-17 14:48:17 +08:00
Calcium-Ion 24ae1fa55e Merge pull request #1240 from RedwindA/fix/redis
Fix: optimize Redis expiration handling and refactor cache duration retrieval
2025-06-17 14:47:00 +08:00
chenjm ab5dfd8d97 fix(audio): :bugs: fix webm audio strconv.ParseFloat: parsing "N/A" 2025-06-17 10:04:36 +08:00
chenjm 99dacb1d4b fix(audio): :bugs: fix webm audio strconv.ParseFloat: parsing "N/A" 2025-06-17 09:21:56 +08:00
RedwindA 09ffc364f2 🔧 refactor(redis): replace direct constant usage with RedisKeyCacheSeconds function for cache duration 2025-06-17 03:24:39 +08:00
RedwindA e34d3c2160 🔧 fix(redis): only set expiration if greater than 0 in RedisHSetObj 2025-06-17 02:37:19 +08:00
CaIon 28a0eb2a6d Merge remote-tracking branch 'origin/alpha' into alpha 2025-06-17 00:09:38 +08:00
CaIon af103dcc7f 🧹 chore(relay): remove unused import in relay-palm.go 2025-06-17 00:09:26 +08:00
Calcium-Ion 71fe637d2a Merge pull request #1231 from RedwindA/feat/gemini-budget-in-name
feat(Gemini): implement thinking budget control in model name
2025-06-17 00:03:53 +08:00
Calcium-Ion 293decb96e Merge pull request #1208 from feitianbubu/pr/fix-hot-reload-sync-options
fix: enabled hot reload SyncOptions
2025-06-16 23:03:29 +08:00
creamlike1024 c6a9df67b1 feat: auto分组 2025-06-16 22:15:12 +08:00
CaIon 288f279379 🔧 refactor(relay): replace UUID generation with helper function for response IDs 2025-06-16 21:02:27 +08:00
Apple\Apple abceadcc6d Merge branch 'main' into alpha 2025-06-16 20:06:40 +08:00
Apple\Apple 35a08da791 🐛 fix(console-setting): ensure announcements are returned in newest-first order
Summary
• Added stable, descending sort to `GetAnnouncements()` so that the API always returns the latest announcements first.
• Introduced helper `getPublishTime()` to safely parse `publishDate` (RFC 3339) and fall back to zero value on failure.
• Switched to `sort.SliceStable` for deterministic ordering when timestamps are identical.
• Imported the standard `sort` package and removed redundant, duplicate date parsing.

Impact
Front-end no longer needs to perform client-side sorting; the latest announcement is guaranteed to appear at the top on all platforms and clients.
2025-06-16 20:05:54 +08:00
RedwindA 9209355f7d refactor: replace inline closure with a helper function 2025-06-16 19:41:42 +08:00
Calcium-Ion bd82e7c341 Merge pull request #1205 from a37836323/fix-azure-responses-api
修复Azure渠道对responses API的兼容性支持 - 为Azure渠道添加对responses API的特殊处理 - 兼容微软新…
2025-06-16 19:17:21 +08:00
Calcium-Ion e0e548eb94 Merge pull request #1232 from RedwindA/fix/playground-group
fix: include group in payload for playground
2025-06-16 18:35:27 +08:00
CaIon c4e652461e 🔧 refactor(dto): update BudgetTokens handling in Thinking struct 2025-06-16 18:29:49 +08:00
Apple\Apple 1f6ec0174a feat: add admin-only "remark" support for users
* backend
  - model: add `Remark` field (varchar 255, `json:"remark,omitempty"`); AutoMigrate handles schema change automatically
  - controller:
    * accept `remark` on user create/update endpoints
    * hide remark from regular users (`GetSelf`) by zero-ing the field before JSON marshalling
    * clarify inline comment explaining the omitempty behaviour

* frontend (React / Semi UI)
  - AddUser.js & EditUser.js: add “Remark” input for admins
  - UsersTable.js:
    * remove standalone “Remark” column
    * show remark as a truncated Tag next to username with Tooltip for full text
    * import Tooltip component
  - i18n: reuse existing translations where applicable

This commit enables administrators to label users with private notes while ensuring those notes are never exposed to the users themselves.
2025-06-16 03:20:54 +08:00
RedwindA fe0ff4726d 🐛 fix(api): include group in payload for playground 2025-06-16 01:12:18 +08:00
RedwindA 45e9607995 update i18n 2025-06-15 23:40:58 +08:00
RedwindA 1e1a6aae55 feat: implement thinking budget control in model name 2025-06-15 23:20:41 +08:00
Apple\Apple 1c4819b21f 💄 style: hide announcement modal scrollbar
Improve UX by hiding the vertical scrollbar inside the announcement (NoticeModal)
while keeping the content scrollable.

Changes
• NoticeModal.js
  - Introduce `notice-content-scroll` class to the content wrapper.
  - Remove inline custom scrollbar styling for cleaner code.

• index.css
  - Add `.notice-content-scroll` to the global hidden-scrollbar rules, ensuring
    scrollbars are hidden across browsers.

Result
Users can still scroll through long announcements, but no scrollbar is shown,
giving the modal a cleaner and more consistent appearance.
2025-06-15 03:28:06 +08:00
Apple\Apple c10f0dd021 🎨 style(dashboard): Standardize Empty component visuals in Detail page
Summary:
Refactored the `Detail` page to deliver a more consistent and compact visual experience when displaying empty states.

Key changes:
1. Introduced a reusable `ILLUSTRATION_SIZE` constant (96 × 96) to ensure all `IllustrationConstruction` / `IllustrationConstructionDark` icons render at a uniform, reduced size.
2. Applied the new size to every `Empty` component instance within the file.
3. Ensured Empty‐state content (title, description, icon) is centrally aligned for better readability.
4. Updated the Uptime panel’s empty description text for greater clarity.

These adjustments improve UI cohesion, reduce visual noise, and make empty messages easier to scan.
2025-06-15 03:22:23 +08:00
Apple\Apple c92ba89173 ♻️ refactor(console_migrate): migrate legacy UptimeKumaUrl/Slug to new uptime_kuma_groups format
* Added migration logic in `controller/console_migrate.go`
  * Detects both `UptimeKumaUrl` and `UptimeKumaSlug`
  * Creates a single-group JSON array under `console_setting.uptime_kuma_groups`
    - Uses `categoryName: "old"` to mark migrated data
    - Preserves original `url` and `slug` values
  * Clears and removes obsolete `UptimeKumaUrl` and `UptimeKumaSlug` keys
* Removes outdated code paths that wrote to `console_setting.uptime_kuma_url` and `console_setting.uptime_kuma_slug`
* Keeps frontend `DashboardSetting.js` compatible — no additional changes required
* Aligns migration behavior with previous `ApiInfo` refactor for consistent console settings management
2025-06-15 03:12:34 +08:00
Apple\Apple 0305ed051e 🚦 feat(uptime-kuma): support custom category names & monitor subgroup rendering
Backend
• controller/uptime_kuma.go
  - Added Group field to Monitor struct to carry publicGroupList.name.
  - Extended status page parsing to capture group Name and inject it into each monitor.
  - Re-worked fetchGroupData loop: aggregate all sub-groups, drop unnecessary pre-allocation/breaks.

Frontend
• web/src/pages/Detail/index.js
  - renderMonitorList now buckets monitors by the new group field and renders a lightweight header per subgroup.
  - Fallback gracefully when group is empty to preserve previous single-list behaviour.

Other
• Expanded anonymous struct definition for statusData.PublicGroupList to include ID/Name, enabling JSON unmarshalling of group names.

Result
Custom CategoryName continues to work while each uptime group’s internal sub-groups are now clearly displayed in the UI, providing finer-grained visibility without impacting performance or existing validation logic.
2025-06-15 02:54:54 +08:00
Calcium-Ion d1431b0c96 Merge pull request #1226 from RedwindA/fix-gemini/empty-system
💬 fix(Gemini): clean up empty system instructions in request
2025-06-14 20:09:22 +08:00
CaIon c6226f3678 feat(database): implement database migration logic for PostgreSQL and add fast migration fallback 2025-06-14 19:47:44 +08:00
RedwindA 66ffccf164 💬 fix(GeminiHelper): clean up empty system instructions in request 2025-06-14 19:36:58 +08:00
Calcium-Ion eaf3b6ac55 Merge pull request #1225 from QuantumNous/fix_mixing_sql_conflicts
Fix mixing databases conflicts
2025-06-14 18:24:53 +08:00
CaIon 49cb5f3fa3 🐛 fix(log): optimize channel ID collection by using a map to prevent duplicates 2025-06-14 18:23:25 +08:00
CaIon a9ec62eea8 🐛 fix(ability, channel): standardize boolean value handling across database queries 2025-06-14 18:15:45 +08:00
CaIon 4371717c78 fix: Resolving conflicts caused by mixing multiple databases 2025-06-14 17:51:05 +08:00
Apple\Apple 882cdbd34a 🎛️ feat(dashboard): add per-panel enable switches & conditional backend payload
Backend:
• ConsoleSetting
  - Introduce `ApiInfoEnabled`, `UptimeKumaEnabled`, `AnnouncementsEnabled`, `FAQEnabled` (default true).
• misc.GetStatus
  - Refactor to build response map dynamically.
  - Return the four *_enabled flags.
  - Only append `api_info`, `announcements`, `faq` when their respective flags are true.

Frontend:
• Detail page
  - Remove all `self_use_mode_enabled` checks.
  - Render API, Announcement, FAQ and Uptime panels based on the new *_enabled flags.
• Dashboard → Settings
  - Added `Switch` controls in:
    · SettingsAPIInfo.js
    · SettingsAnnouncements.js
    · SettingsFAQ.js
    · SettingsUptimeKuma.js
  - Each switch persists its state via `/api/option` to the corresponding
    `console_setting.<panel>_enabled` key and reflects current status on load.
  - DashboardSetting.js now initialises and refreshes the four *_enabled keys so
    child components receive accurate panel states.

Fixes:
• Switches previously defaulted to “on” because *_enabled keys were missing.
  They are now included, ensuring correct visual state when panels are disabled.

No breaking changes; existing functionality remains untouched aside from the
new per-panel visibility control.
2025-06-14 01:39:23 +08:00
Apple\Apple bccb87fef3 feat: Add controller/console_migrate.go providing /api/option/migrate_console_setting endpoint for one-off data migration. 2025-06-14 01:05:09 +08:00
Apple\Apple ce0ff67978 🧹 refactor: drop obsolete ValidateApiInfo API & update callers
Backend
• Removed the exported function `ValidateApiInfo` from `setting/console_setting/validation.go`; it was only a legacy wrapper and is no longer required.
• Updated `controller/option.go` to call `ValidateConsoleSettings(value, "ApiInfo")` directly when validating `console_setting.api_info`.
• Confirmed there are no remaining references to `ValidateApiInfo` in the codebase.

This commit eliminates the last piece of compatibility code related to the old validation interface, keeping the API surface clean and consistent.
2025-06-14 00:59:38 +08:00
Apple\Apple 9d384f9778 refactor(console_setting): migrate console settings to model_setting auto-injection
Backend
- Introduce `setting/console_setting` package that defines `ConsoleSetting` struct with JSON tags and validation rules.
- Register the new module with `config.GlobalConfig` to enable automatic injection/export of configuration values.
- Remove legacy `setting/console.go` and the manual `OptionMap` hooks; clean up `model/option.go`.
- Add `controller/console_migrate.go` providing `/api/option/migrate_console_setting` endpoint for one-off data migration.
- Update controllers (`misc`, `option`, `uptime_kuma`) and router to consume namespaced keys `console_setting.*`.

Frontend
- Refactor dashboard pages (`SettingsAPIInfo`, `SettingsAnnouncements`, `SettingsFAQ`, `SettingsUptimeKuma`) and detail page to read/write the new keys.
- Simplify `DashboardSetting.js` state to only include namespaced options.

BREAKING CHANGE: All console-related option keys are now stored under `console_setting.*`. Run the migration endpoint once after deployment to preserve existing data.
2025-06-14 00:40:29 +08:00
Apple\Apple edd44b7e52 Merge remote-tracking branch 'origin/alpha' into alpha 2025-06-13 20:52:12 +08:00
Apple\Apple f5e3617cf7 🎫 feat: Enhance redemption code expiry handling & improve UI responsiveness
Backend
• Introduced `validateExpiredTime` helper in `controller/redemption.go`; reused in both Add & Update endpoints to enforce “expiry time must not be earlier than now”, eliminating duplicated checks
• Removed `RedemptionCodeStatusExpired` constant and all related references – expiry is now determined exclusively by the `expired_time` field for simpler, safer state management
• Simplified `DeleteInvalidRedemptions`: deletes codes that are `used` / `disabled` or `enabled` but already expired, without relying on extra status codes
• Controller no longer mutates `status` when listing or fetching redemption codes; clients derive expiry status from timestamp

Frontend
• Added reusable `isExpired` helper in `RedemptionsTable.js`; leveraged for:
  – status rendering (orange “Expired” tag)
  – action-menu enable/disable logic
  – row styling
• Removed duplicated inline expiry logic, improving readability and performance
• Adjusted toolbar layout: on small screens the “Clear invalid codes” button now wraps onto its own line, while “Add” & “Copy” remain grouped

Result
The codebase is now more maintainable, secure, and performant with no redundant constants, centralized validation, and cleaner UI behaviour across devices.
2025-06-13 20:51:20 +08:00
CaIon d26ae9aa77 feat(dto): add VlHighResolutionImages parameter to GeneralOpenAIRequest 2025-06-13 17:29:26 +08:00
Apple\Apple 8c5f46cea6 🎨 feat(ui): enhance table headers with descriptive titles and semantic icons
- Add informative header section to TokensTable with Key icon and description
- Replace generic eye icon with semantic Ticket icon in RedemptionsTable header
- Import additional UI components (Divider, Typography) for better layout structure
- Improve user experience with contextual information about token and redemption functionality
- Maintain consistent styling and layout between both table components

The changes provide users with clear understanding of each table's purpose:
- Tokens: "令牌用于API访问认证,可以设置额度限制和模型权限" with Key icon
- Redemptions: "兑换码可以批量生成和分发,适合用于推广活动或批量充值" with Ticket icon
2025-06-13 14:11:39 +08:00
Apple\Apple f4874c2c1d feat(ui): add incremental-add feedback & translations for model lists (#1218)
Front-end enhancements around “Add custom models”:

• EditChannel.js / EditTagModal.js
  – Skip models that already exist instead of blocking the action.
  – Collect actually inserted items and display:
      • Success toast: “Added N models: model1, model2 …”
      • Info toast when no new model detected.
  – Keeps UX smooth while preserving deduplication logic.

• i18n
  – en.json: added keys
      • "已新增 {{count}} 个模型:{{list}}"
      • "未发现新增模型"
  – Fixed a broken JSON string containing smart quotes to maintain valid syntax.

Result:
Users can bulk-paste model names; duplicates are silently ignored and the UI clearly lists what was incrementally appended. All messages are fully internationalised.

Closes #1218
2025-06-13 13:49:15 +08:00
Apple\Apple dd98db59f1 Merge branch 'main' into alpha 2025-06-13 13:03:28 +08:00
Apple\Apple 0c34eb4351 🐛 fix(ability): keep case-sensitive (group, model) keys during deduplication
The previous patch lower-cased `group` and `model` when building the
temporary `abilitySet` used to prevent duplicate inserts.
This merged models that differ only by letter case, e.g.
`GPT-3.5-turbo` vs `gpt-3.5-turbo`, causing them to disappear from the
user’s available-models list and pricing page.

Change:
• ability.go – removed all `strings.ToLower` calls when composing the
  deduplication key (`group|model`), so duplicates are checked in a
  case-sensitive manner while preserving the original data.

Result:
• `GPT-3.5-turbo` and `gpt-3.5-turbo` are now treated as distinct
  models throughout the system.
2025-06-13 13:03:06 +08:00
Apple\Apple 215238332d 🐛 fix(ability): prevent duplicate (group, model) pairs when saving channels
When importing large model lists (≈700+) an attempt to save a channel
could fail with:

    Error 1062 (23000): Duplicate entry 'default-DeepSeek-1' for key 'abilities.PRIMARY'

Root cause: AddAbilities / UpdateAbilities inserted the same
(group, model) pair multiple times if the input list contained
duplicates or case-variants (e.g. `default` vs `Default`).

Changes:
• ability.go
  – AddAbilities: introduced `abilitySet` to deduplicate by
    lower-cased `group|model` key before batch-inserting.
  – UpdateAbilities: applied the same deduplication logic when
    rebuilding abilities inside a transaction.

Notes:
• The lower-casing is only for set comparison; the original
  `group` and `model` values are preserved when persisting to DB,
  so case sensitivity of stored data is unchanged.
• Batch chunking logic (lo.Chunk) and performance characteristics
  remain unaffected.

Fixes #1215
2025-06-13 12:39:54 +08:00
Apple\Apple bbeaa6a2e3 🐛 fix(setup): enforce username length ≤ 12 during initial system setup
The User model applies `validate:"max=12"` to the `Username` field, but the
initial setup flow did not validate this constraint. This allowed creation
of a root user with an overly long username (e.g. "Uselessly1344"), which
later caused every update request to fail with:

  Field validation for 'Username' failed on the 'max' tag

This patch adds an explicit length check in `controller/setup.go` to reject
usernames longer than 12 characters during setup, keeping validation rules
consistent across the entire application.

Refs: #1214
2025-06-13 12:28:26 +08:00
Calcium-Ion 671dcea96e Update README.md 2025-06-13 01:59:34 +08:00
skynono 8c20aef5d1 fix: enabled hot reload SyncOptions 2025-06-12 17:17:07 +08:00
a37836323 a126015940 修复Azure渠道对responses API的兼容性支持 - 为Azure渠道添加对responses API的特殊处理 - 兼容微软新的API格式,使用preview版本的api-version - 修复了Azure渠道无法正确处理responses请求的问题 2025-06-11 22:11:47 +08:00
101 changed files with 3883 additions and 1789 deletions
+3 -1
View File
@@ -27,6 +27,9 @@
<a href="https://goreportcard.com/report/github.com/Calcium-Ion/new-api">
<img src="https://goreportcard.com/badge/github.com/Calcium-Ion/new-api" alt="GoReportCard">
</a>
<a href="https://coderabbit.ai">
<img src="https://img.shields.io/coderabbit/prs/github/QuantumNous/new-api?utm_source=oss&utm_medium=github&utm_campaign=QuantumNous%2Fnew-api&labelColor=171717&color=FF570A&link=https%3A%2F%2Fcoderabbit.ai&label=CodeRabbit+Reviews" alt="CodeRabbit Pull Request Reviews">
</a>
</p>
</div>
@@ -180,7 +183,6 @@ docker run --name new-api -d --restart always -p 3000:3000 -e SQL_DSN="root:1234
其他基于New API的项目:
- [new-api-horizon](https://github.com/Calcium-Ion/new-api-horizon)New API高性能优化版
- [VoAPI](https://github.com/VoAPI/VoAPI):基于New API的前端美化版本
## 帮助支持
+7
View File
@@ -1,7 +1,14 @@
package common
const (
DatabaseTypeMySQL = "mysql"
DatabaseTypeSQLite = "sqlite"
DatabaseTypePostgreSQL = "postgres"
)
var UsingSQLite = false
var UsingPostgreSQL = false
var LogSqlType = DatabaseTypeSQLite // Default to SQLite for logging SQL queries
var UsingMySQL = false
var UsingClickHouse = false
+5 -1
View File
@@ -141,7 +141,11 @@ func RedisHSetObj(key string, obj interface{}, expiration time.Duration) error {
txn := RDB.TxPipeline()
txn.HSet(ctx, key, data)
txn.Expire(ctx, key, expiration)
// 只有在 expiration 大于 0 时才设置过期时间
if expiration > 0 {
txn.Expire(ctx, key, expiration)
}
_, err := txn.Exec(ctx)
if err != nil {
+27 -2
View File
@@ -249,13 +249,38 @@ func SaveTmpFile(filename string, data io.Reader) (string, error) {
}
// GetAudioDuration returns the duration of an audio file in seconds.
func GetAudioDuration(ctx context.Context, filename string) (float64, error) {
func GetAudioDuration(ctx context.Context, filename string, ext string) (float64, error) {
// ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 {{input}}
c := exec.CommandContext(ctx, "ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", filename)
output, err := c.Output()
if err != nil {
return 0, errors.Wrap(err, "failed to get audio duration")
}
durationStr := string(bytes.TrimSpace(output))
if durationStr == "N/A" {
// Create a temporary output file name
tmpFp, err := os.CreateTemp("", "audio-*"+ext)
if err != nil {
return 0, errors.Wrap(err, "failed to create temporary file")
}
tmpName := tmpFp.Name()
// Close immediately so ffmpeg can open the file on Windows.
_ = tmpFp.Close()
defer os.Remove(tmpName)
return strconv.ParseFloat(string(bytes.TrimSpace(output)), 64)
// ffmpeg -y -i filename -vcodec copy -acodec copy <tmpName>
ffmpegCmd := exec.CommandContext(ctx, "ffmpeg", "-y", "-i", filename, "-vcodec", "copy", "-acodec", "copy", tmpName)
if err := ffmpegCmd.Run(); err != nil {
return 0, errors.Wrap(err, "failed to run ffmpeg")
}
// Recalculate the duration of the new file
c = exec.CommandContext(ctx, "ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", tmpName)
output, err := c.Output()
if err != nil {
return 0, errors.Wrap(err, "failed to get audio duration after ffmpeg")
}
durationStr = string(bytes.TrimSpace(output))
}
return strconv.ParseFloat(durationStr, 64)
}
+4 -6
View File
@@ -2,12 +2,10 @@ package constant
import "one-api/common"
var (
TokenCacheSeconds = common.SyncFrequency
UserId2GroupCacheSeconds = common.SyncFrequency
UserId2QuotaCacheSeconds = common.SyncFrequency
UserId2StatusCacheSeconds = common.SyncFrequency
)
// 使用函数来避免初始化顺序带来的赋值问题
func RedisKeyCacheSeconds() int {
return common.SyncFrequency
}
// Cache keys
const (
+3 -3
View File
@@ -165,8 +165,8 @@ func testChannel(channel *model.Channel, testModel string) (err error, openAIErr
tok := time.Now()
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, priceData.UserGroupRatio)
other := service.GenerateTextOtherInfo(c, info, priceData.ModelRatio, priceData.GroupRatioInfo.GroupRatio, priceData.CompletionRatio,
usage.PromptTokensDetails.CachedTokens, priceData.CacheRatio, priceData.ModelPrice, priceData.GroupRatioInfo.GroupSpecialRatio)
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)))
@@ -312,7 +312,7 @@ func testAllChannels(notify bool) error {
channel.UpdateResponseTime(milliseconds)
time.Sleep(common.RequestInterval)
}
if notify {
service.NotifyRootUser(dto.NotifyTypeChannelTest, "通道测试完成", "所有通道测试已完成")
}
+35 -5
View File
@@ -52,6 +52,14 @@ func GetAllChannels(c *gin.Context) {
channelData := make([]*model.Channel, 0)
idSort, _ := strconv.ParseBool(c.Query("id_sort"))
enableTagMode, _ := strconv.ParseBool(c.Query("tag_mode"))
// type filter
typeStr := c.Query("type")
typeFilter := -1
if typeStr != "" {
if t, err := strconv.Atoi(typeStr); err == nil {
typeFilter = t
}
}
var total int64
@@ -72,6 +80,14 @@ func GetAllChannels(c *gin.Context) {
}
// 计算 tag 总数用于分页
total, _ = model.CountAllTags()
} else if typeFilter >= 0 {
channels, err := model.GetChannelsByType((p-1)*pageSize, pageSize, idSort, typeFilter)
if err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()})
return
}
channelData = channels
total, _ = model.CountChannelsByType(typeFilter)
} else {
channels, err := model.GetAllChannels((p-1)*pageSize, pageSize, false, idSort)
if err != nil {
@@ -82,14 +98,18 @@ func GetAllChannels(c *gin.Context) {
total, _ = model.CountAllChannels()
}
// calculate type counts
typeCounts, _ := model.CountChannelsGroupByType()
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": gin.H{
"items": channelData,
"total": total,
"page": p,
"page_size": pageSize,
"items": channelData,
"total": total,
"page": p,
"page_size": pageSize,
"type_counts": typeCounts,
},
})
return
@@ -217,10 +237,20 @@ func SearchChannels(c *gin.Context) {
}
channelData = channels
}
// calculate type counts for search results
typeCounts := make(map[int64]int64)
for _, channel := range channelData {
typeCounts[int64(channel.Type)]++
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": channelData,
"data": gin.H{
"items": channelData,
"type_counts": typeCounts,
},
})
return
}
+103
View File
@@ -0,0 +1,103 @@
// 用于迁移检测的旧键,该文件下个版本会删除
package controller
import (
"encoding/json"
"net/http"
"one-api/common"
"one-api/model"
"github.com/gin-gonic/gin"
)
// MigrateConsoleSetting 迁移旧的控制台相关配置到 console_setting.*
func MigrateConsoleSetting(c *gin.Context) {
// 读取全部 option
opts, err := model.AllOption()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": err.Error()})
return
}
// 建立 map
valMap := map[string]string{}
for _, o := range opts {
valMap[o.Key] = o.Value
}
// 处理 APIInfo
if v := valMap["ApiInfo"]; v != "" {
var arr []map[string]interface{}
if err := json.Unmarshal([]byte(v), &arr); err == nil {
if len(arr) > 50 {
arr = arr[:50]
}
bytes, _ := json.Marshal(arr)
model.UpdateOption("console_setting.api_info", string(bytes))
}
model.UpdateOption("ApiInfo", "")
}
// Announcements 直接搬
if v := valMap["Announcements"]; v != "" {
model.UpdateOption("console_setting.announcements", v)
model.UpdateOption("Announcements", "")
}
// FAQ 转换
if v := valMap["FAQ"]; v != "" {
var arr []map[string]interface{}
if err := json.Unmarshal([]byte(v), &arr); err == nil {
out := []map[string]interface{}{}
for _, item := range arr {
q, _ := item["question"].(string)
if q == "" {
q, _ = item["title"].(string)
}
a, _ := item["answer"].(string)
if a == "" {
a, _ = item["content"].(string)
}
if q != "" && a != "" {
out = append(out, map[string]interface{}{"question": q, "answer": a})
}
}
if len(out) > 50 {
out = out[:50]
}
bytes, _ := json.Marshal(out)
model.UpdateOption("console_setting.faq", string(bytes))
}
model.UpdateOption("FAQ", "")
}
// Uptime Kuma 迁移到新的 groups 结构(console_setting.uptime_kuma_groups
url := valMap["UptimeKumaUrl"]
slug := valMap["UptimeKumaSlug"]
if url != "" && slug != "" {
// 仅当同时存在 URL 与 Slug 时才进行迁移
groups := []map[string]interface{}{
{
"id": 1,
"categoryName": "old",
"url": url,
"slug": slug,
"description": "",
},
}
bytes, _ := json.Marshal(groups)
model.UpdateOption("console_setting.uptime_kuma_groups", string(bytes))
}
// 清空旧键内容
if url != "" {
model.UpdateOption("UptimeKumaUrl", "")
}
if slug != "" {
model.UpdateOption("UptimeKumaSlug", "")
}
// 删除旧键记录
oldKeys := []string{"ApiInfo", "Announcements", "FAQ", "UptimeKumaUrl", "UptimeKumaSlug"}
model.DB.Where("key IN ?", oldKeys).Delete(&model.Option{})
// 重新加载 OptionMap
model.InitOptionMap()
common.SysLog("console setting migrated")
c.JSON(http.StatusOK, gin.H{"success": true, "message": "migrated"})
}
+11 -3
View File
@@ -1,15 +1,17 @@
package controller
import (
"github.com/gin-gonic/gin"
"net/http"
"one-api/model"
"one-api/setting"
"one-api/setting/ratio_setting"
"github.com/gin-gonic/gin"
)
func GetGroups(c *gin.Context) {
groupNames := make([]string, 0)
for groupName, _ := range setting.GetGroupRatioCopy() {
for groupName := range ratio_setting.GetGroupRatioCopy() {
groupNames = append(groupNames, groupName)
}
c.JSON(http.StatusOK, gin.H{
@@ -24,7 +26,7 @@ func GetUserGroups(c *gin.Context) {
userGroup := ""
userId := c.GetInt("id")
userGroup, _ = model.GetUserGroup(userId, false)
for groupName, ratio := range setting.GetGroupRatioCopy() {
for groupName, ratio := range ratio_setting.GetGroupRatioCopy() {
// UserUsableGroups contains the groups that the user can use
userUsableGroups := setting.GetUserUsableGroups(userGroup)
if desc, ok := userUsableGroups[groupName]; ok {
@@ -34,6 +36,12 @@ func GetUserGroups(c *gin.Context) {
}
}
}
if setting.GroupInUserUsableGroups("auto") {
usableGroups["auto"] = map[string]interface{}{
"ratio": "自动",
"desc": setting.GetUsableGroupDescription("auto"),
}
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
+65 -43
View File
@@ -9,6 +9,7 @@ import (
"one-api/middleware"
"one-api/model"
"one-api/setting"
"one-api/setting/console_setting"
"one-api/setting/operation_setting"
"one-api/setting/system_setting"
"strings"
@@ -37,52 +38,73 @@ func TestStatus(c *gin.Context) {
func GetStatus(c *gin.Context) {
cs := console_setting.GetConsoleSetting()
data := gin.H{
"version": common.Version,
"start_time": common.StartTime,
"email_verification": common.EmailVerificationEnabled,
"github_oauth": common.GitHubOAuthEnabled,
"github_client_id": common.GitHubClientId,
"linuxdo_oauth": common.LinuxDOOAuthEnabled,
"linuxdo_client_id": common.LinuxDOClientId,
"telegram_oauth": common.TelegramOAuthEnabled,
"telegram_bot_name": common.TelegramBotName,
"system_name": common.SystemName,
"logo": common.Logo,
"footer_html": common.Footer,
"wechat_qrcode": common.WeChatAccountQRCodeImageURL,
"wechat_login": common.WeChatAuthEnabled,
"server_address": setting.ServerAddress,
"price": setting.Price,
"min_topup": setting.MinTopUp,
"turnstile_check": common.TurnstileCheckEnabled,
"turnstile_site_key": common.TurnstileSiteKey,
"top_up_link": common.TopUpLink,
"docs_link": operation_setting.GetGeneralSetting().DocsLink,
"quota_per_unit": common.QuotaPerUnit,
"display_in_currency": common.DisplayInCurrencyEnabled,
"enable_batch_update": common.BatchUpdateEnabled,
"enable_drawing": common.DrawingEnabled,
"enable_task": common.TaskEnabled,
"enable_data_export": common.DataExportEnabled,
"data_export_default_time": common.DataExportDefaultTime,
"default_collapse_sidebar": common.DefaultCollapseSidebar,
"enable_online_topup": setting.PayAddress != "" && setting.EpayId != "" && setting.EpayKey != "",
"mj_notify_enabled": setting.MjNotifyEnabled,
"chats": setting.Chats,
"demo_site_enabled": operation_setting.DemoSiteEnabled,
"self_use_mode_enabled": operation_setting.SelfUseModeEnabled,
"default_use_auto_group": setting.DefaultUseAutoGroup,
"pay_methods": setting.PayMethods,
// 面板启用开关
"api_info_enabled": cs.ApiInfoEnabled,
"uptime_kuma_enabled": cs.UptimeKumaEnabled,
"announcements_enabled": cs.AnnouncementsEnabled,
"faq_enabled": cs.FAQEnabled,
"oidc_enabled": system_setting.GetOIDCSettings().Enabled,
"oidc_client_id": system_setting.GetOIDCSettings().ClientId,
"oidc_authorization_endpoint": system_setting.GetOIDCSettings().AuthorizationEndpoint,
"setup": constant.Setup,
}
// 根据启用状态注入可选内容
if cs.ApiInfoEnabled {
data["api_info"] = console_setting.GetApiInfo()
}
if cs.AnnouncementsEnabled {
data["announcements"] = console_setting.GetAnnouncements()
}
if cs.FAQEnabled {
data["faq"] = console_setting.GetFAQ()
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": gin.H{
"version": common.Version,
"start_time": common.StartTime,
"email_verification": common.EmailVerificationEnabled,
"github_oauth": common.GitHubOAuthEnabled,
"github_client_id": common.GitHubClientId,
"linuxdo_oauth": common.LinuxDOOAuthEnabled,
"linuxdo_client_id": common.LinuxDOClientId,
"telegram_oauth": common.TelegramOAuthEnabled,
"telegram_bot_name": common.TelegramBotName,
"system_name": common.SystemName,
"logo": common.Logo,
"footer_html": common.Footer,
"wechat_qrcode": common.WeChatAccountQRCodeImageURL,
"wechat_login": common.WeChatAuthEnabled,
"server_address": setting.ServerAddress,
"price": setting.Price,
"min_topup": setting.MinTopUp,
"turnstile_check": common.TurnstileCheckEnabled,
"turnstile_site_key": common.TurnstileSiteKey,
"top_up_link": common.TopUpLink,
"docs_link": operation_setting.GetGeneralSetting().DocsLink,
"quota_per_unit": common.QuotaPerUnit,
"display_in_currency": common.DisplayInCurrencyEnabled,
"enable_batch_update": common.BatchUpdateEnabled,
"enable_drawing": common.DrawingEnabled,
"enable_task": common.TaskEnabled,
"enable_data_export": common.DataExportEnabled,
"data_export_default_time": common.DataExportDefaultTime,
"default_collapse_sidebar": common.DefaultCollapseSidebar,
"enable_online_topup": setting.PayAddress != "" && setting.EpayId != "" && setting.EpayKey != "",
"mj_notify_enabled": setting.MjNotifyEnabled,
"chats": setting.Chats,
"demo_site_enabled": operation_setting.DemoSiteEnabled,
"self_use_mode_enabled": operation_setting.SelfUseModeEnabled,
"oidc_enabled": system_setting.GetOIDCSettings().Enabled,
"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(),
},
"data": data,
})
return
}
+16 -2
View File
@@ -2,7 +2,6 @@ package controller
import (
"fmt"
"github.com/gin-gonic/gin"
"net/http"
"one-api/common"
"one-api/constant"
@@ -15,6 +14,9 @@ import (
"one-api/relay/channel/moonshot"
relaycommon "one-api/relay/common"
relayconstant "one-api/relay/constant"
"one-api/setting"
"github.com/gin-gonic/gin"
)
// https://platform.openai.com/docs/api-reference/models/list
@@ -179,7 +181,19 @@ func ListModels(c *gin.Context) {
if tokenGroup != "" {
group = tokenGroup
}
models := model.GetGroupModels(group)
var models []string
if tokenGroup == "auto" {
for _, autoGroup := range setting.AutoGroups {
groupModels := model.GetGroupModels(autoGroup)
for _, g := range groupModels {
if !common.StringsContains(models, g) {
models = append(models, g)
}
}
}
} else {
models = model.GetGroupModels(group)
}
for _, s := range models {
if _, ok := openAIModelsMap[s]; ok {
userOpenAiModels = append(userOpenAiModels, openAIModelsMap[s])
+18 -7
View File
@@ -6,6 +6,8 @@ import (
"one-api/common"
"one-api/model"
"one-api/setting"
"one-api/setting/console_setting"
"one-api/setting/ratio_setting"
"one-api/setting/system_setting"
"strings"
@@ -102,7 +104,7 @@ func UpdateOption(c *gin.Context) {
return
}
case "GroupRatio":
err = setting.CheckGroupRatio(option.Value)
err = ratio_setting.CheckGroupRatio(option.Value)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
@@ -119,8 +121,8 @@ func UpdateOption(c *gin.Context) {
})
return
}
case "ApiInfo":
err = setting.ValidateApiInfo(option.Value)
case "console_setting.api_info":
err = console_setting.ValidateConsoleSettings(option.Value, "ApiInfo")
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
@@ -128,8 +130,8 @@ func UpdateOption(c *gin.Context) {
})
return
}
case "Announcements":
err = setting.ValidateConsoleSettings(option.Value, "Announcements")
case "console_setting.announcements":
err = console_setting.ValidateConsoleSettings(option.Value, "Announcements")
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
@@ -137,8 +139,17 @@ func UpdateOption(c *gin.Context) {
})
return
}
case "FAQ":
err = setting.ValidateConsoleSettings(option.Value, "FAQ")
case "console_setting.faq":
err = console_setting.ValidateConsoleSettings(option.Value, "FAQ")
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
case "console_setting.uptime_kuma_groups":
err = console_setting.ValidateConsoleSettings(option.Value, "UptimeKumaGroups")
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
+4 -3
View File
@@ -3,7 +3,6 @@ package controller
import (
"errors"
"fmt"
"github.com/gin-gonic/gin"
"net/http"
"one-api/common"
"one-api/constant"
@@ -13,6 +12,8 @@ import (
"one-api/service"
"one-api/setting"
"time"
"github.com/gin-gonic/gin"
)
func Playground(c *gin.Context) {
@@ -57,9 +58,9 @@ func Playground(c *gin.Context) {
c.Set("group", group)
}
c.Set("token_name", "playground-"+group)
channel, err := model.CacheGetRandomSatisfiedChannel(group, playgroundRequest.Model, 0)
channel, finalGroup, err := model.CacheGetRandomSatisfiedChannel(c, group, playgroundRequest.Model, 0)
if err != nil {
message := fmt.Sprintf("当前分组 %s 下对于模型 %s 无可用渠道", group, playgroundRequest.Model)
message := fmt.Sprintf("当前分组 %s 下对于模型 %s 无可用渠道", finalGroup, playgroundRequest.Model)
openaiErr = service.OpenAIErrorWrapperLocal(errors.New(message), "get_playground_channel_failed", http.StatusInternalServerError)
return
}
+6 -6
View File
@@ -3,7 +3,7 @@ package controller
import (
"one-api/model"
"one-api/setting"
"one-api/setting/operation_setting"
"one-api/setting/ratio_setting"
"github.com/gin-gonic/gin"
)
@@ -13,7 +13,7 @@ func GetPricing(c *gin.Context) {
userId, exists := c.Get("id")
usableGroup := map[string]string{}
groupRatio := map[string]float64{}
for s, f := range setting.GetGroupRatioCopy() {
for s, f := range ratio_setting.GetGroupRatioCopy() {
groupRatio[s] = f
}
var group string
@@ -22,7 +22,7 @@ func GetPricing(c *gin.Context) {
if err == nil {
group = user.Group
for g := range groupRatio {
ratio, ok := setting.GetGroupGroupRatio(group, g)
ratio, ok := ratio_setting.GetGroupGroupRatio(group, g)
if ok {
groupRatio[g] = ratio
}
@@ -32,7 +32,7 @@ func GetPricing(c *gin.Context) {
usableGroup = setting.GetUserUsableGroups(group)
// check groupRatio contains usableGroup
for group := range setting.GetGroupRatioCopy() {
for group := range ratio_setting.GetGroupRatioCopy() {
if _, ok := usableGroup[group]; !ok {
delete(groupRatio, group)
}
@@ -47,7 +47,7 @@ func GetPricing(c *gin.Context) {
}
func ResetModelRatio(c *gin.Context) {
defaultStr := operation_setting.DefaultModelRatio2JSONString()
defaultStr := ratio_setting.DefaultModelRatio2JSONString()
err := model.UpdateOption("ModelRatio", defaultStr)
if err != nil {
c.JSON(200, gin.H{
@@ -56,7 +56,7 @@ func ResetModelRatio(c *gin.Context) {
})
return
}
err = operation_setting.UpdateModelRatioByJSONString(defaultStr)
err = ratio_setting.UpdateModelRatioByJSONString(defaultStr)
if err != nil {
c.JSON(200, gin.H{
"success": false,
+39 -3
View File
@@ -5,6 +5,7 @@ import (
"one-api/common"
"one-api/model"
"strconv"
"errors"
"github.com/gin-gonic/gin"
)
@@ -126,6 +127,10 @@ func AddRedemption(c *gin.Context) {
})
return
}
if err := validateExpiredTime(redemption.ExpiredTime); err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()})
return
}
var keys []string
for i := 0; i < redemption.Count; i++ {
key := common.GetUUID()
@@ -135,6 +140,7 @@ func AddRedemption(c *gin.Context) {
Key: key,
CreatedTime: common.GetTimestamp(),
Quota: redemption.Quota,
ExpiredTime: redemption.ExpiredTime,
}
err = cleanRedemption.Insert()
if err != nil {
@@ -191,12 +197,18 @@ func UpdateRedemption(c *gin.Context) {
})
return
}
if statusOnly != "" {
cleanRedemption.Status = redemption.Status
} else {
if statusOnly == "" {
if err := validateExpiredTime(redemption.ExpiredTime); err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()})
return
}
// If you add more fields, please also update redemption.Update()
cleanRedemption.Name = redemption.Name
cleanRedemption.Quota = redemption.Quota
cleanRedemption.ExpiredTime = redemption.ExpiredTime
}
if statusOnly != "" {
cleanRedemption.Status = redemption.Status
}
err = cleanRedemption.Update()
if err != nil {
@@ -213,3 +225,27 @@ func UpdateRedemption(c *gin.Context) {
})
return
}
func DeleteInvalidRedemption(c *gin.Context) {
rows, err := model.DeleteInvalidRedemptions()
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": rows,
})
return
}
func validateExpiredTime(expired int64) error {
if expired != 0 && expired < common.GetTimestamp() {
return errors.New("过期时间不能早于当前时间")
}
return nil
}
+2 -2
View File
@@ -259,7 +259,7 @@ func getChannel(c *gin.Context, group, originalModel string, retryCount int) (*m
AutoBan: &autoBanInt,
}, nil
}
channel, err := model.CacheGetRandomSatisfiedChannel(group, originalModel, retryCount)
channel, _, err := model.CacheGetRandomSatisfiedChannel(c, group, originalModel, retryCount)
if err != nil {
return nil, errors.New(fmt.Sprintf("获取重试渠道失败: %s", err.Error()))
}
@@ -388,7 +388,7 @@ func RelayTask(c *gin.Context) {
retryTimes = 0
}
for i := 0; shouldRetryTaskRelay(c, channelId, taskErr, retryTimes) && i < retryTimes; i++ {
channel, err := model.CacheGetRandomSatisfiedChannel(group, originalModel, i)
channel, _, err := model.CacheGetRandomSatisfiedChannel(c, group, originalModel, i)
if err != nil {
common.LogError(c, fmt.Sprintf("CacheGetRandomSatisfiedChannel failed: %s", err.Error()))
break
+8
View File
@@ -75,6 +75,14 @@ func PostSetup(c *gin.Context) {
// If root doesn't exist, validate and create admin account
if !rootExists {
// Validate username length: max 12 characters to align with model.User validation
if len(req.Username) > 12 {
c.JSON(400, gin.H{
"success": false,
"message": "用户名长度不能超过12个字符",
})
return
}
// Validate password
if req.Password != req.ConfirmPassword {
c.JSON(400, gin.H{
+101 -116
View File
@@ -4,9 +4,9 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"one-api/common"
"one-api/setting/console_setting"
"strconv"
"strings"
"time"
@@ -14,45 +14,25 @@ import (
"golang.org/x/sync/errgroup"
)
type UptimeKumaMonitor struct {
ID int `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
}
const (
requestTimeout = 30 * time.Second
httpTimeout = 10 * time.Second
uptimeKeySuffix = "_24"
apiStatusPath = "/api/status-page/"
apiHeartbeatPath = "/api/status-page/heartbeat/"
)
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 {
type Monitor struct {
Name string `json:"name"`
Uptime float64 `json:"uptime"`
Status int `json:"status"`
Group string `json:"group,omitempty"`
}
var (
ErrUpstreamNon200 = errors.New("upstream non-200")
ErrTimeout = errors.New("context deadline exceeded")
)
type UptimeGroupResult struct {
CategoryName string `json:"categoryName"`
Monitors []Monitor `json:"monitors"`
}
func getAndDecode(ctx context.Context, client *http.Client, url string, dest interface{}) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
@@ -62,108 +42,113 @@ func getAndDecode(ctx context.Context, client *http.Client, url string, dest int
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 errors.New("non-200 status")
}
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
func fetchGroupData(ctx context.Context, client *http.Client, groupConfig map[string]interface{}) UptimeGroupResult {
url, _ := groupConfig["url"].(string)
slug, _ := groupConfig["slug"].(string)
categoryName, _ := groupConfig["categoryName"].(string)
result := UptimeGroupResult{
CategoryName: categoryName,
Monitors: []Monitor{},
}
if url == "" || slug == "" {
return result
}
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
)
baseURL := strings.TrimSuffix(url, "/")
var statusData struct {
PublicGroupList []struct {
ID int `json:"id"`
Name string `json:"name"`
MonitorList []struct {
ID int `json:"id"`
Name string `json:"name"`
} `json:"monitorList"`
} `json:"publicGroupList"`
}
var heartbeatData struct {
HeartbeatList map[string][]struct {
Status int `json:"status"`
} `json:"heartbeatList"`
UptimeList map[string]float64 `json:"uptimeList"`
}
g, gCtx := errgroup.WithContext(ctx)
g.Go(func() error {
return getAndDecode(gCtx, client, statusPageUrl, &statusData)
g.Go(func() error {
return getAndDecode(gCtx, client, baseURL+apiStatusPath+slug, &statusData)
})
g.Go(func() error {
return getAndDecode(gCtx, client, baseURL+apiHeartbeatPath+slug, &heartbeatData)
})
g.Go(func() error {
return getAndDecode(gCtx, client, heartbeatUrl, &heartbeatData)
})
if g.Wait() != nil {
return result
}
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(),
})
for _, pg := range statusData.PublicGroupList {
if len(pg.MonitorList) == 0 {
continue
}
for _, m := range pg.MonitorList {
monitor := Monitor{
Name: m.Name,
Group: pg.Name,
}
monitorID := strconv.Itoa(m.ID)
if uptime, exists := heartbeatData.UptimeList[monitorID+uptimeKeySuffix]; exists {
monitor.Uptime = uptime
}
if heartbeats, exists := heartbeatData.HeartbeatList[monitorID]; exists && len(heartbeats) > 0 {
monitor.Status = heartbeats[0].Status
}
result.Monitors = append(result.Monitors, monitor)
}
}
return result
}
func GetUptimeKumaStatus(c *gin.Context) {
groups := console_setting.GetUptimeKumaGroups()
if len(groups) == 0 {
c.JSON(http.StatusOK, gin.H{"success": true, "message": "", "data": []UptimeGroupResult{}})
return
}
var monitors []MonitorStatus
for _, group := range statusData.PublicGroupList {
for _, monitor := range group.MonitorList {
monitorStatus := MonitorStatus{
Name: monitor.Name,
Uptime: 0.0,
Status: 0,
}
ctx, cancel := context.WithTimeout(c.Request.Context(), requestTimeout)
defer cancel()
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)
}
client := &http.Client{Timeout: httpTimeout}
results := make([]UptimeGroupResult, len(groups))
g, gCtx := errgroup.WithContext(ctx)
for i, group := range groups {
i, group := i, group
g.Go(func() error {
results[i] = fetchGroupData(gCtx, client, group)
return nil
})
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": monitors,
})
g.Wait()
c.JSON(http.StatusOK, gin.H{"success": true, "message": "", "data": results})
}
+6
View File
@@ -226,6 +226,9 @@ func Register(c *gin.Context) {
UnlimitedQuota: true,
ModelLimitsEnabled: false,
}
if setting.DefaultUseAutoGroup {
token.Group = "auto"
}
if err := token.Insert(); err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
@@ -459,6 +462,9 @@ func GetSelf(c *gin.Context) {
})
return
}
// Hide admin remarks: set to empty to trigger omitempty tag, ensuring the remark field is not included in JSON returned to regular users
user.Remark = ""
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
+8 -1
View File
@@ -178,7 +178,14 @@ type ClaudeRequest struct {
type Thinking struct {
Type string `json:"type"`
BudgetTokens int `json:"budget_tokens"`
BudgetTokens *int `json:"budget_tokens,omitempty"`
}
func (c *Thinking) GetBudgetTokens() int {
if c.BudgetTokens == nil {
return 0
}
return *c.BudgetTokens
}
func (c *ClaudeRequest) IsStringSystem() bool {
+3
View File
@@ -53,10 +53,13 @@ type GeneralOpenAIRequest struct {
Modalities json.RawMessage `json:"modalities,omitempty"`
Audio json.RawMessage `json:"audio,omitempty"`
EnableThinking any `json:"enable_thinking,omitempty"` // ali
THINKING json.RawMessage `json:"thinking,omitempty"` // doubao
ExtraBody json.RawMessage `json:"extra_body,omitempty"`
WebSearchOptions *WebSearchOptions `json:"web_search_options,omitempty"`
// OpenRouter Params
Reasoning json.RawMessage `json:"reasoning,omitempty"`
// Ali Qwen Params
VlHighResolutionImages json.RawMessage `json:"vl_high_resolution_images,omitempty"`
}
func (r *GeneralOpenAIRequest) ToMap() map[string]any {
+5 -3
View File
@@ -12,7 +12,7 @@ import (
"one-api/model"
"one-api/router"
"one-api/service"
"one-api/setting/operation_setting"
"one-api/setting/ratio_setting"
"os"
"strconv"
@@ -74,7 +74,7 @@ func main() {
}
// Initialize model settings
operation_setting.InitRatioSettings()
ratio_setting.InitRatioSettings()
// Initialize constants
constant.InitEnv()
// Initialize options
@@ -105,10 +105,12 @@ func main() {
model.InitChannelCache()
}()
go model.SyncOptions(common.SyncFrequency)
go model.SyncChannelCache(common.SyncFrequency)
}
// 热更新配置
go model.SyncOptions(common.SyncFrequency)
// 数据看板
go model.UpdateQuotaData()
+13 -5
View File
@@ -11,6 +11,7 @@ import (
relayconstant "one-api/relay/constant"
"one-api/service"
"one-api/setting"
"one-api/setting/ratio_setting"
"strconv"
"strings"
"time"
@@ -48,9 +49,11 @@ func Distribute() func(c *gin.Context) {
return
}
// check group in common.GroupRatio
if !setting.ContainsGroupRatio(tokenGroup) {
abortWithOpenAiMessage(c, http.StatusForbidden, fmt.Sprintf("分组 %s 已被弃用", tokenGroup))
return
if !ratio_setting.ContainsGroupRatio(tokenGroup) {
if tokenGroup != "auto" {
abortWithOpenAiMessage(c, http.StatusForbidden, fmt.Sprintf("分组 %s 已被弃用", tokenGroup))
return
}
}
userGroup = tokenGroup
}
@@ -95,9 +98,14 @@ func Distribute() func(c *gin.Context) {
}
if shouldSelectChannel {
channel, err = model.CacheGetRandomSatisfiedChannel(userGroup, modelRequest.Model, 0)
var selectGroup string
channel, selectGroup, err = model.CacheGetRandomSatisfiedChannel(c, userGroup, modelRequest.Model, 0)
if err != nil {
message := fmt.Sprintf("当前分组 %s 下对于模型 %s 无可用渠道", userGroup, modelRequest.Model)
showGroup := userGroup
if userGroup == "auto" {
showGroup = fmt.Sprintf("auto(%s)", selectGroup)
}
message := fmt.Sprintf("当前分组 %s 下对于模型 %s 无可用渠道", showGroup, modelRequest.Model)
// 如果错误,但是渠道不为空,说明是数据库一致性问题
if channel != nil {
common.SysError(fmt.Sprintf("渠道不存在:%d", channel.Id))
+21 -16
View File
@@ -8,6 +8,7 @@ import (
"github.com/samber/lo"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type Ability struct {
@@ -23,7 +24,7 @@ type Ability struct {
func GetGroupModels(group string) []string {
var models []string
// Find distinct models
DB.Table("abilities").Where(groupCol+" = ? and enabled = ?", group, true).Distinct("model").Pluck("model", &models)
DB.Table("abilities").Where(commonGroupCol+" = ? and enabled = ?", group, true).Distinct("model").Pluck("model", &models)
return models
}
@@ -41,16 +42,12 @@ func GetAllEnableAbilities() []Ability {
}
func getPriority(group string, model string, retry int) (int, error) {
trueVal := "1"
if common.UsingPostgreSQL {
trueVal = "true"
}
var priorities []int
err := DB.Model(&Ability{}).
Select("DISTINCT(priority)").
Where(groupCol+" = ? and model = ? and enabled = "+trueVal, group, model).
Order("priority DESC"). // 按优先级降序排序
Where(commonGroupCol+" = ? and model = ? and enabled = ?", group, model, commonTrueVal).
Order("priority DESC"). // 按优先级降序排序
Pluck("priority", &priorities).Error // Pluck用于将查询的结果直接扫描到一个切片中
if err != nil {
@@ -75,18 +72,14 @@ func getPriority(group string, model string, retry int) (int, error) {
}
func getChannelQuery(group string, model string, retry int) *gorm.DB {
trueVal := "1"
if common.UsingPostgreSQL {
trueVal = "true"
}
maxPrioritySubQuery := DB.Model(&Ability{}).Select("MAX(priority)").Where(groupCol+" = ? and model = ? and enabled = "+trueVal, group, model)
channelQuery := DB.Where(groupCol+" = ? and model = ? and enabled = "+trueVal+" and priority = (?)", group, model, maxPrioritySubQuery)
maxPrioritySubQuery := DB.Model(&Ability{}).Select("MAX(priority)").Where(commonGroupCol+" = ? and model = ? and enabled = ?", group, model, commonTrueVal)
channelQuery := DB.Where(commonGroupCol+" = ? and model = ? and enabled = ? and priority = (?)", group, model, commonTrueVal, maxPrioritySubQuery)
if retry != 0 {
priority, err := getPriority(group, model, retry)
if err != nil {
common.SysError(fmt.Sprintf("Get priority failed: %s", err.Error()))
} else {
channelQuery = DB.Where(groupCol+" = ? and model = ? and enabled = "+trueVal+" and priority = ?", group, model, priority)
channelQuery = DB.Where(commonGroupCol+" = ? and model = ? and enabled = ? and priority = ?", group, model, commonTrueVal, priority)
}
}
@@ -133,9 +126,15 @@ func GetRandomSatisfiedChannel(group string, model string, retry int) (*Channel,
func (channel *Channel) AddAbilities() error {
models_ := strings.Split(channel.Models, ",")
groups_ := strings.Split(channel.Group, ",")
abilitySet := make(map[string]struct{})
abilities := make([]Ability, 0, len(models_))
for _, model := range models_ {
for _, group := range groups_ {
key := group + "|" + model
if _, exists := abilitySet[key]; exists {
continue
}
abilitySet[key] = struct{}{}
ability := Ability{
Group: group,
Model: model,
@@ -152,7 +151,7 @@ func (channel *Channel) AddAbilities() error {
return nil
}
for _, chunk := range lo.Chunk(abilities, 50) {
err := DB.Create(&chunk).Error
err := DB.Clauses(clause.OnConflict{DoNothing: true}).Create(&chunk).Error
if err != nil {
return err
}
@@ -194,9 +193,15 @@ func (channel *Channel) UpdateAbilities(tx *gorm.DB) error {
// Then add new abilities
models_ := strings.Split(channel.Models, ",")
groups_ := strings.Split(channel.Group, ",")
abilitySet := make(map[string]struct{})
abilities := make([]Ability, 0, len(models_))
for _, model := range models_ {
for _, group := range groups_ {
key := group + "|" + model
if _, exists := abilitySet[key]; exists {
continue
}
abilitySet[key] = struct{}{}
ability := Ability{
Group: group,
Model: model,
@@ -212,7 +217,7 @@ func (channel *Channel) UpdateAbilities(tx *gorm.DB) error {
if len(abilities) > 0 {
for _, chunk := range lo.Chunk(abilities, 50) {
err = tx.Create(&chunk).Error
err = tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&chunk).Error
if err != nil {
if isNewTx {
tx.Rollback()
+40 -1
View File
@@ -5,10 +5,13 @@ import (
"fmt"
"math/rand"
"one-api/common"
"one-api/setting"
"sort"
"strings"
"sync"
"time"
"github.com/gin-gonic/gin"
)
var group2model2channels map[string]map[string][]*Channel
@@ -75,7 +78,43 @@ func SyncChannelCache(frequency int) {
}
}
func CacheGetRandomSatisfiedChannel(group string, model string, retry int) (*Channel, error) {
func CacheGetRandomSatisfiedChannel(c *gin.Context, group string, model string, retry int) (*Channel, string, error) {
var channel *Channel
var err error
selectGroup := group
if group == "auto" {
if len(setting.AutoGroups) == 0 {
return nil, selectGroup, errors.New("auto groups is not enabled")
}
for _, autoGroup := range setting.AutoGroups {
if common.DebugEnabled {
println("autoGroup:", autoGroup)
}
channel, _ = getRandomSatisfiedChannel(autoGroup, model, retry)
if channel == nil {
continue
} else {
c.Set("auto_group", autoGroup)
selectGroup = autoGroup
if common.DebugEnabled {
println("selectGroup:", selectGroup)
}
break
}
}
} else {
channel, err = getRandomSatisfiedChannel(group, model, retry)
if err != nil {
return nil, group, err
}
}
if channel == nil {
return nil, group, errors.New("channel not found")
}
return channel, selectGroup, nil
}
func getRandomSatisfiedChannel(group string, model string, retry int) (*Channel, error) {
if strings.HasPrefix(model, "gpt-4-gizmo") {
model = "gpt-4-gizmo-*"
}
+46 -10
View File
@@ -145,7 +145,7 @@ func SearchChannels(keyword string, group string, model string, idSort bool) ([]
}
// 构造基础查询
baseQuery := DB.Model(&Channel{}).Omit(keyCol)
baseQuery := DB.Model(&Channel{}).Omit("key")
// 构造WHERE子句
var whereClause string
@@ -153,15 +153,15 @@ func SearchChannels(keyword string, group string, model string, idSort bool) ([]
if group != "" && group != "null" {
var groupCondition string
if common.UsingMySQL {
groupCondition = `CONCAT(',', ` + groupCol + `, ',') LIKE ?`
groupCondition = `CONCAT(',', ` + commonGroupCol + `, ',') LIKE ?`
} else {
// sqlite, PostgreSQL
groupCondition = `(',' || ` + groupCol + ` || ',') LIKE ?`
groupCondition = `(',' || ` + commonGroupCol + ` || ',') LIKE ?`
}
whereClause = "(id = ? OR name LIKE ? OR " + keyCol + " = ? OR " + baseURLCol + " LIKE ?) AND " + modelsCol + ` LIKE ? AND ` + groupCondition
whereClause = "(id = ? OR name LIKE ? OR " + commonKeyCol + " = ? OR " + baseURLCol + " LIKE ?) AND " + modelsCol + ` LIKE ? AND ` + groupCondition
args = append(args, common.String2Int(keyword), "%"+keyword+"%", keyword, "%"+keyword+"%", "%"+model+"%", "%,"+group+",%")
} else {
whereClause = "(id = ? OR name LIKE ? OR " + keyCol + " = ? OR " + baseURLCol + " LIKE ?) AND " + modelsCol + " LIKE ?"
whereClause = "(id = ? OR name LIKE ? OR " + commonKeyCol + " = ? OR " + baseURLCol + " LIKE ?) AND " + modelsCol + " LIKE ?"
args = append(args, common.String2Int(keyword), "%"+keyword+"%", keyword, "%"+keyword+"%", "%"+model+"%")
}
@@ -478,7 +478,7 @@ func SearchTags(keyword string, group string, model string, idSort bool) ([]*str
}
// 构造基础查询
baseQuery := DB.Model(&Channel{}).Omit(keyCol)
baseQuery := DB.Model(&Channel{}).Omit("key")
// 构造WHERE子句
var whereClause string
@@ -486,15 +486,15 @@ func SearchTags(keyword string, group string, model string, idSort bool) ([]*str
if group != "" && group != "null" {
var groupCondition string
if common.UsingMySQL {
groupCondition = `CONCAT(',', ` + groupCol + `, ',') LIKE ?`
groupCondition = `CONCAT(',', ` + commonGroupCol + `, ',') LIKE ?`
} else {
// sqlite, PostgreSQL
groupCondition = `(',' || ` + groupCol + ` || ',') LIKE ?`
groupCondition = `(',' || ` + commonGroupCol + ` || ',') LIKE ?`
}
whereClause = "(id = ? OR name LIKE ? OR " + keyCol + " = ? OR " + baseURLCol + " LIKE ?) AND " + modelsCol + ` LIKE ? AND ` + groupCondition
whereClause = "(id = ? OR name LIKE ? OR " + commonKeyCol + " = ? OR " + baseURLCol + " LIKE ?) AND " + modelsCol + ` LIKE ? AND ` + groupCondition
args = append(args, common.String2Int(keyword), "%"+keyword+"%", keyword, "%"+keyword+"%", "%"+model+"%", "%,"+group+",%")
} else {
whereClause = "(id = ? OR name LIKE ? OR " + keyCol + " = ? OR " + baseURLCol + " LIKE ?) AND " + modelsCol + " LIKE ?"
whereClause = "(id = ? OR name LIKE ? OR " + commonKeyCol + " = ? OR " + baseURLCol + " LIKE ?) AND " + modelsCol + " LIKE ?"
args = append(args, common.String2Int(keyword), "%"+keyword+"%", keyword, "%"+keyword+"%", "%"+model+"%")
}
@@ -597,3 +597,39 @@ func CountAllTags() (int64, error) {
err := DB.Model(&Channel{}).Where("tag is not null AND tag != ''").Distinct("tag").Count(&total).Error
return total, err
}
// Get channels of specified type with pagination
func GetChannelsByType(startIdx int, num int, idSort bool, channelType int) ([]*Channel, error) {
var channels []*Channel
order := "priority desc"
if idSort {
order = "id desc"
}
err := DB.Where("type = ?", channelType).Order(order).Limit(num).Offset(startIdx).Omit("key").Find(&channels).Error
return channels, err
}
// Count channels of specific type
func CountChannelsByType(channelType int) (int64, error) {
var count int64
err := DB.Model(&Channel{}).Where("type = ?", channelType).Count(&count).Error
return count, err
}
// Return map[type]count for all channels
func CountChannelsGroupByType() (map[int64]int64, error) {
type result struct {
Type int64 `gorm:"column:type"`
Count int64 `gorm:"column:count"`
}
var results []result
err := DB.Model(&Channel{}).Select("type, count(*) as count").Group("type").Find(&results).Error
if err != nil {
return nil, err
}
counts := make(map[int64]int64)
for _, r := range results {
counts[r.Type] = r.Count
}
return counts, nil
}
+26 -11
View File
@@ -63,7 +63,7 @@ func formatUserLogs(logs []*Log) {
func GetLogByKey(key string) (logs []*Log, err error) {
if os.Getenv("LOG_SQL_DSN") != "" {
var tk Token
if err = DB.Model(&Token{}).Where(keyCol+"=?", strings.TrimPrefix(key, "sk-")).First(&tk).Error; err != nil {
if err = DB.Model(&Token{}).Where(logKeyCol+"=?", strings.TrimPrefix(key, "sk-")).First(&tk).Error; err != nil {
return nil, err
}
err = LOG_DB.Model(&Log{}).Where("token_id=?", tk.Id).Find(&logs).Error
@@ -122,8 +122,13 @@ 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,
Ip: func() string {
if needRecordIp {
return c.ClientIP()
}
return ""
}(),
Other: otherStr,
}
err := LOG_DB.Create(log).Error
if err != nil {
@@ -165,8 +170,13 @@ 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,
Ip: func() string {
if needRecordIp {
return c.ClientIP()
}
return ""
}(),
Other: otherStr,
}
err := LOG_DB.Create(log).Error
if err != nil {
@@ -206,7 +216,7 @@ func GetAllLogs(logType int, startTimestamp int64, endTimestamp int64, modelName
tx = tx.Where("logs.channel_id = ?", channel)
}
if group != "" {
tx = tx.Where("logs."+groupCol+" = ?", group)
tx = tx.Where("logs."+logGroupCol+" = ?", group)
}
err = tx.Model(&Log{}).Count(&total).Error
if err != nil {
@@ -217,13 +227,18 @@ func GetAllLogs(logType int, startTimestamp int64, endTimestamp int64, modelName
return nil, 0, err
}
channelIds := make([]int, 0)
channelIdsMap := make(map[int]struct{})
channelMap := make(map[int]string)
for _, log := range logs {
if log.ChannelId != 0 {
channelIds = append(channelIds, log.ChannelId)
channelIdsMap[log.ChannelId] = struct{}{}
}
}
channelIds := make([]int, 0, len(channelIdsMap))
for channelId := range channelIdsMap {
channelIds = append(channelIds, channelId)
}
if len(channelIds) > 0 {
var channels []struct {
Id int `gorm:"column:id"`
@@ -264,7 +279,7 @@ func GetUserLogs(userId int, logType int, startTimestamp int64, endTimestamp int
tx = tx.Where("logs.created_at <= ?", endTimestamp)
}
if group != "" {
tx = tx.Where("logs."+groupCol+" = ?", group)
tx = tx.Where("logs."+logGroupCol+" = ?", group)
}
err = tx.Model(&Log{}).Count(&total).Error
if err != nil {
@@ -325,8 +340,8 @@ func SumUsedQuota(logType int, startTimestamp int64, endTimestamp int64, modelNa
rpmTpmQuery = rpmTpmQuery.Where("channel_id = ?", channel)
}
if group != "" {
tx = tx.Where(groupCol+" = ?", group)
rpmTpmQuery = rpmTpmQuery.Where(groupCol+" = ?", group)
tx = tx.Where(logGroupCol+" = ?", group)
rpmTpmQuery = rpmTpmQuery.Where(logGroupCol+" = ?", group)
}
tx = tx.Where("type = ?", LogTypeConsume)
+117 -55
View File
@@ -1,6 +1,7 @@
package model
import (
"fmt"
"log"
"one-api/common"
"one-api/constant"
@@ -15,18 +16,48 @@ import (
"gorm.io/gorm"
)
var groupCol string
var keyCol string
var commonGroupCol string
var commonKeyCol string
var commonTrueVal string
var commonFalseVal string
var logKeyCol string
var logGroupCol string
func initCol() {
// init common column names
if common.UsingPostgreSQL {
groupCol = `"group"`
keyCol = `"key"`
commonGroupCol = `"group"`
commonKeyCol = `"key"`
commonTrueVal = "true"
commonFalseVal = "false"
} else {
groupCol = "`group`"
keyCol = "`key`"
commonGroupCol = "`group`"
commonKeyCol = "`key`"
commonTrueVal = "1"
commonFalseVal = "0"
}
if os.Getenv("LOG_SQL_DSN") != "" {
switch common.LogSqlType {
case common.DatabaseTypePostgreSQL:
logGroupCol = `"group"`
logKeyCol = `"key"`
default:
logGroupCol = commonGroupCol
logKeyCol = commonKeyCol
}
} else {
// LOG_SQL_DSN 为空时,日志数据库与主数据库相同
if common.UsingPostgreSQL {
logGroupCol = `"group"`
logKeyCol = `"key"`
} else {
logGroupCol = commonGroupCol
logKeyCol = commonKeyCol
}
}
// log sql type and database type
common.SysLog("Using Log SQL Type: " + common.LogSqlType)
}
var DB *gorm.DB
@@ -83,7 +114,7 @@ func CheckSetup() {
}
}
func chooseDB(envName string) (*gorm.DB, error) {
func chooseDB(envName string, isLog bool) (*gorm.DB, error) {
defer func() {
initCol()
}()
@@ -92,7 +123,11 @@ func chooseDB(envName string) (*gorm.DB, error) {
if strings.HasPrefix(dsn, "postgres://") || strings.HasPrefix(dsn, "postgresql://") {
// Use PostgreSQL
common.SysLog("using PostgreSQL as database")
common.UsingPostgreSQL = true
if !isLog {
common.UsingPostgreSQL = true
} else {
common.LogSqlType = common.DatabaseTypePostgreSQL
}
return gorm.Open(postgres.New(postgres.Config{
DSN: dsn,
PreferSimpleProtocol: true, // disables implicit prepared statement usage
@@ -102,7 +137,11 @@ func chooseDB(envName string) (*gorm.DB, error) {
}
if strings.HasPrefix(dsn, "local") {
common.SysLog("SQL_DSN not set, using SQLite as database")
common.UsingSQLite = true
if !isLog {
common.UsingSQLite = true
} else {
common.LogSqlType = common.DatabaseTypeSQLite
}
return gorm.Open(sqlite.Open(common.SQLitePath), &gorm.Config{
PrepareStmt: true, // precompile SQL
})
@@ -117,7 +156,11 @@ func chooseDB(envName string) (*gorm.DB, error) {
dsn += "?parseTime=true"
}
}
common.UsingMySQL = true
if !isLog {
common.UsingMySQL = true
} else {
common.LogSqlType = common.DatabaseTypeMySQL
}
return gorm.Open(mysql.Open(dsn), &gorm.Config{
PrepareStmt: true, // precompile SQL
})
@@ -131,7 +174,7 @@ func chooseDB(envName string) (*gorm.DB, error) {
}
func InitDB() (err error) {
db, err := chooseDB("SQL_DSN")
db, err := chooseDB("SQL_DSN", false)
if err == nil {
if common.DebugEnabled {
db = db.Debug()
@@ -149,7 +192,7 @@ func InitDB() (err error) {
return nil
}
if common.UsingMySQL {
_, _ = sqlDB.Exec("ALTER TABLE channels MODIFY model_mapping TEXT;") // TODO: delete this line when most users have upgraded
//_, _ = sqlDB.Exec("ALTER TABLE channels MODIFY model_mapping TEXT;") // TODO: delete this line when most users have upgraded
}
common.SysLog("database migration started")
err = migrateDB()
@@ -165,7 +208,7 @@ func InitLogDB() (err error) {
LOG_DB = DB
return
}
db, err := chooseDB("LOG_SQL_DSN")
db, err := chooseDB("LOG_SQL_DSN", true)
if err == nil {
if common.DebugEnabled {
db = db.Debug()
@@ -198,54 +241,73 @@ func InitLogDB() (err error) {
}
func migrateDB() error {
err := DB.AutoMigrate(&Channel{})
if !common.UsingPostgreSQL {
return migrateDBFast()
}
err := DB.AutoMigrate(
&Channel{},
&Token{},
&User{},
&Option{},
&Redemption{},
&Ability{},
&Log{},
&Midjourney{},
&TopUp{},
&QuotaData{},
&Task{},
&Setup{},
)
if err != nil {
return err
}
err = DB.AutoMigrate(&Token{})
if err != nil {
return err
return nil
}
func migrateDBFast() error {
var wg sync.WaitGroup
errChan := make(chan error, 12) // Buffer size matches number of migrations
migrations := []struct {
model interface{}
name string
}{
{&Channel{}, "Channel"},
{&Token{}, "Token"},
{&User{}, "User"},
{&Option{}, "Option"},
{&Redemption{}, "Redemption"},
{&Ability{}, "Ability"},
{&Log{}, "Log"},
{&Midjourney{}, "Midjourney"},
{&TopUp{}, "TopUp"},
{&QuotaData{}, "QuotaData"},
{&Task{}, "Task"},
{&Setup{}, "Setup"},
}
err = DB.AutoMigrate(&User{})
if err != nil {
return err
for _, m := range migrations {
wg.Add(1)
go func(model interface{}, name string) {
defer wg.Done()
if err := DB.AutoMigrate(model); err != nil {
errChan <- fmt.Errorf("failed to migrate %s: %v", name, err)
}
}(m.model, m.name)
}
err = DB.AutoMigrate(&Option{})
if err != nil {
return err
// Wait for all migrations to complete
wg.Wait()
close(errChan)
// Check for any errors
for err := range errChan {
if err != nil {
return err
}
}
err = DB.AutoMigrate(&Redemption{})
if err != nil {
return err
}
err = DB.AutoMigrate(&Ability{})
if err != nil {
return err
}
err = DB.AutoMigrate(&Log{})
if err != nil {
return err
}
err = DB.AutoMigrate(&Midjourney{})
if err != nil {
return err
}
err = DB.AutoMigrate(&TopUp{})
if err != nil {
return err
}
err = DB.AutoMigrate(&QuotaData{})
if err != nil {
return err
}
err = DB.AutoMigrate(&Task{})
if err != nil {
return err
}
err = DB.AutoMigrate(&Setup{})
common.SysLog("database migrated")
//err = createRootAccountIfNeed()
return err
return nil
}
func migrateLOGDB() error {
+23 -16
View File
@@ -5,6 +5,7 @@ import (
"one-api/setting"
"one-api/setting/config"
"one-api/setting/operation_setting"
"one-api/setting/ratio_setting"
"strconv"
"strings"
"time"
@@ -76,6 +77,9 @@ func InitOptionMap() {
common.OptionMap["MinTopUp"] = strconv.Itoa(setting.MinTopUp)
common.OptionMap["TopupGroupRatio"] = common.TopupGroupRatio2JSONString()
common.OptionMap["Chats"] = setting.Chats2JsonString()
common.OptionMap["AutoGroups"] = setting.AutoGroups2JsonString()
common.OptionMap["DefaultUseAutoGroup"] = strconv.FormatBool(setting.DefaultUseAutoGroup)
common.OptionMap["PayMethods"] = setting.PayMethods2JsonString()
common.OptionMap["GitHubClientId"] = ""
common.OptionMap["GitHubClientSecret"] = ""
common.OptionMap["TelegramBotToken"] = ""
@@ -94,13 +98,13 @@ func InitOptionMap() {
common.OptionMap["ModelRequestRateLimitDurationMinutes"] = strconv.Itoa(setting.ModelRequestRateLimitDurationMinutes)
common.OptionMap["ModelRequestRateLimitSuccessCount"] = strconv.Itoa(setting.ModelRequestRateLimitSuccessCount)
common.OptionMap["ModelRequestRateLimitGroup"] = setting.ModelRequestRateLimitGroup2JSONString()
common.OptionMap["ModelRatio"] = operation_setting.ModelRatio2JSONString()
common.OptionMap["ModelPrice"] = operation_setting.ModelPrice2JSONString()
common.OptionMap["CacheRatio"] = operation_setting.CacheRatio2JSONString()
common.OptionMap["GroupRatio"] = setting.GroupRatio2JSONString()
common.OptionMap["GroupGroupRatio"] = setting.GroupGroupRatio2JSONString()
common.OptionMap["ModelRatio"] = ratio_setting.ModelRatio2JSONString()
common.OptionMap["ModelPrice"] = ratio_setting.ModelPrice2JSONString()
common.OptionMap["CacheRatio"] = ratio_setting.CacheRatio2JSONString()
common.OptionMap["GroupRatio"] = ratio_setting.GroupRatio2JSONString()
common.OptionMap["GroupGroupRatio"] = ratio_setting.GroupGroupRatio2JSONString()
common.OptionMap["UserUsableGroups"] = setting.UserUsableGroups2JSONString()
common.OptionMap["CompletionRatio"] = operation_setting.CompletionRatio2JSONString()
common.OptionMap["CompletionRatio"] = ratio_setting.CompletionRatio2JSONString()
common.OptionMap["TopUpLink"] = common.TopUpLink
//common.OptionMap["ChatLink"] = common.ChatLink
//common.OptionMap["ChatLink2"] = common.ChatLink2
@@ -123,9 +127,6 @@ 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()
@@ -195,7 +196,7 @@ func updateOptionMap(key string, value string) (err error) {
common.ImageDownloadPermission = intValue
}
}
if strings.HasSuffix(key, "Enabled") || key == "DefaultCollapseSidebar" {
if strings.HasSuffix(key, "Enabled") || key == "DefaultCollapseSidebar" || key == "DefaultUseAutoGroup" {
boolValue := value == "true"
switch key {
case "PasswordRegisterEnabled":
@@ -264,6 +265,8 @@ func updateOptionMap(key string, value string) (err error) {
common.SMTPSSLEnabled = boolValue
case "WorkerAllowHttpImageRequestEnabled":
setting.WorkerAllowHttpImageRequestEnabled = boolValue
case "DefaultUseAutoGroup":
setting.DefaultUseAutoGroup = boolValue
}
}
switch key {
@@ -290,6 +293,8 @@ func updateOptionMap(key string, value string) (err error) {
setting.PayAddress = value
case "Chats":
err = setting.UpdateChatsByJsonString(value)
case "AutoGroups":
err = setting.UpdateAutoGroupsByJsonString(value)
case "CustomCallbackAddress":
setting.CustomCallbackAddress = value
case "EpayId":
@@ -355,19 +360,19 @@ func updateOptionMap(key string, value string) (err error) {
case "DataExportDefaultTime":
common.DataExportDefaultTime = value
case "ModelRatio":
err = operation_setting.UpdateModelRatioByJSONString(value)
err = ratio_setting.UpdateModelRatioByJSONString(value)
case "GroupRatio":
err = setting.UpdateGroupRatioByJSONString(value)
err = ratio_setting.UpdateGroupRatioByJSONString(value)
case "GroupGroupRatio":
err = setting.UpdateGroupGroupRatioByJSONString(value)
err = ratio_setting.UpdateGroupGroupRatioByJSONString(value)
case "UserUsableGroups":
err = setting.UpdateUserUsableGroupsByJSONString(value)
case "CompletionRatio":
err = operation_setting.UpdateCompletionRatioByJSONString(value)
err = ratio_setting.UpdateCompletionRatioByJSONString(value)
case "ModelPrice":
err = operation_setting.UpdateModelPriceByJSONString(value)
err = ratio_setting.UpdateModelPriceByJSONString(value)
case "CacheRatio":
err = operation_setting.UpdateCacheRatioByJSONString(value)
err = ratio_setting.UpdateCacheRatioByJSONString(value)
case "TopUpLink":
common.TopUpLink = value
//case "ChatLink":
@@ -384,6 +389,8 @@ func updateOptionMap(key string, value string) (err error) {
operation_setting.AutomaticDisableKeywordsFromString(value)
case "StreamCacheQueueLength":
setting.StreamCacheQueueLength, _ = strconv.Atoi(value)
case "PayMethods":
err = setting.UpdatePayMethodsByJsonString(value)
}
return err
}
+4 -4
View File
@@ -2,7 +2,7 @@ package model
import (
"one-api/common"
"one-api/setting/operation_setting"
"one-api/setting/ratio_setting"
"sync"
"time"
)
@@ -65,14 +65,14 @@ func updatePricing() {
ModelName: model,
EnableGroup: groups,
}
modelPrice, findPrice := operation_setting.GetModelPrice(model, false)
modelPrice, findPrice := ratio_setting.GetModelPrice(model, false)
if findPrice {
pricing.ModelPrice = modelPrice
pricing.QuotaType = 1
} else {
modelRatio, _ := operation_setting.GetModelRatio(model)
modelRatio, _ := ratio_setting.GetModelRatio(model)
pricing.ModelRatio = modelRatio
pricing.CompletionRatio = operation_setting.GetCompletionRatio(model)
pricing.CompletionRatio = ratio_setting.GetCompletionRatio(model)
pricing.QuotaType = 0
}
pricingMap = append(pricingMap, pricing)
+11 -1
View File
@@ -21,6 +21,7 @@ type Redemption struct {
Count int `json:"count" gorm:"-:all"` // only for api request
UsedUserId int `json:"used_user_id"`
DeletedAt gorm.DeletedAt `gorm:"index"`
ExpiredTime int64 `json:"expired_time" gorm:"bigint"` // 过期时间,0 表示不过期
}
func GetAllRedemptions(startIdx int, num int) (redemptions []*Redemption, total int64, err error) {
@@ -131,6 +132,9 @@ func Redeem(key string, userId int) (quota int, err error) {
if redemption.Status != common.RedemptionCodeStatusEnabled {
return errors.New("该兑换码已被使用")
}
if redemption.ExpiredTime != 0 && redemption.ExpiredTime < common.GetTimestamp() {
return errors.New("该兑换码已过期")
}
err = tx.Model(&User{}).Where("id = ?", userId).Update("quota", gorm.Expr("quota + ?", redemption.Quota)).Error
if err != nil {
return err
@@ -162,7 +166,7 @@ func (redemption *Redemption) SelectUpdate() error {
// Update Make sure your token's fields is completed, because this will update non-zero values
func (redemption *Redemption) Update() error {
var err error
err = DB.Model(redemption).Select("name", "status", "quota", "redeemed_time").Updates(redemption).Error
err = DB.Model(redemption).Select("name", "status", "quota", "redeemed_time", "expired_time").Updates(redemption).Error
return err
}
@@ -183,3 +187,9 @@ func DeleteRedemptionById(id int) (err error) {
}
return redemption.Delete()
}
func DeleteInvalidRedemptions() (int64, error) {
now := common.GetTimestamp()
result := DB.Where("status IN ? OR (status = ? AND expired_time != 0 AND expired_time < ?)", []int{common.RedemptionCodeStatusUsed, common.RedemptionCodeStatusDisabled}, common.RedemptionCodeStatusEnabled, now).Delete(&Redemption{})
return result.RowsAffected, result.Error
}
+2 -2
View File
@@ -66,7 +66,7 @@ func SearchUserTokens(userId int, keyword string, token string) (tokens []*Token
if token != "" {
token = strings.Trim(token, "sk-")
}
err = DB.Where("user_id = ?", userId).Where("name LIKE ?", "%"+keyword+"%").Where(keyCol+" LIKE ?", "%"+token+"%").Find(&tokens).Error
err = DB.Where("user_id = ?", userId).Where("name LIKE ?", "%"+keyword+"%").Where(commonKeyCol+" LIKE ?", "%"+token+"%").Find(&tokens).Error
return tokens, err
}
@@ -161,7 +161,7 @@ func GetTokenByKey(key string, fromDB bool) (token *Token, err error) {
// Don't return error - fall through to DB
}
fromDB = true
err = DB.Where(keyCol+" = ?", key).First(&token).Error
err = DB.Where(commonKeyCol+" = ?", key).First(&token).Error
return token, err
}
+1 -1
View File
@@ -10,7 +10,7 @@ import (
func cacheSetToken(token Token) error {
key := common.GenerateHMAC(token.Key)
token.Clean()
err := common.RedisHSetObj(fmt.Sprintf("token:%s", key), &token, time.Duration(constant.TokenCacheSeconds)*time.Second)
err := common.RedisHSetObj(fmt.Sprintf("token:%s", key), &token, time.Duration(constant.RedisKeyCacheSeconds())*time.Second)
if err != nil {
return err
}
+5 -3
View File
@@ -41,6 +41,7 @@ type User struct {
DeletedAt gorm.DeletedAt `gorm:"index"`
LinuxDOId string `json:"linux_do_id" gorm:"column:linux_do_id;index"`
Setting string `json:"setting" gorm:"type:text;column:setting"`
Remark string `json:"remark,omitempty" gorm:"type:varchar(255)" validate:"max=255"`
}
func (user *User) ToBaseUser() *UserBase {
@@ -175,7 +176,7 @@ func SearchUsers(keyword string, group string, startIdx int, num int) ([]*User,
// 如果是数字,同时搜索ID和其他字段
likeCondition = "id = ? OR " + likeCondition
if group != "" {
query = query.Where("("+likeCondition+") AND "+groupCol+" = ?",
query = query.Where("("+likeCondition+") AND "+commonGroupCol+" = ?",
keywordInt, "%"+keyword+"%", "%"+keyword+"%", "%"+keyword+"%", group)
} else {
query = query.Where(likeCondition,
@@ -184,7 +185,7 @@ func SearchUsers(keyword string, group string, startIdx int, num int) ([]*User,
} else {
// 非数字关键字,只搜索字符串字段
if group != "" {
query = query.Where("("+likeCondition+") AND "+groupCol+" = ?",
query = query.Where("("+likeCondition+") AND "+commonGroupCol+" = ?",
"%"+keyword+"%", "%"+keyword+"%", "%"+keyword+"%", group)
} else {
query = query.Where(likeCondition,
@@ -366,6 +367,7 @@ func (user *User) Edit(updatePassword bool) error {
"display_name": newUser.DisplayName,
"group": newUser.Group,
"quota": newUser.Quota,
"remark": newUser.Remark,
}
if updatePassword {
updates["password"] = newUser.Password
@@ -615,7 +617,7 @@ func GetUserGroup(id int, fromDB bool) (group string, err error) {
// Don't return error - fall through to DB
}
fromDB = true
err = DB.Model(&User{}).Where("id = ?", id).Select(groupCol).Find(&group).Error
err = DB.Model(&User{}).Where("id = ?", id).Select(commonGroupCol).Find(&group).Error
if err != nil {
return "", err
}
+1 -1
View File
@@ -70,7 +70,7 @@ func updateUserCache(user User) error {
return common.RedisHSetObj(
getUserCacheKey(user.Id),
user.ToBaseUser(),
time.Duration(constant.UserId2QuotaCacheSeconds)*time.Second,
time.Duration(constant.RedisKeyCacheSeconds())*time.Second,
)
}
+19 -2
View File
@@ -2,11 +2,12 @@ package model
import (
"errors"
"github.com/bytedance/gopkg/util/gopool"
"gorm.io/gorm"
"one-api/common"
"sync"
"time"
"github.com/bytedance/gopkg/util/gopool"
"gorm.io/gorm"
)
const (
@@ -48,6 +49,22 @@ func addNewRecord(type_ int, id int, value int) {
}
func batchUpdate() {
// check if there's any data to update
hasData := false
for i := 0; i < BatchUpdateTypeCount; i++ {
batchUpdateLocks[i].Lock()
if len(batchUpdateStores[i]) > 0 {
hasData = true
batchUpdateLocks[i].Unlock()
break
}
batchUpdateLocks[i].Unlock()
}
if !hasData {
return
}
common.SysLog("batch update started")
for i := 0; i < BatchUpdateTypeCount; i++ {
batchUpdateLocks[i].Lock()
+36 -37
View File
@@ -113,7 +113,7 @@ func RequestOpenAI2ClaudeMessage(textRequest dto.GeneralOpenAIRequest) (*dto.Cla
// BudgetTokens 为 max_tokens 的 80%
claudeRequest.Thinking = &dto.Thinking{
Type: "enabled",
BudgetTokens: int(float64(claudeRequest.MaxTokens) * model_setting.GetClaudeSettings().ThinkingAdapterBudgetTokensPercentage),
BudgetTokens: common.GetPointer[int](int(float64(claudeRequest.MaxTokens) * model_setting.GetClaudeSettings().ThinkingAdapterBudgetTokensPercentage)),
}
// TODO: 临时处理
// https://docs.anthropic.com/en/docs/build-with-claude/extended-thinking#important-considerations-when-using-extended-thinking
@@ -454,6 +454,7 @@ type ClaudeResponseInfo struct {
Model string
ResponseText strings.Builder
Usage *dto.Usage
Done bool
}
func FormatClaudeResponseInfo(requestMode int, claudeResponse *dto.ClaudeResponse, oaiResponse *dto.ChatCompletionsStreamResponse, claudeInfo *ClaudeResponseInfo) bool {
@@ -461,20 +462,32 @@ func FormatClaudeResponseInfo(requestMode int, claudeResponse *dto.ClaudeRespons
claudeInfo.ResponseText.WriteString(claudeResponse.Completion)
} else {
if claudeResponse.Type == "message_start" {
// message_start, 获取usage
claudeInfo.ResponseId = claudeResponse.Message.Id
claudeInfo.Model = claudeResponse.Message.Model
// message_start, 获取usage
claudeInfo.Usage.PromptTokens = claudeResponse.Message.Usage.InputTokens
claudeInfo.Usage.PromptTokensDetails.CachedTokens = claudeResponse.Message.Usage.CacheReadInputTokens
claudeInfo.Usage.PromptTokensDetails.CachedCreationTokens = claudeResponse.Message.Usage.CacheCreationInputTokens
claudeInfo.Usage.CompletionTokens = claudeResponse.Message.Usage.OutputTokens
} else if claudeResponse.Type == "content_block_delta" {
if claudeResponse.Delta.Text != nil {
claudeInfo.ResponseText.WriteString(*claudeResponse.Delta.Text)
}
if claudeResponse.Delta.Thinking != "" {
claudeInfo.ResponseText.WriteString(claudeResponse.Delta.Thinking)
}
} else if claudeResponse.Type == "message_delta" {
claudeInfo.Usage.CompletionTokens = claudeResponse.Usage.OutputTokens
// 最终的usage获取
if claudeResponse.Usage.InputTokens > 0 {
// 不叠加,只取最新的
claudeInfo.Usage.PromptTokens = claudeResponse.Usage.InputTokens
}
claudeInfo.Usage.TotalTokens = claudeInfo.Usage.PromptTokens + claudeResponse.Usage.OutputTokens
claudeInfo.Usage.CompletionTokens = claudeResponse.Usage.OutputTokens
claudeInfo.Usage.TotalTokens = claudeInfo.Usage.PromptTokens + claudeInfo.Usage.CompletionTokens
// 判断是否完整
claudeInfo.Done = true
} else if claudeResponse.Type == "content_block_start" {
} else {
return false
@@ -506,25 +519,15 @@ func HandleStreamResponseData(c *gin.Context, info *relaycommon.RelayInfo, claud
}
}
if info.RelayFormat == relaycommon.RelayFormatClaude {
FormatClaudeResponseInfo(requestMode, &claudeResponse, nil, claudeInfo)
if requestMode == RequestModeCompletion {
claudeInfo.ResponseText.WriteString(claudeResponse.Completion)
} else {
if claudeResponse.Type == "message_start" {
// message_start, 获取usage
info.UpstreamModelName = claudeResponse.Message.Model
claudeInfo.Usage.PromptTokens = claudeResponse.Message.Usage.InputTokens
claudeInfo.Usage.PromptTokensDetails.CachedTokens = claudeResponse.Message.Usage.CacheReadInputTokens
claudeInfo.Usage.PromptTokensDetails.CachedCreationTokens = claudeResponse.Message.Usage.CacheCreationInputTokens
claudeInfo.Usage.CompletionTokens = claudeResponse.Message.Usage.OutputTokens
} else if claudeResponse.Type == "content_block_delta" {
claudeInfo.ResponseText.WriteString(claudeResponse.Delta.GetText())
} else if claudeResponse.Type == "message_delta" {
if claudeResponse.Usage.InputTokens > 0 {
// 不叠加,只取最新的
claudeInfo.Usage.PromptTokens = claudeResponse.Usage.InputTokens
}
claudeInfo.Usage.CompletionTokens = claudeResponse.Usage.OutputTokens
claudeInfo.Usage.TotalTokens = claudeInfo.Usage.PromptTokens + claudeInfo.Usage.CompletionTokens
}
}
helper.ClaudeChunkData(c, claudeResponse, data)
@@ -544,29 +547,25 @@ func HandleStreamResponseData(c *gin.Context, info *relaycommon.RelayInfo, claud
}
func HandleStreamFinalResponse(c *gin.Context, info *relaycommon.RelayInfo, claudeInfo *ClaudeResponseInfo, requestMode int) {
if requestMode == RequestModeCompletion {
claudeInfo.Usage, _ = service.ResponseText2Usage(claudeInfo.ResponseText.String(), info.UpstreamModelName, info.PromptTokens)
} else {
if claudeInfo.Usage.PromptTokens == 0 {
//上游出错
}
if claudeInfo.Usage.CompletionTokens == 0 || !claudeInfo.Done {
if common.DebugEnabled {
common.SysError("claude response usage is not complete, maybe upstream error")
}
claudeInfo.Usage, _ = service.ResponseText2Usage(claudeInfo.ResponseText.String(), info.UpstreamModelName, claudeInfo.Usage.PromptTokens)
}
}
if info.RelayFormat == relaycommon.RelayFormatClaude {
if requestMode == RequestModeCompletion {
claudeInfo.Usage, _ = service.ResponseText2Usage(claudeInfo.ResponseText.String(), info.UpstreamModelName, info.PromptTokens)
} else {
// 说明流模式建立失败,可能为官方出错
if claudeInfo.Usage.PromptTokens == 0 {
//usage.PromptTokens = info.PromptTokens
}
if claudeInfo.Usage.CompletionTokens == 0 {
claudeInfo.Usage, _ = service.ResponseText2Usage(claudeInfo.ResponseText.String(), info.UpstreamModelName, claudeInfo.Usage.PromptTokens)
}
}
//
} else if info.RelayFormat == relaycommon.RelayFormatOpenAI {
if requestMode == RequestModeCompletion {
claudeInfo.Usage, _ = service.ResponseText2Usage(claudeInfo.ResponseText.String(), info.UpstreamModelName, info.PromptTokens)
} else {
if claudeInfo.Usage.PromptTokens == 0 {
//上游出错
}
if claudeInfo.Usage.CompletionTokens == 0 {
claudeInfo.Usage, _ = service.ResponseText2Usage(claudeInfo.ResponseText.String(), info.UpstreamModelName, claudeInfo.Usage.PromptTokens)
}
}
if info.ShouldIncludeUsage {
response := helper.GenerateFinalUsageResponse(claudeInfo.ResponseId, claudeInfo.Created, info.UpstreamModelName, *claudeInfo.Usage)
err := helper.ObjectData(c, response)
+1 -2
View File
@@ -3,7 +3,6 @@ package cohere
import (
"bufio"
"encoding/json"
"fmt"
"github.com/gin-gonic/gin"
"io"
"net/http"
@@ -78,7 +77,7 @@ func stopReasonCohere2OpenAI(reason string) string {
}
func cohereStreamHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (*dto.OpenAIErrorWithStatusCode, *dto.Usage) {
responseId := fmt.Sprintf("chatcmpl-%s", common.GetUUID())
responseId := helper.GetResponseID(c)
createdTime := common.GetTimestamp()
usage := &dto.Usage{}
responseText := ""
+5 -2
View File
@@ -72,8 +72,11 @@ func (a *Adaptor) Init(info *relaycommon.RelayInfo) {
func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
if model_setting.GetGeminiSettings().ThinkingAdapterEnabled {
// suffix -thinking and -nothinking
if strings.HasSuffix(info.OriginModelName, "-thinking") {
// 新增逻辑:处理 -thinking-<budget> 格式
if strings.Contains(info.OriginModelName, "-thinking-") {
parts := strings.Split(info.UpstreamModelName, "-thinking-")
info.UpstreamModelName = parts[0]
} else if strings.HasSuffix(info.OriginModelName, "-thinking") { // 旧的适配
info.UpstreamModelName = strings.TrimSuffix(info.UpstreamModelName, "-thinking")
} else if strings.HasSuffix(info.OriginModelName, "-nothinking") {
info.UpstreamModelName = strings.TrimSuffix(info.UpstreamModelName, "-nothinking")
+1
View File
@@ -140,6 +140,7 @@ type GeminiChatGenerationConfig struct {
Seed int64 `json:"seed,omitempty"`
ResponseModalities []string `json:"responseModalities,omitempty"`
ThinkingConfig *GeminiThinkingConfig `json:"thinkingConfig,omitempty"`
SpeechConfig json.RawMessage `json:"speechConfig,omitempty"` // RawMessage to allow flexible speech config
}
type GeminiChatCandidate struct {
+3 -16
View File
@@ -35,23 +35,10 @@ func GeminiTextGenerationHandler(c *gin.Context, resp *http.Response, info *rela
return nil, service.OpenAIErrorWrapper(err, "unmarshal_response_body_failed", http.StatusInternalServerError)
}
// 检查是否有候选响应
if len(geminiResponse.Candidates) == 0 {
return nil, &dto.OpenAIErrorWithStatusCode{
Error: dto.OpenAIError{
Message: "No candidates returned",
Type: "server_error",
Param: "",
Code: 500,
},
StatusCode: resp.StatusCode,
}
}
// 计算使用量(基于 UsageMetadata
usage := dto.Usage{
PromptTokens: geminiResponse.UsageMetadata.PromptTokenCount,
CompletionTokens: geminiResponse.UsageMetadata.CandidatesTokenCount,
CompletionTokens: geminiResponse.UsageMetadata.CandidatesTokenCount + geminiResponse.UsageMetadata.ThoughtsTokenCount,
TotalTokens: geminiResponse.UsageMetadata.TotalTokenCount,
}
@@ -108,7 +95,7 @@ func GeminiTextGenerationStreamHandler(c *gin.Context, resp *http.Response, info
// 更新使用量统计
if geminiResponse.UsageMetadata.TotalTokenCount != 0 {
usage.PromptTokens = geminiResponse.UsageMetadata.PromptTokenCount
usage.CompletionTokens = geminiResponse.UsageMetadata.CandidatesTokenCount
usage.CompletionTokens = geminiResponse.UsageMetadata.CandidatesTokenCount + geminiResponse.UsageMetadata.ThoughtsTokenCount
usage.TotalTokens = geminiResponse.UsageMetadata.TotalTokenCount
usage.CompletionTokenDetails.ReasoningTokens = geminiResponse.UsageMetadata.ThoughtsTokenCount
for _, detail := range geminiResponse.UsageMetadata.PromptTokensDetails {
@@ -136,7 +123,7 @@ func GeminiTextGenerationStreamHandler(c *gin.Context, resp *http.Response, info
}
// 计算最终使用量
usage.CompletionTokens = usage.TotalTokens - usage.PromptTokens
// usage.CompletionTokens = usage.TotalTokens - usage.PromptTokens
// 移除流式响应结尾的[Done],因为Gemini API没有发送Done的行为
//helper.Done(c)
+74 -39
View File
@@ -12,6 +12,7 @@ import (
"one-api/relay/helper"
"one-api/service"
"one-api/setting/model_setting"
"strconv"
"strings"
"unicode/utf8"
@@ -36,6 +37,47 @@ var geminiSupportedMimeTypes = map[string]bool{
"video/flv": true,
}
// Gemini 允许的思考预算范围
const (
pro25MinBudget = 128
pro25MaxBudget = 32768
flash25MaxBudget = 24576
flash25LiteMinBudget = 512
flash25LiteMaxBudget = 24576
)
// clampThinkingBudget 根据模型名称将预算限制在允许的范围内
func clampThinkingBudget(modelName string, budget int) int {
isNew25Pro := strings.HasPrefix(modelName, "gemini-2.5-pro") &&
!strings.HasPrefix(modelName, "gemini-2.5-pro-preview-05-06") &&
!strings.HasPrefix(modelName, "gemini-2.5-pro-preview-03-25")
is25FlashLite := strings.HasPrefix(modelName, "gemini-2.5-flash-lite")
if is25FlashLite {
if budget < flash25LiteMinBudget {
return flash25LiteMinBudget
}
if budget > flash25LiteMaxBudget {
return flash25LiteMaxBudget
}
} else if isNew25Pro {
if budget < pro25MinBudget {
return pro25MinBudget
}
if budget > pro25MaxBudget {
return pro25MaxBudget
}
} else { // 其他模型
if budget < 0 {
return 0
}
if budget > flash25MaxBudget {
return flash25MaxBudget
}
}
return budget
}
// Setting safety to the lowest possible values since Gemini is already powerless enough
func CovertGemini2OpenAI(textRequest dto.GeneralOpenAIRequest, info *relaycommon.RelayInfo) (*GeminiChatRequest, error) {
@@ -57,16 +99,31 @@ func CovertGemini2OpenAI(textRequest dto.GeneralOpenAIRequest, info *relaycommon
}
if model_setting.GetGeminiSettings().ThinkingAdapterEnabled {
if strings.HasSuffix(info.OriginModelName, "-thinking") {
// 硬编码不支持 ThinkingBudget 的旧模型
modelName := info.OriginModelName
isNew25Pro := strings.HasPrefix(modelName, "gemini-2.5-pro") &&
!strings.HasPrefix(modelName, "gemini-2.5-pro-preview-05-06") &&
!strings.HasPrefix(modelName, "gemini-2.5-pro-preview-03-25")
is25FlashLite := strings.HasPrefix(modelName, "gemini-2.5-flash-lite")
if strings.Contains(modelName, "-thinking-") {
parts := strings.SplitN(modelName, "-thinking-", 2)
if len(parts) == 2 && parts[1] != "" {
if budgetTokens, err := strconv.Atoi(parts[1]); err == nil {
clampedBudget := clampThinkingBudget(modelName, budgetTokens)
geminiRequest.GenerationConfig.ThinkingConfig = &GeminiThinkingConfig{
ThinkingBudget: common.GetPointer(clampedBudget),
IncludeThoughts: true,
}
}
}
} else if strings.HasSuffix(modelName, "-thinking") {
unsupportedModels := []string{
"gemini-2.5-pro-preview-05-06",
"gemini-2.5-pro-preview-03-25",
}
isUnsupported := false
for _, unsupportedModel := range unsupportedModels {
if strings.HasPrefix(info.OriginModelName, unsupportedModel) {
if strings.HasPrefix(modelName, unsupportedModel) {
isUnsupported = true
break
}
@@ -78,39 +135,14 @@ func CovertGemini2OpenAI(textRequest dto.GeneralOpenAIRequest, info *relaycommon
}
} else {
budgetTokens := model_setting.GetGeminiSettings().ThinkingAdapterBudgetTokensPercentage * float64(geminiRequest.GenerationConfig.MaxOutputTokens)
// 检查是否为新的2.5pro模型(支持ThinkingBudget但有特殊范围)
isNew25Pro := strings.HasPrefix(info.OriginModelName, "gemini-2.5-pro") &&
!strings.HasPrefix(info.OriginModelName, "gemini-2.5-pro-preview-05-06") &&
!strings.HasPrefix(info.OriginModelName, "gemini-2.5-pro-preview-03-25")
if isNew25Pro {
// 新的2.5pro模型:ThinkingBudget范围为128-32768
if budgetTokens == 0 || budgetTokens < 128 {
budgetTokens = 128
} else if budgetTokens > 32768 {
budgetTokens = 32768
}
} else {
// 其他模型:ThinkingBudget范围为0-24576
if budgetTokens == 0 || budgetTokens > 24576 {
budgetTokens = 24576
}
}
clampedBudget := clampThinkingBudget(modelName, int(budgetTokens))
geminiRequest.GenerationConfig.ThinkingConfig = &GeminiThinkingConfig{
ThinkingBudget: common.GetPointer(int(budgetTokens)),
ThinkingBudget: common.GetPointer(clampedBudget),
IncludeThoughts: true,
}
}
} else if strings.HasSuffix(info.OriginModelName, "-nothinking") {
// 检查是否为新的2.5pro模型(不支持-nothinking,因为最低值只能为128
isNew25Pro := strings.HasPrefix(info.OriginModelName, "gemini-2.5-pro") &&
!strings.HasPrefix(info.OriginModelName, "gemini-2.5-pro-preview-05-06") &&
!strings.HasPrefix(info.OriginModelName, "gemini-2.5-pro-preview-03-25")
if !isNew25Pro {
// 只有非新2.5pro模型才支持-nothinking
} else if strings.HasSuffix(modelName, "-nothinking") {
if !isNew25Pro && !is25FlashLite {
geminiRequest.GenerationConfig.ThinkingConfig = &GeminiThinkingConfig{
ThinkingBudget: common.GetPointer(0),
}
@@ -283,7 +315,8 @@ func CovertGemini2OpenAI(textRequest dto.GeneralOpenAIRequest, info *relaycommon
// 校验 MimeType 是否在 Gemini 支持的白名单中
if _, ok := geminiSupportedMimeTypes[strings.ToLower(fileData.MimeType)]; !ok {
return nil, fmt.Errorf("MIME type '%s' from URL '%s' is not supported by Gemini. Supported types are: %v", fileData.MimeType, part.GetImageMedia().Url, getSupportedMimeTypesList())
url := part.GetImageMedia().Url
return nil, fmt.Errorf("mime type is not supported by Gemini: '%s', url: '%s', supported types are: %v", fileData.MimeType, url, getSupportedMimeTypesList())
}
parts = append(parts, GeminiPart{
@@ -341,7 +374,9 @@ func CovertGemini2OpenAI(textRequest dto.GeneralOpenAIRequest, info *relaycommon
if content.Role == "assistant" {
content.Role = "model"
}
geminiRequest.Contents = append(geminiRequest.Contents, content)
if len(content.Parts) > 0 {
geminiRequest.Contents = append(geminiRequest.Contents, content)
}
}
if len(system_content) > 0 {
@@ -611,9 +646,9 @@ func getResponseToolCall(item *GeminiPart) *dto.ToolCallResponse {
}
}
func responseGeminiChat2OpenAI(response *GeminiChatResponse) *dto.OpenAITextResponse {
func responseGeminiChat2OpenAI(c *gin.Context, response *GeminiChatResponse) *dto.OpenAITextResponse {
fullTextResponse := dto.OpenAITextResponse{
Id: fmt.Sprintf("chatcmpl-%s", common.GetUUID()),
Id: helper.GetResponseID(c),
Object: "chat.completion",
Created: common.GetTimestamp(),
Choices: make([]dto.OpenAITextResponseChoice, 0, len(response.Candidates)),
@@ -754,7 +789,7 @@ func streamResponseGeminiChat2OpenAI(geminiResponse *GeminiChatResponse) (*dto.C
func GeminiChatStreamHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (*dto.OpenAIErrorWithStatusCode, *dto.Usage) {
// responseText := ""
id := fmt.Sprintf("chatcmpl-%s", common.GetUUID())
id := helper.GetResponseID(c)
createAt := common.GetTimestamp()
var usage = &dto.Usage{}
var imageCount int
@@ -849,7 +884,7 @@ func GeminiChatHandler(c *gin.Context, resp *http.Response, info *relaycommon.Re
StatusCode: resp.StatusCode,
}, nil
}
fullTextResponse := responseGeminiChat2OpenAI(&geminiResponse)
fullTextResponse := responseGeminiChat2OpenAI(c, &geminiResponse)
fullTextResponse.Model = info.UpstreamModelName
usage := dto.Usage{
PromptTokens: geminiResponse.UsageMetadata.PromptTokenCount,
+7
View File
@@ -88,6 +88,13 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
requestURL := strings.Split(info.RequestURLPath, "?")[0]
requestURL = fmt.Sprintf("%s?api-version=%s", requestURL, apiVersion)
task := strings.TrimPrefix(requestURL, "/v1/")
// 特殊处理 responses API
if info.RelayMode == constant.RelayModeResponses {
requestURL = fmt.Sprintf("/openai/v1/responses?api-version=preview")
return relaycommon.GetFullRequestURL(info.BaseUrl, requestURL, info.ChannelType), nil
}
model_ := info.UpstreamModelName
// 2025年5月10日后创建的渠道不移除.
if info.ChannelCreateTime < constant2.AzureNoRemoveDotTime {
+5 -3
View File
@@ -8,6 +8,7 @@ import (
"math"
"mime/multipart"
"net/http"
"path/filepath"
"one-api/common"
"one-api/constant"
"one-api/dto"
@@ -345,13 +346,14 @@ func countAudioTokens(c *gin.Context) (int, error) {
if err = c.ShouldBind(&reqBody); err != nil {
return 0, errors.WithStack(err)
}
ext := filepath.Ext(reqBody.File.Filename) // 获取文件扩展名
reqFp, err := reqBody.File.Open()
if err != nil {
return 0, errors.WithStack(err)
}
defer reqFp.Close()
tmpFp, err := os.CreateTemp("", "audio-*")
tmpFp, err := os.CreateTemp("", "audio-*"+ext)
if err != nil {
return 0, errors.WithStack(err)
}
@@ -365,7 +367,7 @@ func countAudioTokens(c *gin.Context) (int, error) {
return 0, errors.WithStack(err)
}
duration, err := common.GetAudioDuration(c.Request.Context(), tmpFp.Name())
duration, err := common.GetAudioDuration(c.Request.Context(), tmpFp.Name(), ext)
if err != nil {
return 0, errors.WithStack(err)
}
+1 -2
View File
@@ -2,7 +2,6 @@ package palm
import (
"encoding/json"
"fmt"
"github.com/gin-gonic/gin"
"io"
"net/http"
@@ -73,7 +72,7 @@ func streamResponsePaLM2OpenAI(palmResponse *PaLMChatResponse) *dto.ChatCompleti
func palmStreamHandler(c *gin.Context, resp *http.Response) (*dto.OpenAIErrorWithStatusCode, string) {
responseText := ""
responseId := fmt.Sprintf("chatcmpl-%s", common.GetUUID())
responseId := helper.GetResponseID(c)
createdTime := common.GetTimestamp()
dataChan := make(chan string)
stopChan := make(chan bool)
+17 -8
View File
@@ -123,14 +123,23 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
if v, ok := claudeModelMap[info.UpstreamModelName]; ok {
model = v
}
return fmt.Sprintf(
"https://%s-aiplatform.googleapis.com/v1/projects/%s/locations/%s/publishers/anthropic/models/%s:%s",
region,
adc.ProjectID,
region,
model,
suffix,
), nil
if region == "global" {
return fmt.Sprintf(
"https://aiplatform.googleapis.com/v1/projects/%s/locations/global/publishers/anthropic/models/%s:%s",
adc.ProjectID,
model,
suffix,
), nil
} else {
return fmt.Sprintf(
"https://%s-aiplatform.googleapis.com/v1/projects/%s/locations/%s/publishers/anthropic/models/%s:%s",
region,
adc.ProjectID,
region,
model,
suffix,
), nil
}
} else if a.RequestMode == RequestModeLlama {
return fmt.Sprintf(
"https://%s-aiplatform.googleapis.com/v1beta1/projects/%s/locations/%s/endpoints/openapi/chat/completions",
+1 -1
View File
@@ -98,7 +98,7 @@ func ClaudeHelper(c *gin.Context) (claudeError *dto.ClaudeErrorWithStatusCode) {
// BudgetTokens 为 max_tokens 的 80%
textRequest.Thinking = &dto.Thinking{
Type: "enabled",
BudgetTokens: int(float64(textRequest.MaxTokens) * model_setting.GetClaudeSettings().ThinkingAdapterBudgetTokensPercentage),
BudgetTokens: common.GetPointer[int](int(float64(textRequest.MaxTokens) * model_setting.GetClaudeSettings().ThinkingAdapterBudgetTokensPercentage)),
}
// TODO: 临时处理
// https://docs.anthropic.com/en/docs/build-with-claude/extended-thinking#important-considerations-when-using-extended-thinking
+52 -22
View File
@@ -5,12 +5,16 @@ import (
"one-api/common"
constant2 "one-api/constant"
relaycommon "one-api/relay/common"
"one-api/setting"
"one-api/setting/operation_setting"
"one-api/setting/ratio_setting"
"github.com/gin-gonic/gin"
)
type GroupRatioInfo struct {
GroupRatio float64
GroupSpecialRatio float64
}
type PriceData struct {
ModelPrice float64
ModelRatio float64
@@ -18,23 +22,50 @@ type PriceData struct {
CacheRatio float64
CacheCreationRatio float64
ImageRatio float64
GroupRatio float64
UserGroupRatio float64
UsePrice bool
ShouldPreConsumedQuota int
GroupRatioInfo GroupRatioInfo
}
func (p PriceData) ToSetting() string {
return fmt.Sprintf("ModelPrice: %f, ModelRatio: %f, CompletionRatio: %f, CacheRatio: %f, GroupRatio: %f, UsePrice: %t, CacheCreationRatio: %f, ShouldPreConsumedQuota: %d, ImageRatio: %f", p.ModelPrice, p.ModelRatio, p.CompletionRatio, p.CacheRatio, p.GroupRatio, p.UsePrice, p.CacheCreationRatio, p.ShouldPreConsumedQuota, p.ImageRatio)
return fmt.Sprintf("ModelPrice: %f, ModelRatio: %f, CompletionRatio: %f, CacheRatio: %f, GroupRatio: %f, UsePrice: %t, CacheCreationRatio: %f, ShouldPreConsumedQuota: %d, ImageRatio: %f", p.ModelPrice, p.ModelRatio, p.CompletionRatio, p.CacheRatio, p.GroupRatioInfo.GroupRatio, p.UsePrice, p.CacheCreationRatio, p.ShouldPreConsumedQuota, p.ImageRatio)
}
// HandleGroupRatio checks for "auto_group" in the context and updates the group ratio and relayInfo.Group if present
func HandleGroupRatio(ctx *gin.Context, relayInfo *relaycommon.RelayInfo) GroupRatioInfo {
groupRatioInfo := GroupRatioInfo{
GroupRatio: 1.0, // default ratio
GroupSpecialRatio: -1,
}
// check auto group
autoGroup, exists := ctx.Get("auto_group")
if exists {
if common.DebugEnabled {
println(fmt.Sprintf("final group: %s", autoGroup))
}
relayInfo.Group = autoGroup.(string)
}
// check user group special ratio
userGroupRatio, ok := ratio_setting.GetGroupGroupRatio(relayInfo.UserGroup, relayInfo.Group)
if ok {
// user group special ratio
groupRatioInfo.GroupSpecialRatio = userGroupRatio
groupRatioInfo.GroupRatio = userGroupRatio
} else {
// normal group ratio
groupRatioInfo.GroupRatio = ratio_setting.GetGroupRatio(relayInfo.Group)
}
return groupRatioInfo
}
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
}
modelPrice, usePrice := ratio_setting.GetModelPrice(info.OriginModelName, false)
groupRatioInfo := HandleGroupRatio(c, info)
var preConsumedQuota int
var modelRatio float64
var completionRatio float64
@@ -47,7 +78,7 @@ func ModelPriceHelper(c *gin.Context, info *relaycommon.RelayInfo, promptTokens
preConsumedTokens = promptTokens + maxTokens
}
var success bool
modelRatio, success = operation_setting.GetModelRatio(info.OriginModelName)
modelRatio, success = ratio_setting.GetModelRatio(info.OriginModelName)
if !success {
acceptUnsetRatio := false
if accept, ok := info.UserSetting[constant2.UserAcceptUnsetRatioModel]; ok {
@@ -60,22 +91,21 @@ func ModelPriceHelper(c *gin.Context, info *relaycommon.RelayInfo, promptTokens
return PriceData{}, fmt.Errorf("模型 %s 倍率或价格未配置,请联系管理员设置或开始自用模式;Model %s ratio or price not set, please set or start self-use mode", info.OriginModelName, info.OriginModelName)
}
}
completionRatio = operation_setting.GetCompletionRatio(info.OriginModelName)
cacheRatio, _ = operation_setting.GetCacheRatio(info.OriginModelName)
cacheCreationRatio, _ = operation_setting.GetCreateCacheRatio(info.OriginModelName)
imageRatio, _ = operation_setting.GetImageRatio(info.OriginModelName)
ratio := modelRatio * groupRatio
completionRatio = ratio_setting.GetCompletionRatio(info.OriginModelName)
cacheRatio, _ = ratio_setting.GetCacheRatio(info.OriginModelName)
cacheCreationRatio, _ = ratio_setting.GetCreateCacheRatio(info.OriginModelName)
imageRatio, _ = ratio_setting.GetImageRatio(info.OriginModelName)
ratio := modelRatio * groupRatioInfo.GroupRatio
preConsumedQuota = int(float64(preConsumedTokens) * ratio)
} else {
preConsumedQuota = int(modelPrice * common.QuotaPerUnit * groupRatio)
preConsumedQuota = int(modelPrice * common.QuotaPerUnit * groupRatioInfo.GroupRatio)
}
priceData := PriceData{
ModelPrice: modelPrice,
ModelRatio: modelRatio,
CompletionRatio: completionRatio,
GroupRatio: groupRatio,
UserGroupRatio: userGroupRatio,
GroupRatioInfo: groupRatioInfo,
UsePrice: usePrice,
CacheRatio: cacheRatio,
ImageRatio: imageRatio,
@@ -91,11 +121,11 @@ func ModelPriceHelper(c *gin.Context, info *relaycommon.RelayInfo, promptTokens
}
func ContainPriceOrRatio(modelName string) bool {
_, ok := operation_setting.GetModelPrice(modelName, false)
_, ok := ratio_setting.GetModelPrice(modelName, false)
if ok {
return true
}
_, ok = operation_setting.GetModelRatio(modelName)
_, ok = ratio_setting.GetModelRatio(modelName)
if ok {
return true
}
+33
View File
@@ -136,19 +136,52 @@ func GeminiHelper(c *gin.Context) (openaiErr *dto.OpenAIErrorWithStatusCode) {
adaptor.Init(relayInfo)
// Clean up empty system instruction
if req.SystemInstructions != nil {
hasContent := false
for _, part := range req.SystemInstructions.Parts {
if part.Text != "" {
hasContent = true
break
}
}
if !hasContent {
req.SystemInstructions = nil
}
}
requestBody, err := json.Marshal(req)
if err != nil {
return service.OpenAIErrorWrapperLocal(err, "marshal_text_request_failed", http.StatusInternalServerError)
}
if common.DebugEnabled {
println("Gemini request body: %s", string(requestBody))
}
resp, err := adaptor.DoRequest(c, relayInfo, bytes.NewReader(requestBody))
if err != nil {
common.LogError(c, "Do gemini request failed: "+err.Error())
return service.OpenAIErrorWrapperLocal(err, "do_request_failed", http.StatusInternalServerError)
}
statusCodeMappingStr := c.GetString("status_code_mapping")
var httpResp *http.Response
if resp != nil {
httpResp = resp.(*http.Response)
relayInfo.IsStream = relayInfo.IsStream || strings.HasPrefix(httpResp.Header.Get("Content-Type"), "text/event-stream")
if httpResp.StatusCode != http.StatusOK {
openaiErr = service.RelayErrorHandler(httpResp, false)
// reset status code 重置状态码
service.ResetStatusCode(openaiErr, statusCodeMappingStr)
return openaiErr
}
}
usage, openaiErr := adaptor.DoResponse(c, resp.(*http.Response), relayInfo)
if openaiErr != nil {
service.ResetStatusCode(openaiErr, statusCodeMappingStr)
return openaiErr
}
+1 -1
View File
@@ -162,7 +162,7 @@ func ImageHelper(c *gin.Context) *dto.OpenAIErrorWithStatusCode {
// reset model price
priceData.ModelPrice *= sizeRatio * qualityRatio * float64(imageRequest.N)
quota = int(priceData.ModelPrice * priceData.GroupRatio * common.QuotaPerUnit)
quota = int(priceData.ModelPrice * priceData.GroupRatioInfo.GroupRatio * common.QuotaPerUnit)
userQuota, err = model.GetUserQuota(relayInfo.UserId, false)
if err != nil {
return service.OpenAIErrorWrapperLocal(err, "get_user_quota_failed", http.StatusInternalServerError)
+7 -7
View File
@@ -15,7 +15,7 @@ import (
relayconstant "one-api/relay/constant"
"one-api/service"
"one-api/setting"
"one-api/setting/operation_setting"
"one-api/setting/ratio_setting"
"strconv"
"strings"
"time"
@@ -174,17 +174,17 @@ func RelaySwapFace(c *gin.Context) *dto.MidjourneyResponse {
return service.MidjourneyErrorWrapper(constant.MjRequestError, "sour_base64_and_target_base64_is_required")
}
modelName := service.CoverActionToModelName(constant.MjActionSwapFace)
modelPrice, success := operation_setting.GetModelPrice(modelName, true)
modelPrice, success := ratio_setting.GetModelPrice(modelName, true)
// 如果没有配置价格,则使用默认价格
if !success {
defaultPrice, ok := operation_setting.GetDefaultModelRatioMap()[modelName]
defaultPrice, ok := ratio_setting.GetDefaultModelRatioMap()[modelName]
if !ok {
modelPrice = 0.1
} else {
modelPrice = defaultPrice
}
}
groupRatio := setting.GetGroupRatio(group)
groupRatio := ratio_setting.GetGroupRatio(group)
ratio := modelPrice * groupRatio
userQuota, err := model.GetUserQuota(userId, false)
if err != nil {
@@ -480,17 +480,17 @@ func RelayMidjourneySubmit(c *gin.Context, relayMode int) *dto.MidjourneyRespons
fullRequestURL := fmt.Sprintf("%s%s", baseURL, requestURL)
modelName := service.CoverActionToModelName(midjRequest.Action)
modelPrice, success := operation_setting.GetModelPrice(modelName, true)
modelPrice, success := ratio_setting.GetModelPrice(modelName, true)
// 如果没有配置价格,则使用默认价格
if !success {
defaultPrice, ok := operation_setting.GetDefaultModelRatioMap()[modelName]
defaultPrice, ok := ratio_setting.GetDefaultModelRatioMap()[modelName]
if !ok {
modelPrice = 0.1
} else {
modelPrice = defaultPrice
}
}
groupRatio := setting.GetGroupRatio(group)
groupRatio := ratio_setting.GetGroupRatio(group)
ratio := modelPrice * groupRatio
userQuota, err := model.GetUserQuota(userId, false)
if err != nil {
+6 -6
View File
@@ -90,15 +90,16 @@ func TextHelper(c *gin.Context) (openaiErr *dto.OpenAIErrorWithStatusCode) {
// get & validate textRequest 获取并验证文本请求
textRequest, err := getAndValidateTextRequest(c, relayInfo)
if textRequest.WebSearchOptions != nil {
c.Set("chat_completion_web_search_context_size", textRequest.WebSearchOptions.SearchContextSize)
}
if err != nil {
common.LogError(c, fmt.Sprintf("getAndValidateTextRequest failed: %s", err.Error()))
return service.OpenAIErrorWrapperLocal(err, "invalid_text_request", http.StatusBadRequest)
}
if textRequest.WebSearchOptions != nil {
c.Set("chat_completion_web_search_context_size", textRequest.WebSearchOptions.SearchContextSize)
}
if setting.ShouldCheckPromptSensitive() {
words, err := checkRequestSensitive(textRequest, relayInfo)
if err != nil {
@@ -361,9 +362,8 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo,
cacheRatio := priceData.CacheRatio
imageRatio := priceData.ImageRatio
modelRatio := priceData.ModelRatio
groupRatio := priceData.GroupRatio
groupRatio := priceData.GroupRatioInfo.GroupRatio
modelPrice := priceData.ModelPrice
userGroupRatio := priceData.UserGroupRatio
// Convert values to decimal for precise calculation
dPromptTokens := decimal.NewFromInt(int64(promptTokens))
@@ -511,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, userGroupRatio)
other := service.GenerateTextOtherInfo(ctx, relayInfo, modelRatio, groupRatio, completionRatio, cacheTokens, cacheRatio, modelPrice, priceData.GroupRatioInfo.GroupSpecialRatio)
if imageTokens != 0 {
other["image"] = true
other["image_ratio"] = imageRatio
+4 -5
View File
@@ -15,8 +15,7 @@ import (
relaycommon "one-api/relay/common"
relayconstant "one-api/relay/constant"
"one-api/service"
"one-api/setting"
"one-api/setting/operation_setting"
"one-api/setting/ratio_setting"
)
/*
@@ -38,9 +37,9 @@ func RelayTaskSubmit(c *gin.Context, relayMode int) (taskErr *dto.TaskError) {
}
modelName := service.CoverTaskActionToModelName(platform, relayInfo.Action)
modelPrice, success := operation_setting.GetModelPrice(modelName, true)
modelPrice, success := ratio_setting.GetModelPrice(modelName, true)
if !success {
defaultPrice, ok := operation_setting.GetDefaultModelRatioMap()[modelName]
defaultPrice, ok := ratio_setting.GetDefaultModelRatioMap()[modelName]
if !ok {
modelPrice = 0.1
} else {
@@ -49,7 +48,7 @@ func RelayTaskSubmit(c *gin.Context, relayMode int) (taskErr *dto.TaskError) {
}
// 预扣
groupRatio := setting.GetGroupRatio(relayInfo.Group)
groupRatio := ratio_setting.GetGroupRatio(relayInfo.Group)
ratio := modelPrice * groupRatio
userQuota, err := model.GetUserQuota(relayInfo.UserId, false)
if err != nil {
+6 -37
View File
@@ -6,12 +6,10 @@ import (
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"net/http"
"one-api/common"
"one-api/dto"
relaycommon "one-api/relay/common"
"one-api/relay/helper"
"one-api/service"
"one-api/setting"
"one-api/setting/operation_setting"
)
func WssHelper(c *gin.Context, ws *websocket.Conn) (openaiErr *dto.OpenAIErrorWithStatusCode) {
@@ -39,43 +37,14 @@ func WssHelper(c *gin.Context, ws *websocket.Conn) (openaiErr *dto.OpenAIErrorWi
//isModelMapped = true
}
}
//relayInfo.UpstreamModelName = textRequest.Model
modelPrice, getModelPriceSuccess := operation_setting.GetModelPrice(relayInfo.UpstreamModelName, false)
groupRatio := setting.GetGroupRatio(relayInfo.Group)
var preConsumedQuota int
var ratio float64
var modelRatio float64
//err := service.SensitiveWordsCheck(textRequest)
//if constant.ShouldCheckPromptSensitive() {
// err = checkRequestSensitive(textRequest, relayInfo)
// if err != nil {
// return service.OpenAIErrorWrapperLocal(err, "sensitive_words_detected", http.StatusBadRequest)
// }
//}
//promptTokens, err := getWssPromptTokens(realtimeEvent, relayInfo)
//// count messages token error 计算promptTokens错误
//if err != nil {
// return service.OpenAIErrorWrapper(err, "count_token_messages_failed", http.StatusInternalServerError)
//}
//
if !getModelPriceSuccess {
preConsumedTokens := common.PreConsumedQuota
//if realtimeEvent.Session.MaxResponseOutputTokens != 0 {
// preConsumedTokens = promptTokens + int(realtimeEvent.Session.MaxResponseOutputTokens)
//}
modelRatio, _ = operation_setting.GetModelRatio(relayInfo.UpstreamModelName)
ratio = modelRatio * groupRatio
preConsumedQuota = int(float64(preConsumedTokens) * ratio)
} else {
preConsumedQuota = int(modelPrice * common.QuotaPerUnit * groupRatio)
relayInfo.UsePrice = true
priceData, err := helper.ModelPriceHelper(c, relayInfo, 0, 0)
if err != nil {
return service.OpenAIErrorWrapperLocal(err, "model_price_error", http.StatusInternalServerError)
}
// pre-consume quota 预消耗配额
preConsumedQuota, userQuota, openaiErr := preConsumeQuota(c, preConsumedQuota, relayInfo)
preConsumedQuota, userQuota, openaiErr := preConsumeQuota(c, priceData.ShouldPreConsumedQuota, relayInfo)
if openaiErr != nil {
return openaiErr
}
@@ -113,6 +82,6 @@ func WssHelper(c *gin.Context, ws *websocket.Conn) (openaiErr *dto.OpenAIErrorWi
return openaiErr
}
service.PostWssConsumeQuota(c, relayInfo, relayInfo.UpstreamModelName, usage.(*dto.RealtimeUsage), preConsumedQuota,
userQuota, modelRatio, groupRatio, modelPrice, getModelPriceSuccess, "")
userQuota, priceData, "")
return nil
}
+2
View File
@@ -81,6 +81,7 @@ func SetApiRouter(router *gin.Engine) {
optionRoute.GET("/", controller.GetOptions)
optionRoute.PUT("/", controller.UpdateOption)
optionRoute.POST("/rest_model_ratio", controller.ResetModelRatio)
optionRoute.POST("/migrate_console_setting", controller.MigrateConsoleSetting) // 用于迁移检测的旧键,下个版本会删除
}
channelRoute := apiRouter.Group("/channel")
channelRoute.Use(middleware.AdminAuth())
@@ -126,6 +127,7 @@ func SetApiRouter(router *gin.Engine) {
redemptionRoute.GET("/:id", controller.GetRedemption)
redemptionRoute.POST("/", controller.AddRedemption)
redemptionRoute.PUT("/", controller.UpdateRedemption)
redemptionRoute.DELETE("/invalid", controller.DeleteInvalidRedemption)
redemptionRoute.DELETE("/:id", controller.DeleteRedemption)
}
logRoute := apiRouter.Group("/log")
+2
View File
@@ -59,6 +59,8 @@ func ShouldDisableChannel(channelType int, err *dto.OpenAIErrorWithStatusCode) b
return true
case "billing_not_active":
return true
case "pre_consume_token_quota_failed":
return true
}
switch err.Error.Type {
case "insufficient_quota":
+2 -2
View File
@@ -21,10 +21,10 @@ func ClaudeToOpenAIRequest(claudeRequest dto.ClaudeRequest, info *relaycommon.Re
isOpenRouter := info.ChannelType == common.ChannelTypeOpenRouter
if claudeRequest.Thinking != nil {
if claudeRequest.Thinking != nil && claudeRequest.Thinking.Type == "enabled" {
if isOpenRouter {
reasoning := openrouter.RequestReasoning{
MaxTokens: claudeRequest.Thinking.BudgetTokens,
MaxTokens: claudeRequest.Thinking.GetBudgetTokens(),
}
reasoningJSON, err := json.Marshal(reasoning)
if err != nil {
+10 -6
View File
@@ -29,9 +29,11 @@ func MidjourneyErrorWithStatusCodeWrapper(code int, desc string, statusCode int)
func OpenAIErrorWrapper(err error, code string, statusCode int) *dto.OpenAIErrorWithStatusCode {
text := err.Error()
lowerText := strings.ToLower(text)
if strings.Contains(lowerText, "post") || strings.Contains(lowerText, "dial") || strings.Contains(lowerText, "http") {
common.SysLog(fmt.Sprintf("error: %s", text))
text = "请求上游地址失败"
if !strings.HasPrefix(lowerText, "get file base64 from url") && !strings.HasPrefix(lowerText, "mime type is not supported") {
if strings.Contains(lowerText, "post") || strings.Contains(lowerText, "dial") || strings.Contains(lowerText, "http") {
common.SysLog(fmt.Sprintf("error: %s", text))
text = "请求上游地址失败"
}
}
openAIError := dto.OpenAIError{
Message: text,
@@ -53,9 +55,11 @@ func OpenAIErrorWrapperLocal(err error, code string, statusCode int) *dto.OpenAI
func ClaudeErrorWrapper(err error, code string, statusCode int) *dto.ClaudeErrorWithStatusCode {
text := err.Error()
lowerText := strings.ToLower(text)
if strings.Contains(lowerText, "post") || strings.Contains(lowerText, "dial") || strings.Contains(lowerText, "http") {
common.SysLog(fmt.Sprintf("error: %s", text))
text = "请求上游地址失败"
if !strings.HasPrefix(lowerText, "get file base64 from url") {
if strings.Contains(lowerText, "post") || strings.Contains(lowerText, "dial") || strings.Contains(lowerText, "http") {
common.SysLog(fmt.Sprintf("error: %s", text))
text = "请求上游地址失败"
}
}
claudeError := dto.ClaudeError{
Message: text,
+98 -1
View File
@@ -4,8 +4,10 @@ import (
"encoding/base64"
"fmt"
"io"
"one-api/common"
"one-api/constant"
"one-api/dto"
"strings"
)
func GetFileBase64FromUrl(url string) (*dto.LocalFileData, error) {
@@ -30,9 +32,104 @@ func GetFileBase64FromUrl(url string) (*dto.LocalFileData, error) {
// Convert to base64
base64Data := base64.StdEncoding.EncodeToString(fileBytes)
mimeType := resp.Header.Get("Content-Type")
if len(strings.Split(mimeType, ";")) > 1 {
// If Content-Type has parameters, take the first part
mimeType = strings.Split(mimeType, ";")[0]
}
if mimeType == "application/octet-stream" {
if common.DebugEnabled {
println("MIME type is application/octet-stream, trying to guess from URL or filename")
}
// try to guess the MIME type from the url last segment
urlParts := strings.Split(url, "/")
if len(urlParts) > 0 {
lastSegment := urlParts[len(urlParts)-1]
if strings.Contains(lastSegment, ".") {
// Extract the file extension
filename := strings.Split(lastSegment, ".")
if len(filename) > 1 {
ext := strings.ToLower(filename[len(filename)-1])
// Guess MIME type based on file extension
mimeType = GetMimeTypeByExtension(ext)
}
}
} else {
// try to guess the MIME type from the file extension
fileName := resp.Header.Get("Content-Disposition")
if fileName != "" {
// Extract the filename from the Content-Disposition header
parts := strings.Split(fileName, ";")
for _, part := range parts {
if strings.HasPrefix(strings.TrimSpace(part), "filename=") {
fileName = strings.TrimSpace(strings.TrimPrefix(part, "filename="))
// Remove quotes if present
if len(fileName) > 2 && fileName[0] == '"' && fileName[len(fileName)-1] == '"' {
fileName = fileName[1 : len(fileName)-1]
}
// Guess MIME type based on file extension
if ext := strings.ToLower(strings.TrimPrefix(fileName, ".")); ext != "" {
mimeType = GetMimeTypeByExtension(ext)
}
break
}
}
}
}
}
return &dto.LocalFileData{
Base64Data: base64Data,
MimeType: resp.Header.Get("Content-Type"),
MimeType: mimeType,
Size: int64(len(fileBytes)),
}, nil
}
func GetMimeTypeByExtension(ext string) string {
// Convert to lowercase for case-insensitive comparison
ext = strings.ToLower(ext)
switch ext {
// Text files
case "txt", "md", "markdown", "csv", "json", "xml", "html", "htm":
return "text/plain"
// Image files
case "jpg", "jpeg":
return "image/jpeg"
case "png":
return "image/png"
case "gif":
return "image/gif"
// Audio files
case "mp3":
return "audio/mp3"
case "wav":
return "audio/wav"
case "mpeg":
return "audio/mpeg"
// Video files
case "mp4":
return "video/mp4"
case "wmv":
return "video/wmv"
case "flv":
return "video/flv"
case "mov":
return "video/mov"
case "mpg":
return "video/mpg"
case "avi":
return "video/avi"
case "mpegps":
return "video/mpegps"
// Document files
case "pdf":
return "application/pdf"
default:
return "application/octet-stream" // Default for unknown types
}
}
+39 -37
View File
@@ -3,6 +3,7 @@ package service
import (
"errors"
"fmt"
"log"
"one-api/common"
constant2 "one-api/constant"
"one-api/dto"
@@ -10,7 +11,7 @@ import (
relaycommon "one-api/relay/common"
"one-api/relay/helper"
"one-api/setting"
"one-api/setting/operation_setting"
"one-api/setting/ratio_setting"
"strings"
"time"
@@ -45,9 +46,9 @@ func calculateAudioQuota(info QuotaInfo) int {
return int(quota.IntPart())
}
completionRatio := decimal.NewFromFloat(operation_setting.GetCompletionRatio(info.ModelName))
audioRatio := decimal.NewFromFloat(operation_setting.GetAudioRatio(info.ModelName))
audioCompletionRatio := decimal.NewFromFloat(operation_setting.GetAudioCompletionRatio(info.ModelName))
completionRatio := decimal.NewFromFloat(ratio_setting.GetCompletionRatio(info.ModelName))
audioRatio := decimal.NewFromFloat(ratio_setting.GetAudioRatio(info.ModelName))
audioCompletionRatio := decimal.NewFromFloat(ratio_setting.GetAudioCompletionRatio(info.ModelName))
groupRatio := decimal.NewFromFloat(info.GroupRatio)
modelRatio := decimal.NewFromFloat(info.ModelRatio)
@@ -93,12 +94,21 @@ func PreWssConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usag
textOutTokens := usage.OutputTokenDetails.TextTokens
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
groupRatio := ratio_setting.GetGroupRatio(relayInfo.Group)
modelRatio, _ := ratio_setting.GetModelRatio(modelName)
autoGroup, exists := ctx.Get("auto_group")
if exists {
groupRatio = ratio_setting.GetGroupRatio(autoGroup.(string))
log.Printf("final group ratio: %f", groupRatio)
relayInfo.Group = autoGroup.(string)
}
actualGroupRatio := groupRatio
userGroupRatio, ok := ratio_setting.GetGroupGroupRatio(relayInfo.UserGroup, relayInfo.Group)
if ok {
actualGroupRatio = userGroupRatio
}
modelRatio, _ := operation_setting.GetModelRatio(modelName)
quotaInfo := QuotaInfo{
InputDetails: TokenDetails{
@@ -112,7 +122,7 @@ func PreWssConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usag
ModelName: modelName,
UsePrice: relayInfo.UsePrice,
ModelRatio: modelRatio,
GroupRatio: groupRatio,
GroupRatio: actualGroupRatio,
}
quota := calculateAudioQuota(quotaInfo)
@@ -134,8 +144,7 @@ func PreWssConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usag
}
func PostWssConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, modelName string,
usage *dto.RealtimeUsage, preConsumedQuota int, userQuota int, modelRatio float64, groupRatio float64,
modelPrice float64, usePrice bool, extraContent string) {
usage *dto.RealtimeUsage, preConsumedQuota int, userQuota int, priceData helper.PriceData, extraContent string) {
useTimeSeconds := time.Now().Unix() - relayInfo.StartTime.Unix()
textInputTokens := usage.InputTokenDetails.TextTokens
@@ -145,15 +154,15 @@ func PostWssConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, mod
audioOutTokens := usage.OutputTokenDetails.AudioTokens
tokenName := ctx.GetString("token_name")
completionRatio := decimal.NewFromFloat(operation_setting.GetCompletionRatio(modelName))
audioRatio := decimal.NewFromFloat(operation_setting.GetAudioRatio(relayInfo.OriginModelName))
audioCompletionRatio := decimal.NewFromFloat(operation_setting.GetAudioCompletionRatio(modelName))
completionRatio := decimal.NewFromFloat(ratio_setting.GetCompletionRatio(modelName))
audioRatio := decimal.NewFromFloat(ratio_setting.GetAudioRatio(relayInfo.OriginModelName))
audioCompletionRatio := decimal.NewFromFloat(ratio_setting.GetAudioCompletionRatio(modelName))
modelRatio := priceData.ModelRatio
groupRatio := priceData.GroupRatioInfo.GroupRatio
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,
@@ -166,7 +175,7 @@ func PostWssConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, mod
ModelName: modelName,
UsePrice: usePrice,
ModelRatio: modelRatio,
GroupRatio: actualGroupRatio,
GroupRatio: groupRatio,
}
quota := calculateAudioQuota(quotaInfo)
@@ -198,7 +207,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, userGroupRatio)
completionRatio.InexactFloat64(), audioRatio.InexactFloat64(), audioCompletionRatio.InexactFloat64(), modelPrice, priceData.GroupRatioInfo.GroupSpecialRatio)
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)
}
@@ -214,9 +223,8 @@ func PostClaudeConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo,
tokenName := ctx.GetString("token_name")
completionRatio := priceData.CompletionRatio
modelRatio := priceData.ModelRatio
groupRatio := priceData.GroupRatio
groupRatio := priceData.GroupRatioInfo.GroupRatio
modelPrice := priceData.ModelPrice
userGroupRatio := priceData.UserGroupRatio
cacheRatio := priceData.CacheRatio
cacheTokens := usage.PromptTokensDetails.CachedTokens
@@ -265,7 +273,7 @@ func PostClaudeConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo,
}
other := GenerateClaudeOtherInfo(ctx, relayInfo, modelRatio, groupRatio, completionRatio,
cacheTokens, cacheRatio, cacheCreationTokens, cacheCreationRatio, modelPrice, userGroupRatio)
cacheTokens, cacheRatio, cacheCreationTokens, cacheCreationRatio, modelPrice, priceData.GroupRatioInfo.GroupSpecialRatio)
model.RecordConsumeLog(ctx, relayInfo.UserId, relayInfo.ChannelId, promptTokens, completionTokens, modelName,
tokenName, quota, logContent, relayInfo.TokenId, userQuota, int(useTimeSeconds), relayInfo.IsStream, relayInfo.Group, other)
}
@@ -281,21 +289,15 @@ func PostAudioConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo,
audioOutTokens := usage.CompletionTokenDetails.AudioTokens
tokenName := ctx.GetString("token_name")
completionRatio := decimal.NewFromFloat(operation_setting.GetCompletionRatio(relayInfo.OriginModelName))
audioRatio := decimal.NewFromFloat(operation_setting.GetAudioRatio(relayInfo.OriginModelName))
audioCompletionRatio := decimal.NewFromFloat(operation_setting.GetAudioCompletionRatio(relayInfo.OriginModelName))
completionRatio := decimal.NewFromFloat(ratio_setting.GetCompletionRatio(relayInfo.OriginModelName))
audioRatio := decimal.NewFromFloat(ratio_setting.GetAudioRatio(relayInfo.OriginModelName))
audioCompletionRatio := decimal.NewFromFloat(ratio_setting.GetAudioCompletionRatio(relayInfo.OriginModelName))
modelRatio := priceData.ModelRatio
groupRatio := priceData.GroupRatio
groupRatio := priceData.GroupRatioInfo.GroupRatio
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,
@@ -308,7 +310,7 @@ func PostAudioConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo,
ModelName: relayInfo.OriginModelName,
UsePrice: usePrice,
ModelRatio: modelRatio,
GroupRatio: actualGroupRatio,
GroupRatio: groupRatio,
}
quota := calculateAudioQuota(quotaInfo)
@@ -348,7 +350,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, userGroupRatio)
completionRatio.InexactFloat64(), audioRatio.InexactFloat64(), audioCompletionRatio.InexactFloat64(), modelPrice, priceData.GroupRatioInfo.GroupSpecialRatio)
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)
}
+31
View File
@@ -0,0 +1,31 @@
package setting
import "encoding/json"
var AutoGroups = []string{
"default",
}
var DefaultUseAutoGroup = false
func ContainsAutoGroup(group string) bool {
for _, autoGroup := range AutoGroups {
if autoGroup == group {
return true
}
}
return false
}
func UpdateAutoGroupsByJsonString(jsonString string) error {
AutoGroups = make([]string, 0)
return json.Unmarshal([]byte(jsonString), &AutoGroups)
}
func AutoGroups2JsonString() string {
jsonBytes, err := json.Marshal(AutoGroups)
if err != nil {
return "[]"
}
return string(jsonBytes)
}
-327
View File
@@ -1,327 +0,0 @@
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
}
+39
View File
@@ -0,0 +1,39 @@
package console_setting
import "one-api/setting/config"
type ConsoleSetting struct {
ApiInfo string `json:"api_info"` // 控制台 API 信息 (JSON 数组字符串)
UptimeKumaGroups string `json:"uptime_kuma_groups"` // Uptime Kuma 分组配置 (JSON 数组字符串)
Announcements string `json:"announcements"` // 系统公告 (JSON 数组字符串)
FAQ string `json:"faq"` // 常见问题 (JSON 数组字符串)
ApiInfoEnabled bool `json:"api_info_enabled"` // 是否启用 API 信息面板
UptimeKumaEnabled bool `json:"uptime_kuma_enabled"` // 是否启用 Uptime Kuma 面板
AnnouncementsEnabled bool `json:"announcements_enabled"` // 是否启用系统公告面板
FAQEnabled bool `json:"faq_enabled"` // 是否启用常见问答面板
}
// 默认配置
var defaultConsoleSetting = ConsoleSetting{
ApiInfo: "",
UptimeKumaGroups: "",
Announcements: "",
FAQ: "",
ApiInfoEnabled: true,
UptimeKumaEnabled: true,
AnnouncementsEnabled: true,
FAQEnabled: true,
}
// 全局实例
var consoleSetting = defaultConsoleSetting
func init() {
// 注册到全局配置管理器,键名为 console_setting
config.GlobalConfig.Register("console_setting", &consoleSetting)
}
// GetConsoleSetting 获取 ConsoleSetting 配置实例
func GetConsoleSetting() *ConsoleSetting {
return &consoleSetting
}
+304
View File
@@ -0,0 +1,304 @@
package console_setting
import (
"encoding/json"
"fmt"
"net/url"
"regexp"
"strings"
"time"
"sort"
)
var (
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})?(?:/.*)?$`)
dangerousChars = []string{"<script", "<iframe", "javascript:", "onload=", "onerror=", "onclick="}
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,
}
slugRegex = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`)
)
func parseJSONArray(jsonStr string, typeName string) ([]map[string]interface{}, error) {
var list []map[string]interface{}
if err := json.Unmarshal([]byte(jsonStr), &list); err != nil {
return nil, fmt.Errorf("%s格式错误:%s", typeName, err.Error())
}
return list, nil
}
func validateURL(urlStr string, index int, itemType string) error {
if !urlRegex.MatchString(urlStr) {
return fmt.Errorf("第%d个%s的URL格式不正确", index, itemType)
}
if _, err := url.Parse(urlStr); err != nil {
return fmt.Errorf("第%d个%s的URL无法解析:%s", index, itemType, err.Error())
}
return nil
}
func checkDangerousContent(content string, index int, itemType string) error {
lower := strings.ToLower(content)
for _, d := range dangerousChars {
if strings.Contains(lower, d) {
return fmt.Errorf("第%d个%s包含不允许的内容", index, itemType)
}
}
return nil
}
func getJSONList(jsonStr string) []map[string]interface{} {
if jsonStr == "" {
return []map[string]interface{}{}
}
var list []map[string]interface{}
json.Unmarshal([]byte(jsonStr), &list)
return list
}
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)
case "UptimeKumaGroups":
return validateUptimeKumaGroups(settingsStr)
default:
return fmt.Errorf("未知的设置类型:%s", settingType)
}
}
func validateApiInfo(apiInfoStr string) error {
apiInfoList, err := parseJSONArray(apiInfoStr, "API信息")
if err != nil {
return err
}
if len(apiInfoList) > 50 {
return fmt.Errorf("API信息数量不能超过50个")
}
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)
}
if err := validateURL(urlStr, i+1, "API信息"); err != nil {
return err
}
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)
}
if err := checkDangerousContent(description, i+1, "API信息"); err != nil {
return err
}
if err := checkDangerousContent(route, i+1, "API信息"); err != nil {
return err
}
}
return nil
}
func GetApiInfo() []map[string]interface{} {
return getJSONList(GetConsoleSetting().ApiInfo)
}
func validateAnnouncements(announcementsStr string) error {
list, err := parseJSONArray(announcementsStr, "系统公告")
if err != nil {
return err
}
if len(list) > 100 {
return fmt.Errorf("系统公告数量不能超过100个")
}
validTypes := map[string]bool{
"default": true, "ongoing": true, "success": true, "warning": true, "error": true,
}
for i, ann := range list {
content, ok := ann["content"].(string)
if !ok || content == "" {
return fmt.Errorf("第%d个公告缺少内容字段", i+1)
}
publishDateAny, exists := ann["publishDate"]
if !exists {
return fmt.Errorf("第%d个公告缺少发布日期字段", i+1)
}
publishDateStr, ok := publishDateAny.(string)
if !ok || publishDateStr == "" {
return fmt.Errorf("第%d个公告的发布日期不能为空", i+1)
}
if _, err := time.Parse(time.RFC3339, publishDateStr); err != nil {
return fmt.Errorf("第%d个公告的发布日期格式错误", i+1)
}
if t, exists := ann["type"]; exists {
if typeStr, ok := t.(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 := ann["extra"]; exists {
if extraStr, ok := extra.(string); ok && len(extraStr) > 200 {
return fmt.Errorf("第%d个公告的说明长度不能超过200字符", i+1)
}
}
}
return nil
}
func validateFAQ(faqStr string) error {
list, err := parseJSONArray(faqStr, "FAQ信息")
if err != nil {
return err
}
if len(list) > 100 {
return fmt.Errorf("FAQ数量不能超过100个")
}
for i, faq := range list {
question, ok := faq["question"].(string)
if !ok || question == "" {
return fmt.Errorf("第%d个FAQ缺少问题字段", i+1)
}
answer, ok := faq["answer"].(string)
if !ok || answer == "" {
return fmt.Errorf("第%d个FAQ缺少答案字段", i+1)
}
if len(question) > 200 {
return fmt.Errorf("第%d个FAQ的问题长度不能超过200字符", i+1)
}
if len(answer) > 1000 {
return fmt.Errorf("第%d个FAQ的答案长度不能超过1000字符", i+1)
}
}
return nil
}
func getPublishTime(item map[string]interface{}) time.Time {
if v, ok := item["publishDate"]; ok {
if s, ok2 := v.(string); ok2 {
if t, err := time.Parse(time.RFC3339, s); err == nil {
return t
}
}
}
return time.Time{}
}
func GetAnnouncements() []map[string]interface{} {
list := getJSONList(GetConsoleSetting().Announcements)
sort.SliceStable(list, func(i, j int) bool {
return getPublishTime(list[i]).After(getPublishTime(list[j]))
})
return list
}
func GetFAQ() []map[string]interface{} {
return getJSONList(GetConsoleSetting().FAQ)
}
func validateUptimeKumaGroups(groupsStr string) error {
groups, err := parseJSONArray(groupsStr, "Uptime Kuma分组配置")
if err != nil {
return err
}
if len(groups) > 20 {
return fmt.Errorf("Uptime Kuma分组数量不能超过20个")
}
nameSet := make(map[string]bool)
for i, group := range groups {
categoryName, ok := group["categoryName"].(string)
if !ok || categoryName == "" {
return fmt.Errorf("第%d个分组缺少分类名称字段", i+1)
}
if nameSet[categoryName] {
return fmt.Errorf("第%d个分组的分类名称与其他分组重复", i+1)
}
nameSet[categoryName] = true
urlStr, ok := group["url"].(string)
if !ok || urlStr == "" {
return fmt.Errorf("第%d个分组缺少URL字段", i+1)
}
slug, ok := group["slug"].(string)
if !ok || slug == "" {
return fmt.Errorf("第%d个分组缺少Slug字段", i+1)
}
description, ok := group["description"].(string)
if !ok {
description = ""
}
if err := validateURL(urlStr, i+1, "分组"); err != nil {
return err
}
if len(categoryName) > 50 {
return fmt.Errorf("第%d个分组的分类名称长度不能超过50字符", i+1)
}
if len(urlStr) > 500 {
return fmt.Errorf("第%d个分组的URL长度不能超过500字符", i+1)
}
if len(slug) > 100 {
return fmt.Errorf("第%d个分组的Slug长度不能超过100字符", i+1)
}
if len(description) > 200 {
return fmt.Errorf("第%d个分组的描述长度不能超过200字符", i+1)
}
if !slugRegex.MatchString(slug) {
return fmt.Errorf("第%d个分组的Slug只能包含字母、数字、下划线和连字符", i+1)
}
if err := checkDangerousContent(description, i+1, "分组"); err != nil {
return err
}
if err := checkDangerousContent(categoryName, i+1, "分组"); err != nil {
return err
}
}
return nil
}
func GetUptimeKumaGroups() []map[string]interface{} {
return getJSONList(GetConsoleSetting().UptimeKumaGroups)
}
+37
View File
@@ -1,8 +1,45 @@
package setting
import "encoding/json"
var PayAddress = ""
var CustomCallbackAddress = ""
var EpayId = ""
var EpayKey = ""
var Price = 7.3
var MinTopUp = 1
var PayMethods = []map[string]string{
{
"name": "支付宝",
"color": "rgba(var(--semi-blue-5), 1)",
"type": "zfb",
},
{
"name": "微信",
"color": "rgba(var(--semi-green-5), 1)",
"type": "wx",
},
}
func UpdatePayMethodsByJsonString(jsonString string) error {
PayMethods = make([]map[string]string, 0)
return json.Unmarshal([]byte(jsonString), &PayMethods)
}
func PayMethods2JsonString() string {
jsonBytes, err := json.Marshal(PayMethods)
if err != nil {
return "[]"
}
return string(jsonBytes)
}
func ContainsPayMethod(method string) bool {
for _, payMethod := range PayMethods {
if payMethod["type"] == method {
return true
}
}
return false
}
@@ -1,4 +1,4 @@
package operation_setting
package ratio_setting
import (
"encoding/json"
@@ -1,4 +1,4 @@
package setting
package ratio_setting
import (
"encoding/json"
@@ -1,8 +1,9 @@
package operation_setting
package ratio_setting
import (
"encoding/json"
"one-api/common"
"one-api/setting/operation_setting"
"strings"
"sync"
)
@@ -142,6 +143,11 @@ var defaultModelRatio = map[string]float64{
"gemini-2.5-flash-preview-04-17": 0.075,
"gemini-2.5-flash-preview-04-17-thinking": 0.075,
"gemini-2.5-flash-preview-04-17-nothinking": 0.075,
"gemini-2.5-flash-preview-05-20": 0.075,
"gemini-2.5-flash-preview-05-20-thinking": 0.075,
"gemini-2.5-flash-preview-05-20-nothinking": 0.075,
"gemini-2.5-flash-thinking-*": 0.075, // 用于为后续所有2.5 flash thinking budget 模型设置默认倍率
"gemini-2.5-pro-thinking-*": 0.625, // 用于为后续所有2.5 pro thinking budget 模型设置默认倍率
"text-embedding-004": 0.001,
"chatglm_turbo": 0.3572, // ¥0.005 / 1k tokens
"chatglm_pro": 0.7143, // ¥0.01 / 1k tokens
@@ -342,16 +348,26 @@ func UpdateModelRatioByJSONString(jsonStr string) error {
return json.Unmarshal([]byte(jsonStr), &modelRatioMap)
}
// 处理带有思考预算的模型名称,方便统一定价
func handleThinkingBudgetModel(name, prefix, wildcard string) string {
if strings.HasPrefix(name, prefix) && strings.Contains(name, "-thinking-") {
return wildcard
}
return name
}
func GetModelRatio(name string) (float64, bool) {
modelRatioMapMutex.RLock()
defer modelRatioMapMutex.RUnlock()
name = handleThinkingBudgetModel(name, "gemini-2.5-flash", "gemini-2.5-flash-thinking-*")
name = handleThinkingBudgetModel(name, "gemini-2.5-pro", "gemini-2.5-pro-thinking-*")
if strings.HasPrefix(name, "gpt-4-gizmo") {
name = "gpt-4-gizmo-*"
}
ratio, ok := modelRatioMap[name]
if !ok {
return 37.5, SelfUseModeEnabled
return 37.5, operation_setting.SelfUseModeEnabled
}
return ratio, true
}
@@ -470,9 +486,9 @@ func getHardcodedCompletionModelRatio(name string) (float64, bool) {
return 4, true
} else if strings.HasPrefix(name, "gemini-2.0") {
return 4, true
} else if strings.HasPrefix(name, "gemini-2.5-pro-preview") {
} else if strings.HasPrefix(name, "gemini-2.5-pro") { // 移除preview来增加兼容性,这里假设正式版的倍率和preview一致
return 8, true
} else if strings.HasPrefix(name, "gemini-2.5-flash-preview") {
} else if strings.HasPrefix(name, "gemini-2.5-flash") { // 同上
if strings.HasSuffix(name, "-nothinking") {
return 4, false
} else {
+7
View File
@@ -50,3 +50,10 @@ func GroupInUserUsableGroups(groupName string) bool {
_, ok := userUsableGroups[groupName]
return ok
}
func GetUsableGroupDescription(groupName string) string {
if desc, ok := userUsableGroups[groupName]; ok {
return desc
}
return groupName
}
+1 -5
View File
@@ -64,11 +64,7 @@ const NoticeModal = ({ visible, onClose, isMobile }) => {
return (
<div
dangerouslySetInnerHTML={{ __html: noticeContent }}
className="max-h-[60vh] overflow-y-auto pr-2"
style={{
scrollbarWidth: 'thin',
scrollbarColor: 'var(--semi-color-tertiary) transparent'
}}
className="notice-content-scroll max-h-[60vh] overflow-y-auto pr-2"
/>
);
};
@@ -1,6 +1,6 @@
import React, { useEffect, useState } from 'react';
import { Card, Spin } from '@douyinfe/semi-ui';
import { API, showError } from '../../helpers';
import React, { useEffect, useState, useMemo } from 'react';
import { Card, Spin, Button, Modal } from '@douyinfe/semi-ui';
import { API, showError, showSuccess } 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';
@@ -8,6 +8,16 @@ import SettingsUptimeKuma from '../../pages/Setting/Dashboard/SettingsUptimeKuma
const DashboardSetting = () => {
let [inputs, setInputs] = useState({
'console_setting.api_info': '',
'console_setting.announcements': '',
'console_setting.faq': '',
'console_setting.uptime_kuma_groups': '',
'console_setting.api_info_enabled': '',
'console_setting.announcements_enabled': '',
'console_setting.faq_enabled': '',
'console_setting.uptime_kuma_enabled': '',
// 用于迁移检测的旧键,下个版本会删除
ApiInfo: '',
Announcements: '',
FAQ: '',
@@ -16,6 +26,7 @@ const DashboardSetting = () => {
});
let [loading, setLoading] = useState(false);
const [showMigrateModal, setShowMigrateModal] = useState(false); // 下个版本会删除
const getOptions = async () => {
const res = await API.get('/api/option/');
@@ -49,9 +60,52 @@ const DashboardSetting = () => {
onRefresh();
}, []);
// 用于迁移检测的旧键,下个版本会删除
const hasLegacyData = useMemo(() => {
const legacyKeys = ['ApiInfo', 'Announcements', 'FAQ', 'UptimeKumaUrl', 'UptimeKumaSlug'];
return legacyKeys.some(k => inputs[k]);
}, [inputs]);
useEffect(() => {
if (hasLegacyData) {
setShowMigrateModal(true);
}
}, [hasLegacyData]);
const handleMigrate = async () => {
try {
setLoading(true);
await API.post('/api/option/migrate_console_setting');
showSuccess('旧配置迁移完成');
await onRefresh();
setShowMigrateModal(false);
} catch (err) {
console.error(err);
showError('迁移失败: ' + (err.message || '未知错误'));
} finally {
setLoading(false);
}
};
return (
<>
<Spin spinning={loading} size='large'>
{/* 用于迁移检测的旧键模态框,下个版本会删除 */}
<Modal
title="配置迁移确认"
visible={showMigrateModal}
onOk={handleMigrate}
onCancel={() => setShowMigrateModal(false)}
confirmLoading={loading}
okText="确认迁移"
cancelText="取消"
>
<p>检测到旧版本的配置数据是否要迁移到新的配置格式</p>
<p style={{ color: '#f57c00', marginTop: '10px' }}>
<strong>注意</strong>
</p>
</Modal>
{/* API信息管理 */}
<Card style={{ marginTop: '10px' }}>
<SettingsAPIInfo options={inputs} refresh={onRefresh} />
+33 -65
View File
@@ -1,5 +1,5 @@
import React, { useEffect, useState } from 'react';
import { Card, Spin, Tabs } from '@douyinfe/semi-ui';
import { Card, Spin } from '@douyinfe/semi-ui';
import SettingsGeneral from '../../pages/Setting/Operation/SettingsGeneral.js';
import SettingsDrawing from '../../pages/Setting/Operation/SettingsDrawing.js';
import SettingsSensitiveWords from '../../pages/Setting/Operation/SettingsSensitiveWords.js';
@@ -7,61 +7,58 @@ import SettingsLog from '../../pages/Setting/Operation/SettingsLog.js';
import SettingsDataDashboard from '../../pages/Setting/Operation/SettingsDataDashboard.js';
import SettingsMonitoring from '../../pages/Setting/Operation/SettingsMonitoring.js';
import SettingsCreditLimit from '../../pages/Setting/Operation/SettingsCreditLimit.js';
import ModelSettingsVisualEditor from '../../pages/Setting/Operation/ModelSettingsVisualEditor.js';
import GroupRatioSettings from '../../pages/Setting/Operation/GroupRatioSettings.js';
import ModelRatioSettings from '../../pages/Setting/Operation/ModelRatioSettings.js';
import { API, showError, showSuccess } from '../../helpers';
import SettingsChats from '../../pages/Setting/Operation/SettingsChats.js';
import { useTranslation } from 'react-i18next';
import ModelRatioNotSetEditor from '../../pages/Setting/Operation/ModelRationNotSetEditor.js';
import { API, showError } from '../../helpers';
const OperationSetting = () => {
const { t } = useTranslation();
let [inputs, setInputs] = useState({
/* 额度相关 */
QuotaForNewUser: 0,
PreConsumedQuota: 0,
QuotaForInviter: 0,
QuotaForInvitee: 0,
QuotaRemindThreshold: 0,
PreConsumedQuota: 0,
StreamCacheQueueLength: 0,
ModelRatio: '',
CacheRatio: '',
CompletionRatio: '',
ModelPrice: '',
GroupRatio: '',
GroupGroupRatio: '',
UserUsableGroups: '',
/* 通用设置 */
TopUpLink: '',
'general_setting.docs_link': '',
// ChatLink2: '', // 添加的新状态变量
QuotaPerUnit: 0,
AutomaticDisableChannelEnabled: false,
AutomaticEnableChannelEnabled: false,
ChannelDisableThreshold: 0,
LogConsumeEnabled: false,
RetryTimes: 0,
DisplayInCurrencyEnabled: false,
DisplayTokenStatEnabled: false,
CheckSensitiveEnabled: false,
CheckSensitiveOnPromptEnabled: false,
CheckSensitiveOnCompletionEnabled: '',
StopOnSensitiveEnabled: '',
SensitiveWords: '',
DefaultCollapseSidebar: false,
DemoSiteEnabled: false,
SelfUseModeEnabled: false,
/* 绘图设置 */
DrawingEnabled: false,
MjNotifyEnabled: false,
MjAccountFilterEnabled: false,
MjModeClearEnabled: false,
MjForwardUrlEnabled: false,
MjModeClearEnabled: false,
MjActionCheckSuccessEnabled: false,
DrawingEnabled: false,
/* 敏感词设置 */
CheckSensitiveEnabled: false,
CheckSensitiveOnPromptEnabled: false,
SensitiveWords: '',
/* 日志设置 */
LogConsumeEnabled: false,
/* 数据看板 */
DataExportEnabled: false,
DataExportDefaultTime: 'hour',
DataExportInterval: 5,
DefaultCollapseSidebar: false, // 默认折叠侧边栏
RetryTimes: 0,
Chats: '[]',
DemoSiteEnabled: false,
SelfUseModeEnabled: false,
/* 监控设置 */
ChannelDisableThreshold: 0,
QuotaRemindThreshold: 0,
AutomaticDisableChannelEnabled: false,
AutomaticEnableChannelEnabled: false,
AutomaticDisableKeywords: '',
/* 聊天设置 */
Chats: '[]',
});
let [loading, setLoading] = useState(false);
@@ -72,17 +69,6 @@ const OperationSetting = () => {
if (success) {
let newInputs = {};
data.forEach((item) => {
if (
item.key === 'ModelRatio' ||
item.key === 'GroupRatio' ||
item.key === 'GroupGroupRatio' ||
item.key === 'UserUsableGroups' ||
item.key === 'CompletionRatio' ||
item.key === 'ModelPrice' ||
item.key === 'CacheRatio'
) {
item.value = JSON.stringify(JSON.parse(item.value), null, 2);
}
if (
item.key.endsWith('Enabled') ||
['DefaultCollapseSidebar'].includes(item.key)
@@ -149,24 +135,6 @@ const OperationSetting = () => {
<Card style={{ marginTop: '10px' }}>
<SettingsChats options={inputs} refresh={onRefresh} />
</Card>
{/* 分组倍率设置 */}
<Card style={{ marginTop: '10px' }}>
<GroupRatioSettings options={inputs} refresh={onRefresh} />
</Card>
{/* 合并模型倍率设置和可视化倍率设置 */}
<Card style={{ marginTop: '10px' }}>
<Tabs type='line'>
<Tabs.TabPane tab={t('模型倍率设置')} itemKey='model'>
<ModelRatioSettings options={inputs} refresh={onRefresh} />
</Tabs.TabPane>
<Tabs.TabPane tab={t('可视化倍率设置')} itemKey='visual'>
<ModelSettingsVisualEditor options={inputs} refresh={onRefresh} />
</Tabs.TabPane>
<Tabs.TabPane tab={t('未设置倍率模型')} itemKey='unset_models'>
<ModelRatioNotSetEditor options={inputs} refresh={onRefresh} />
</Tabs.TabPane>
</Tabs>
</Card>
</Spin>
</>
);
+109
View File
@@ -0,0 +1,109 @@
import React, { useEffect, useState } from 'react';
import { Card, Spin, Tabs } from '@douyinfe/semi-ui';
import { useTranslation } from 'react-i18next';
import GroupRatioSettings from '../../pages/Setting/Ratio/GroupRatioSettings.js';
import ModelRatioSettings from '../../pages/Setting/Ratio/ModelRatioSettings.js';
import ModelSettingsVisualEditor from '../../pages/Setting/Ratio/ModelSettingsVisualEditor.js';
import ModelRatioNotSetEditor from '../../pages/Setting/Ratio/ModelRationNotSetEditor.js';
import { API, showError } from '../../helpers';
const RatioSetting = () => {
const { t } = useTranslation();
let [inputs, setInputs] = useState({
ModelPrice: '',
ModelRatio: '',
CacheRatio: '',
CompletionRatio: '',
GroupRatio: '',
GroupGroupRatio: '',
AutoGroups: '',
DefaultUseAutoGroup: false,
UserUsableGroups: '',
});
const [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 === 'ModelRatio' ||
item.key === 'GroupRatio' ||
item.key === 'GroupGroupRatio' ||
item.key === 'AutoGroups' ||
item.key === 'UserUsableGroups' ||
item.key === 'CompletionRatio' ||
item.key === 'ModelPrice' ||
item.key === 'CacheRatio'
) {
try {
item.value = JSON.stringify(JSON.parse(item.value), null, 2);
} catch (e) {
// 如果后端返回的不是合法 JSON,直接展示
}
}
if (['DefaultUseAutoGroup'].includes(item.key)) {
newInputs[item.key] = item.value === 'true' ? true : false;
} else {
newInputs[item.key] = item.value;
}
});
setInputs(newInputs);
} else {
showError(message);
}
};
const onRefresh = async () => {
try {
setLoading(true);
await getOptions();
} catch (error) {
showError('刷新失败');
} finally {
setLoading(false);
}
};
useEffect(() => {
onRefresh();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<Spin spinning={loading} size='large'>
{/* 分组倍率设置 */}
<Card style={{ marginTop: '10px' }}>
<GroupRatioSettings options={inputs} refresh={onRefresh} />
</Card>
{/* 模型倍率设置以及可视化编辑器 */}
<Card style={{ marginTop: '10px' }}>
<Tabs type='line'>
<Tabs.TabPane tab={t('模型倍率设置')} itemKey='model'>
<ModelRatioSettings options={inputs} refresh={onRefresh} />
</Tabs.TabPane>
<Tabs.TabPane tab={t('可视化倍率设置')} itemKey='visual'>
<ModelSettingsVisualEditor
options={inputs}
refresh={onRefresh}
/>
</Tabs.TabPane>
<Tabs.TabPane tab={t('未设置倍率模型')} itemKey='unset_models'>
<ModelRatioNotSetEditor
options={inputs}
refresh={onRefresh}
/>
</Tabs.TabPane>
</Tabs>
</Card>
</Spin>
);
};
export default RatioSetting;
+17 -1
View File
@@ -17,7 +17,7 @@ import {
removeTrailingSlash,
showError,
showSuccess,
verifyJSON
verifyJSON,
} from '../../helpers';
import axios from 'axios';
@@ -73,6 +73,7 @@ const SystemSetting = () => {
LinuxDOOAuthEnabled: '',
LinuxDOClientId: '',
LinuxDOClientSecret: '',
PayMethods: '',
});
const [originInputs, setOriginInputs] = useState({});
@@ -230,6 +231,12 @@ const SystemSetting = () => {
return;
}
}
if (originInputs['PayMethods'] !== inputs.PayMethods) {
if (!verifyJSON(inputs.PayMethods)) {
showError('充值方式设置不是合法的 JSON 字符串');
return;
}
}
const options = [
{ key: 'PayAddress', value: removeTrailingSlash(inputs.PayAddress) },
@@ -256,6 +263,9 @@ const SystemSetting = () => {
if (originInputs['TopupGroupRatio'] !== inputs.TopupGroupRatio) {
options.push({ key: 'TopupGroupRatio', value: inputs.TopupGroupRatio });
}
if (originInputs['PayMethods'] !== inputs.PayMethods) {
options.push({ key: 'PayMethods', value: inputs.PayMethods });
}
await updateOptions(options);
};
@@ -658,6 +668,12 @@ const SystemSetting = () => {
placeholder='为一个 JSON 文本,键为组名称,值为倍率'
autosize
/>
<Form.TextArea
field='PayMethods'
label='充值方式设置'
placeholder='为一个 JSON 文本'
autosize
/>
<Button onClick={submitPayAddress}>更新支付设置</Button>
</Form.Section>
</Card>
+116 -22
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useState, useMemo, useRef } from 'react';
import {
API,
showError,
@@ -16,11 +16,6 @@ import {
XCircle,
AlertCircle,
HelpCircle,
TestTube,
Zap,
Timer,
Clock,
AlertTriangle,
Coins,
Tags
} from 'lucide-react';
@@ -43,7 +38,9 @@ import {
Typography,
Checkbox,
Card,
Form
Form,
Tabs,
TabPane
} from '@douyinfe/semi-ui';
import {
IllustrationNoResult,
@@ -141,31 +138,31 @@ const ChannelsTable = () => {
time = time.toFixed(2) + t(' 秒');
if (responseTime === 0) {
return (
<Tag size='large' color='grey' shape='circle' prefixIcon={<TestTube size={14} />}>
<Tag size='large' color='grey' shape='circle'>
{t('未测试')}
</Tag>
);
} else if (responseTime <= 1000) {
return (
<Tag size='large' color='green' shape='circle' prefixIcon={<Zap size={14} />}>
<Tag size='large' color='green' shape='circle'>
{time}
</Tag>
);
} else if (responseTime <= 3000) {
return (
<Tag size='large' color='lime' shape='circle' prefixIcon={<Timer size={14} />}>
<Tag size='large' color='lime' shape='circle'>
{time}
</Tag>
);
} else if (responseTime <= 5000) {
return (
<Tag size='large' color='yellow' shape='circle' prefixIcon={<Clock size={14} />}>
<Tag size='large' color='yellow' shape='circle'>
{time}
</Tag>
);
} else {
return (
<Tag size='large' color='red' shape='circle' prefixIcon={<AlertTriangle size={14} />}>
<Tag size='large' color='red' shape='circle'>
{time}
</Tag>
);
@@ -682,11 +679,10 @@ const ChannelsTable = () => {
const [isBatchTesting, setIsBatchTesting] = useState(false);
const [testQueue, setTestQueue] = useState([]);
const [isProcessingQueue, setIsProcessingQueue] = useState(false);
// Form API 引用
const [activeTypeKey, setActiveTypeKey] = useState('all');
const [typeCounts, setTypeCounts] = useState({});
const requestCounter = useRef(0);
const [formApi, setFormApi] = useState(null);
// Form 初始值
const formInitValues = {
searchKeyword: '',
searchGroup: '',
@@ -868,17 +864,23 @@ const ChannelsTable = () => {
setChannels(channelDates);
};
const loadChannels = async (page, pageSize, idSort, enableTagMode) => {
const loadChannels = async (page, pageSize, idSort, enableTagMode, typeKey = activeTypeKey) => {
const reqId = ++requestCounter.current; // 记录当前请求序号
setLoading(true);
const typeParam = (!enableTagMode && typeKey !== 'all') ? `&type=${typeKey}` : '';
const res = await API.get(
`/api/channel/?p=${page}&page_size=${pageSize}&id_sort=${idSort}&tag_mode=${enableTagMode}`,
`/api/channel/?p=${page}&page_size=${pageSize}&id_sort=${idSort}&tag_mode=${enableTagMode}${typeParam}`,
);
if (res === undefined) {
if (res === undefined || reqId !== requestCounter.current) {
return;
}
const { success, message, data } = res.data;
if (success) {
const { items, total } = data;
const { items, total, type_counts } = data;
if (type_counts) {
const sumAll = Object.values(type_counts).reduce((acc, v) => acc + v, 0);
setTypeCounts({ ...type_counts, all: sumAll });
}
setChannelFormat(items, enableTagMode);
setChannelCount(total);
} else {
@@ -1044,12 +1046,16 @@ const ChannelsTable = () => {
return;
}
const typeParam = (!enableTagMode && activeTypeKey !== 'all') ? `&type=${activeTypeKey}` : '';
const res = await API.get(
`/api/channel/search?keyword=${searchKeyword}&group=${searchGroup}&model=${searchModel}&id_sort=${idSort}&tag_mode=${enableTagMode}`,
`/api/channel/search?keyword=${searchKeyword}&group=${searchGroup}&model=${searchModel}&id_sort=${idSort}&tag_mode=${enableTagMode}${typeParam}`,
);
const { success, message, data } = res.data;
if (success) {
setChannelFormat(data, enableTagMode);
const { items = [], type_counts = {} } = data;
const sumAll = Object.values(type_counts).reduce((acc, v) => acc + v, 0);
setTypeCounts({ ...type_counts, all: sumAll });
setChannelFormat(items, enableTagMode);
setActivePage(1);
} else {
showError(message);
@@ -1179,7 +1185,94 @@ const ChannelsTable = () => {
}
};
const channelTypeCounts = useMemo(() => {
if (Object.keys(typeCounts).length > 0) return typeCounts;
// fallback 本地计算
const counts = { all: channels.length };
channels.forEach((channel) => {
const collect = (ch) => {
const type = ch.type;
counts[type] = (counts[type] || 0) + 1;
};
if (channel.children !== undefined) {
channel.children.forEach(collect);
} else {
collect(channel);
}
});
return counts;
}, [typeCounts, channels]);
const availableTypeKeys = useMemo(() => {
const keys = ['all'];
Object.entries(channelTypeCounts).forEach(([k, v]) => {
if (k !== 'all' && v > 0) keys.push(String(k));
});
return keys;
}, [channelTypeCounts]);
const renderTypeTabs = () => {
if (enableTagMode) return null;
return (
<Tabs
activeKey={activeTypeKey}
type="card"
collapsible
onChange={(key) => {
setActiveTypeKey(key);
setActivePage(1);
loadChannels(1, pageSize, idSort, enableTagMode, key);
}}
className="mb-4"
>
<TabPane
itemKey="all"
tab={
<span className="flex items-center gap-2">
{t('全部')}
<Tag color={activeTypeKey === 'all' ? 'red' : 'grey'} size='small' shape='circle'>
{channelTypeCounts['all'] || 0}
</Tag>
</span>
}
/>
{CHANNEL_OPTIONS.filter((opt) => availableTypeKeys.includes(String(opt.value))).map((option) => {
const key = String(option.value);
const count = channelTypeCounts[option.value] || 0;
return (
<TabPane
key={key}
itemKey={key}
tab={
<span className="flex items-center gap-2">
{getChannelIcon(option.value)}
{option.label}
<Tag color={activeTypeKey === key ? 'red' : 'grey'} size='small' shape='circle'>
{count}
</Tag>
</span>
}
/>
);
})}
</Tabs>
);
};
let pageData = channels;
if (activeTypeKey !== 'all') {
const typeVal = parseInt(activeTypeKey);
if (!isNaN(typeVal)) {
pageData = pageData.filter((ch) => {
if (ch.children !== undefined) {
return ch.children.some((c) => c.type === typeVal);
}
return ch.type === typeVal;
});
}
}
const handlePageChange = (page) => {
setActivePage(page);
@@ -1371,6 +1464,7 @@ const ChannelsTable = () => {
const renderHeader = () => (
<div className="flex flex-col w-full">
{renderTypeTabs()}
<div className="flex flex-col md:flex-row justify-between gap-4">
<div className="flex flex-wrap md:flex-nowrap items-center gap-2 w-full md:w-auto order-2 md:order-1">
<Button
+79 -36
View File
@@ -13,7 +13,8 @@ import {
XCircle,
Minus,
HelpCircle,
Coins
Coins,
Ticket
} from 'lucide-react';
import { ITEMS_PER_PAGE } from '../../constants';
@@ -58,7 +59,16 @@ function renderTimestamp(timestamp) {
const RedemptionsTable = () => {
const { t } = useTranslation();
const renderStatus = (status) => {
const isExpired = (rec) => {
return rec.status === 1 && rec.expired_time !== 0 && rec.expired_time < Math.floor(Date.now() / 1000);
};
const renderStatus = (status, record) => {
if (isExpired(record)) {
return (
<Tag color='orange' size='large' shape='circle' prefixIcon={<Minus size={14} />}>{t('已过期')}</Tag>
);
}
switch (status) {
case 1:
return (
@@ -101,7 +111,7 @@ const RedemptionsTable = () => {
dataIndex: 'status',
key: 'status',
render: (text, record, index) => {
return <div>{renderStatus(text)}</div>;
return <div>{renderStatus(text, record)}</div>;
},
},
{
@@ -124,6 +134,13 @@ const RedemptionsTable = () => {
return <div>{renderTimestamp(text)}</div>;
},
},
{
title: t('过期时间'),
dataIndex: 'expired_time',
render: (text) => {
return <div>{text === 0 ? t('永不过期') : renderTimestamp(text)}</div>;
},
},
{
title: t('兑换人ID'),
dataIndex: 'used_user_id',
@@ -157,8 +174,7 @@ const RedemptionsTable = () => {
}
];
// 动态添加启用/禁用按钮
if (record.status === 1) {
if (record.status === 1 && !isExpired(record)) {
moreMenuItems.push({
node: 'item',
name: t('禁用'),
@@ -168,7 +184,7 @@ const RedemptionsTable = () => {
manageRedemption(record.id, 'disable', record);
},
});
} else {
} else if (!isExpired(record)) {
moreMenuItems.push({
node: 'item',
name: t('启用'),
@@ -435,7 +451,7 @@ const RedemptionsTable = () => {
};
const handleRow = (record, index) => {
if (record.status !== 1) {
if (record.status !== 1 || isExpired(record)) {
return {
style: {
background: 'var(--semi-color-disabled-border)',
@@ -450,7 +466,7 @@ const RedemptionsTable = () => {
<div className="flex flex-col w-full">
<div className="mb-2">
<div className="flex items-center text-orange-500">
<IconEyeOpened className="mr-2" />
<Ticket size={16} className="mr-2" />
<Text>{t('兑换码可以批量生成和分发,适合用于推广活动或批量充值。')}</Text>
</div>
</div>
@@ -458,39 +474,66 @@ const RedemptionsTable = () => {
<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">
<div className="flex flex-col sm:flex-row gap-2 w-full md:w-auto order-2 md:order-1">
<div className="flex gap-2 w-full sm:w-auto">
<Button
theme='light'
type='primary'
icon={<IconPlus />}
className="!rounded-full w-full sm:w-auto"
onClick={() => {
setEditingRedemption({
id: undefined,
});
setShowEdit(true);
}}
>
{t('添加兑换码')}
</Button>
<Button
type='warning'
icon={<IconCopy />}
className="!rounded-full w-full sm:w-auto"
onClick={async () => {
if (selectedKeys.length === 0) {
showError(t('请至少选择一个兑换码!'));
return;
}
let keys = '';
for (let i = 0; i < selectedKeys.length; i++) {
keys +=
selectedKeys[i].name + ' ' + selectedKeys[i].key + '\n';
}
await copyText(keys);
}}
>
{t('复制所选兑换码到剪贴板')}
</Button>
</div>
<Button
theme='light'
type='primary'
icon={<IconPlus />}
className="!rounded-full w-full md:w-auto"
type='danger'
icon={<IconDelete />}
className="!rounded-full w-full sm:w-auto"
onClick={() => {
setEditingRedemption({
id: undefined,
Modal.confirm({
title: t('确定清除所有失效兑换码?'),
content: t('将删除已使用、已禁用及过期的兑换码,此操作不可撤销。'),
onOk: async () => {
setLoading(true);
const res = await API.delete('/api/redemption/invalid');
const { success, message, data } = res.data;
if (success) {
showSuccess(t('已删除 {{count}} 条失效兑换码', { count: data }));
await refresh();
} else {
showError(message);
}
setLoading(false);
},
});
setShowEdit(true);
}}
>
{t('添加兑换码')}
</Button>
<Button
type='warning'
icon={<IconCopy />}
className="!rounded-full w-full md:w-auto"
onClick={async () => {
if (selectedKeys.length === 0) {
showError(t('请至少选择一个兑换码!'));
return;
}
let keys = '';
for (let i = 0; i < selectedKeys.length; i++) {
keys +=
selectedKeys[i].name + ' ' + selectedKeys[i].key + '\n';
}
await copyText(keys);
}}
>
{t('复制所选兑换码到剪贴板')}
{t('清除失效兑换码')}
</Button>
</div>
+1 -13
View File
@@ -96,20 +96,8 @@ const renderTimestamp = (timestampInSeconds) => {
};
function renderDuration(submit_time, finishTime) {
// 确保startTime和finishTime都是有效的时间戳
if (!submit_time || !finishTime) return 'N/A';
// 将时间戳转换为Date对象
const start = new Date(submit_time);
const finish = new Date(finishTime);
// 计算时间差(毫秒)
const durationMs = finish - start;
// 将时间差转换为秒,并保留一位小数
const durationSec = (durationMs / 1000).toFixed(1);
// 设置颜色:大于60秒则为红色,小于等于60秒则为绿色
const durationSec = finishTime - submit_time;
const color = durationSec > 60 ? 'red' : 'green';
// 返回带有样式的颜色标签
+16 -2
View File
@@ -14,6 +14,7 @@ import { ITEMS_PER_PAGE } from '../../constants';
import {
Button,
Card,
Divider,
Dropdown,
Empty,
Form,
@@ -21,7 +22,8 @@ import {
Space,
SplitButtonGroup,
Table,
Tag
Tag,
Typography
} from '@douyinfe/semi-ui';
import {
IllustrationNoResult,
@@ -36,7 +38,8 @@ import {
Gauge,
HelpCircle,
Infinity,
Coins
Coins,
Key
} from 'lucide-react';
import {
@@ -54,6 +57,8 @@ import {
import EditToken from '../../pages/Token/EditToken';
import { useTranslation } from 'react-i18next';
const { Text } = Typography;
function renderTimestamp(timestamp) {
return <>{timestamp2string(timestamp)}</>;
}
@@ -580,6 +585,15 @@ const TokensTable = () => {
const renderHeader = () => (
<div className="flex flex-col w-full">
<div className="mb-2">
<div className="flex items-center text-blue-500">
<Key size={16} className="mr-2" />
<Text>{t('令牌用于API访问认证,可以设置额度限制和模型权限。')}</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
+22
View File
@@ -26,6 +26,7 @@ import {
Space,
Table,
Tag,
Tooltip,
Typography
} from '@douyinfe/semi-ui';
import {
@@ -110,6 +111,27 @@ const UsersTable = () => {
{
title: t('用户名'),
dataIndex: 'username',
render: (text, record) => {
const remark = record.remark;
if (!remark) {
return <span>{text}</span>;
}
const maxLen = 10;
const displayRemark = remark.length > maxLen ? remark.slice(0, maxLen) + '…' : remark;
return (
<Space spacing={2}>
<span>{text}</span>
<Tooltip content={remark} position="top" showArrow>
<Tag color='white' size='large' shape='circle' className="!text-xs">
<div className="flex items-center gap-1">
<div className="w-2 h-2 rounded-full flex-shrink-0" style={{ backgroundColor: '#10b981' }} />
{displayRemark}
</div>
</Tag>
</Tooltip>
</Space>
);
},
},
{
title: t('分组'),
+1
View File
@@ -83,6 +83,7 @@ export const buildApiPayload = (messages, systemPrompt, inputs, parameterEnabled
const payload = {
model: inputs.model,
messages: processedMessages,
group: inputs.group,
stream: inputs.stream,
};
+1
View File
@@ -9,6 +9,7 @@ export function setStatusData(data) {
localStorage.setItem('enable_task', data.enable_task);
localStorage.setItem('enable_data_export', data.enable_data_export);
localStorage.setItem('chats', JSON.stringify(data.chats));
localStorage.setItem('pay_methods', JSON.stringify(data.pay_methods));
localStorage.setItem(
'data_export_default_time',
data.data_export_default_time,
+26 -13
View File
@@ -1373,6 +1373,12 @@
"示例": "Example",
"缺省 MaxTokens": "Default MaxTokens",
"启用Claude思考适配(-thinking后缀)": "Enable Claude thinking adaptation (-thinking suffix)",
"和Claude不同,默认情况下Gemini的思考模型会自动决定要不要思考,就算不开启适配模型也可以正常使用,": "Unlike Claude, Gemini's thinking model automatically decides whether to think by default, and can be used normally even without enabling the adaptation model.",
"如果您需要计费,推荐设置无后缀模型价格按思考价格设置。": "If you need billing, it is recommended to set the no-suffix model price according to the thinking price.",
"支持使用 gemini-2.5-pro-preview-06-05-thinking-128 格式来精确传递思考预算。": "Supports using gemini-2.5-pro-preview-06-05-thinking-128 format to precisely pass thinking budget.",
"启用Gemini思考后缀适配": "Enable Gemini thinking suffix adaptation",
"适配-thinking、-thinking-预算数字和-nothinking后缀": "Adapt -thinking, -thinking-budgetNumber, and -nothinking suffixes",
"思考预算占比": "Thinking budget ratio",
"Claude思考适配 BudgetTokens = MaxTokens * BudgetTokens 百分比": "Claude thinking adaptation BudgetTokens = MaxTokens * BudgetTokens percentage",
"思考适配 BudgetTokens 百分比": "Thinking adaptation BudgetTokens percentage",
"0.1-1之间的小数": "Decimal between 0.1 and 1",
@@ -1582,7 +1588,7 @@
"性能指标": "Performance Indicators",
"模型数据分析": "Model Data Analysis",
"搜索无结果": "No results found",
"仪表盘置": "Dashboard Configuration",
"仪表盘置": "Dashboard Settings",
"API信息管理,可以配置多个API地址用于状态展示和负载均衡(最多50个)": "API information management, you can configure multiple API addresses for status display and load balancing (maximum 50)",
"线路描述": "Route description",
"颜色": "Color",
@@ -1598,6 +1604,7 @@
"请联系管理员在系统设置中配置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.",
"请联系管理员在系统设置中配置Uptime": "Please contact the administrator to configure Uptime in the system settings.",
"确定要删除此API信息吗?": "Are you sure you want to delete this API information?",
"测速": "Speed Test",
"批量删除": "Batch Delete",
@@ -1628,16 +1635,15 @@
"常见问答管理,为用户提供常见问题的答案(最多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",
"Uptime Kuma监控分类管理,可以配置多个监控分类用于服务状态展示(最多20个)": "Uptime Kuma monitoring category management, you can configure multiple monitoring categories for service status display (maximum 20)",
"添加分类": "Add Category",
"分类名称": "Category Name",
"Uptime Kuma地址": "Uptime Kuma Address",
"状态页面Slug": "Status Page Slug",
"请输入分类名称,如:OpenAI、Claude等": "Please enter the category name, such as: OpenAI, Claude, etc.",
"请输入Uptime Kuma服务地址,如:https://status.example.com": "Please enter the Uptime Kuma service address, such as: https://status.example.com",
"请输入状态页面的Slug,如:my-status": "Please enter the slug for the status page, such as: my-status",
"确定要删除此分类吗?": "Are you sure you want to delete this category?",
"配置": "Configure",
"服务监控地址,用于展示服务状态信息": "service monitoring address for displaying status information",
"服务可用性": "Service Status",
@@ -1646,11 +1652,18 @@
"高延迟": "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"
"设置保存失败": "Settings save failed",
"已新增 {{count}} 个模型:{{list}}": "Added {{count}} models: {{list}}",
"未发现新增模型": "No new models were added",
"令牌用于API访问认证,可以设置额度限制和模型权限。": "Tokens are used for API access authentication, and can set quota limits and model permissions.",
"清除失效兑换码": "Clear invalid redemption codes",
"确定清除所有失效兑换码?": "Are you sure you want to clear all invalid redemption codes?",
"将删除已使用、已禁用及过期的兑换码,此操作不可撤销。": "This will delete all used, disabled, and expired redemption codes, this operation cannot be undone.",
"选择过期时间(可选,留空为永久)": "Select expiration time (optional, leave blank for permanent)",
"请输入备注(仅管理员可见)": "Please enter a remark (only visible to administrators)"
}
+11 -9
View File
@@ -375,23 +375,25 @@ code {
}
/* 隐藏卡片内容区域的滚动条 */
.card-content-scroll::-webkit-scrollbar,
.model-settings-scroll::-webkit-scrollbar,
.thinking-content-scroll::-webkit-scrollbar,
.custom-request-textarea .semi-input::-webkit-scrollbar,
.custom-request-textarea textarea::-webkit-scrollbar {
display: none;
}
.card-content-scroll,
.model-settings-scroll,
.thinking-content-scroll,
.custom-request-textarea .semi-input,
.custom-request-textarea textarea {
.custom-request-textarea textarea,
.notice-content-scroll {
-ms-overflow-style: none;
scrollbar-width: none;
}
.card-content-scroll::-webkit-scrollbar,
.model-settings-scroll::-webkit-scrollbar,
.thinking-content-scroll::-webkit-scrollbar,
.custom-request-textarea .semi-input::-webkit-scrollbar,
.custom-request-textarea textarea::-webkit-scrollbar,
.notice-content-scroll::-webkit-scrollbar {
display: none;
}
/* 图片列表滚动条样式 */
.image-list-scroll::-webkit-scrollbar {
width: 6px;
+35 -19
View File
@@ -298,18 +298,27 @@ const EditChannel = (props) => {
}
};
useEffect(() => {
let localModelOptions = [...originModelOptions];
inputs.models.forEach((model) => {
if (!localModelOptions.find((option) => option.label === model)) {
localModelOptions.push({
label: model,
value: model,
});
}
});
setModelOptions(localModelOptions);
}, [originModelOptions, inputs.models]);
useEffect(() => {
// 使用 Map 来避免重复,以 value 为键
const modelMap = new Map();
// 先添加原始模型选项
originModelOptions.forEach(option => {
modelMap.set(option.value, option);
});
// 再添加当前选中的模型(如果不存在)
inputs.models.forEach(model => {
if (!modelMap.has(model)) {
modelMap.set(model, {
label: model,
value: model,
});
}
});
setModelOptions(Array.from(modelMap.values()));
}, [originModelOptions, inputs.models]);
useEffect(() => {
fetchModels().then();
@@ -385,7 +394,7 @@ const EditChannel = (props) => {
let localModels = [...inputs.models];
let localModelOptions = [...modelOptions];
let hasError = false;
const addedModels = [];
modelArray.forEach((model) => {
if (model && !localModels.includes(model)) {
@@ -395,17 +404,24 @@ const EditChannel = (props) => {
text: model,
value: model,
});
} else if (model) {
showError(t('某些模型已存在!'));
hasError = true;
addedModels.push(model);
}
});
if (hasError) return;
setModelOptions(localModelOptions);
setCustomModel('');
handleInputChange('models', localModels);
if (addedModels.length > 0) {
showSuccess(
t('已新增 {{count}} 个模型:{{list}}', {
count: addedModels.length,
list: addedModels.join(', '),
})
);
} else {
showInfo(t('未发现新增模型'));
}
};
return (
@@ -523,7 +539,7 @@ const EditChannel = (props) => {
handleInputChange('key', value);
}}
value={inputs.key}
style={{ minHeight: 150, fontFamily: 'JetBrains Mono, Consolas' }}
autosize={{ minRows: 6, maxRows: 6 }}
autoComplete='new-password'
className="!rounded-lg"
/>
+13 -6
View File
@@ -229,7 +229,7 @@ const EditTagModal = (props) => {
let localModels = [...inputs.models];
let localModelOptions = [...modelOptions];
let hasError = false;
const addedModels = [];
modelArray.forEach((model) => {
// 检查模型是否已存在,且模型名称非空
@@ -241,18 +241,25 @@ const EditTagModal = (props) => {
text: model,
value: model,
});
} else if (model) {
showError('某些模型已存在!');
hasError = true;
addedModels.push(model);
}
});
if (hasError) return; // 如果有错误则终止操作
// 更新状态值
setModelOptions(localModelOptions);
setCustomModel('');
handleInputChange('models', localModels);
if (addedModels.length > 0) {
showSuccess(
t('已新增 {{count}} 个模型:{{list}}', {
count: addedModels.length,
list: addedModels.join(', '),
})
);
} else {
showInfo(t('未发现新增模型'));
}
};
return (
+329 -212
View File
@@ -16,7 +16,8 @@ import {
Tag,
Timeline,
Collapse,
Progress
Progress,
Divider
} from '@douyinfe/semi-ui';
import {
IconRefresh,
@@ -86,10 +87,21 @@ const Detail = (props) => {
const ICON_BUTTON_CLASS = "text-white hover:bg-opacity-80 !rounded-full";
const FLEX_CENTER_GAP2 = "flex items-center gap-2";
const ILLUSTRATION_SIZE = { width: 96, height: 96 };
// ========== Constants ==========
let now = new Date();
const isAdminUser = isAdmin();
// ========== Panel enable flags ==========
const apiInfoEnabled = statusState?.status?.api_info_enabled ?? true;
const announcementsEnabled = statusState?.status?.announcements_enabled ?? true;
const faqEnabled = statusState?.status?.faq_enabled ?? true;
const uptimeEnabled = statusState?.status?.uptime_kuma_enabled ?? true;
const hasApiInfoPanel = apiInfoEnabled;
const hasInfoPanels = announcementsEnabled || faqEnabled || uptimeEnabled;
// ========== Helper Functions ==========
const getDefaultTime = useCallback(() => {
return localStorage.getItem('data_export_default_time') || 'hour';
@@ -206,6 +218,7 @@ const Detail = (props) => {
const announcementScrollRef = useRef(null);
const faqScrollRef = useRef(null);
const uptimeScrollRef = useRef(null);
const uptimeTabScrollRefs = useRef({});
// ========== Additional State for scroll hints ==========
const [showAnnouncementScrollHint, setShowAnnouncementScrollHint] = useState(false);
@@ -215,6 +228,7 @@ const Detail = (props) => {
// ========== Uptime data ==========
const [uptimeData, setUptimeData] = useState([]);
const [uptimeLoading, setUptimeLoading] = useState(false);
const [activeUptimeTab, setActiveUptimeTab] = useState('');
// ========== Props Destructuring ==========
const { username, model_name, start_timestamp, end_timestamp, channel } = inputs;
@@ -570,6 +584,9 @@ const Detail = (props) => {
const { success, message, data } = res.data;
if (success) {
setUptimeData(data || []);
if (data && data.length > 0 && !activeUptimeTab) {
setActiveUptimeTab(data[0].categoryName);
}
} else {
showError(message);
}
@@ -578,7 +595,7 @@ const Detail = (props) => {
} finally {
setUptimeLoading(false);
}
}, []);
}, [activeUptimeTab]);
const refresh = useCallback(async () => {
await Promise.all([loadQuotaData(), loadUptimeData()]);
@@ -635,10 +652,18 @@ const Detail = (props) => {
checkApiScrollable();
checkCardScrollable(announcementScrollRef, setShowAnnouncementScrollHint);
checkCardScrollable(faqScrollRef, setShowFaqScrollHint);
checkCardScrollable(uptimeScrollRef, setShowUptimeScrollHint);
if (uptimeData.length === 1) {
checkCardScrollable(uptimeScrollRef, setShowUptimeScrollHint);
} else if (uptimeData.length > 1 && activeUptimeTab) {
const activeTabRef = uptimeTabScrollRefs.current[activeUptimeTab];
if (activeTabRef) {
checkCardScrollable(activeTabRef, setShowUptimeScrollHint);
}
}
}, 100);
return () => clearTimeout(timer);
}, [uptimeData]);
}, [uptimeData, activeUptimeTab]);
const getUserData = async () => {
let res = await API.get(`/api/user/self`);
@@ -874,7 +899,6 @@ const Detail = (props) => {
const announcementData = useMemo(() => {
const announcements = statusState?.status?.announcements || [];
// 处理后台配置的公告数据,自动生成相对时间
return announcements.map(item => ({
...item,
time: getRelativeTime(item.publishDate)
@@ -885,6 +909,67 @@ const Detail = (props) => {
return statusState?.status?.faq || [];
}, [statusState?.status?.faq]);
const renderMonitorList = useCallback((monitors) => {
if (!monitors || monitors.length === 0) {
return (
<div className="flex justify-center items-center py-4">
<Empty
image={<IllustrationConstruction style={ILLUSTRATION_SIZE} />}
darkModeImage={<IllustrationConstructionDark style={ILLUSTRATION_SIZE} />}
title={t('暂无监控数据')}
/>
</div>
);
}
const grouped = {};
monitors.forEach((m) => {
const g = m.group || '';
if (!grouped[g]) grouped[g] = [];
grouped[g].push(m);
});
const renderItem = (monitor, idx) => (
<div key={idx} className="p-2 hover:bg-white rounded-lg transition-colors">
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-2">
<div
className="w-2 h-2 rounded-full flex-shrink-0"
style={{ backgroundColor: getUptimeStatusColor(monitor.status) }}
/>
<span className="text-sm font-medium text-gray-900">{monitor.name}</span>
</div>
<span className="text-xs text-gray-500">{((monitor.uptime || 0) * 100).toFixed(2)}%</span>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-gray-500">{getUptimeStatusText(monitor.status)}</span>
<div className="flex-1">
<Progress
percent={(monitor.uptime || 0) * 100}
showInfo={false}
aria-label={`${monitor.name} uptime`}
stroke={getUptimeStatusColor(monitor.status)}
/>
</div>
</div>
</div>
);
return Object.entries(grouped).map(([gname, list]) => (
<div key={gname || 'default'} className="mb-2">
{gname && (
<>
<div className="text-md font-semibold text-gray-500 px-2 py-1">
{gname}
</div>
<Divider />
</>
)}
{list.map(renderItem)}
</div>
));
}, [t, getUptimeStatusColor, getUptimeStatusText]);
// ========== Hooks - Effects ==========
useEffect(() => {
getUserData();
@@ -1015,10 +1100,10 @@ const Detail = (props) => {
</div>
<div className="mb-4">
<div className={`grid grid-cols-1 gap-4 ${!statusState?.status?.self_use_mode_enabled ? 'lg:grid-cols-4' : ''}`}>
<div className={`grid grid-cols-1 gap-4 ${hasApiInfoPanel ? 'lg:grid-cols-4' : ''}`}>
<Card
{...CARD_PROPS}
className={`shadow-sm !rounded-2xl ${!statusState?.status?.self_use_mode_enabled ? 'lg:col-span-3' : ''}`}
className={`shadow-sm !rounded-2xl ${hasApiInfoPanel ? 'lg:col-span-3' : ''}`}
title={
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between w-full gap-3">
<div className={FLEX_CENTER_GAP2}>
@@ -1027,6 +1112,7 @@ const Detail = (props) => {
</div>
<Tabs
type="button"
preventScroll={true}
activeKey={activeChartTab}
onChange={setActiveChartTab}
>
@@ -1061,7 +1147,7 @@ const Detail = (props) => {
</div>
</Card>
{!statusState?.status?.self_use_mode_enabled && (
{hasApiInfoPanel && (
<Card
{...CARD_PROPS}
className="bg-gray-50 border-0 !rounded-2xl"
@@ -1118,11 +1204,10 @@ const Detail = (props) => {
) : (
<div className="flex justify-center items-center py-8">
<Empty
image={<IllustrationConstruction style={{ width: 80, height: 80 }} />}
darkModeImage={<IllustrationConstructionDark style={{ width: 80, height: 80 }} />}
image={<IllustrationConstruction style={ILLUSTRATION_SIZE} />}
darkModeImage={<IllustrationConstructionDark style={ILLUSTRATION_SIZE} />}
title={t('暂无API信息')}
description={t('请联系管理员在系统设置中配置API信息')}
style={{ padding: '12px' }}
/>
</div>
)}
@@ -1138,219 +1223,251 @@ const Detail = (props) => {
</div>
{/* 系统公告和常见问答卡片 */}
{!statusState?.status?.self_use_mode_enabled && (
{hasInfoPanels && (
<div className="mb-4">
<div className="grid grid-cols-1 lg:grid-cols-4 gap-4">
{/* 公告卡片 */}
<Card
{...CARD_PROPS}
className="shadow-sm !rounded-2xl lg:col-span-2"
title={
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-2 w-full">
<div className="flex items-center gap-2">
<Bell size={16} />
{t('系统公告')}
<Tag size="small" color="grey" shape="circle">
{t('显示最新20条')}
</Tag>
</div>
{/* 图例 */}
<div className="flex flex-wrap gap-3 text-xs">
{announcementLegendData.map((legend, index) => (
<div key={index} className="flex items-center gap-1">
<div
className="w-2 h-2 rounded-full"
style={{
backgroundColor: legend.color === 'grey' ? '#8b9aa7' :
legend.color === 'blue' ? '#3b82f6' :
legend.color === 'green' ? '#10b981' :
legend.color === 'orange' ? '#f59e0b' :
legend.color === 'red' ? '#ef4444' : '#8b9aa7'
}}
/>
<span className="text-gray-600">{legend.label}</span>
</div>
))}
</div>
</div>
}
>
<div className="card-content-container">
<div
ref={announcementScrollRef}
className="p-2 max-h-96 overflow-y-auto card-content-scroll"
onScroll={() => handleCardScroll(announcementScrollRef, setShowAnnouncementScrollHint)}
>
{announcementData.length > 0 ? (
<Timeline
mode="alternate"
dataSource={announcementData}
/>
) : (
<div className="flex justify-center items-center py-8">
<Empty
image={<IllustrationConstruction style={{ width: 80, height: 80 }} />}
darkModeImage={<IllustrationConstructionDark style={{ width: 80, height: 80 }} />}
title={t('暂无系统公告')}
description={t('请联系管理员在系统设置中配置公告信息')}
style={{ padding: '12px' }}
/>
{announcementsEnabled && (
<Card
{...CARD_PROPS}
className="shadow-sm !rounded-2xl lg:col-span-2"
title={
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-2 w-full">
<div className="flex items-center gap-2">
<Bell size={16} />
{t('系统公告')}
<Tag size="small" color="grey" shape="circle">
{t('显示最新20条')}
</Tag>
</div>
)}
</div>
<div
className="card-content-fade-indicator"
style={{ opacity: showAnnouncementScrollHint ? 1 : 0 }}
/>
</div>
</Card>
{/* 常见问答卡片 */}
<Card
{...CARD_PROPS}
className="shadow-sm !rounded-2xl lg:col-span-1"
title={
<div className={FLEX_CENTER_GAP2}>
<HelpCircle size={16} />
{t('常见问答')}
</div>
}
>
<div className="card-content-container">
<div
ref={faqScrollRef}
className="p-2 max-h-96 overflow-y-auto card-content-scroll"
onScroll={() => handleCardScroll(faqScrollRef, setShowFaqScrollHint)}
>
{faqData.length > 0 ? (
<Collapse
accordion
expandIcon={<IconPlus />}
collapseIcon={<IconMinus />}
>
{faqData.map((item, index) => (
<Collapse.Panel
key={index}
header={item.title}
itemKey={index.toString()}
>
<p>{item.content}</p>
</Collapse.Panel>
))}
</Collapse>
) : (
<div className="flex justify-center items-center py-8">
<Empty
image={<IllustrationConstruction style={{ width: 80, height: 80 }} />}
darkModeImage={<IllustrationConstructionDark style={{ width: 80, height: 80 }} />}
title={t('暂无常见问答')}
description={t('请联系管理员在系统设置中配置常见问答')}
style={{ padding: '12px' }}
/>
</div>
)}
</div>
<div
className="card-content-fade-indicator"
style={{ opacity: showFaqScrollHint ? 1 : 0 }}
/>
</div>
</Card>
{/* 服务可用性卡片 */}
<Card
{...CARD_PROPS}
className="shadow-sm !rounded-2xl lg:col-span-1"
title={
<div className="flex items-center justify-between w-full gap-2">
<div className="flex items-center gap-2">
<Gauge size={16} />
{t('服务可用性')}
</div>
<IconButton
icon={<IconRefresh />}
onClick={loadUptimeData}
loading={uptimeLoading}
size="small"
theme="borderless"
className="text-gray-500 hover:text-blue-500 hover:bg-blue-50 !rounded-full"
/>
</div>
}
footer={uptimeData.length > 0 ? (
<Card
bordered={false}
className="!rounded-2xl backdrop-blur !shadow-none"
>
<div className="flex flex-wrap gap-3 text-xs justify-center">
{uptimeLegendData.map((legend, index) => (
<div key={index} className="flex items-center gap-1">
<div
className="w-2 h-2 rounded-full"
style={{ backgroundColor: legend.color }}
/>
<span className="text-gray-600">{legend.label}</span>
</div>
))}
</div>
</Card>
) : null}
footerStyle={uptimeData.length > 0 ? { padding: '0px' } : undefined}
>
<div className="card-content-container">
<Spin spinning={uptimeLoading}>
<div
ref={uptimeScrollRef}
className="p-2 max-h-80 overflow-y-auto card-content-scroll"
onScroll={() => handleCardScroll(uptimeScrollRef, setShowUptimeScrollHint)}
>
{uptimeData.length > 0 ? (
uptimeData.map((monitor, idx) => (
<div key={idx} className="p-2 hover:bg-white rounded-lg transition-colors">
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-2">
<div
className="w-2 h-2 rounded-full flex-shrink-0"
style={{
backgroundColor: getUptimeStatusColor(monitor.status)
}}
/>
<span className="text-sm font-medium text-gray-900">{monitor.name}</span>
</div>
<span className="text-xs text-gray-500">{((monitor.uptime || 0) * 100).toFixed(2)}%</span>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-gray-500">{getUptimeStatusText(monitor.status)}</span>
<div className="flex-1">
<Progress
percent={(monitor.uptime || 0) * 100}
showInfo={false}
aria-label={`${monitor.name} uptime`}
stroke={getUptimeStatusColor(monitor.status)}
/>
</div>
</div>
{/* 图例 */}
<div className="flex flex-wrap gap-3 text-xs">
{announcementLegendData.map((legend, index) => (
<div key={index} className="flex items-center gap-1">
<div
className="w-2 h-2 rounded-full"
style={{
backgroundColor: legend.color === 'grey' ? '#8b9aa7' :
legend.color === 'blue' ? '#3b82f6' :
legend.color === 'green' ? '#10b981' :
legend.color === 'orange' ? '#f59e0b' :
legend.color === 'red' ? '#ef4444' : '#8b9aa7'
}}
/>
<span className="text-gray-600">{legend.label}</span>
</div>
))
))}
</div>
</div>
}
>
<div className="card-content-container">
<div
ref={announcementScrollRef}
className="p-2 max-h-96 overflow-y-auto card-content-scroll"
onScroll={() => handleCardScroll(announcementScrollRef, setShowAnnouncementScrollHint)}
>
{announcementData.length > 0 ? (
<Timeline
mode="alternate"
dataSource={announcementData}
/>
) : (
<div className="flex justify-center items-center py-8">
<Empty
image={<IllustrationConstruction style={{ width: 80, height: 80 }} />}
darkModeImage={<IllustrationConstructionDark style={{ width: 80, height: 80 }} />}
title={t('暂无监控数据')}
description={t('请联系管理员在系统设置中配置Uptime')}
style={{ padding: '12px' }}
image={<IllustrationConstruction style={ILLUSTRATION_SIZE} />}
darkModeImage={<IllustrationConstructionDark style={ILLUSTRATION_SIZE} />}
title={t('暂无系统公告')}
description={t('请联系管理员在系统设置中配置公告信息')}
/>
</div>
)}
</div>
</Spin>
<div
className="card-content-fade-indicator"
style={{ opacity: showUptimeScrollHint ? 1 : 0 }}
/>
</div>
</Card>
<div
className="card-content-fade-indicator"
style={{ opacity: showAnnouncementScrollHint ? 1 : 0 }}
/>
</div>
</Card>
)}
{/* 常见问答卡片 */}
{faqEnabled && (
<Card
{...CARD_PROPS}
className="shadow-sm !rounded-2xl lg:col-span-1"
title={
<div className={FLEX_CENTER_GAP2}>
<HelpCircle size={16} />
{t('常见问答')}
</div>
}
bodyStyle={{ padding: 0 }}
>
<div className="card-content-container">
<div
ref={faqScrollRef}
className="p-2 max-h-96 overflow-y-auto card-content-scroll"
onScroll={() => handleCardScroll(faqScrollRef, setShowFaqScrollHint)}
>
{faqData.length > 0 ? (
<Collapse
accordion
expandIcon={<IconPlus />}
collapseIcon={<IconMinus />}
>
{faqData.map((item, index) => (
<Collapse.Panel
key={index}
header={item.question}
itemKey={index.toString()}
>
<p>{item.answer}</p>
</Collapse.Panel>
))}
</Collapse>
) : (
<div className="flex justify-center items-center py-8">
<Empty
image={<IllustrationConstruction style={ILLUSTRATION_SIZE} />}
darkModeImage={<IllustrationConstructionDark style={ILLUSTRATION_SIZE} />}
title={t('暂无常见问答')}
description={t('请联系管理员在系统设置中配置常见问答')}
/>
</div>
)}
</div>
<div
className="card-content-fade-indicator"
style={{ opacity: showFaqScrollHint ? 1 : 0 }}
/>
</div>
</Card>
)}
{/* 服务可用性卡片 */}
{uptimeEnabled && (
<Card
{...CARD_PROPS}
className="shadow-sm !rounded-2xl lg:col-span-1 flex flex-col"
title={
<div className="flex items-center justify-between w-full gap-2">
<div className="flex items-center gap-2">
<Gauge size={16} />
{t('服务可用性')}
</div>
<IconButton
icon={<IconRefresh />}
onClick={loadUptimeData}
loading={uptimeLoading}
size="small"
theme="borderless"
className="text-gray-500 hover:text-blue-500 hover:bg-blue-50 !rounded-full"
/>
</div>
}
bodyStyle={{ padding: 0 }}
>
{/* 内容区域 */}
<div className="flex-1 relative">
<Spin spinning={uptimeLoading}>
{uptimeData.length > 0 ? (
uptimeData.length === 1 ? (
<div className="card-content-container">
<div
ref={uptimeScrollRef}
className="p-2 max-h-[24rem] overflow-y-auto card-content-scroll"
onScroll={() => handleCardScroll(uptimeScrollRef, setShowUptimeScrollHint)}
>
{renderMonitorList(uptimeData[0].monitors)}
</div>
<div
className="card-content-fade-indicator"
style={{ opacity: showUptimeScrollHint ? 1 : 0 }}
/>
</div>
) : (
<Tabs
type="card"
preventScroll={true}
collapsible
activeKey={activeUptimeTab}
onChange={setActiveUptimeTab}
size="small"
>
{uptimeData.map((group, groupIdx) => {
if (!uptimeTabScrollRefs.current[group.categoryName]) {
uptimeTabScrollRefs.current[group.categoryName] = React.createRef();
}
const tabScrollRef = uptimeTabScrollRefs.current[group.categoryName];
return (
<TabPane
tab={
<span className="flex items-center gap-2">
<Gauge size={14} />
{group.categoryName}
<Tag
color={activeUptimeTab === group.categoryName ? 'red' : 'grey'}
size='small'
shape='circle'
>
{group.monitors ? group.monitors.length : 0}
</Tag>
</span>
}
itemKey={group.categoryName}
key={groupIdx}
>
<div className="card-content-container">
<div
ref={tabScrollRef}
className="p-2 max-h-[21.5rem] overflow-y-auto card-content-scroll"
onScroll={() => handleCardScroll(tabScrollRef, setShowUptimeScrollHint)}
>
{renderMonitorList(group.monitors)}
</div>
<div
className="card-content-fade-indicator"
style={{ opacity: activeUptimeTab === group.categoryName ? showUptimeScrollHint ? 1 : 0 : 0 }}
/>
</div>
</TabPane>
);
})}
</Tabs>
)
) : (
<div className="flex justify-center items-center py-8">
<Empty
image={<IllustrationConstruction style={ILLUSTRATION_SIZE} />}
darkModeImage={<IllustrationConstructionDark style={ILLUSTRATION_SIZE} />}
title={t('暂无监控数据')}
description={t('请联系管理员在系统设置中配置Uptime')}
/>
</div>
)}
</Spin>
</div>
{/* 固定在底部的图例 */}
{uptimeData.length > 0 && (
<div className="p-3 mt-auto bg-gray-50 rounded-b-2xl">
<div className="flex flex-wrap gap-3 text-xs justify-center">
{uptimeLegendData.map((legend, index) => (
<div key={index} className="flex items-center gap-1">
<div
className="w-2 h-2 rounded-full"
style={{ backgroundColor: legend.color }}
/>
<span className="text-gray-600">{legend.label}</span>
</div>
))}
</div>
</div>
)}
</Card>
)}
</div>
</div>
)}
+26 -1
View File
@@ -20,6 +20,8 @@ import {
Typography,
Card,
Tag,
Form,
DatePicker,
} from '@douyinfe/semi-ui';
import {
IconCreditCard,
@@ -40,9 +42,10 @@ const EditRedemption = (props) => {
name: '',
quota: 100000,
count: 1,
expired_time: 0,
};
const [inputs, setInputs] = useState(originInputs);
const { name, quota, count } = inputs;
const { name, quota, count, expired_time } = inputs;
const handleCancel = () => {
props.handleClose();
@@ -85,6 +88,9 @@ const EditRedemption = (props) => {
localInputs.count = parseInt(localInputs.count);
localInputs.quota = parseInt(localInputs.quota);
localInputs.name = name;
if (localInputs.expired_time === null || localInputs.expired_time === undefined) {
localInputs.expired_time = 0;
}
let res;
if (isEdit) {
res = await API.put(`/api/redemption/`, {
@@ -220,6 +226,25 @@ const EditRedemption = (props) => {
required={!isEdit}
/>
</div>
<div>
<Text strong className="block mb-2">{t('过期时间')}</Text>
<DatePicker
type="dateTime"
placeholder={t('选择过期时间(可选,留空为永久)')}
showClear
value={expired_time ? new Date(expired_time * 1000) : null}
onChange={(value) => {
if (value === null || value === undefined) {
handleInputChange('expired_time', 0);
} else {
const timestamp = Math.floor(value.getTime() / 1000);
handleInputChange('expired_time', timestamp);
}
}}
size="large"
className="!rounded-lg w-full"
/>
</div>
</div>
</Card>
@@ -9,7 +9,8 @@ import {
Divider,
Avatar,
Modal,
Tag
Tag,
Switch
} from '@douyinfe/semi-ui';
import {
IllustrationNoResult,
@@ -48,6 +49,9 @@ const SettingsAPIInfo = ({ options, refresh }) => {
const [pageSize, setPageSize] = useState(10);
const [selectedRowKeys, setSelectedRowKeys] = useState([]);
// 面板启用状态 state
const [panelEnabled, setPanelEnabled] = useState(true);
const colorOptions = [
{ value: 'blue', label: 'blue' },
{ value: 'green', label: 'green' },
@@ -85,7 +89,7 @@ const SettingsAPIInfo = ({ options, refresh }) => {
try {
setLoading(true);
const apiInfoJson = JSON.stringify(apiInfoList);
await updateOption('ApiInfo', apiInfoJson);
await updateOption('console_setting.api_info', apiInfoJson);
setHasChanges(false);
} catch (error) {
console.error('API信息更新失败', error);
@@ -185,10 +189,35 @@ const SettingsAPIInfo = ({ options, refresh }) => {
};
useEffect(() => {
if (options.ApiInfo !== undefined) {
parseApiInfo(options.ApiInfo);
const apiInfoStr = options['console_setting.api_info'] ?? options.ApiInfo;
if (apiInfoStr !== undefined) {
parseApiInfo(apiInfoStr);
}
}, [options.ApiInfo]);
}, [options['console_setting.api_info'], options.ApiInfo]);
useEffect(() => {
const enabledStr = options['console_setting.api_info_enabled'];
setPanelEnabled(enabledStr === undefined ? true : enabledStr === 'true' || enabledStr === true);
}, [options['console_setting.api_info_enabled']]);
const handleToggleEnabled = async (checked) => {
const newValue = checked ? 'true' : 'false';
try {
const res = await API.put('/api/option/', {
key: 'console_setting.api_info_enabled',
value: newValue,
});
if (res.data.success) {
setPanelEnabled(checked);
showSuccess(t('设置已保存'));
refresh?.();
} else {
showError(res.data.message);
}
} catch (err) {
showError(err.message);
}
};
const columns = [
{
@@ -324,6 +353,15 @@ const SettingsAPIInfo = ({ options, refresh }) => {
{t('保存设置')}
</Button>
</div>
{/* 启用开关 */}
<div className="order-1 md:order-2 flex items-center gap-2">
<Switch
checked={panelEnabled}
onChange={handleToggleEnabled}
/>
<Text>{panelEnabled ? t('已启用') : t('已禁用')}</Text>
</div>
</div>
</div>
);
@@ -8,7 +8,8 @@ import {
Empty,
Divider,
Modal,
Tag
Tag,
Switch
} from '@douyinfe/semi-ui';
import {
IllustrationNoResult,
@@ -47,6 +48,9 @@ const SettingsAnnouncements = ({ options, refresh }) => {
const [pageSize, setPageSize] = useState(10);
const [selectedRowKeys, setSelectedRowKeys] = useState([]);
// 面板启用状态
const [panelEnabled, setPanelEnabled] = useState(true);
const typeOptions = [
{ value: 'default', label: t('默认') },
{ value: 'ongoing', label: t('进行中') },
@@ -176,7 +180,7 @@ const SettingsAnnouncements = ({ options, refresh }) => {
try {
setLoading(true);
const announcementsJson = JSON.stringify(announcementsList);
await updateOption('Announcements', announcementsJson);
await updateOption('console_setting.announcements', announcementsJson);
setHasChanges(false);
} catch (error) {
console.error('系统公告更新失败', error);
@@ -288,10 +292,35 @@ const SettingsAnnouncements = ({ options, refresh }) => {
};
useEffect(() => {
if (options.Announcements !== undefined) {
parseAnnouncements(options.Announcements);
const annStr = options['console_setting.announcements'] ?? options.Announcements;
if (annStr !== undefined) {
parseAnnouncements(annStr);
}
}, [options.Announcements]);
}, [options['console_setting.announcements'], options.Announcements]);
useEffect(() => {
const enabledStr = options['console_setting.announcements_enabled'];
setPanelEnabled(enabledStr === undefined ? true : enabledStr === 'true' || enabledStr === true);
}, [options['console_setting.announcements_enabled']]);
const handleToggleEnabled = async (checked) => {
const newValue = checked ? 'true' : 'false';
try {
const res = await API.put('/api/option/', {
key: 'console_setting.announcements_enabled',
value: newValue,
});
if (res.data.success) {
setPanelEnabled(checked);
showSuccess(t('设置已保存'));
refresh?.();
} else {
showError(res.data.message);
}
} catch (err) {
showError(err.message);
}
};
const handleBatchDelete = () => {
if (selectedRowKeys.length === 0) {
@@ -349,6 +378,12 @@ const SettingsAnnouncements = ({ options, refresh }) => {
{t('保存设置')}
</Button>
</div>
{/* 启用开关 */}
<div className="order-1 md:order-2 flex items-center gap-2">
<Switch checked={panelEnabled} onChange={handleToggleEnabled} />
<Text>{panelEnabled ? t('已启用') : t('已禁用')}</Text>
</div>
</div>
</div>
);
+54 -20
View File
@@ -7,7 +7,8 @@ import {
Typography,
Empty,
Divider,
Modal
Modal,
Switch
} from '@douyinfe/semi-ui';
import {
IllustrationNoResult,
@@ -37,18 +38,21 @@ const SettingsFAQ = ({ options, refresh }) => {
const [loading, setLoading] = useState(false);
const [hasChanges, setHasChanges] = useState(false);
const [faqForm, setFaqForm] = useState({
title: '',
content: ''
question: '',
answer: ''
});
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [selectedRowKeys, setSelectedRowKeys] = useState([]);
// 面板启用状态
const [panelEnabled, setPanelEnabled] = useState(true);
const columns = [
{
title: t('问题标题'),
dataIndex: 'title',
key: 'title',
dataIndex: 'question',
key: 'question',
render: (text) => (
<div style={{
maxWidth: '300px',
@@ -61,8 +65,8 @@ const SettingsFAQ = ({ options, refresh }) => {
},
{
title: t('回答内容'),
dataIndex: 'content',
key: 'content',
dataIndex: 'answer',
key: 'answer',
render: (text) => (
<div style={{
maxWidth: '400px',
@@ -124,7 +128,7 @@ const SettingsFAQ = ({ options, refresh }) => {
try {
setLoading(true);
const faqJson = JSON.stringify(faqList);
await updateOption('FAQ', faqJson);
await updateOption('console_setting.faq', faqJson);
setHasChanges(false);
} catch (error) {
console.error('常见问答更新失败', error);
@@ -137,8 +141,8 @@ const SettingsFAQ = ({ options, refresh }) => {
const handleAddFaq = () => {
setEditingFaq(null);
setFaqForm({
title: '',
content: ''
question: '',
answer: ''
});
setShowFaqModal(true);
};
@@ -146,8 +150,8 @@ const SettingsFAQ = ({ options, refresh }) => {
const handleEditFaq = (faq) => {
setEditingFaq(faq);
setFaqForm({
title: faq.title,
content: faq.content
question: faq.question,
answer: faq.answer
});
setShowFaqModal(true);
};
@@ -169,7 +173,7 @@ const SettingsFAQ = ({ options, refresh }) => {
};
const handleSaveFaq = async () => {
if (!faqForm.title || !faqForm.content) {
if (!faqForm.question || !faqForm.answer) {
showError('请填写完整的问答信息');
return;
}
@@ -226,10 +230,34 @@ const SettingsFAQ = ({ options, refresh }) => {
};
useEffect(() => {
if (options.FAQ !== undefined) {
parseFAQ(options.FAQ);
if (options['console_setting.faq'] !== undefined) {
parseFAQ(options['console_setting.faq']);
}
}, [options.FAQ]);
}, [options['console_setting.faq']]);
useEffect(() => {
const enabledStr = options['console_setting.faq_enabled'];
setPanelEnabled(enabledStr === undefined ? true : enabledStr === 'true' || enabledStr === true);
}, [options['console_setting.faq_enabled']]);
const handleToggleEnabled = async (checked) => {
const newValue = checked ? 'true' : 'false';
try {
const res = await API.put('/api/option/', {
key: 'console_setting.faq_enabled',
value: newValue,
});
if (res.data.success) {
setPanelEnabled(checked);
showSuccess(t('设置已保存'));
refresh?.();
} else {
showError(res.data.message);
}
} catch (err) {
showError(err.message);
}
};
const handleBatchDelete = () => {
if (selectedRowKeys.length === 0) {
@@ -287,6 +315,12 @@ const SettingsFAQ = ({ options, refresh }) => {
{t('保存设置')}
</Button>
</div>
{/* 启用开关 */}
<div className="order-1 md:order-2 flex items-center gap-2">
<Switch checked={panelEnabled} onChange={handleToggleEnabled} />
<Text>{panelEnabled ? t('已启用') : t('已禁用')}</Text>
</div>
</div>
</div>
);
@@ -372,21 +406,21 @@ const SettingsFAQ = ({ options, refresh }) => {
>
<Form layout='vertical' initValues={faqForm} key={editingFaq ? editingFaq.id : 'new'}>
<Form.Input
field='title'
field='question'
label={t('问题标题')}
placeholder={t('请输入问题标题')}
maxLength={200}
rules={[{ required: true, message: t('请输入问题标题') }]}
onChange={(value) => setFaqForm({ ...faqForm, title: value })}
onChange={(value) => setFaqForm({ ...faqForm, question: value })}
/>
<Form.TextArea
field='content'
field='answer'
label={t('回答内容')}
placeholder={t('请输入回答内容')}
maxCount={1000}
rows={6}
rules={[{ required: true, message: t('请输入回答内容') }]}
onChange={(value) => setFaqForm({ ...faqForm, content: value })}
onChange={(value) => setFaqForm({ ...faqForm, answer: value })}
/>
</Form>
</Modal>
@@ -1,12 +1,23 @@
import React, { useEffect, useState, useRef, useMemo, useCallback } from 'react';
import React, { useEffect, useState } from 'react';
import {
Form,
Button,
Space,
Table,
Form,
Typography,
Row,
Col,
Empty,
Divider,
Modal,
Switch
} from '@douyinfe/semi-ui';
import {
IllustrationNoResult,
IllustrationNoResultDark
} from '@douyinfe/semi-illustrations';
import {
Plus,
Edit,
Trash2,
Save,
Activity
} from 'lucide-react';
@@ -18,168 +29,453 @@ const { Text } = Typography;
const SettingsUptimeKuma = ({ options, refresh }) => {
const { t } = useTranslation();
const [uptimeGroupsList, setUptimeGroupsList] = useState([]);
const [showUptimeModal, setShowUptimeModal] = useState(false);
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [deletingGroup, setDeletingGroup] = useState(null);
const [editingGroup, setEditingGroup] = useState(null);
const [modalLoading, setModalLoading] = useState(false);
const [loading, setLoading] = useState(false);
const formApiRef = useRef(null);
const [hasChanges, setHasChanges] = useState(false);
const [uptimeForm, setUptimeForm] = useState({
categoryName: '',
url: '',
slug: '',
});
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [selectedRowKeys, setSelectedRowKeys] = useState([]);
const [panelEnabled, setPanelEnabled] = useState(true);
const initValues = useMemo(() => ({
uptimeKumaUrl: options?.UptimeKumaUrl || '',
uptimeKumaSlug: options?.UptimeKumaSlug || ''
}), [options?.UptimeKumaUrl, options?.UptimeKumaSlug]);
useEffect(() => {
if (formApiRef.current) {
formApiRef.current.setValues(initValues, { isOverride: true });
const columns = [
{
title: t('分类名称'),
dataIndex: 'categoryName',
key: 'categoryName',
render: (text) => (
<div style={{
fontWeight: 'bold',
color: 'var(--semi-color-text-0)'
}}>
{text}
</div>
)
},
{
title: t('Uptime Kuma地址'),
dataIndex: 'url',
key: 'url',
render: (text) => (
<div style={{
maxWidth: '300px',
wordBreak: 'break-all',
fontFamily: 'monospace',
color: 'var(--semi-color-primary)'
}}>
{text}
</div>
)
},
{
title: t('状态页面Slug'),
dataIndex: 'slug',
key: 'slug',
render: (text) => (
<div style={{
fontFamily: 'monospace',
color: 'var(--semi-color-text-1)'
}}>
{text}
</div>
)
},
{
title: t('操作'),
key: 'action',
fixed: 'right',
width: 150,
render: (text, record) => (
<Space>
<Button
icon={<Edit size={14} />}
theme='light'
type='tertiary'
size='small'
className="!rounded-full"
onClick={() => handleEditGroup(record)}
>
{t('编辑')}
</Button>
<Button
icon={<Trash2 size={14} />}
type='danger'
theme='light'
size='small'
className="!rounded-full"
onClick={() => handleDeleteGroup(record)}
>
{t('删除')}
</Button>
</Space>
)
}
}, [initValues]);
];
const handleSave = async () => {
const api = formApiRef.current;
if (!api) {
showError(t('表单未初始化'));
return;
const updateOption = async (key, value) => {
const res = await API.put('/api/option/', {
key,
value,
});
const { success, message } = res.data;
if (success) {
showSuccess('Uptime Kuma配置已更新');
if (refresh) refresh();
} else {
showError(message);
}
};
const submitUptimeGroups = async () => {
try {
setLoading(true);
const { uptimeKumaUrl, uptimeKumaSlug } = await api.validate();
const trimmedUrl = (uptimeKumaUrl || '').trim();
const trimmedSlug = (uptimeKumaSlug || '').trim();
if (trimmedUrl === options?.UptimeKumaUrl && trimmedSlug === options?.UptimeKumaSlug) {
showSuccess(t('无需保存,配置未变动'));
return;
}
const [urlRes, slugRes] = await Promise.all([
trimmedUrl === options?.UptimeKumaUrl ? Promise.resolve({ data: { success: true } }) : API.put('/api/option/', {
key: 'UptimeKumaUrl',
value: trimmedUrl
}),
trimmedSlug === options?.UptimeKumaSlug ? Promise.resolve({ data: { success: true } }) : API.put('/api/option/', {
key: 'UptimeKumaSlug',
value: trimmedSlug
})
]);
if (!urlRes.data.success) throw new Error(urlRes.data.message || t('URL 保存失败'));
if (!slugRes.data.success) throw new Error(slugRes.data.message || t('Slug 保存失败'));
showSuccess(t('Uptime Kuma 设置保存成功'));
refresh?.();
} catch (err) {
console.error(err);
showError(err.message || t('保存失败,请重试'));
const groupsJson = JSON.stringify(uptimeGroupsList);
await updateOption('console_setting.uptime_kuma_groups', groupsJson);
setHasChanges(false);
} catch (error) {
console.error('Uptime Kuma配置更新失败', error);
showError('Uptime Kuma配置更新失败');
} finally {
setLoading(false);
}
};
const isValidUrl = useCallback((string) => {
try {
new URL(string);
return true;
} catch (_) {
return false;
const handleAddGroup = () => {
setEditingGroup(null);
setUptimeForm({
categoryName: '',
url: '',
slug: '',
});
setShowUptimeModal(true);
};
const handleEditGroup = (group) => {
setEditingGroup(group);
setUptimeForm({
categoryName: group.categoryName,
url: group.url,
slug: group.slug,
});
setShowUptimeModal(true);
};
const handleDeleteGroup = (group) => {
setDeletingGroup(group);
setShowDeleteModal(true);
};
const confirmDeleteGroup = () => {
if (deletingGroup) {
const newList = uptimeGroupsList.filter(item => item.id !== deletingGroup.id);
setUptimeGroupsList(newList);
setHasChanges(true);
showSuccess('分类已删除,请及时点击“保存设置”进行保存');
}
}, []);
setShowDeleteModal(false);
setDeletingGroup(null);
};
const handleSaveGroup = async () => {
if (!uptimeForm.categoryName || !uptimeForm.url || !uptimeForm.slug) {
showError('请填写完整的分类信息');
return;
}
try {
new URL(uptimeForm.url);
} catch (error) {
showError('请输入有效的URL地址');
return;
}
if (!/^[a-zA-Z0-9_-]+$/.test(uptimeForm.slug)) {
showError('Slug只能包含字母、数字、下划线和连字符');
return;
}
try {
setModalLoading(true);
let newList;
if (editingGroup) {
newList = uptimeGroupsList.map(item =>
item.id === editingGroup.id
? { ...item, ...uptimeForm }
: item
);
} else {
const newId = Math.max(...uptimeGroupsList.map(item => item.id), 0) + 1;
const newGroup = {
id: newId,
...uptimeForm
};
newList = [...uptimeGroupsList, newGroup];
}
setUptimeGroupsList(newList);
setHasChanges(true);
setShowUptimeModal(false);
showSuccess(editingGroup ? '分类已更新,请及时点击“保存设置”进行保存' : '分类已添加,请及时点击“保存设置”进行保存');
} catch (error) {
showError('操作失败: ' + error.message);
} finally {
setModalLoading(false);
}
};
const parseUptimeGroups = (groupsStr) => {
if (!groupsStr) {
setUptimeGroupsList([]);
return;
}
try {
const parsed = JSON.parse(groupsStr);
const list = Array.isArray(parsed) ? parsed : [];
const listWithIds = list.map((item, index) => ({
...item,
id: item.id || index + 1
}));
setUptimeGroupsList(listWithIds);
} catch (error) {
console.error('解析Uptime Kuma配置失败:', error);
setUptimeGroupsList([]);
}
};
useEffect(() => {
const groupsStr = options['console_setting.uptime_kuma_groups'];
if (groupsStr !== undefined) {
parseUptimeGroups(groupsStr);
}
}, [options['console_setting.uptime_kuma_groups']]);
useEffect(() => {
const enabledStr = options['console_setting.uptime_kuma_enabled'];
setPanelEnabled(enabledStr === undefined ? true : enabledStr === 'true' || enabledStr === true);
}, [options['console_setting.uptime_kuma_enabled']]);
const handleToggleEnabled = async (checked) => {
const newValue = checked ? 'true' : 'false';
try {
const res = await API.put('/api/option/', {
key: 'console_setting.uptime_kuma_enabled',
value: newValue,
});
if (res.data.success) {
setPanelEnabled(checked);
showSuccess(t('设置已保存'));
refresh?.();
} else {
showError(res.data.message);
}
} catch (err) {
showError(err.message);
}
};
const handleBatchDelete = () => {
if (selectedRowKeys.length === 0) {
showError('请先选择要删除的分类');
return;
}
const newList = uptimeGroupsList.filter(item => !selectedRowKeys.includes(item.id));
setUptimeGroupsList(newList);
setSelectedRowKeys([]);
setHasChanges(true);
showSuccess(`已删除 ${selectedRowKeys.length} 个分类,请及时点击“保存设置”进行保存`);
};
const renderHeader = () => (
<div className="flex flex-col w-full">
<div className="flex flex-col md:flex-row md:justify-between md:items-center gap-4 mb-2">
<div className="mb-2">
<div className="flex items-center text-blue-500">
<Activity size={16} className="mr-2" />
<Text>
{t('配置')}&nbsp;
<a
href="https://github.com/louislam/uptime-kuma"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline"
>
Uptime&nbsp;Kuma
</a>
&nbsp;{t('服务监控地址,用于展示服务状态信息')}
</Text>
<Text>{t('Uptime Kuma监控分类管理,可以配置多个监控分类用于服务状态展示(最多20个)')}</Text>
</div>
</div>
<div className="flex gap-2">
<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={handleAddGroup}
>
{t('添加分类')}
</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} />}
theme='solid'
type='primary'
onClick={handleSave}
onClick={submitUptimeGroups}
loading={loading}
className="!rounded-full"
disabled={!hasChanges}
type='secondary'
className="!rounded-full w-full md:w-auto"
>
{t('保存设置')}
</Button>
</div>
{/* 启用开关 */}
<div className="order-1 md:order-2 flex items-center gap-2">
<Switch checked={panelEnabled} onChange={handleToggleEnabled} />
<Text>{panelEnabled ? t('已启用') : t('已禁用')}</Text>
</div>
</div>
</div>
);
const getCurrentPageData = () => {
const startIndex = (currentPage - 1) * pageSize;
const endIndex = startIndex + pageSize;
return uptimeGroupsList.slice(startIndex, endIndex);
};
const rowSelection = {
selectedRowKeys,
onChange: (selectedRowKeys, selectedRows) => {
setSelectedRowKeys(selectedRowKeys);
},
onSelect: (record, selected, selectedRows) => {
console.log(`选择行: ${selected}`, record);
},
onSelectAll: (selected, selectedRows) => {
console.log(`全选: ${selected}`, selectedRows);
},
getCheckboxProps: (record) => ({
disabled: false,
name: record.id,
}),
};
return (
<Form.Section text={renderHeader()}>
<Form
layout="vertical"
autoScrollToError
initValues={initValues}
getFormApi={(api) => {
formApiRef.current = api;
<>
<Form.Section text={renderHeader()}>
<Table
columns={columns}
dataSource={getCurrentPageData()}
rowSelection={rowSelection}
rowKey="id"
scroll={{ x: 'max-content' }}
pagination={{
currentPage: currentPage,
pageSize: pageSize,
total: uptimeGroupsList.length,
showSizeChanger: true,
showQuickJumper: true,
formatPageText: (page) => t('第 {{start}} - {{end}} 条,共 {{total}} 条', {
start: page.currentStart,
end: page.currentEnd,
total: uptimeGroupsList.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('暂无监控数据')}
style={{ padding: 30 }}
/>
}
className="rounded-xl overflow-hidden"
/>
</Form.Section>
<Modal
title={editingGroup ? t('编辑分类') : t('添加分类')}
visible={showUptimeModal}
onOk={handleSaveGroup}
onCancel={() => setShowUptimeModal(false)}
okText={t('保存')}
cancelText={t('取消')}
className="rounded-xl"
confirmLoading={modalLoading}
width={600}
>
<Form layout='vertical' initValues={uptimeForm} key={editingGroup ? editingGroup.id : 'new'}>
<Form.Input
field='categoryName'
label={t('分类名称')}
placeholder={t('请输入分类名称,如:OpenAI、Claude等')}
maxLength={50}
rules={[{ required: true, message: t('请输入分类名称') }]}
onChange={(value) => setUptimeForm({ ...uptimeForm, categoryName: value })}
/>
<Form.Input
field='url'
label={t('Uptime Kuma地址')}
placeholder={t('请输入Uptime Kuma服务地址,如:https://status.example.com')}
maxLength={500}
rules={[{ required: true, message: t('请输入Uptime Kuma地址') }]}
onChange={(value) => setUptimeForm({ ...uptimeForm, url: value })}
/>
<Form.Input
field='slug'
label={t('状态页面Slug')}
placeholder={t('请输入状态页面的Slug,如:my-status')}
maxLength={100}
rules={[{ required: true, message: t('请输入状态页面Slug') }]}
onChange={(value) => setUptimeForm({ ...uptimeForm, slug: value })}
/>
</Form>
</Modal>
<Modal
title={t('确认删除')}
visible={showDeleteModal}
onOk={confirmDeleteGroup}
onCancel={() => {
setShowDeleteModal(false);
setDeletingGroup(null);
}}
okText={t('确认删除')}
cancelText={t('取消')}
type="warning"
className="rounded-xl"
okButtonProps={{
type: 'danger',
theme: 'solid'
}}
>
<Row gutter={[24, 24]}>
<Col xs={24} md={12}>
<Form.Input
showClear
field="uptimeKumaUrl"
label={{ text: t("Uptime Kuma 服务地址") }}
placeholder={t("请输入 Uptime Kuma 服务地址")}
style={{ fontFamily: 'monospace' }}
helpText={t("请输入 Uptime Kuma 服务的完整地址,例如:https://uptime.example.com")}
rules={[
{
validator: (_, value) => {
const url = (value || '').trim();
if (url && !isValidUrl(url)) {
return Promise.reject(t('请输入有效的 URL 地址'));
}
return Promise.resolve();
}
}
]}
/>
</Col>
<Col xs={24} md={12}>
<Form.Input
showClear
field="uptimeKumaSlug"
label={{ text: t("状态页面 Slug") }}
placeholder={t("请输入状态页面 Slug")}
style={{ fontFamily: 'monospace' }}
helpText={t("请输入状态页面的 slug 标识符,例如:my-status")}
rules={[
{
validator: (_, value) => {
const slug = (value || '').trim();
if (slug && !/^[a-zA-Z0-9_-]+$/.test(slug)) {
return Promise.reject(t('Slug 只能包含字母、数字、下划线和连字符'));
}
return Promise.resolve();
}
}
]}
/>
</Col>
</Row>
</Form>
</Form.Section>
<Text>{t('确定要删除此分类吗?')}</Text>
</Modal>
</>
);
};
@@ -173,7 +173,8 @@ export default function SettingGeminiModel(props) {
<Text>
{t(
"和Claude不同,默认情况下Gemini的思考模型会自动决定要不要思考,就算不开启适配模型也可以正常使用," +
"如果您需要计费,推荐设置无后缀模型价格按思考价格设置"
"如果您需要计费,推荐设置无后缀模型价格按思考价格设置" +
"支持使用 gemini-2.5-pro-preview-06-05-thinking-128 格式来精确传递思考预算。"
)}
</Text>
</Col>
@@ -183,7 +184,7 @@ export default function SettingGeminiModel(props) {
<Form.Switch
label={t('启用Gemini思考后缀适配')}
field={'gemini.thinking_adapter_enabled'}
extraText={"适配-thinking-nothinking后缀"}
extraText={t('适配 -thinking、-thinking-预算数字 和 -nothinking 后缀')}
onChange={(value) =>
setInputs({
...inputs,
@@ -205,7 +206,7 @@ export default function SettingGeminiModel(props) {
<Row>
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
<Form.InputNumber
label={t('请求模型带-thinking后缀的BudgetTokens数(超出24576的部分将被忽略)')}
label={t('思考预算占比')}
field={'gemini.thinking_adapter_budget_tokens_percentage'}
initValue={''}
extraText={t('0.1-1之间的小数')}
@@ -17,6 +17,8 @@ export default function GroupRatioSettings(props) {
GroupRatio: '',
UserUsableGroups: '',
GroupGroupRatio: '',
AutoGroups: '',
DefaultUseAutoGroup: false,
});
const refForm = useRef();
const [inputsRow, setInputsRow] = useState(inputs);
@@ -167,6 +169,59 @@ export default function GroupRatioSettings(props) {
/>
</Col>
</Row>
<Row gutter={16}>
<Col xs={24} sm={16}>
<Form.TextArea
label={t('自动分组auto,从第一个开始选择')}
placeholder={t('为一个 JSON 文本')}
field={'AutoGroups'}
autosize={{ minRows: 6, maxRows: 12 }}
trigger='blur'
stopValidateWithError
rules={[
{
validator: (rule, value) => {
if (!value || value.trim() === '') {
return true; // Allow empty values
}
// First check if it's valid JSON
try {
const parsed = JSON.parse(value);
// Check if it's an array
if (!Array.isArray(parsed)) {
return false;
}
// Check if every element is a string
return parsed.every(item => typeof item === 'string');
} catch (error) {
return false;
}
},
message: t('必须是有效的 JSON 字符串数组,例如:["g1","g2"]'),
},
]}
onChange={(value) =>
setInputs({ ...inputs, AutoGroups: value })
}
/>
</Col>
</Row>
<Row gutter={16}>
<Col span={16}>
<Form.Switch
label={t(
'创建令牌默认选择auto分组,初始令牌也将设为auto(否则留空,为用户默认分组)',
)}
field={'DefaultUseAutoGroup'}
onChange={(value) =>
setInputs({ ...inputs, DefaultUseAutoGroup: value })
}
/>
</Col>
</Row>
</Form.Section>
</Form>
<Button onClick={onSubmit}>{t('保存分组倍率设置')}</Button>
+7 -1
View File
@@ -10,6 +10,7 @@ import OperationSetting from '../../components/settings/OperationSetting.js';
import RateLimitSetting from '../../components/settings/RateLimitSetting.js';
import ModelSetting from '../../components/settings/ModelSetting.js';
import DashboardSetting from '../../components/settings/DashboardSetting.js';
import RatioSetting from '../../components/settings/RatioSetting.js';
const Setting = () => {
const { t } = useTranslation();
@@ -24,6 +25,11 @@ const Setting = () => {
content: <OperationSetting />,
itemKey: 'operation',
});
panes.push({
tab: t('倍率设置'),
content: <RatioSetting />,
itemKey: 'ratio',
});
panes.push({
tab: t('速率限制设置'),
content: <RateLimitSetting />,
@@ -45,7 +51,7 @@ const Setting = () => {
itemKey: 'other',
});
panes.push({
tab: t('仪表盘置'),
tab: t('仪表盘置'),
content: <DashboardSetting />,
itemKey: 'dashboard',
});
+233 -129
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useState, useContext } from 'react';
import { useNavigate } from 'react-router-dom';
import {
API,
@@ -7,7 +7,7 @@ import {
showSuccess,
timestamp2string,
renderGroupOption,
renderQuotaWithPrompt
renderQuotaWithPrompt,
} from '../../helpers';
import {
AutoComplete,
@@ -37,11 +37,13 @@ import {
IconPlusCircle,
} from '@douyinfe/semi-icons';
import { useTranslation } from 'react-i18next';
import { StatusContext } from '../../context/Status';
const { Text, Title } = Typography;
const EditToken = (props) => {
const { t } = useTranslation();
const [statusState, statusDispatch] = useContext(StatusContext);
const [isEdit, setIsEdit] = useState(false);
const [loading, setLoading] = useState(isEdit);
const originInputs = {
@@ -119,7 +121,19 @@ const EditToken = (props) => {
value: group,
ratio: info.ratio,
}));
if (statusState?.status?.default_use_auto_group) {
// if contain auto, add it to the first position
if (localGroupOptions.some((group) => group.value === 'auto')) {
// 排序
localGroupOptions.sort((a, b) => (a.value === 'auto' ? -1 : 1));
} else {
localGroupOptions.unshift({ label: t('自动选择'), value: 'auto' });
}
}
setGroups(localGroupOptions);
if (statusState?.status?.default_use_auto_group) {
setInputs({ ...inputs, group: 'auto' });
}
} else {
showError(t(message));
}
@@ -268,32 +282,37 @@ const EditToken = (props) => {
placement={isEdit ? 'right' : 'left'}
title={
<Space>
{isEdit ?
<Tag color="blue" shape="circle">{t('更新')}</Tag> :
<Tag color="green" shape="circle">{t('新')}</Tag>
}
<Title heading={4} className="m-0">
{isEdit ? (
<Tag color='blue' shape='circle'>
{t('新')}
</Tag>
) : (
<Tag color='green' shape='circle'>
{t('新建')}
</Tag>
)}
<Title heading={4} className='m-0'>
{isEdit ? t('更新令牌信息') : t('创建新的令牌')}
</Title>
</Space>
}
headerStyle={{
borderBottom: '1px solid var(--semi-color-border)',
padding: '24px'
padding: '24px',
}}
bodyStyle={{
backgroundColor: 'var(--semi-color-bg-0)',
padding: '0'
padding: '0',
}}
visible={props.visiable}
width={isMobile() ? '100%' : 600}
footer={
<div className="flex justify-end bg-white">
<div className='flex justify-end bg-white'>
<Space>
<Button
theme="solid"
size="large"
className="!rounded-full"
theme='solid'
size='large'
className='!rounded-full'
onClick={submit}
icon={<IconSave />}
loading={loading}
@@ -301,10 +320,10 @@ const EditToken = (props) => {
{t('提交')}
</Button>
<Button
theme="light"
size="large"
className="!rounded-full"
type="primary"
theme='light'
size='large'
className='!rounded-full'
type='primary'
onClick={handleCancel}
icon={<IconClose />}
>
@@ -317,87 +336,107 @@ const EditToken = (props) => {
onCancel={() => handleCancel()}
>
<Spin spinning={loading}>
<div className="p-6">
<Card className="!rounded-2xl shadow-sm border-0 mb-6">
<div className="flex items-center mb-4 p-6 rounded-xl" style={{
background: 'linear-gradient(135deg, #1e3a8a 0%, #2563eb 50%, #3b82f6 100%)',
position: 'relative'
}}>
<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-8 -left-8 w-24 h-24 bg-white opacity-10 rounded-full"></div>
<div className='p-6'>
<Card className='!rounded-2xl shadow-sm border-0 mb-6'>
<div
className='flex items-center mb-4 p-6 rounded-xl'
style={{
background:
'linear-gradient(135deg, #1e3a8a 0%, #2563eb 50%, #3b82f6 100%)',
position: 'relative',
}}
>
<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-8 -left-8 w-24 h-24 bg-white opacity-10 rounded-full'></div>
</div>
<div className="w-10 h-10 rounded-full bg-white/20 flex items-center justify-center mr-4 relative">
<IconPlusCircle size="large" style={{ color: '#ffffff' }} />
<div className='w-10 h-10 rounded-full bg-white/20 flex items-center justify-center mr-4 relative'>
<IconPlusCircle size='large' style={{ color: '#ffffff' }} />
</div>
<div className="relative">
<Text style={{ color: '#ffffff' }} className="text-lg font-medium">{t('基本信息')}</Text>
<div style={{ color: '#ffffff' }} className="text-sm opacity-80">{t('设置令牌的基本信息')}</div>
<div className='relative'>
<Text
style={{ color: '#ffffff' }}
className='text-lg font-medium'
>
{t('基本信息')}
</Text>
<div
style={{ color: '#ffffff' }}
className='text-sm opacity-80'
>
{t('设置令牌的基本信息')}
</div>
</div>
</div>
<div className="space-y-4">
<div className='space-y-4'>
<div>
<Text strong className="block mb-2">{t('名称')}</Text>
<Text strong className='block mb-2'>
{t('名称')}
</Text>
<Input
placeholder={t('请输入名称')}
onChange={(value) => handleInputChange('name', value)}
value={name}
autoComplete="new-password"
size="large"
className="!rounded-lg"
autoComplete='new-password'
size='large'
className='!rounded-lg'
showClear
required
/>
</div>
<div>
<Text strong className="block mb-2">{t('过期时间')}</Text>
<div className="mb-2">
<Text strong className='block mb-2'>
{t('过期时间')}
</Text>
<div className='mb-2'>
<DatePicker
placeholder={t('请选择过期时间')}
onChange={(value) => handleInputChange('expired_time', value)}
onChange={(value) =>
handleInputChange('expired_time', value)
}
value={expired_time}
autoComplete="new-password"
type="dateTime"
className="w-full !rounded-lg"
size="large"
autoComplete='new-password'
type='dateTime'
className='w-full !rounded-lg'
size='large'
prefix={<IconCalendar />}
/>
</div>
<div className="flex flex-wrap gap-2">
<div className='flex flex-wrap gap-2'>
<Button
theme="light"
type="primary"
theme='light'
type='primary'
onClick={() => setExpiredTime(0, 0, 0, 0)}
className="!rounded-full"
className='!rounded-full'
>
{t('永不过期')}
</Button>
<Button
theme="light"
type="tertiary"
theme='light'
type='tertiary'
onClick={() => setExpiredTime(0, 0, 1, 0)}
className="!rounded-full"
className='!rounded-full'
icon={<IconClock />}
>
{t('一小时')}
</Button>
<Button
theme="light"
type="tertiary"
theme='light'
type='tertiary'
onClick={() => setExpiredTime(0, 1, 0, 0)}
className="!rounded-full"
className='!rounded-full'
icon={<IconCalendar />}
>
{t('一天')}
</Button>
<Button
theme="light"
type="tertiary"
theme='light'
type='tertiary'
onClick={() => setExpiredTime(1, 0, 0, 0)}
className="!rounded-full"
className='!rounded-full'
icon={<IconCalendar />}
>
{t('一个月')}
@@ -407,44 +446,62 @@ const EditToken = (props) => {
</div>
</Card>
<Card className="!rounded-2xl shadow-sm border-0 mb-6">
<div className="flex items-center mb-4 p-6 rounded-xl" style={{
background: 'linear-gradient(135deg, #065f46 0%, #059669 50%, #10b981 100%)',
position: 'relative'
}}>
<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-8 -left-8 w-24 h-24 bg-white opacity-10 rounded-full"></div>
<Card className='!rounded-2xl shadow-sm border-0 mb-6'>
<div
className='flex items-center mb-4 p-6 rounded-xl'
style={{
background:
'linear-gradient(135deg, #065f46 0%, #059669 50%, #10b981 100%)',
position: 'relative',
}}
>
<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-8 -left-8 w-24 h-24 bg-white opacity-10 rounded-full'></div>
</div>
<div className="w-10 h-10 rounded-full bg-white/20 flex items-center justify-center mr-4 relative">
<IconCreditCard size="large" style={{ color: '#ffffff' }} />
<div className='w-10 h-10 rounded-full bg-white/20 flex items-center justify-center mr-4 relative'>
<IconCreditCard size='large' style={{ color: '#ffffff' }} />
</div>
<div className="relative">
<Text style={{ color: '#ffffff' }} className="text-lg font-medium">{t('额度设置')}</Text>
<div style={{ color: '#ffffff' }} className="text-sm opacity-80">{t('设置令牌可用额度和数量')}</div>
<div className='relative'>
<Text
style={{ color: '#ffffff' }}
className='text-lg font-medium'
>
{t('额度设置')}
</Text>
<div
style={{ color: '#ffffff' }}
className='text-sm opacity-80'
>
{t('设置令牌可用额度和数量')}
</div>
</div>
</div>
<Banner
type="warning"
description={t('注意,令牌的额度仅用于限制令牌本身的最大额度使用量,实际的使用受到账户的剩余额度限制。')}
className="mb-4 !rounded-lg"
type='warning'
description={t(
'注意,令牌的额度仅用于限制令牌本身的最大额度使用量,实际的使用受到账户的剩余额度限制。',
)}
className='mb-4 !rounded-lg'
/>
<div className="space-y-4">
<div className='space-y-4'>
<div>
<div className="flex justify-between mb-2">
<div className='flex justify-between mb-2'>
<Text strong>{t('额度')}</Text>
<Text type="tertiary">{renderQuotaWithPrompt(remain_quota)}</Text>
<Text type='tertiary'>
{renderQuotaWithPrompt(remain_quota)}
</Text>
</div>
<AutoComplete
placeholder={t('请输入额度')}
onChange={(value) => handleInputChange('remain_quota', value)}
value={remain_quota}
autoComplete="new-password"
type="number"
size="large"
className="w-full !rounded-lg"
autoComplete='new-password'
type='number'
size='large'
className='w-full !rounded-lg'
prefix={<IconCreditCard />}
data={[
{ value: 500000, label: '1$' },
@@ -460,16 +517,18 @@ const EditToken = (props) => {
{!isEdit && (
<div>
<Text strong className="block mb-2">{t('新建数量')}</Text>
<Text strong className='block mb-2'>
{t('新建数量')}
</Text>
<AutoComplete
placeholder={t('请选择或输入创建令牌的数量')}
onChange={(value) => handleTokenCountChange(value)}
onSelect={(value) => handleTokenCountChange(value)}
value={tokenCount.toString()}
autoComplete="off"
type="number"
className="w-full !rounded-lg"
size="large"
autoComplete='off'
type='number'
className='w-full !rounded-lg'
size='large'
prefix={<IconPlusCircle />}
data={[
{ value: 10, label: t('10个') },
@@ -482,12 +541,12 @@ const EditToken = (props) => {
</div>
)}
<div className="flex justify-end">
<div className='flex justify-end'>
<Button
theme="light"
type={unlimited_quota ? "danger" : "warning"}
theme='light'
type={unlimited_quota ? 'danger' : 'warning'}
onClick={setUnlimitedQuota}
className="!rounded-full"
className='!rounded-full'
>
{unlimited_quota ? t('取消无限额度') : t('设为无限额度')}
</Button>
@@ -495,92 +554,137 @@ const EditToken = (props) => {
</div>
</Card>
<Card className="!rounded-2xl shadow-sm border-0 mb-6">
<div className="flex items-center mb-4 p-6 rounded-xl" style={{
background: 'linear-gradient(135deg, #4c1d95 0%, #6d28d9 50%, #7c3aed 100%)',
position: 'relative'
}}>
<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-8 -left-8 w-24 h-24 bg-white opacity-10 rounded-full"></div>
<Card className='!rounded-2xl shadow-sm border-0 mb-6'>
<div
className='flex items-center mb-4 p-6 rounded-xl'
style={{
background:
'linear-gradient(135deg, #4c1d95 0%, #6d28d9 50%, #7c3aed 100%)',
position: 'relative',
}}
>
<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-8 -left-8 w-24 h-24 bg-white opacity-10 rounded-full'></div>
</div>
<div className="w-10 h-10 rounded-full bg-white/20 flex items-center justify-center mr-4 relative">
<IconLink size="large" style={{ color: '#ffffff' }} />
<div className='w-10 h-10 rounded-full bg-white/20 flex items-center justify-center mr-4 relative'>
<IconLink size='large' style={{ color: '#ffffff' }} />
</div>
<div className="relative">
<Text style={{ color: '#ffffff' }} className="text-lg font-medium">{t('访问限制')}</Text>
<div style={{ color: '#ffffff' }} className="text-sm opacity-80">{t('设置令牌的访问限制')}</div>
<div className='relative'>
<Text
style={{ color: '#ffffff' }}
className='text-lg font-medium'
>
{t('访问限制')}
</Text>
<div
style={{ color: '#ffffff' }}
className='text-sm opacity-80'
>
{t('设置令牌的访问限制')}
</div>
</div>
</div>
<div className="space-y-4">
<div className='space-y-4'>
<div>
<Text strong className="block mb-2">{t('IP白名单')}</Text>
<Text strong className='block mb-2'>
{t('IP白名单')}
</Text>
<TextArea
placeholder={t('允许的IP,一行一个,不填写则不限制')}
onChange={(value) => handleInputChange('allow_ips', value)}
value={inputs.allow_ips}
style={{ fontFamily: 'JetBrains Mono, Consolas' }}
className="!rounded-lg"
className='!rounded-lg'
rows={4}
/>
<Text type="tertiary" className="mt-1 block text-xs">{t('请勿过度信任此功能,IP可能被伪造')}</Text>
<Text type='tertiary' className='mt-1 block text-xs'>
{t('请勿过度信任此功能,IP可能被伪造')}
</Text>
</div>
<div>
<div className="flex items-center mb-2">
<div className='flex items-center mb-2'>
<Checkbox
checked={model_limits_enabled}
onChange={(e) => handleInputChange('model_limits_enabled', e.target.checked)}
onChange={(e) =>
handleInputChange(
'model_limits_enabled',
e.target.checked,
)
}
>
<Text strong>{t('模型限制')}</Text>
</Checkbox>
</div>
<Select
placeholder={model_limits_enabled ? t('请选择该渠道所支持的模型') : t('勾选启用模型限制后可选择')}
placeholder={
model_limits_enabled
? t('请选择该渠道所支持的模型')
: t('勾选启用模型限制后可选择')
}
onChange={(value) => handleInputChange('model_limits', value)}
value={inputs.model_limits}
multiple
size="large"
className="w-full !rounded-lg"
size='large'
className='w-full !rounded-lg'
prefix={<IconServer />}
optionList={models}
disabled={!model_limits_enabled}
maxTagCount={3}
/>
<Text type="tertiary" className="mt-1 block text-xs">{t('非必要,不建议启用模型限制')}</Text>
<Text type='tertiary' className='mt-1 block text-xs'>
{t('非必要,不建议启用模型限制')}
</Text>
</div>
</div>
</Card>
<Card className="!rounded-2xl shadow-sm border-0">
<div className="flex items-center mb-4 p-6 rounded-xl" style={{
background: 'linear-gradient(135deg, #92400e 0%, #d97706 50%, #f59e0b 100%)',
position: 'relative'
}}>
<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-8 -left-8 w-24 h-24 bg-white opacity-10 rounded-full"></div>
<Card className='!rounded-2xl shadow-sm border-0'>
<div
className='flex items-center mb-4 p-6 rounded-xl'
style={{
background:
'linear-gradient(135deg, #92400e 0%, #d97706 50%, #f59e0b 100%)',
position: 'relative',
}}
>
<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-8 -left-8 w-24 h-24 bg-white opacity-10 rounded-full'></div>
</div>
<div className="w-10 h-10 rounded-full bg-white/20 flex items-center justify-center mr-4 relative">
<IconUserGroup size="large" style={{ color: '#ffffff' }} />
<div className='w-10 h-10 rounded-full bg-white/20 flex items-center justify-center mr-4 relative'>
<IconUserGroup size='large' style={{ color: '#ffffff' }} />
</div>
<div className="relative">
<Text style={{ color: '#ffffff' }} className="text-lg font-medium">{t('分组信息')}</Text>
<div style={{ color: '#ffffff' }} className="text-sm opacity-80">{t('设置令牌的分组')}</div>
<div className='relative'>
<Text
style={{ color: '#ffffff' }}
className='text-lg font-medium'
>
{t('分组信息')}
</Text>
<div
style={{ color: '#ffffff' }}
className='text-sm opacity-80'
>
{t('设置令牌的分组')}
</div>
</div>
</div>
<div>
<Text strong className="block mb-2">{t('令牌分组')}</Text>
<Text strong className='block mb-2'>
{t('令牌分组')}
</Text>
{groups.length > 0 ? (
<Select
placeholder={t('令牌分组,默认为用户的分组')}
onChange={(value) => handleInputChange('group', value)}
renderOptionItem={renderGroupOption}
value={inputs.group}
size="large"
className="w-full !rounded-lg"
size='large'
className='w-full !rounded-lg'
prefix={<IconUserGroup />}
optionList={groups}
/>
@@ -588,8 +692,8 @@ const EditToken = (props) => {
<Select
placeholder={t('管理员未设置用户可选分组')}
disabled={true}
size="large"
className="w-full !rounded-lg"
size='large'
className='w-full !rounded-lg'
prefix={<IconUserGroup />}
/>
)}
+432 -185
View File
@@ -7,7 +7,7 @@ import {
renderQuota,
renderQuotaWithAmount,
copy,
getQuotaPerUnit
getQuotaPerUnit,
} from '../../helpers';
import {
Avatar,
@@ -34,7 +34,7 @@ import {
Copy,
Users,
User,
Coins
Coins,
} from 'lucide-react';
const { Text, Title } = Typography;
@@ -49,9 +49,15 @@ const TopUp = () => {
const [topUpCode, setTopUpCode] = useState('');
const [amount, setAmount] = useState(0.0);
const [minTopUp, setMinTopUp] = useState(statusState?.status?.min_topup || 1);
const [topUpCount, setTopUpCount] = useState(statusState?.status?.min_topup || 1);
const [topUpLink, setTopUpLink] = useState(statusState?.status?.top_up_link || '');
const [enableOnlineTopUp, setEnableOnlineTopUp] = useState(statusState?.status?.enable_online_topup || false);
const [topUpCount, setTopUpCount] = useState(
statusState?.status?.min_topup || 1,
);
const [topUpLink, setTopUpLink] = useState(
statusState?.status?.top_up_link || '',
);
const [enableOnlineTopUp, setEnableOnlineTopUp] = useState(
statusState?.status?.enable_online_topup || false,
);
const [priceRatio, setPriceRatio] = useState(statusState?.status?.price || 1);
const [userQuota, setUserQuota] = useState(0);
const [isSubmitting, setIsSubmitting] = useState(false);
@@ -61,6 +67,7 @@ const TopUp = () => {
const [amountLoading, setAmountLoading] = useState(false);
const [paymentLoading, setPaymentLoading] = useState(false);
const [confirmLoading, setConfirmLoading] = useState(false);
const [payMethods, setPayMethods] = useState([]);
// 邀请相关状态
const [affLink, setAffLink] = useState('');
@@ -76,7 +83,7 @@ const TopUp = () => {
{ value: 100 },
{ value: 300 },
{ value: 500 },
{ value: 1000 }
{ value: 1000 },
]);
const [selectedPreset, setSelectedPreset] = useState(null);
@@ -126,7 +133,7 @@ const TopUp = () => {
if (userState.user) {
const updatedUser = {
...userState.user,
quota: userState.user.quota + data
quota: userState.user.quota + data,
};
userDispatch({ type: 'login', payload: updatedUser });
}
@@ -283,6 +290,34 @@ const TopUp = () => {
}
getAffLink().then();
setTransferAmount(getQuotaPerUnit());
let payMethods = localStorage.getItem('pay_methods');
try {
payMethods = JSON.parse(payMethods);
if (payMethods && payMethods.length > 0) {
// 检查name和type是否为空
payMethods = payMethods.filter((method) => {
return method.name && method.type;
});
// 如果没有color,则设置默认颜色
payMethods = payMethods.map((method) => {
if (!method.color) {
if (method.type === 'zfb') {
method.color = 'rgba(var(--semi-blue-5), 1)';
} else if (method.type === 'wx') {
method.color = 'rgba(var(--semi-green-5), 1)';
} else {
method.color = 'rgba(var(--semi-primary-5), 1)';
}
}
return method;
});
setPayMethods(payMethods);
}
} catch (e) {
console.log(e);
showError(t('支付方式配置错误, 请联系管理员'));
}
}, []);
useEffect(() => {
@@ -347,12 +382,12 @@ const TopUp = () => {
};
return (
<div className="mx-auto relative min-h-screen lg:min-h-0">
<div className='mx-auto relative min-h-screen lg:min-h-0'>
{/* 划转模态框 */}
<Modal
title={
<div className="flex items-center">
<CreditCard className="mr-2" size={18} />
<div className='flex items-center'>
<CreditCard className='mr-2' size={18} />
{t('划转邀请额度')}
</div>
}
@@ -360,22 +395,22 @@ const TopUp = () => {
onOk={transfer}
onCancel={handleTransferCancel}
maskClosable={false}
size="small"
size='small'
centered
>
<div className="space-y-4">
<div className='space-y-4'>
<div>
<Typography.Text strong className="block mb-2">
<Typography.Text strong className='block mb-2'>
{t('可用邀请额度')}
</Typography.Text>
<Input
value={renderQuota(userState?.user?.aff_quota)}
disabled
size="large"
size='large'
/>
</div>
<div>
<Typography.Text strong className="block mb-2">
<Typography.Text strong className='block mb-2'>
{t('划转额度')} ({t('最低') + renderQuota(getQuotaPerUnit())})
</Typography.Text>
<InputNumber
@@ -383,8 +418,8 @@ const TopUp = () => {
max={userState?.user?.aff_quota || 0}
value={transferAmount}
onChange={(value) => setTransferAmount(value)}
size="large"
className="w-full"
size='large'
className='w-full'
/>
</div>
</div>
@@ -393,8 +428,8 @@ const TopUp = () => {
{/* 充值确认模态框 */}
<Modal
title={
<div className="flex items-center">
<CreditCard className="mr-2" size={18} />
<div className='flex items-center'>
<CreditCard className='mr-2' size={18} />
{t('充值确认')}
</div>
}
@@ -402,57 +437,80 @@ const TopUp = () => {
onOk={onlineTopUp}
onCancel={handleCancel}
maskClosable={false}
size="small"
size='small'
centered
confirmLoading={confirmLoading}
>
<div className="space-y-4">
<div className="flex justify-between items-center py-2">
<div className='space-y-4'>
<div className='flex justify-between items-center py-2'>
<Text strong>{t('充值数量')}</Text>
<Text>{renderQuotaWithAmount(topUpCount)}</Text>
</div>
<div className="flex justify-between items-center py-2">
<div className='flex justify-between items-center py-2'>
<Text strong>{t('实付金额')}</Text>
{amountLoading ? (
<Skeleton.Title style={{ width: '60px', height: '16px' }} />
) : (
<Text type="danger" strong>{renderAmount()}</Text>
<Text type='danger' strong>
{renderAmount()}
</Text>
)}
</div>
<div className="flex justify-between items-center py-2">
<div className='flex justify-between items-center py-2'>
<Text strong>{t('支付方式')}</Text>
<Text>
{payWay === 'zfb' ? (
<div className="flex items-center">
<SiAlipay className="mr-1" size={16} />
{t('支付宝')}
</div>
) : (
<div className="flex items-center">
<SiWechat className="mr-1" size={16} />
{t('微信')}
</div>
)}
{(() => {
const payMethod = payMethods.find(
(method) => method.type === payWay,
);
if (payMethod) {
return (
<div className='flex items-center'>
{payMethod.type === 'zfb' ? (
<SiAlipay className='mr-1' size={16} />
) : payMethod.type === 'wx' ? (
<SiWechat className='mr-1' size={16} />
) : (
<CreditCard className='mr-1' size={16} />
)}
{payMethod.name}
</div>
);
} else {
// 默认充值方式
return payWay === 'zfb' ? (
<div className='flex items-center'>
<SiAlipay className='mr-1' size={16} />
{t('支付宝')}
</div>
) : (
<div className='flex items-center'>
<SiWechat className='mr-1' size={16} />
{t('微信')}
</div>
);
}
})()}
</Text>
</div>
</div>
</Modal>
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6">
<div className='grid grid-cols-1 lg:grid-cols-12 gap-6'>
{/* 左侧充值区域 */}
<div className="lg:col-span-7 space-y-6 w-full">
<div className='lg:col-span-7 space-y-6 w-full'>
{/* 在线充值卡片 */}
<Card
className="!rounded-2xl"
className='!rounded-2xl'
shadows='always'
bordered={false}
header={
<div className="px-5 py-4 pb-0">
<div className="flex items-center justify-between">
<div className="flex items-center">
<div className='px-5 py-4 pb-0'>
<div className='flex items-center justify-between'>
<div className='flex items-center'>
<Avatar
className="mr-3 shadow-md flex-shrink-0"
color="blue"
className='mr-3 shadow-md flex-shrink-0'
color='blue'
>
<CreditCard size={24} />
</Avatar>
@@ -460,21 +518,23 @@ const TopUp = () => {
<Title heading={5} style={{ margin: 0 }}>
{t('在线充值')}
</Title>
<Text type="tertiary" className="text-sm">
<Text type='tertiary' className='text-sm'>
{t('快速方便的充值方式')}
</Text>
</div>
</div>
<div className="flex items-center">
<div className='flex items-center'>
{userDataLoading ? (
<Skeleton.Paragraph style={{ width: '120px' }} rows={1} />
) : (
<Text type="tertiary" className="hidden sm:block">
<div className="flex items-center">
<User size={14} className="mr-1" />
<span className="hidden md:inline">{getUsername()} ({getUserRole()})</span>
<span className="md:hidden">{getUsername()}</span>
<Text type='tertiary' className='hidden sm:block'>
<div className='flex items-center'>
<User size={14} className='mr-1' />
<span className='hidden md:inline'>
{getUsername()} ({getUserRole()})
</span>
<span className='md:hidden'>{getUsername()}</span>
</div>
</Text>
)}
@@ -483,29 +543,33 @@ const TopUp = () => {
</div>
}
>
<div className="space-y-4">
<div className='space-y-4'>
{/* 账户余额信息 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-2">
<Card className="!rounded-2xl">
<Text type="tertiary" className="mb-1">
<div className='grid grid-cols-1 md:grid-cols-2 gap-4 mb-2'>
<Card className='!rounded-2xl'>
<Text type='tertiary' className='mb-1'>
{t('当前余额')}
</Text>
{userDataLoading ? (
<Skeleton.Title style={{ width: '100px', height: '30px' }} />
<Skeleton.Title
style={{ width: '100px', height: '30px' }}
/>
) : (
<div className="text-xl font-semibold mt-2">
<div className='text-xl font-semibold mt-2'>
{renderQuota(userState?.user?.quota || userQuota)}
</div>
)}
</Card>
<Card className="!rounded-2xl">
<Text type="tertiary" className="mb-1">
<Card className='!rounded-2xl'>
<Text type='tertiary' className='mb-1'>
{t('历史消耗')}
</Text>
{userDataLoading ? (
<Skeleton.Title style={{ width: '100px', height: '30px' }} />
<Skeleton.Title
style={{ width: '100px', height: '30px' }}
/>
) : (
<div className="text-xl font-semibold mt-2">
<div className='text-xl font-semibold mt-2'>
{renderQuota(userState?.user?.used_quota || 0)}
</div>
)}
@@ -516,8 +580,10 @@ const TopUp = () => {
<>
{/* 预设充值额度卡片网格 */}
<div>
<Text strong className="block mb-3">{t('选择充值额度')}</Text>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-3">
<Text strong className='block mb-3'>
{t('选择充值额度')}
</Text>
<div className='grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-3'>
{presetAmounts.map((preset, index) => (
<Card
key={index}
@@ -528,35 +594,44 @@ const TopUp = () => {
}`}
bodyStyle={{ textAlign: 'center' }}
>
<div className="font-medium text-lg flex items-center justify-center mb-1">
<Coins size={16} className="mr-0.5" />
<div className='font-medium text-lg flex items-center justify-center mb-1'>
<Coins size={16} className='mr-0.5' />
{formatLargeNumber(preset.value)}
</div>
<div className="text-xs text-gray-500">
{t('实付')} {(preset.value * priceRatio).toFixed(2)}
<div className='text-xs text-gray-500'>
{t('实付')}
{(preset.value * priceRatio).toFixed(2)}
</div>
</Card>
))}
</div>
</div>
{/* 桌面端显示的自定义金额和支付按钮 */}
<div className="hidden md:block space-y-4">
<div className='hidden md:block space-y-4'>
<Divider style={{ margin: '24px 0' }}>
<Text className="text-sm font-medium">{t('或输入自定义金额')}</Text>
<Text className='text-sm font-medium'>
{t('或输入自定义金额')}
</Text>
</Divider>
<div>
<div className="flex justify-between mb-2">
<div className='flex justify-between mb-2'>
<Text strong>{t('充值数量')}</Text>
{amountLoading ? (
<Skeleton.Title style={{ width: '80px', height: '16px' }} />
<Skeleton.Title
style={{ width: '80px', height: '16px' }}
/>
) : (
<Text type="tertiary">{t('实付金额:') + renderAmount()}</Text>
<Text type='tertiary'>
{t('实付金额:') + renderAmount()}
</Text>
)}
</div>
<InputNumber
disabled={!enableOnlineTopUp}
placeholder={t('充值数量,最低 ') + renderQuotaWithAmount(minTopUp)}
placeholder={
t('充值数量,最低 ') + renderQuotaWithAmount(minTopUp)
}
value={topUpCount}
min={minTopUp}
max={999999999}
@@ -576,36 +651,148 @@ const TopUp = () => {
getAmount(1);
}
}}
size="large"
className="w-full"
formatter={(value) => value ? `${value}` : ''}
parser={(value) => value ? parseInt(value.replace(/[^\d]/g, '')) : 0}
size='large'
className='w-full'
formatter={(value) => (value ? `${value}` : '')}
parser={(value) =>
value ? parseInt(value.replace(/[^\d]/g, '')) : 0
}
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<Button
type="primary"
onClick={() => preTopUp('zfb')}
size="large"
disabled={!enableOnlineTopUp}
loading={paymentLoading && payWay === 'zfb'}
icon={<SiAlipay size={18} />}
style={{ height: '44px' }}
>
<span className="ml-2">{t('支付宝')}</span>
</Button>
<Button
type="primary"
onClick={() => preTopUp('wx')}
size="large"
disabled={!enableOnlineTopUp}
loading={paymentLoading && payWay === 'wx'}
icon={<SiWechat size={18} />}
style={{ height: '44px' }}
>
<span className="ml-2">{t('微信')}</span>
</Button>
<div>
<Text strong className='block mb-3'>
{t('选择支付方式')}
</Text>
{payMethods.length === 2 ? (
<div className='grid grid-cols-1 sm:grid-cols-2 gap-3'>
{payMethods.map((payMethod) => (
<Button
key={payMethod.type}
type='primary'
onClick={() => preTopUp(payMethod.type)}
size='large'
disabled={!enableOnlineTopUp}
loading={paymentLoading && payWay === payMethod.type}
icon={
payMethod.type === 'zfb' ? (
<SiAlipay size={16} />
) : payMethod.type === 'wx' ? (
<SiWechat size={16} />
) : (
<CreditCard size={16} />
)
}
style={{
height: '40px',
color: payMethod.color,
}}
className='transition-all hover:shadow-md w-full'
>
<span className='ml-1'>{payMethod.name}</span>
</Button>
))}
</div>
) : payMethods.length === 3 ? (
<div className='grid grid-cols-1 sm:grid-cols-3 gap-3'>
{payMethods.map((payMethod) => (
<Button
key={payMethod.type}
type='primary'
onClick={() => preTopUp(payMethod.type)}
size='large'
disabled={!enableOnlineTopUp}
loading={paymentLoading && payWay === payMethod.type}
icon={
payMethod.type === 'zfb' ? (
<SiAlipay size={16} />
) : payMethod.type === 'wx' ? (
<SiWechat size={16} />
) : (
<CreditCard size={16} />
)
}
style={{
height: '40px',
color: payMethod.color,
}}
className='transition-all hover:shadow-md w-full'
>
<span className='ml-1'>{payMethod.name}</span>
</Button>
))}
</div>
) : payMethods.length > 3 ? (
<div className='grid grid-cols-2 sm:grid-cols-4 gap-3'>
{payMethods.map((payMethod) => (
<Card
key={payMethod.type}
onClick={() => preTopUp(payMethod.type)}
disabled={!enableOnlineTopUp}
className={`cursor-pointer !rounded-xl p-0 transition-all hover:shadow-md ${paymentLoading && payWay === payMethod.type
? 'border-blue-400'
: 'border-gray-200 hover:border-gray-300'
}`}
bodyStyle={{
padding: '10px',
textAlign: 'center',
opacity: !enableOnlineTopUp ? 0.5 : 1
}}
>
{paymentLoading && payWay === payMethod.type ? (
<div className='flex flex-col items-center justify-center h-full'>
<div className='mb-1'>
<div className='animate-spin rounded-full h-4 w-4 border-b-2 border-blue-500'></div>
</div>
<div className='text-xs text-gray-500'>{t('处理中')}</div>
</div>
) : (
<>
<div className='flex items-center justify-center mb-1'>
{payMethod.type === 'zfb' ? (
<SiAlipay size={20} color={payMethod.color} />
) : payMethod.type === 'wx' ? (
<SiWechat size={20} color={payMethod.color} />
) : (
<CreditCard size={20} color={payMethod.color} />
)}
</div>
<div className='text-sm font-medium'>{payMethod.name}</div>
</>
)}
</Card>
))}
</div>
) : (
<div className='grid grid-cols-1 gap-3'>
{payMethods.map((payMethod) => (
<Button
key={payMethod.type}
type='primary'
onClick={() => preTopUp(payMethod.type)}
size='large'
disabled={!enableOnlineTopUp}
loading={paymentLoading && payWay === payMethod.type}
icon={
payMethod.type === 'zfb' ? (
<SiAlipay size={16} />
) : payMethod.type === 'wx' ? (
<SiWechat size={16} />
) : (
<CreditCard size={16} />
)
}
style={{
height: '40px',
color: payMethod.color,
}}
className='transition-all hover:shadow-md w-full'
>
<span className='ml-1'>{payMethod.name}</span>
</Button>
))}
</div>
)}
</div>
</div>
</>
@@ -613,39 +800,41 @@ const TopUp = () => {
{!enableOnlineTopUp && (
<Banner
type="warning"
description={t('管理员未开启在线充值功能,请联系管理员开启或使用兑换码充值。')}
type='warning'
description={t(
'管理员未开启在线充值功能,请联系管理员开启或使用兑换码充值。',
)}
closeIcon={null}
className="!rounded-2xl"
className='!rounded-2xl'
/>
)}
<Divider style={{ margin: '24px 0' }}>
<Text className="text-sm font-medium">{t('兑换码充值')}</Text>
<Text className='text-sm font-medium'>{t('兑换码充值')}</Text>
</Divider>
<Card className="!rounded-2xl">
<div className="flex items-start mb-4">
<Gift size={16} className="mr-2 mt-0.5" />
<Card className='!rounded-2xl'>
<div className='flex items-start mb-4'>
<Gift size={16} className='mr-2 mt-0.5' />
<Text strong>{t('使用兑换码快速充值')}</Text>
</div>
<div className="mb-4">
<div className='mb-4'>
<Input
placeholder={t('请输入兑换码')}
value={redemptionCode}
onChange={(value) => setRedemptionCode(value)}
size="large"
size='large'
/>
</div>
<div className="flex flex-col sm:flex-row gap-3">
<div className='flex flex-col sm:flex-row gap-3'>
{topUpLink && (
<Button
type="secondary"
type='secondary'
onClick={openTopUpLink}
size="large"
className="flex-1"
size='large'
className='flex-1'
icon={<LinkIcon size={16} />}
style={{ height: '40px' }}
>
@@ -653,12 +842,12 @@ const TopUp = () => {
</Button>
)}
<Button
type="primary"
type='primary'
onClick={topUp}
disabled={isSubmitting || !redemptionCode}
loading={isSubmitting}
size="large"
className="flex-1"
size='large'
className='flex-1'
style={{ height: '40px' }}
>
{isSubmitting ? t('兑换中...') : t('兑换')}
@@ -670,18 +859,18 @@ const TopUp = () => {
</div>
{/* 右侧邀请信息卡片 */}
<div className="lg:col-span-5">
<div className='lg:col-span-5'>
<Card
className="!rounded-2xl"
className='!rounded-2xl'
shadows='always'
bordered={false}
header={
<div className="px-5 py-4 pb-0">
<div className="flex items-center justify-between">
<div className="flex items-center">
<div className='px-5 py-4 pb-0'>
<div className='flex items-center justify-between'>
<div className='flex items-center'>
<Avatar
className="mr-3 shadow-md flex-shrink-0"
color="green"
className='mr-3 shadow-md flex-shrink-0'
color='green'
>
<Users size={24} />
</Avatar>
@@ -689,7 +878,7 @@ const TopUp = () => {
<Title heading={5} style={{ margin: 0 }}>
{t('邀请奖励')}
</Title>
<Text type="tertiary" className="text-sm">
<Text type='tertiary' className='text-sm'>
{t('邀请好友获得额外奖励')}
</Text>
</div>
@@ -698,53 +887,56 @@ const TopUp = () => {
</div>
}
>
<div className="space-y-6">
<div className="grid grid-cols-1 gap-4">
<Card className="!rounded-2xl">
<div className="flex justify-between items-center">
<Text type="tertiary">{t('待使用收益')}</Text>
<div className='space-y-6'>
<div className='grid grid-cols-1 gap-4'>
<Card className='!rounded-2xl'>
<div className='flex justify-between items-center'>
<Text type='tertiary'>{t('待使用收益')}</Text>
<Button
type="primary"
theme="solid"
size="small"
disabled={!userState?.user?.aff_quota || userState?.user?.aff_quota <= 0}
type='primary'
theme='solid'
size='small'
disabled={
!userState?.user?.aff_quota ||
userState?.user?.aff_quota <= 0
}
onClick={() => setOpenTransfer(true)}
>
{t('划转到余额')}
</Button>
</div>
<div className="text-2xl font-semibold mt-2">
<div className='text-2xl font-semibold mt-2'>
{renderQuota(userState?.user?.aff_quota || 0)}
</div>
</Card>
<div className="grid grid-cols-2 gap-4">
<Card className="!rounded-2xl">
<Text type="tertiary">{t('总收益')}</Text>
<div className="text-xl font-semibold mt-2">
<div className='grid grid-cols-2 gap-4'>
<Card className='!rounded-2xl'>
<Text type='tertiary'>{t('总收益')}</Text>
<div className='text-xl font-semibold mt-2'>
{renderQuota(userState?.user?.aff_history_quota || 0)}
</div>
</Card>
<Card className="!rounded-2xl">
<Text type="tertiary">{t('邀请人数')}</Text>
<div className="text-xl font-semibold mt-2 flex items-center">
<Users size={16} className="mr-1" />
<Card className='!rounded-2xl'>
<Text type='tertiary'>{t('邀请人数')}</Text>
<div className='text-xl font-semibold mt-2 flex items-center'>
<Users size={16} className='mr-1' />
{userState?.user?.aff_count || 0}
</div>
</Card>
</div>
</div>
<div className="space-y-4">
<div className='space-y-4'>
<Title heading={6}>{t('邀请链接')}</Title>
<Input
value={affLink}
readOnly
size="large"
size='large'
suffix={
<Button
type="primary"
theme="light"
type='primary'
theme='light'
onClick={handleAffLinkClick}
icon={<Copy size={14} />}
>
@@ -753,24 +945,24 @@ const TopUp = () => {
}
/>
<div className="mt-4">
<Card className="!rounded-2xl">
<div className="space-y-4">
<div className="flex items-start">
<div className="w-1.5 h-1.5 rounded-full bg-blue-500 mt-2 mr-3 flex-shrink-0"></div>
<Text type="tertiary" className="text-sm leading-6">
<div className='mt-4'>
<Card className='!rounded-2xl'>
<div className='space-y-4'>
<div className='flex items-start'>
<div className='w-1.5 h-1.5 rounded-full bg-blue-500 mt-2 mr-3 flex-shrink-0'></div>
<Text type='tertiary' className='text-sm leading-6'>
{t('邀请好友注册,好友充值后您可获得相应奖励')}
</Text>
</div>
<div className="flex items-start">
<div className="w-1.5 h-1.5 rounded-full bg-green-500 mt-2 mr-3 flex-shrink-0"></div>
<Text type="tertiary" className="text-sm leading-6">
<div className='flex items-start'>
<div className='w-1.5 h-1.5 rounded-full bg-green-500 mt-2 mr-3 flex-shrink-0'></div>
<Text type='tertiary' className='text-sm leading-6'>
{t('通过划转功能将奖励额度转入到您的账户余额中')}
</Text>
</div>
<div className="flex items-start">
<div className="w-1.5 h-1.5 rounded-full bg-purple-500 mt-2 mr-3 flex-shrink-0"></div>
<Text type="tertiary" className="text-sm leading-6">
<div className='flex items-start'>
<div className='w-1.5 h-1.5 rounded-full bg-purple-500 mt-2 mr-3 flex-shrink-0'></div>
<Text type='tertiary' className='text-sm leading-6'>
{t('邀请的好友越多,获得的奖励越多')}
</Text>
</div>
@@ -785,20 +977,27 @@ const TopUp = () => {
{/* 移动端底部固定的自定义金额和支付区域 */}
{enableOnlineTopUp && (
<div className="md:hidden fixed bottom-0 left-0 right-0 p-4 shadow-lg z-50" style={{ background: 'var(--semi-color-bg-0)' }}>
<div className="space-y-4">
<div
className='md:hidden fixed bottom-0 left-0 right-0 p-4 shadow-lg z-50'
style={{ background: 'var(--semi-color-bg-0)' }}
>
<div className='space-y-4'>
<div>
<div className="flex justify-between mb-2">
<div className='flex justify-between mb-2'>
<Text strong>{t('充值数量')}</Text>
{amountLoading ? (
<Skeleton.Title style={{ width: '80px', height: '16px' }} />
) : (
<Text type="tertiary">{t('实付金额:') + renderAmount()}</Text>
<Text type='tertiary'>
{t('实付金额:') + renderAmount()}
</Text>
)}
</div>
<InputNumber
disabled={!enableOnlineTopUp}
placeholder={t('充值数量,最低 ') + renderQuotaWithAmount(minTopUp)}
placeholder={
t('充值数量,最低 ') + renderQuotaWithAmount(minTopUp)
}
value={topUpCount}
min={minTopUp}
max={999999999}
@@ -818,31 +1017,79 @@ const TopUp = () => {
getAmount(1);
}
}}
className="w-full"
formatter={(value) => value ? `${value}` : ''}
parser={(value) => value ? parseInt(value.replace(/[^\d]/g, '')) : 0}
className='w-full'
formatter={(value) => (value ? `${value}` : '')}
parser={(value) =>
value ? parseInt(value.replace(/[^\d]/g, '')) : 0
}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<Button
type="primary"
onClick={() => preTopUp('zfb')}
disabled={!enableOnlineTopUp}
loading={paymentLoading && payWay === 'zfb'}
icon={<SiAlipay size={18} />}
>
<span className="ml-2">{t('支付宝')}</span>
</Button>
<Button
type="primary"
onClick={() => preTopUp('wx')}
disabled={!enableOnlineTopUp}
loading={paymentLoading && payWay === 'wx'}
icon={<SiWechat size={18} />}
>
<span className="ml-2">{t('微信')}</span>
</Button>
<div>
{payMethods.length === 2 ? (
<div className='grid grid-cols-2 gap-3'>
{payMethods.map((payMethod) => (
<Button
key={payMethod.type}
type='primary'
onClick={() => preTopUp(payMethod.type)}
disabled={!enableOnlineTopUp}
loading={paymentLoading && payWay === payMethod.type}
icon={
payMethod.type === 'zfb' ? (
<SiAlipay size={16} />
) : payMethod.type === 'wx' ? (
<SiWechat size={16} />
) : (
<CreditCard size={16} />
)
}
style={{
color: payMethod.color,
}}
className='h-10'
>
<span className='ml-1'>{payMethod.name}</span>
</Button>
))}
</div>
) : (
<div className='grid grid-cols-4 gap-2'>
{payMethods.map((payMethod) => (
<Card
key={payMethod.type}
onClick={() => preTopUp(payMethod.type)}
disabled={!enableOnlineTopUp}
className={`cursor-pointer !rounded-xl p-0 transition-all ${paymentLoading && payWay === payMethod.type
? 'border-blue-400'
: 'border-gray-200'
}`}
bodyStyle={{
padding: '8px',
textAlign: 'center',
opacity: !enableOnlineTopUp ? 0.5 : 1
}}
>
{paymentLoading && payWay === payMethod.type ? (
<div className='animate-spin rounded-full h-4 w-4 border-b-2 border-blue-500 mx-auto'></div>
) : (
<>
<div className='flex justify-center'>
{payMethod.type === 'zfb' ? (
<SiAlipay size={18} color={payMethod.color} />
) : payMethod.type === 'wx' ? (
<SiWechat size={18} color={payMethod.color} />
) : (
<CreditCard size={18} color={payMethod.color} />
)}
</div>
<div className='text-xs mt-1'>{payMethod.name}</div>
</>
)}
</Card>
))}
</div>
)}
</div>
</div>
</div>
+17 -1
View File
@@ -16,6 +16,7 @@ import {
IconClose,
IconKey,
IconUserAdd,
IconEdit,
} from '@douyinfe/semi-icons';
import { useTranslation } from 'react-i18next';
@@ -27,10 +28,11 @@ const AddUser = (props) => {
username: '',
display_name: '',
password: '',
remark: '',
};
const [inputs, setInputs] = useState(originInputs);
const [loading, setLoading] = useState(false);
const { username, display_name, password } = inputs;
const { username, display_name, password, remark } = inputs;
const handleInputChange = (name, value) => {
setInputs((inputs) => ({ ...inputs, [name]: value }));
@@ -175,6 +177,20 @@ const AddUser = (props) => {
required
/>
</div>
<div>
<Text strong className="block mb-2">{t('备注')}</Text>
<Input
placeholder={t('请输入备注(仅管理员可见)')}
onChange={(value) => handleInputChange('remark', value)}
value={remark}
autoComplete="off"
size="large"
className="!rounded-lg"
prefix={<IconEdit />}
showClear
/>
</div>
</div>
</Card>
</div>

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