Compare commits

...

48 Commits

Author SHA1 Message Date
Calcium-Ion 7ef4531a0f Merge pull request #1274 from feitianbubu/feat/add-channel-jimeng
feat: 支持即梦视频渠道
2025-06-27 21:16:50 +08:00
CaIon cc08347caf 🐛 fix: handle response body errors more gracefully in OpenAI handler
Changes:
- Replaced error returns with logging for response body copy failures to prevent early termination of the request.
- Ensured that the response body is closed properly after writing to the client.
- Added comments to clarify the handling of billing and error reporting after the response has been sent.

This update improves error handling and maintains resource management in the OpenAI handler.
2025-06-27 21:13:21 +08:00
skynono 221d2b5552 feat: add video channel jimeng 2025-06-27 17:08:20 +08:00
CaIon e234ad0740 feat: add Function and Container fields to ResponsesToolsCall struct #1305 2025-06-27 16:56:54 +08:00
Xyfacai 416a357c34 Merge pull request #1291 from feitianbubu/pr/add-origin-kling-api
feat: add origin kling api
2025-06-27 16:08:03 +08:00
t0ng7u d3602cbb39 chore: Improve channel creation UX: defer "Fetch Model List" action until after creation
Previously, the "Fetch Model List" button was visible in the channel-creation view even though
it only functions once a channel record exists, leading to user confusion.

Changes introduced:
• Render the "Fetch Model List" button only when editing an existing channel (`isEdit === true`).
• Display an informational Banner in creation mode to remind users that the upstream model list
  can be fetched after the channel has been created.
• Refactored JSX to apply the above conditional rendering without altering existing logic.

This update streamlines the creation workflow and sets clearer expectations for users.
2025-06-27 10:08:44 +08:00
t0ng7u 39fc38662e 💄refactor: enhance EditUser and AddUser form validation & UX
Changes in `web/src/pages/User/EditUser.js`:
• Added `rules` to
  – `Form.Select group`: now required with error “Please select group”.
  – `Form.InputNumber quota`: now required with error “Please enter quota”.
• Added `step={500000}` to quota `InputNumber` for quicker numeric input.
• Replaced invalid `readonly` with React-correct `readOnly`, and added descriptive placeholders for all binding-info fields (GitHub/OIDC/WeChat/Email/Telegram).
• Removed unused `downloadTextAsFile` import.

These updates tighten form validation, improve data entry ergonomics, and restore clear read-only indicators for third-party bindings.
2025-06-27 09:44:18 +08:00
t0ng7u 0cad369fda 🐛 fix(redemptions-table): correct initial page index and pagination state
Summary:
The redemption list occasionally displayed an invalid range such as “Items -9 - 0” and failed to highlight page 1 after a refresh. This was caused by the table being initialized with `currentPage = 0`.

Changes:
• update `useEffect` to load data starting from page 1 instead of page 0
• refactor `loadRedemptions` to accept `page` (default 1) and sanitize backend‐returned pages (`<= 0` coerced to 1)
• keep other logic unchanged

Impact:
Pagination text and page selection now show correct values on first load or refresh, eliminating negative ranges and ensuring the first page is properly highlighted.
2025-06-27 07:42:04 +08:00
t0ng7u 256b9a9fe5 🐛 fix(redemptions-table): show loading indicator while refetching data
Previously, the table did not enter the loading state after performing actions such as deleting, enabling, or disabling a redemption code. This caused a brief period where the UI appeared unresponsive while awaiting the backend response.

Changes made:
• Added `setLoading(true)` at the beginning of `loadRedemptions` to activate the loading spinner whenever data is (re)fetched.
• Added an explanatory code comment to clarify the intent.

This improves user experience by clearly indicating that the system is processing and prevents confusion during data refresh operations.
2025-06-27 07:29:28 +08:00
t0ng7u f2dd64a3b1 🎁 refactor(ui/redemption): migrate EditRedemption page to Semi Form & enhance UX
SUMMARY
• Re-implemented `web/src/pages/Redemption/EditRedemption.js` with Semi Form components, removing legacy local-state handling.
• Added `formApiRef` for centralized control; external “Submit” button now triggers `formApi.submitForm()`.
• Replaced `Input/AutoComplete/DatePicker` etc. with `<Form.*>` fields, leveraging built-in validation & accessibility.
• Field validations:
  – `name` (create only), `quota`, `count` → required with localized messages.
• Expiration-time flow:
  – Default value `null` (no more 1970-01-01).
  – When loading data, convert 0 → null, timestamp → Date.
  – On submit, Date → unix seconds; empty → 0.
• Responsive grid layout (`Row/Col`) for tidy alignment.
• Added helpful `showClear` & full-width styling for inputs; quota presets retained.
• Cleaned unused imports & handlers; fixed linter issues.

RESULT
The Redemption form now benefits from higher performance, clearer validation, and a cleaner codebase consistent with Semi Design best practices.
2025-06-27 07:25:46 +08:00
t0ng7u 0900f43c2a refactor(ui/token): migrate EditToken page to Semi Form API and polish UX
SUMMARY
• Re-implemented `EditToken.js` with Semi Form components, eliminating manual state handling and reducing re-renders.
• Added grid-based layout; “Expiration Time” selector now sits inline with quick-set buttons for consistent alignment on desktop & mobile.
• Introduced dedicated “Quota”, “Access”, “Model Limits”, and “Group” cards for clearer field grouping.
• Reworked model-limit interaction: single multi-select list replaces checkbox toggle; backend flag `model_limits_enabled` is now inferred automatically.
• Applied required validation rules to critical fields (`name`, `remain_quota`, `group`, `expired_time`, `tokenCount`) with localized messages.
• Enabled dynamic option loading for models & groups; default auto-group honoured.
• Added unlimited-quota switch, quota presets, and helpful extraText/tooltips.
• Removed obsolete `handleInputChange` & `setUnlimitedQuota` helpers; formApi now manages all data flow.
• Cleaned imports (e.g., dropped unused `IconUserGroup`), fixed linter errors, and updated submit logic to use `formApi.submitForm()`.

RESULT
The token creation/editing experience is faster, more accessible, and easier to maintain, fully aligned with Semi Design best practices.
2025-06-26 22:58:25 +08:00
t0ng7u b785071ca6 🎨 style: remove all prefix icons to simplify the layout of the sidesheet component 2025-06-26 16:36:36 +08:00
t0ng7u f80eb95864 🎨 style: unify card header UI, switch to Avatar icons & remove oversized props
Summary
• Replaced gradient header blocks with compact, neutral headers wrapped in `Avatar` across the following pages:
  - Channel / EditChannel.js
  - Channel / EditTagModal.js
  - Redemption / EditRedemption.js
  - Token / EditToken.js
  - User / EditUser.js
  - User / AddUser.js

Details
1. Added `Avatar` import and substituted raw icon elements, assigning semantic colors (`blue`, `green`, `purple`, `orange`, etc.) and consistent 16 px icons for a cleaner look.
2. Removed gradient backgrounds, decorative “blur-ball” shapes, and extra paddings from header containers to achieve a tight, flat design.
3. Stripped all `size="large"` attributes from `Button`, `Input`, `Select`, `DatePicker`, `AutoComplete`, and `Avatar` components, allowing default sizing for better visual density.
4. Eliminated redundant `bodyStyle` background overrides in some `SideSheet` components.
5. No business logic touched; all changes are purely presentational.

Result
The editing and creation dialogs now share a unified, compact style consistent with the latest design language, improving readability and user experience without altering functionality.
2025-06-26 16:05:13 +08:00
t0ng7u a976d9d366 🔒 style: Hide registration link when Self-Use Mode is enabled
• Add conditional rendering (`!status.self_use_mode_enabled`) to LoginForm
• Suppress “Don't have an account? Register” CTA in self-hosted scenarios
• Keeps UI clean and prevents unintended user sign-ups under self-use mode
• No impact on regular multi-user deployments
2025-06-26 04:29:44 +08:00
t0ng7u 1275667175 🐛 fix(auth): prevent duplicate “session expired” toast on login
Login Form used to display the message “未登录或登录已过期,请重新登录” twice
because the `useEffect` that inspects the `expired` query parameter was
re-executed on every re-render (e.g. language change or React StrictMode’s
double-mount in development).

### What's changed
• **LoginForm.js** – `useEffect` that shows the toast now has an empty
  dependency array so it runs only once on initial mount.
• Reviewed **PasswordResetConfirm.js**, **PasswordResetForm.js** and
  **RegisterForm.js** and confirmed they do not contain the same issue;
  no changes were required.

### Impact
Users now see the “session expired” notification exactly once, removing
confusion and improving the overall UX.
2025-06-26 03:51:19 +08:00
t0ng7u 7630838743 🧶style(TokensTable): add IconDelete in Delete selected token button 2025-06-25 23:23:59 +08:00
t0ng7u dabd9a9a23 🐛 fix(auth): restore proper state & context destructuring in Login- and Register-forms
Why
Clicking the “Continue” button on the login page no longer triggered the submission logic. The issue was introduced when `useState`/`useContext` hooks were destructured incorrectly, breaking the setter reference and omitting required values.

What’s changed
• **LoginForm.js**
  – Re-added setter in `useSearchParams` (`[searchParams, setSearchParams]`).
  – Corrected order of destructuring for `inputs` so `username`/`password` are available after hooks.
  – Switched `useContext` to `[userState, userDispatch]` for consistency.

• **RegisterForm.js**
  – Adopted `[userState, userDispatch]` from `UserContext` to mirror LoginForm and retain full state access.

Outcome
Login button now successfully invokes `handleSubmit`, and both auth components have consistent, fully-featured hook destructuring, preventing runtime errors and ensuring future state usage is straightforward.
2025-06-25 23:13:55 +08:00
t0ng7u 06b7b595c6 💫 feat(ui): introduce dispersed blur-ball background to all auth views
This commit refreshes the visual design of the authentication pages and aligns them with the Home banner style.

Details
• LoginForm.js / RegisterForm.js / PasswordResetForm.js / PasswordResetConfirm.js
  – Wrap top-level container with `relative overflow-hidden` to provide a positioning context.
  – Inject two decorative blur balls:
    ▸ Indigo ball on the top-right (`blur-ball-indigo`).
    ▸ Teal ball on the middle-left (`blur-ball-teal`).
  – Disabled the default X-axis transform on the indigo ball to keep the ball anchored to the corner.
  – Removed redundant `mt-[64px]` from the outer container and shifted it to the inner wrapper to maintain vertical rhythm without affecting the background placement.

Result
The auth screens now feature subtle, non-intrusive atmospheric gradients in the top-right and mid-left corners, offering a cohesive look & feel across the application without obstructing the main content.
2025-06-25 22:57:04 +08:00
t0ng7u 8fb5cb6686 🐛 fix(auth): prevent initial render flicker & clean up state usage
• LoginForm / RegisterForm now initialise `status` directly from localStorage,
  avoiding a post-mount state update that caused a UI flash between OAuth
  options and email/username forms.

• Move Turnstile configuration into a dedicated effect that depends on
  `status`, ensuring setState is not called during rendering.

• Remove unused `setStatus` setter to resolve ESLint “declared but never read”
  warnings.

• Minor refactors: reorder hooks, de-duplicate navigate/context variables and
  streamline state destructuring for improved readability.
2025-06-25 22:46:11 +08:00
t0ng7u f9971df2ef 🍭style: add mt-[64px] in class auth componets 2025-06-25 22:21:14 +08:00
CaIon 038202b1f6 🔧 fix(xinference): update Document type to 'any' for flexibility
- Changed the type of `Document` in `XinRerankResponseDocument` from `string` to `any` to accommodate various data types.
- Updated the `RerankHandler` to handle `Document` as `any`, ensuring proper assignment based on its actual type.

These modifications enhance the handling of document data, allowing for greater versatility in response structures.
2025-06-25 18:04:34 +08:00
t0ng7u 6d2f34a889 🎨 style(ui): refactor Tabs in ModelPricing to use native Semi UI styling
• Removed the custom `renderArrow` helper and its `Dropdown`-based arrow navigation, simplifying the component logic.
• Switched the `<Tabs>` component to rely on Semi UI’s built-in behaviour (no more `renderArrow` override).
• Kept `type="card"` and `collapsible` props for consistent visual appearance while still using the default style.
• Eliminated the now-unused `Dropdown` import.

This cleanup reduces bespoke UI code, makes future maintenance easier, and keeps the interface consistent with the rest of the application.
2025-06-25 15:40:27 +08:00
t0ng7u 3a5618ede9 Merge remote-tracking branch 'origin/alpha' into alpha 2025-06-25 15:26:59 +08:00
t0ng7u 70a4b5765d feat(homepage): enhance banner visuals & UX
• Added read-only Base URL input that shows `status.server_address` (fallback `window.location.origin`) and copies value on click.
• Embedded `ScrollList` as input `suffix`; auto-cycles common endpoints every 3 s and allows manual selection.
• Introduced `API_ENDPOINTS` array in `web/src/constants/common.constant.js` for centralized endpoint management.
• Implemented custom CSS to hide ScrollList wheel indicators / scrollbars for a cleaner look.
• Created two blurred colour spheres behind the banner (`blur-ball-indigo`, `blur-ball-teal`) with light-/dark-mode opacity tweaks and lower vertical placement.
• Increased letter-spacing for Chinese heading via conditional `tracking-wide` / `md:tracking-wider` classes to improve readability.
• Misc: updated imports, helper functions, and responsive sizes to keep UI consistent across devices.
2025-06-25 15:26:51 +08:00
CaIon 04d525e317 🚀 feat(auth): support new model API paths in authentication and routing
- Updated TokenAuth middleware to handle requests for both `/v1beta/models/` and `/v1/models/`.
- Adjusted distributor middleware to recognize the new model path.
- Enhanced relay mode determination to include the new model path.
- Added route for handling POST requests to `/models/*path`.

These changes ensure compatibility with the new model API structure, improving the overall routing and authentication flow.
2025-06-25 00:19:38 +08:00
t0ng7u 6220b3ee0f Merge remote-tracking branch 'origin/alpha' into alpha 2025-06-24 18:10:04 +08:00
t0ng7u 87e90e5219 🔧 style(ui): Inline tag edit action in ChannelsTable
• Removed the dropdown menu previously used for tag-level operations.
• Added a standalone “Edit” button directly after the “Disable All” button, reducing the number of clicks required to edit a tag group.
• Deleted the now-unused `IconEdit` import and its icon reference.

This streamlines the tag management flow and keeps the UI cleaner and more accessible.
2025-06-24 18:09:16 +08:00
CaIon 51086c3ba6 🔧 fix(model_ratio): adjust return values for gemini-2.5-pro and gemini-2.5-flash models 2025-06-24 18:08:42 +08:00
t0ng7u 7a4c213b65 🎨 style(channels-table): standardize operation component size to small
All operation-related UI controls in `ChannelsTable` (buttons, dropdowns,
switches, inputs, tags, etc.) now explicitly use `size="small"`.

Reasons & benefits:
- Creates a more compact and consistent look across the table and modals.
- Improves visual coherence between desktop and mobile views.
- Purely presentational; no functional logic is affected.

No database changes or API interactions are involved.
2025-06-24 18:02:34 +08:00
t0ng7u 610853e9c8 🚀 feat: enhance model testing UI with bulk selection, copy & success-filter buttons (#1288)
* ChannelsTable
  - Added row-level checkboxes to the model-testing table for multi-selection
  - Implemented cross-page “Select All / Deselect All” via rowSelection.onSelectAll
  - Introduced allSelectingRef to ignore redundant onChange after onSelectAll
  - Added “Copy Selected” button to copy chosen model names (comma-separated) using helpers.copy
  - Added “Select Successful” button to auto-tick all models that passed testing
  - Moved search bar and new action buttons into the modal title for better UX
  - Centralised page size constant MODEL_TABLE_PAGE_SIZE in channel.constants.js
  - Fixed pagination slicing and auto-page-switch logic during batch testing

* channel.constants
  - Exported MODEL_TABLE_PAGE_SIZE (default 10) for unified pagination control

This commit enables users to conveniently copy or filter successful models, fully supports cross-page bulk operations, and resolves previous selection inconsistencies.

Refs: #1288
2025-06-24 17:46:08 +08:00
t0ng7u 62daf16b19 fix: ensure table shows correct loading state on first render & during search
Frontend (`ChannelsTable.js`)
1. Initialize `loading` state to `true` so the spinner is visible while the first data request is in-flight.
2. Set `<Table>` prop `loading={loading || searching}` — the spinner now appears for both the initial load and any subsequent search requests.

Result
Users immediately see a loading indicator on page entry and whenever a search is running, improving perceived responsiveness.
2025-06-24 05:20:54 +08:00
t0ng7u d19ab54e32 🚀 feat: Align search API with channel listing & fix sorting toggle
1. Backend
   • `controller/channel.go`
     – Added pagination (`p`, `page_size`) support to `SearchChannels`.
     – Added independent `type` filter (keeps `type_counts` unaffected).
     – Returned `total`, `type_counts` to match `/api/channel/` response.

2. Frontend
   • `ChannelsTable.js`
     – `loadChannels` / `searchChannels` now pass `p`, `page_size`, `id_sort`, `type`, `status` correctly.
     – Pagination, page-size selector and type tabs work for both normal list and search mode.
     – Switch for “ID sort” calls proper API and keeps UI state in sync.
     – Removed unnecessary `normalize` helper; `getFormValues` back to concise form.

Result
• Search mode and normal listing now share identical pagination and filtering behavior.
• Type tabs show correct counts even after searching.
• “ID Sort” toggle no longer inverses actual behaviour.
2025-06-24 05:13:47 +08:00
t0ng7u 8f0f0c0d27 fix(channels-table): preserve group filter when switching type or status tabs
Refactors `ChannelsTable.js` to ensure that the selected group filter is **never lost** when:

1. Cycling between channel-type tabs.
2. Changing the status dropdown (all / enabled / disabled).

Key points:

• `loadChannels` now detects active search filters (keyword / group / model) and transparently delegates to `searchChannels`, guaranteeing all parameters are sent in every request.
• `searchChannels` accepts optional `typeKey` and `statusF` arguments, enabling reuse without code duplication.
• Loading state handling is unified; no extra renders or side effects were introduced, keeping UI performance intact.
• Duplicate logic removed and responsibilities clearly separated for easier future maintenance.
2025-06-24 04:16:40 +08:00
t0ng7u 9bf32ef581 Revert "🐛 fix: preserve group filter when switching channel type/status"
This reverts commit 4949d986c7.
2025-06-24 01:51:26 +08:00
t0ng7u 9469c4973c 💄 i18n: shorten channel search placeholder and update i18n
Replaced the verbose placeholder “Search channel ID, name, key and API address ...”
with a concise version “Channel ID, name, key, API address” in
`ChannelsTable.js` and synchronized the corresponding i18n entries.

This improves readability and keeps UI text consistent across languages.
2025-06-24 01:48:39 +08:00
t0ng7u 4949d986c7 🐛 fix: preserve group filter when switching channel type/status
Ensure that the selected "group" filter (and other form search values) persist across
type tab changes, status filter updates, pagination, and page-size changes.

Changes include:
• loadChannels: added `searchParams` argument and now appends keyword, group and model
  query strings to API calls.
• refresh / page handlers / type tabs / status Select: now pass current form values
  to loadChannels, keeping filters intact.
• searchChannels: maintains active type and status filters when issuing search requests.
• Form.Select (searchGroup): triggers loadChannels when only group filter is active,
  preventing parameter loss.
• Minor cleanup and comment adjustments.
2025-06-24 01:45:22 +08:00
t0ng7u 949d462534 🐛 fix(channel): remove duplicate model names in “Edit Channel” model dropdown (#1292)
• Unify the Select option structure as `{ key, label, value }`; add missing `key` to prevent duplicated rendering by Semi-UI.
• Trim and deduplicate the `models` array via `Set` inside `handleInputChange`, ensuring state always contains unique values.
• In the options-merging `useEffect`, use a `Map` keyed by `value` (after `trim`) to guarantee a unique `optionList` when combining backend data with currently selected models.
• Apply the same structure and de-duplication when:
  – Fetching models from `/api/channel/models`
  – Adding custom models (`addCustomModels`)
  – Fetching upstream model lists (`fetchUpstreamModelList`)
• Replace obsolete `text` field with `label` in custom option objects for consistency.

No backend changes are required; the fix is entirely front-end.

Closes #1292
2025-06-24 00:25:29 +08:00
t0ng7u fc2e2c1aff 🎨 feat(EditChannel): improve model selection UX, clipboard feedback & rounded styling (#1290)
* Added a dedicated effect to merge origin and selected models, ensuring selected items always remain in the dropdown list.
* Enhanced “Copy all models” button:
  * Shows info message when list is empty.
  * Displays success / error notification based on copy result.
* Unified UI look-and-feel by applying `!rounded-lg` class to inputs, selects, banners and buttons.
* i18n: added English translations for new prompts
  - "No models to copy"
  - "Model list copied to clipboard"
  - "Copy failed"
2025-06-24 00:02:22 +08:00
同語 e7506ee9cf 🧬merge: Add a button to copy the selected model in the channel (#1290)
Merge pull request #1290 from JoeyLearnsToCode/feat-copy-models
2025-06-23 23:46:54 +08:00
t0ng7u 13fd901d17 🚀 feat: add enabled/disabled channel filtering & optimize type-based pagination (#1289)
WHAT’S NEW
• Backend
  – Introduced `parseStatusFilter` helper to normalize `status` query across handlers.
  – `GET /api/channel` & `GET /api/channel/search` now accept `status=enabled|disabled` to return only enabled or disabled channels.
  – Tag-mode branch respects both `statusFilter` and `typeFilter`; SQL paths trimmed to one query + one lightweight `GROUP BY` for `type_counts`.

• Frontend (`ChannelsTable.js`)
  – Added “Status Filter” `<Select>` (All / Enabled / Disabled) with localStorage persistence.
  – All data-loading and search requests now always append `type` (when not “all”) and `status` params, so filtering & pagination are handled entirely server-side.
  – Removed client-side post-filtering for type, preventing short pages and reducing CPU work.
  – Tabs’ type counts stay in sync via backend-provided `type_counts`.

IMPROVEMENTS
• Eliminated duplicated status-parsing logic; single source of truth eases future extension.
• Reduced redundant queries, improved consistency of counts in UI.
• Secured key leakage with `Omit("key")` unchanged; no perf regressions observed.

Closes #1289
2025-06-23 23:40:34 +08:00
skynono 34693e338f feat: add origin kling api 2025-06-23 22:36:23 +08:00
t0ng7u bce87295b6 Merge remote-tracking branch 'origin/alpha' into alpha 2025-06-23 17:35:50 +08:00
t0ng7u 0ac7406db8 🚀 chore(ui): Refactor UpstreamRatioSync with conflict-modal component, performance hooks & cleanup (#1286)
WHAT’S NEW
• Extracted reusable ConflictConfirmModal for clearer JSX hierarchy
• Added detailed conflict detection & confirmation flow before syncing options
• Refactored state-heavy callbacks (`selectValue`, `performSync`) with `useCallback` to avoid unnecessary renders
• Introduced build-time constants (later removed unused export) and unified helper utilities
• Ensured final ratios are rebuilt accurately before API `PUT`, fixing “value not updated” bug
• Enhanced UI hints: warning icon on conflict, multiline billing info, mobile-friendly modal size
• General code cleanup: removed dead variables, adopted early returns, improved comments

WHY
Improves maintainability, user clarity when billing-type collisions occur, and guarantees data consistency after synchronisation.
2025-06-23 17:35:39 +08:00
t0ng7u ef9c5b3acb 🐛 fix(ratio-sync): reset pagination when filter/search changes
Add a `useEffect` hook in `UpstreamRatioSync.js` to automatically set
`currentPage` to `1` whenever `ratioTypeFilter` or `searchKeyword`
updates.
This prevents the table from appearing empty when users switch to the
“model_price” (fixed price) filter or perform a new search while on a
later page.

Additional changes:
- Import `useEffect` from React.

This enhancement delivers a smoother UX by ensuring the first page of
results is always shown after any filtering action.
2025-06-23 16:34:00 +08:00
JoeyLearnsToCode c9529d00d5 Merge branch 'main' into feat-copy-models 2025-06-23 16:12:18 +08:00
t0ng7u f73da57acb 🎛️ feat(web): add “Conflict Rates” filter & highlight in Model Settings Visual Editor (#1286)
Introduce the ability to quickly locate models with conflicting billing configurations.

Key points
• Added `hasConflict` flag to detect models that define both a fixed price (`ModelPrice`) and any ratio (`ModelRatio` or `CompletionRatio`).
• Added “Show Only Conflict Rates” `Checkbox` to toolbar; filtering logic now supports keyword + conflict filtering.
• Display a red `Tag` beside the model name when a conflict is detected for immediate visual feedback.
• Kept `hasConflict` state in sync during add, update and delete operations.
• Imported `Checkbox` and `Tag` from **@douyinfe/semi-ui**.
• Minor UI tweaks (circle tag style, margin) for consistency.

This enhancement helps administrators swiftly identify and resolve incompatible pricing rules, addressing the need discussed in issue #1286.
2025-06-23 15:55:10 +08:00
CaIon 8a1e437ce9 🔧 chore: update STREAMING_TIMEOUT default value to 120 seconds in configuration 2025-06-22 18:47:40 +08:00
JoeyLearnsToCode 607d5fc25e feat: 渠道编辑页增加复制所有模型功能 2025-05-19 19:33:29 +08:00
71 changed files with 3392 additions and 2722 deletions
+1 -1
View File
@@ -59,7 +59,7 @@
# 设置 Dify 渠道是否输出工作流和节点信息到客户端
# DIFY_DEBUG=true
# 设置流式一次回复的超时时间
# STREAMING_TIMEOUT=90
# STREAMING_TIMEOUT=120
# 节点类型
+1 -1
View File
@@ -100,7 +100,7 @@ This version supports multiple models, please refer to [API Documentation-Relay
For detailed configuration instructions, please refer to [Installation Guide-Environment Variables Configuration](https://docs.newapi.pro/installation/environment-variables):
- `GENERATE_DEFAULT_TOKEN`: Whether to generate initial tokens for newly registered users, default is `false`
- `STREAMING_TIMEOUT`: Streaming response timeout, default is 60 seconds
- `STREAMING_TIMEOUT`: Streaming response timeout, default is 120 seconds
- `DIFY_DEBUG`: Whether to output workflow and node information for Dify channels, default is `true`
- `FORCE_STREAM_OPTION`: Whether to override client stream_options parameter, default is `true`
- `GET_MEDIA_TOKEN`: Whether to count image tokens, default is `true`
+1 -1
View File
@@ -103,7 +103,7 @@ New API提供了丰富的功能,详细特性请参考[特性说明](https://do
详细配置说明请参考[安装指南-环境变量配置](https://docs.newapi.pro/installation/environment-variables)
- `GENERATE_DEFAULT_TOKEN`:是否为新注册用户生成初始令牌,默认为 `false`
- `STREAMING_TIMEOUT`:流式回复超时时间,默认60秒
- `STREAMING_TIMEOUT`:流式回复超时时间,默认120秒
- `DIFY_DEBUG`:Dify渠道是否输出工作流和节点信息,默认 `true`
- `FORCE_STREAM_OPTION`:是否覆盖客户端stream_options参数,默认 `true`
- `GET_MEDIA_TOKEN`:是否统计图片token,默认 `true`
+2
View File
@@ -242,6 +242,7 @@ const (
ChannelTypeXai = 48
ChannelTypeCoze = 49
ChannelTypeKling = 50
ChannelTypeJimeng = 51
ChannelTypeDummy // this one is only for count, do not add any channel after this
)
@@ -298,4 +299,5 @@ var ChannelBaseURLs = []string{
"https://api.x.ai", //48
"https://api.coze.cn", //49
"https://api.klingai.com", //50
"https://visual.volcengineapi.com", //51
}
+1 -1
View File
@@ -23,7 +23,7 @@ var ErrorLogEnabled bool
//}
func InitEnv() {
StreamingTimeout = common.GetEnvOrDefault("STREAMING_TIMEOUT", 60)
StreamingTimeout = common.GetEnvOrDefault("STREAMING_TIMEOUT", 120)
DifyDebug = common.GetEnvOrDefaultBool("DIFY_DEBUG", true)
MaxFileDownloadMB = common.GetEnvOrDefault("MAX_FILE_DOWNLOAD_MB", 20)
// ForceStreamOption 覆盖请求参数,强制返回usage信息
+1
View File
@@ -6,6 +6,7 @@ const (
TaskPlatformSuno TaskPlatform = "suno"
TaskPlatformMidjourney = "mj"
TaskPlatformKling TaskPlatform = "kling"
TaskPlatformJimeng TaskPlatform = "jimeng"
)
const (
+3
View File
@@ -43,6 +43,9 @@ func testChannel(channel *model.Channel, testModel string) (err error, openAIErr
if channel.Type == common.ChannelTypeKling {
return errors.New("kling channel test is not supported"), nil
}
if channel.Type == common.ChannelTypeJimeng {
return errors.New("jimeng channel test is not supported"), nil
}
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
+124 -21
View File
@@ -40,6 +40,17 @@ type OpenAIModelsResponse struct {
Success bool `json:"success"`
}
func parseStatusFilter(statusParam string) int {
switch strings.ToLower(statusParam) {
case "enabled", "1":
return common.ChannelStatusEnabled
case "disabled", "0":
return 0
default:
return -1
}
}
func GetAllChannels(c *gin.Context) {
p, _ := strconv.Atoi(c.Query("p"))
pageSize, _ := strconv.Atoi(c.Query("page_size"))
@@ -52,6 +63,9 @@ func GetAllChannels(c *gin.Context) {
channelData := make([]*model.Channel, 0)
idSort, _ := strconv.ParseBool(c.Query("id_sort"))
enableTagMode, _ := strconv.ParseBool(c.Query("tag_mode"))
statusParam := c.Query("status")
// statusFilter: -1 all, 1 enabled, 0 disabled (include auto & manual)
statusFilter := parseStatusFilter(statusParam)
// type filter
typeStr := c.Query("type")
typeFilter := -1
@@ -64,42 +78,75 @@ func GetAllChannels(c *gin.Context) {
var total int64
if enableTagMode {
// tag 分页:先分页 tag,再取各 tag 下 channels
tags, err := model.GetPaginatedTags((p-1)*pageSize, pageSize)
if err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()})
return
}
for _, tag := range tags {
if tag != nil && *tag != "" {
tagChannel, err := model.GetChannelsByTag(*tag, idSort)
if err == nil {
channelData = append(channelData, tagChannel...)
}
if tag == nil || *tag == "" {
continue
}
tagChannels, err := model.GetChannelsByTag(*tag, idSort)
if err != nil {
continue
}
filtered := make([]*model.Channel, 0)
for _, ch := range tagChannels {
if statusFilter == common.ChannelStatusEnabled && ch.Status != common.ChannelStatusEnabled {
continue
}
if statusFilter == 0 && ch.Status == common.ChannelStatusEnabled {
continue
}
if typeFilter >= 0 && ch.Type != typeFilter {
continue
}
filtered = append(filtered, ch)
}
channelData = append(channelData, filtered...)
}
// 计算 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)
baseQuery := model.DB.Model(&model.Channel{})
if typeFilter >= 0 {
baseQuery = baseQuery.Where("type = ?", typeFilter)
}
if statusFilter == common.ChannelStatusEnabled {
baseQuery = baseQuery.Where("status = ?", common.ChannelStatusEnabled)
} else if statusFilter == 0 {
baseQuery = baseQuery.Where("status != ?", common.ChannelStatusEnabled)
}
baseQuery.Count(&total)
order := "priority desc"
if idSort {
order = "id desc"
}
err := baseQuery.Order(order).Limit(pageSize).Offset((p-1)*pageSize).Omit("key").Find(&channelData).Error
if err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()})
return
}
channelData = channels
total, _ = model.CountAllChannels()
}
// calculate type counts
typeCounts, _ := model.CountChannelsGroupByType()
countQuery := model.DB.Model(&model.Channel{})
if statusFilter == common.ChannelStatusEnabled {
countQuery = countQuery.Where("status = ?", common.ChannelStatusEnabled)
} else if statusFilter == 0 {
countQuery = countQuery.Where("status != ?", common.ChannelStatusEnabled)
}
var results []struct {
Type int64
Count int64
}
_ = countQuery.Select("type, count(*) as count").Group("type").Find(&results).Error
typeCounts := make(map[int64]int64)
for _, r := range results {
typeCounts[r.Type] = r.Count
}
c.JSON(http.StatusOK, gin.H{
"success": true,
@@ -199,6 +246,8 @@ func SearchChannels(c *gin.Context) {
keyword := c.Query("keyword")
group := c.Query("group")
modelKeyword := c.Query("model")
statusParam := c.Query("status")
statusFilter := parseStatusFilter(statusParam)
idSort, _ := strconv.ParseBool(c.Query("id_sort"))
enableTagMode, _ := strconv.ParseBool(c.Query("tag_mode"))
channelData := make([]*model.Channel, 0)
@@ -231,17 +280,71 @@ func SearchChannels(c *gin.Context) {
channelData = channels
}
if statusFilter == common.ChannelStatusEnabled || statusFilter == 0 {
filtered := make([]*model.Channel, 0, len(channelData))
for _, ch := range channelData {
if statusFilter == common.ChannelStatusEnabled && ch.Status != common.ChannelStatusEnabled {
continue
}
if statusFilter == 0 && ch.Status == common.ChannelStatusEnabled {
continue
}
filtered = append(filtered, ch)
}
channelData = filtered
}
// calculate type counts for search results
typeCounts := make(map[int64]int64)
for _, channel := range channelData {
typeCounts[int64(channel.Type)]++
}
typeParam := c.Query("type")
typeFilter := -1
if typeParam != "" {
if tp, err := strconv.Atoi(typeParam); err == nil {
typeFilter = tp
}
}
if typeFilter >= 0 {
filtered := make([]*model.Channel, 0, len(channelData))
for _, ch := range channelData {
if ch.Type == typeFilter {
filtered = append(filtered, ch)
}
}
channelData = filtered
}
page, _ := strconv.Atoi(c.DefaultQuery("p", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
if page < 1 {
page = 1
}
if pageSize <= 0 {
pageSize = 20
}
total := len(channelData)
startIdx := (page - 1) * pageSize
if startIdx > total {
startIdx = total
}
endIdx := startIdx + pageSize
if endIdx > total {
endIdx = total
}
pagedData := channelData[startIdx:endIdx]
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": gin.H{
"items": channelData,
"items": pagedData,
"total": total,
"type_counts": typeCounts,
},
})
+2 -2
View File
@@ -74,8 +74,8 @@ func UpdateTaskByPlatform(platform constant.TaskPlatform, taskChannelM map[int][
//_ = UpdateMidjourneyTaskAll(context.Background(), tasks)
case constant.TaskPlatformSuno:
_ = UpdateSunoTaskAll(context.Background(), taskChannelM, taskM)
case constant.TaskPlatformKling:
_ = UpdateVideoTaskAll(context.Background(), taskChannelM, taskM)
case constant.TaskPlatformKling, constant.TaskPlatformJimeng:
_ = UpdateVideoTaskAll(context.Background(), platform, taskChannelM, taskM)
default:
common.SysLog("未知平台")
}
+60 -62
View File
@@ -2,27 +2,26 @@ package controller
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"one-api/common"
"one-api/constant"
"one-api/model"
"one-api/relay"
"one-api/relay/channel"
"time"
)
func UpdateVideoTaskAll(ctx context.Context, taskChannelM map[int][]string, taskM map[string]*model.Task) error {
func UpdateVideoTaskAll(ctx context.Context, platform constant.TaskPlatform, taskChannelM map[int][]string, taskM map[string]*model.Task) error {
for channelId, taskIds := range taskChannelM {
if err := updateVideoTaskAll(ctx, channelId, taskIds, taskM); err != nil {
if err := updateVideoTaskAll(ctx, platform, channelId, taskIds, taskM); err != nil {
common.LogError(ctx, fmt.Sprintf("Channel #%d failed to update video async tasks: %s", channelId, err.Error()))
}
}
return nil
}
func updateVideoTaskAll(ctx context.Context, channelId int, taskIds []string, taskM map[string]*model.Task) error {
func updateVideoTaskAll(ctx context.Context, platform constant.TaskPlatform, channelId int, taskIds []string, taskM map[string]*model.Task) error {
common.LogInfo(ctx, fmt.Sprintf("Channel #%d pending video tasks: %d", channelId, len(taskIds)))
if len(taskIds) == 0 {
return nil
@@ -39,7 +38,7 @@ func updateVideoTaskAll(ctx context.Context, channelId int, taskIds []string, ta
}
return fmt.Errorf("CacheGetChannel failed: %w", err)
}
adaptor := relay.GetTaskAdaptor(constant.TaskPlatformKling)
adaptor := relay.GetTaskAdaptor(platform)
if adaptor == nil {
return fmt.Errorf("video adaptor not found")
}
@@ -56,70 +55,64 @@ func updateVideoSingleTask(ctx context.Context, adaptor channel.TaskAdaptor, cha
if channel.GetBaseURL() != "" {
baseURL = channel.GetBaseURL()
}
resp, err := adaptor.FetchTask(baseURL, channel.Key, map[string]any{
"task_id": taskId,
})
if err != nil {
return fmt.Errorf("FetchTask failed for task %s: %w", taskId, err)
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("Get Video Task status code: %d", resp.StatusCode)
}
defer resp.Body.Close()
responseBody, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("ReadAll failed for task %s: %w", taskId, err)
}
var responseItem map[string]interface{}
err = json.Unmarshal(responseBody, &responseItem)
if err != nil {
common.LogError(ctx, fmt.Sprintf("Failed to parse video task response body: %v, body: %s", err, string(responseBody)))
return fmt.Errorf("Unmarshal failed for task %s: %w", taskId, err)
}
code, _ := responseItem["code"].(float64)
if code != 0 {
return fmt.Errorf("video task fetch failed for task %s", taskId)
}
data, ok := responseItem["data"].(map[string]interface{})
if !ok {
common.LogError(ctx, fmt.Sprintf("Video task data format error: %s", string(responseBody)))
return fmt.Errorf("video task data format error for task %s", taskId)
}
task := taskM[taskId]
if task == nil {
common.LogError(ctx, fmt.Sprintf("Task %s not found in taskM", taskId))
return fmt.Errorf("task %s not found", taskId)
}
if status, ok := data["task_status"].(string); ok {
switch status {
case "submitted", "queued":
task.Status = model.TaskStatusSubmitted
case "processing":
task.Status = model.TaskStatusInProgress
case "succeed":
task.Status = model.TaskStatusSuccess
task.Progress = "100%"
if url, err := adaptor.ParseResultUrl(responseItem); err == nil {
task.FailReason = url
} else {
common.LogWarn(ctx, fmt.Sprintf("Failed to get url from body for task %s: %s", task.TaskID, err.Error()))
}
case "failed":
task.Status = model.TaskStatusFailure
task.Progress = "100%"
if reason, ok := data["fail_reason"].(string); ok {
task.FailReason = reason
}
}
resp, err := adaptor.FetchTask(baseURL, channel.Key, map[string]any{
"task_id": taskId,
"action": task.Action,
})
if err != nil {
return fmt.Errorf("fetchTask failed for task %s: %w", taskId, err)
}
//if resp.StatusCode != http.StatusOK {
//return fmt.Errorf("get Video Task status code: %d", resp.StatusCode)
//}
defer resp.Body.Close()
responseBody, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("readAll failed for task %s: %w", taskId, err)
}
// If task failed, refund quota
if task.Status == model.TaskStatusFailure {
taskResult, err := adaptor.ParseTaskResult(responseBody)
if err != nil {
return fmt.Errorf("parseTaskResult failed for task %s: %w", taskId, err)
}
//if taskResult.Code != 0 {
// return fmt.Errorf("video task fetch failed for task %s", taskId)
//}
now := time.Now().Unix()
if taskResult.Status == "" {
return fmt.Errorf("task %s status is empty", taskId)
}
task.Status = model.TaskStatus(taskResult.Status)
switch taskResult.Status {
case model.TaskStatusSubmitted:
task.Progress = "10%"
case model.TaskStatusQueued:
task.Progress = "20%"
case model.TaskStatusInProgress:
task.Progress = "30%"
if task.StartTime == 0 {
task.StartTime = now
}
case model.TaskStatusSuccess:
task.Progress = "100%"
if task.FinishTime == 0 {
task.FinishTime = now
}
task.FailReason = taskResult.Url
case model.TaskStatusFailure:
task.Status = model.TaskStatusFailure
task.Progress = "100%"
if task.FinishTime == 0 {
task.FinishTime = now
}
task.FailReason = taskResult.Reason
common.LogInfo(ctx, fmt.Sprintf("Task %s failed: %s", task.TaskID, task.FailReason))
quota := task.Quota
if quota != 0 {
@@ -129,6 +122,11 @@ func updateVideoSingleTask(ctx context.Context, adaptor channel.TaskAdaptor, cha
logContent := fmt.Sprintf("Video async task failed %s, refund %s", task.TaskID, common.LogQuota(quota))
model.RecordLog(task.UserId, model.LogTypeSystem, logContent)
}
default:
return fmt.Errorf("unknown task status %s for task %s", taskResult.Status, taskId)
}
if taskResult.Progress != "" {
task.Progress = taskResult.Progress
}
task.Data = responseBody
+2
View File
@@ -646,4 +646,6 @@ type ResponsesToolsCall struct {
Name string `json:"name,omitempty"`
Description string `json:"description,omitempty"`
Parameters json.RawMessage `json:"parameters,omitempty"`
Function json.RawMessage `json:"function,omitempty"`
Container json.RawMessage `json:"container,omitempty"`
}
+1 -1
View File
@@ -184,7 +184,7 @@ func TokenAuth() func(c *gin.Context) {
}
}
// gemini api 从query中获取key
if strings.HasPrefix(c.Request.URL.Path, "/v1beta/models/") {
if strings.HasPrefix(c.Request.URL.Path, "/v1beta/models/") || strings.HasPrefix(c.Request.URL.Path, "/v1/models/") {
skKey := c.Query("key")
if skKey != "" {
c.Request.Header.Set("Authorization", "Bearer "+skKey)
+16 -6
View File
@@ -171,15 +171,25 @@ func getModelRequest(c *gin.Context) (*ModelRequest, bool, error) {
c.Set("platform", string(constant.TaskPlatformSuno))
c.Set("relay_mode", relayMode)
} else if strings.Contains(c.Request.URL.Path, "/v1/video/generations") {
relayMode := relayconstant.Path2RelayKling(c.Request.Method, c.Request.URL.Path)
if relayMode == relayconstant.RelayModeKlingFetchByID {
shouldSelectChannel = false
err = common.UnmarshalBodyReusable(c, &modelRequest)
var platform string
var relayMode int
if strings.HasPrefix(modelRequest.Model, "jimeng") {
platform = string(constant.TaskPlatformJimeng)
relayMode = relayconstant.Path2RelayJimeng(c.Request.Method, c.Request.URL.Path)
if relayMode == relayconstant.RelayModeJimengFetchByID {
shouldSelectChannel = false
}
} else {
err = common.UnmarshalBodyReusable(c, &modelRequest)
platform = string(constant.TaskPlatformKling)
relayMode = relayconstant.Path2RelayKling(c.Request.Method, c.Request.URL.Path)
if relayMode == relayconstant.RelayModeKlingFetchByID {
shouldSelectChannel = false
}
}
c.Set("platform", string(constant.TaskPlatformKling))
c.Set("platform", platform)
c.Set("relay_mode", relayMode)
} else if strings.HasPrefix(c.Request.URL.Path, "/v1beta/models/") {
} else if strings.HasPrefix(c.Request.URL.Path, "/v1beta/models/") || strings.HasPrefix(c.Request.URL.Path, "/v1/models/") {
// Gemini API 路径处理: /v1beta/models/gemini-2.0-flash:generateContent
relayMode := relayconstant.RelayModeGemini
modelName := extractModelNameFromGeminiPath(c.Request.URL.Path)
+45
View File
@@ -0,0 +1,45 @@
package middleware
import (
"bytes"
"encoding/json"
"github.com/gin-gonic/gin"
"io"
"one-api/common"
)
func KlingRequestConvert() func(c *gin.Context) {
return func(c *gin.Context) {
var originalReq map[string]interface{}
if err := common.UnmarshalBodyReusable(c, &originalReq); err != nil {
c.Next()
return
}
model, _ := originalReq["model"].(string)
prompt, _ := originalReq["prompt"].(string)
unifiedReq := map[string]interface{}{
"model": model,
"prompt": prompt,
"metadata": originalReq,
}
jsonData, err := json.Marshal(unifiedReq)
if err != nil {
c.Next()
return
}
// Rewrite request body and path
c.Request.Body = io.NopCloser(bytes.NewBuffer(jsonData))
c.Request.URL.Path = "/v1/video/generations"
if image := originalReq["image"]; image == "" {
c.Set("action", "textGenerate")
}
// We have to reset the request body for the next handlers
c.Set(common.KeyRequestBody, jsonData)
c.Next()
}
}
+1 -1
View File
@@ -45,5 +45,5 @@ type TaskAdaptor interface {
// FetchTask
FetchTask(baseUrl, key string, body map[string]any) (*http.Response, error)
ParseResultUrl(resp map[string]any) (string, error)
ParseTaskResult(respBody []byte) (*relaycommon.TaskInfo, error)
}
+5 -5
View File
@@ -623,13 +623,13 @@ func OpenaiHandlerWithUsage(c *gin.Context, resp *http.Response, info *relaycomm
c.Writer.WriteHeader(resp.StatusCode)
_, err = io.Copy(c.Writer, resp.Body)
if err != nil {
return service.OpenAIErrorWrapper(err, "copy_response_body_failed", http.StatusInternalServerError), nil
}
err = resp.Body.Close()
if err != nil {
return service.OpenAIErrorWrapper(err, "close_response_body_failed", http.StatusInternalServerError), nil
common.SysError("error copying response body: " + err.Error())
}
_ = resp.Body.Close()
// Once we've written to the client, we should not return errors anymore
// because the upstream has already consumed resources and returned content
// We should still perform billing even if parsing fails
var usageResp dto.SimpleResponse
err = json.Unmarshal(responseBody, &usageResp)
if err != nil {
+379
View File
@@ -0,0 +1,379 @@
package jimeng
import (
"bytes"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"one-api/model"
"sort"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/pkg/errors"
"one-api/common"
"one-api/dto"
"one-api/relay/channel"
relaycommon "one-api/relay/common"
"one-api/service"
)
// ============================
// Request / Response structures
// ============================
type requestPayload struct {
ReqKey string `json:"req_key"`
BinaryDataBase64 []string `json:"binary_data_base64,omitempty"`
ImageUrls []string `json:"image_urls,omitempty"`
Prompt string `json:"prompt,omitempty"`
Seed int64 `json:"seed"`
AspectRatio string `json:"aspect_ratio"`
}
type responsePayload struct {
Code int `json:"code"`
Message string `json:"message"`
RequestId string `json:"request_id"`
Data struct {
TaskID string `json:"task_id"`
} `json:"data"`
}
type responseTask struct {
Code int `json:"code"`
Data struct {
BinaryDataBase64 []interface{} `json:"binary_data_base64"`
ImageUrls interface{} `json:"image_urls"`
RespData string `json:"resp_data"`
Status string `json:"status"`
VideoUrl string `json:"video_url"`
} `json:"data"`
Message string `json:"message"`
RequestId string `json:"request_id"`
Status int `json:"status"`
TimeElapsed string `json:"time_elapsed"`
}
// ============================
// Adaptor implementation
// ============================
type TaskAdaptor struct {
ChannelType int
accessKey string
secretKey string
baseURL string
}
func (a *TaskAdaptor) Init(info *relaycommon.TaskRelayInfo) {
a.ChannelType = info.ChannelType
a.baseURL = info.BaseUrl
// apiKey format: "access_key,secret_key"
keyParts := strings.Split(info.ApiKey, ",")
if len(keyParts) == 2 {
a.accessKey = strings.TrimSpace(keyParts[0])
a.secretKey = strings.TrimSpace(keyParts[1])
}
}
// ValidateRequestAndSetAction parses body, validates fields and sets default action.
func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycommon.TaskRelayInfo) (taskErr *dto.TaskError) {
// Accept only POST /v1/video/generations as "generate" action.
action := "generate"
info.Action = action
req := relaycommon.TaskSubmitReq{}
if err := common.UnmarshalBodyReusable(c, &req); err != nil {
taskErr = service.TaskErrorWrapperLocal(err, "invalid_request", http.StatusBadRequest)
return
}
if strings.TrimSpace(req.Prompt) == "" {
taskErr = service.TaskErrorWrapperLocal(fmt.Errorf("prompt is required"), "invalid_request", http.StatusBadRequest)
return
}
// Store into context for later usage
c.Set("task_request", req)
return nil
}
// BuildRequestURL constructs the upstream URL.
func (a *TaskAdaptor) BuildRequestURL(info *relaycommon.TaskRelayInfo) (string, error) {
return fmt.Sprintf("%s/?Action=CVSync2AsyncSubmitTask&Version=2022-08-31", a.baseURL), nil
}
// BuildRequestHeader sets required headers.
func (a *TaskAdaptor) BuildRequestHeader(c *gin.Context, req *http.Request, info *relaycommon.TaskRelayInfo) error {
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
return a.signRequest(req, a.accessKey, a.secretKey)
}
// BuildRequestBody converts request into Jimeng specific format.
func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, info *relaycommon.TaskRelayInfo) (io.Reader, error) {
v, exists := c.Get("task_request")
if !exists {
return nil, fmt.Errorf("request not found in context")
}
req := v.(relaycommon.TaskSubmitReq)
body, err := a.convertToRequestPayload(&req)
if err != nil {
return nil, errors.Wrap(err, "convert request payload failed")
}
data, err := json.Marshal(body)
if err != nil {
return nil, err
}
return bytes.NewReader(data), nil
}
// DoRequest delegates to common helper.
func (a *TaskAdaptor) DoRequest(c *gin.Context, info *relaycommon.TaskRelayInfo, requestBody io.Reader) (*http.Response, error) {
return channel.DoTaskApiRequest(a, c, info, requestBody)
}
// DoResponse handles upstream response, returns taskID etc.
func (a *TaskAdaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.TaskRelayInfo) (taskID string, taskData []byte, taskErr *dto.TaskError) {
responseBody, err := io.ReadAll(resp.Body)
if err != nil {
taskErr = service.TaskErrorWrapper(err, "read_response_body_failed", http.StatusInternalServerError)
return
}
_ = resp.Body.Close()
// Parse Jimeng response
var jResp responsePayload
if err := json.Unmarshal(responseBody, &jResp); err != nil {
taskErr = service.TaskErrorWrapper(errors.Wrapf(err, "body: %s", responseBody), "unmarshal_response_body_failed", http.StatusInternalServerError)
return
}
if jResp.Code != 10000 {
taskErr = service.TaskErrorWrapper(fmt.Errorf(jResp.Message), fmt.Sprintf("%d", jResp.Code), http.StatusInternalServerError)
return
}
c.JSON(http.StatusOK, gin.H{"task_id": jResp.Data.TaskID})
return jResp.Data.TaskID, responseBody, nil
}
// FetchTask fetch task status
func (a *TaskAdaptor) FetchTask(baseUrl, key string, body map[string]any) (*http.Response, error) {
taskID, ok := body["task_id"].(string)
if !ok {
return nil, fmt.Errorf("invalid task_id")
}
uri := fmt.Sprintf("%s/?Action=CVSync2AsyncGetResult&Version=2022-08-31", baseUrl)
payload := map[string]string{
"req_key": "jimeng_vgfm_t2v_l20", // This is fixed value from doc: https://www.volcengine.com/docs/85621/1544774
"task_id": taskID,
}
payloadBytes, err := json.Marshal(payload)
if err != nil {
return nil, errors.Wrap(err, "marshal fetch task payload failed")
}
req, err := http.NewRequest(http.MethodPost, uri, bytes.NewBuffer(payloadBytes))
if err != nil {
return nil, err
}
req.Header.Set("Accept", "application/json")
req.Header.Set("Content-Type", "application/json")
keyParts := strings.Split(key, ",")
if len(keyParts) != 2 {
return nil, fmt.Errorf("invalid api key format for jimeng: expected 'ak,sk'")
}
accessKey := strings.TrimSpace(keyParts[0])
secretKey := strings.TrimSpace(keyParts[1])
if err := a.signRequest(req, accessKey, secretKey); err != nil {
return nil, errors.Wrap(err, "sign request failed")
}
return service.GetHttpClient().Do(req)
}
func (a *TaskAdaptor) GetModelList() []string {
return []string{"jimeng_vgfm_t2v_l20"}
}
func (a *TaskAdaptor) GetChannelName() string {
return "jimeng"
}
func (a *TaskAdaptor) signRequest(req *http.Request, accessKey, secretKey string) error {
var bodyBytes []byte
var err error
if req.Body != nil {
bodyBytes, err = io.ReadAll(req.Body)
if err != nil {
return errors.Wrap(err, "read request body failed")
}
_ = req.Body.Close()
req.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) // Rewind
} else {
bodyBytes = []byte{}
}
payloadHash := sha256.Sum256(bodyBytes)
hexPayloadHash := hex.EncodeToString(payloadHash[:])
t := time.Now().UTC()
xDate := t.Format("20060102T150405Z")
shortDate := t.Format("20060102")
req.Header.Set("Host", req.URL.Host)
req.Header.Set("X-Date", xDate)
req.Header.Set("X-Content-Sha256", hexPayloadHash)
// Sort and encode query parameters to create canonical query string
queryParams := req.URL.Query()
sortedKeys := make([]string, 0, len(queryParams))
for k := range queryParams {
sortedKeys = append(sortedKeys, k)
}
sort.Strings(sortedKeys)
var queryParts []string
for _, k := range sortedKeys {
values := queryParams[k]
sort.Strings(values)
for _, v := range values {
queryParts = append(queryParts, fmt.Sprintf("%s=%s", url.QueryEscape(k), url.QueryEscape(v)))
}
}
canonicalQueryString := strings.Join(queryParts, "&")
headersToSign := map[string]string{
"host": req.URL.Host,
"x-date": xDate,
"x-content-sha256": hexPayloadHash,
}
if req.Header.Get("Content-Type") != "" {
headersToSign["content-type"] = req.Header.Get("Content-Type")
}
var signedHeaderKeys []string
for k := range headersToSign {
signedHeaderKeys = append(signedHeaderKeys, k)
}
sort.Strings(signedHeaderKeys)
var canonicalHeaders strings.Builder
for _, k := range signedHeaderKeys {
canonicalHeaders.WriteString(k)
canonicalHeaders.WriteString(":")
canonicalHeaders.WriteString(strings.TrimSpace(headersToSign[k]))
canonicalHeaders.WriteString("\n")
}
signedHeaders := strings.Join(signedHeaderKeys, ";")
canonicalRequest := fmt.Sprintf("%s\n%s\n%s\n%s\n%s\n%s",
req.Method,
req.URL.Path,
canonicalQueryString,
canonicalHeaders.String(),
signedHeaders,
hexPayloadHash,
)
hashedCanonicalRequest := sha256.Sum256([]byte(canonicalRequest))
hexHashedCanonicalRequest := hex.EncodeToString(hashedCanonicalRequest[:])
region := "cn-north-1"
serviceName := "cv"
credentialScope := fmt.Sprintf("%s/%s/%s/request", shortDate, region, serviceName)
stringToSign := fmt.Sprintf("HMAC-SHA256\n%s\n%s\n%s",
xDate,
credentialScope,
hexHashedCanonicalRequest,
)
kDate := hmacSHA256([]byte(secretKey), []byte(shortDate))
kRegion := hmacSHA256(kDate, []byte(region))
kService := hmacSHA256(kRegion, []byte(serviceName))
kSigning := hmacSHA256(kService, []byte("request"))
signature := hex.EncodeToString(hmacSHA256(kSigning, []byte(stringToSign)))
authorization := fmt.Sprintf("HMAC-SHA256 Credential=%s/%s, SignedHeaders=%s, Signature=%s",
accessKey,
credentialScope,
signedHeaders,
signature,
)
req.Header.Set("Authorization", authorization)
return nil
}
func hmacSHA256(key []byte, data []byte) []byte {
h := hmac.New(sha256.New, key)
h.Write(data)
return h.Sum(nil)
}
func (a *TaskAdaptor) convertToRequestPayload(req *relaycommon.TaskSubmitReq) (*requestPayload, error) {
r := requestPayload{
ReqKey: "jimeng_vgfm_i2v_l20",
Prompt: req.Prompt,
AspectRatio: "16:9", // Default aspect ratio
Seed: -1, // Default to random
}
// Handle one-of image_urls or binary_data_base64
if req.Image != "" {
if strings.HasPrefix(req.Image, "http") {
r.ImageUrls = []string{req.Image}
} else {
r.BinaryDataBase64 = []string{req.Image}
}
}
metadata := req.Metadata
medaBytes, err := json.Marshal(metadata)
if err != nil {
return nil, errors.Wrap(err, "metadata marshal metadata failed")
}
err = json.Unmarshal(medaBytes, &r)
if err != nil {
return nil, errors.Wrap(err, "unmarshal metadata failed")
}
return &r, nil
}
func (a *TaskAdaptor) ParseTaskResult(respBody []byte) (*relaycommon.TaskInfo, error) {
resTask := responseTask{}
if err := json.Unmarshal(respBody, &resTask); err != nil {
return nil, errors.Wrap(err, "unmarshal task result failed")
}
taskResult := relaycommon.TaskInfo{}
if resTask.Code == 10000 {
taskResult.Code = 0
} else {
taskResult.Code = resTask.Code // todo uni code
taskResult.Reason = resTask.Message
taskResult.Status = model.TaskStatusFailure
taskResult.Progress = "100%"
}
switch resTask.Data.Status {
case "in_queue":
taskResult.Status = model.TaskStatusQueued
taskResult.Progress = "10%"
case "done":
taskResult.Status = model.TaskStatusSuccess
taskResult.Progress = "100%"
}
taskResult.Url = resTask.Data.VideoUrl
return &taskResult, nil
}
+76 -43
View File
@@ -2,11 +2,12 @@ package kling
import (
"bytes"
"context"
"encoding/json"
"fmt"
"github.com/samber/lo"
"io"
"net/http"
"one-api/model"
"strings"
"time"
@@ -41,16 +42,27 @@ type requestPayload struct {
Mode string `json:"mode,omitempty"`
Duration string `json:"duration,omitempty"`
AspectRatio string `json:"aspect_ratio,omitempty"`
Model string `json:"model,omitempty"`
ModelName string `json:"model_name,omitempty"`
CfgScale float64 `json:"cfg_scale,omitempty"`
}
type responsePayload struct {
Code int `json:"code"`
Message string `json:"message"`
Data struct {
TaskID string `json:"task_id"`
Code int `json:"code"`
Message string `json:"message"`
RequestId string `json:"request_id"`
Data struct {
TaskId string `json:"task_id"`
TaskStatus string `json:"task_status"`
TaskStatusMsg string `json:"task_status_msg"`
TaskResult struct {
Videos []struct {
Id string `json:"id"`
Url string `json:"url"`
Duration string `json:"duration"`
} `json:"videos"`
} `json:"task_result"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
} `json:"data"`
}
@@ -94,13 +106,14 @@ func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycom
}
// Store into context for later usage
c.Set("kling_request", req)
c.Set("task_request", req)
return nil
}
// BuildRequestURL constructs the upstream URL.
func (a *TaskAdaptor) BuildRequestURL(info *relaycommon.TaskRelayInfo) (string, error) {
return fmt.Sprintf("%s/v1/videos/image2video", a.baseURL), nil
path := lo.Ternary(info.Action == "generate", "/v1/videos/image2video", "/v1/videos/text2video")
return fmt.Sprintf("%s%s", a.baseURL, path), nil
}
// BuildRequestHeader sets required headers.
@@ -119,13 +132,16 @@ func (a *TaskAdaptor) BuildRequestHeader(c *gin.Context, req *http.Request, info
// BuildRequestBody converts request into Kling specific format.
func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, info *relaycommon.TaskRelayInfo) (io.Reader, error) {
v, exists := c.Get("kling_request")
v, exists := c.Get("task_request")
if !exists {
return nil, fmt.Errorf("request not found in context")
}
req := v.(SubmitReq)
body := a.convertToRequestPayload(&req)
body, err := a.convertToRequestPayload(&req)
if err != nil {
return nil, err
}
data, err := json.Marshal(body)
if err != nil {
return nil, err
@@ -135,6 +151,9 @@ func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, info *relaycommon.TaskRel
// DoRequest delegates to common helper.
func (a *TaskAdaptor) DoRequest(c *gin.Context, info *relaycommon.TaskRelayInfo, requestBody io.Reader) (*http.Response, error) {
if action := c.GetString("action"); action != "" {
info.Action = action
}
return channel.DoTaskApiRequest(a, c, info, requestBody)
}
@@ -149,8 +168,8 @@ func (a *TaskAdaptor) DoResponse(c *gin.Context, resp *http.Response, info *rela
// Attempt Kling response parse first.
var kResp responsePayload
if err := json.Unmarshal(responseBody, &kResp); err == nil && kResp.Code == 0 {
c.JSON(http.StatusOK, gin.H{"task_id": kResp.Data.TaskID})
return kResp.Data.TaskID, responseBody, nil
c.JSON(http.StatusOK, gin.H{"task_id": kResp.Data.TaskId})
return kResp.Data.TaskId, responseBody, nil
}
// Fallback generic task response.
@@ -175,7 +194,12 @@ func (a *TaskAdaptor) FetchTask(baseUrl, key string, body map[string]any) (*http
if !ok {
return nil, fmt.Errorf("invalid task_id")
}
url := fmt.Sprintf("%s/v1/videos/image2video/%s", baseUrl, taskID)
action, ok := body["action"].(string)
if !ok {
return nil, fmt.Errorf("invalid action")
}
path := lo.Ternary(action == "generate", "/v1/videos/image2video", "/v1/videos/text2video")
url := fmt.Sprintf("%s%s/%s", baseUrl, path, taskID)
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
@@ -187,10 +211,6 @@ func (a *TaskAdaptor) FetchTask(baseUrl, key string, body map[string]any) (*http
token = key
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
req = req.WithContext(ctx)
req.Header.Set("Accept", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("User-Agent", "kling-sdk/1.0")
@@ -210,22 +230,29 @@ func (a *TaskAdaptor) GetChannelName() string {
// helpers
// ============================
func (a *TaskAdaptor) convertToRequestPayload(req *SubmitReq) *requestPayload {
r := &requestPayload{
func (a *TaskAdaptor) convertToRequestPayload(req *SubmitReq) (*requestPayload, error) {
r := requestPayload{
Prompt: req.Prompt,
Image: req.Image,
Mode: defaultString(req.Mode, "std"),
Duration: fmt.Sprintf("%d", defaultInt(req.Duration, 5)),
AspectRatio: a.getAspectRatio(req.Size),
Model: req.Model,
ModelName: req.Model,
CfgScale: 0.5,
}
if r.Model == "" {
r.Model = "kling-v1"
if r.ModelName == "" {
r.ModelName = "kling-v1"
}
return r
metadata := req.Metadata
medaBytes, err := json.Marshal(metadata)
if err != nil {
return nil, errors.Wrap(err, "metadata marshal metadata failed")
}
err = json.Unmarshal(medaBytes, &r)
if err != nil {
return nil, errors.Wrap(err, "unmarshal metadata failed")
}
return &r, nil
}
func (a *TaskAdaptor) getAspectRatio(size string) string {
@@ -286,27 +313,33 @@ func (a *TaskAdaptor) createJWTTokenWithKeys(accessKey, secretKey string) (strin
return token.SignedString([]byte(secretKey))
}
// ParseResultUrl 提取视频任务结果的 url
func (a *TaskAdaptor) ParseResultUrl(resp map[string]any) (string, error) {
data, ok := resp["data"].(map[string]any)
if !ok {
return "", fmt.Errorf("data field not found or invalid")
func (a *TaskAdaptor) ParseTaskResult(respBody []byte) (*relaycommon.TaskInfo, error) {
resPayload := responsePayload{}
err := json.Unmarshal(respBody, &resPayload)
if err != nil {
return nil, errors.Wrap(err, "failed to unmarshal response body")
}
taskResult, ok := data["task_result"].(map[string]any)
if !ok {
return "", fmt.Errorf("task_result field not found or invalid")
taskInfo := &relaycommon.TaskInfo{}
taskInfo.Code = resPayload.Code
taskInfo.TaskID = resPayload.Data.TaskId
taskInfo.Reason = resPayload.Message
//任务状态,枚举值:submitted(已提交)、processing(处理中)、succeed(成功)、failed(失败)
status := resPayload.Data.TaskStatus
switch status {
case "submitted":
taskInfo.Status = model.TaskStatusSubmitted
case "processing":
taskInfo.Status = model.TaskStatusInProgress
case "succeed":
taskInfo.Status = model.TaskStatusSuccess
case "failed":
taskInfo.Status = model.TaskStatusFailure
default:
return nil, fmt.Errorf("unknown task status: %s", status)
}
videos, ok := taskResult["videos"].([]interface{})
if !ok || len(videos) == 0 {
return "", fmt.Errorf("videos field not found or empty")
if videos := resPayload.Data.TaskResult.Videos; len(videos) > 0 {
video := videos[0]
taskInfo.Url = video.Url
}
video, ok := videos[0].(map[string]interface{})
if !ok {
return "", fmt.Errorf("video item invalid")
}
url, ok := video["url"].(string)
if !ok || url == "" {
return "", fmt.Errorf("url field not found or invalid")
}
return url, nil
return taskInfo, nil
}
+2 -2
View File
@@ -22,8 +22,8 @@ type TaskAdaptor struct {
ChannelType int
}
func (a *TaskAdaptor) ParseResultUrl(resp map[string]any) (string, error) {
return "", nil // todo implement this method if needed
func (a *TaskAdaptor) ParseTaskResult([]byte) (*relaycommon.TaskInfo, error) {
return nil, fmt.Errorf("not implement") // todo implement this method if needed
}
func (a *TaskAdaptor) Init(info *relaycommon.TaskRelayInfo) {
+1 -1
View File
@@ -1,7 +1,7 @@
package xinference
type XinRerankResponseDocument struct {
Document string `json:"document,omitempty"`
Document any `json:"document,omitempty"`
Index int `json:"index"`
RelevanceScore float64 `json:"relevance_score"`
}
+19
View File
@@ -313,3 +313,22 @@ func GenTaskRelayInfo(c *gin.Context) *TaskRelayInfo {
}
return info
}
type TaskSubmitReq struct {
Prompt string `json:"prompt"`
Model string `json:"model,omitempty"`
Mode string `json:"mode,omitempty"`
Image string `json:"image,omitempty"`
Size string `json:"size,omitempty"`
Duration int `json:"duration,omitempty"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
}
type TaskInfo struct {
Code int `json:"code"`
TaskID string `json:"task_id"`
Status string `json:"status"`
Reason string `json:"reason,omitempty"`
Url string `json:"url,omitempty"`
Progress string `json:"progress,omitempty"`
}
+10 -4
View File
@@ -38,10 +38,16 @@ func RerankHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Respo
}
if info.ReturnDocuments {
var document any
if result.Document == "" {
document = info.Documents[result.Index]
} else {
document = result.Document
if result.Document != nil {
if doc, ok := result.Document.(string); ok {
if doc == "" {
document = info.Documents[result.Index]
} else {
document = doc
}
} else {
document = result.Document
}
}
respResult.Document = document
}
+14 -1
View File
@@ -41,6 +41,9 @@ const (
RelayModeKlingFetchByID
RelayModeKlingSubmit
RelayModeJimengFetchByID
RelayModeJimengSubmit
RelayModeRerank
RelayModeResponses
@@ -80,7 +83,7 @@ func Path2RelayMode(path string) int {
relayMode = RelayModeRerank
} else if strings.HasPrefix(path, "/v1/realtime") {
relayMode = RelayModeRealtime
} else if strings.HasPrefix(path, "/v1beta/models") {
} else if strings.HasPrefix(path, "/v1beta/models") || strings.HasPrefix(path, "/v1/models") {
relayMode = RelayModeGemini
}
return relayMode
@@ -146,3 +149,13 @@ func Path2RelayKling(method, path string) int {
}
return relayMode
}
func Path2RelayJimeng(method, path string) int {
relayMode := RelayModeUnknown
if method == http.MethodPost && strings.HasSuffix(path, "/video/generations") {
relayMode = RelayModeJimengSubmit
} else if method == http.MethodGet && strings.Contains(path, "/video/generations/") {
relayMode = RelayModeJimengFetchByID
}
return relayMode
}
+3
View File
@@ -22,6 +22,7 @@ import (
"one-api/relay/channel/palm"
"one-api/relay/channel/perplexity"
"one-api/relay/channel/siliconflow"
"one-api/relay/channel/task/jimeng"
"one-api/relay/channel/task/kling"
"one-api/relay/channel/task/suno"
"one-api/relay/channel/tencent"
@@ -104,6 +105,8 @@ func GetTaskAdaptor(platform commonconstant.TaskPlatform) channel.TaskAdaptor {
return &suno.TaskAdaptor{}
case commonconstant.TaskPlatformKling:
return &kling.TaskAdaptor{}
case commonconstant.TaskPlatformJimeng:
return &jimeng.TaskAdaptor{}
}
return nil
}
+1 -1
View File
@@ -245,7 +245,7 @@ func sunoFetchByIDRespBodyBuilder(c *gin.Context) (respBody []byte, taskResp *dt
}
func videoFetchByIDRespBodyBuilder(c *gin.Context) (respBody []byte, taskResp *dto.TaskError) {
taskId := c.Param("id")
taskId := c.Param("task_id")
userId := c.GetInt("id")
originTask, exist, err := model.GetByTaskId(userId, taskId)
+1
View File
@@ -63,6 +63,7 @@ func SetRelayRouter(router *gin.Engine) {
httpRouter.DELETE("/models/:model", controller.RelayNotImplemented)
httpRouter.POST("/moderations", controller.Relay)
httpRouter.POST("/rerank", controller.Relay)
httpRouter.POST("/models/*path", controller.Relay)
}
relayMjRouter := router.Group("/mj")
+7
View File
@@ -14,4 +14,11 @@ func SetVideoRouter(router *gin.Engine) {
videoV1Router.POST("/video/generations", controller.RelayTask)
videoV1Router.GET("/video/generations/:task_id", controller.RelayTask)
}
klingV1Router := router.Group("/kling/v1")
klingV1Router.Use(middleware.KlingRequestConvert(), middleware.TokenAuth(), middleware.Distribute())
{
klingV1Router.POST("/videos/text2video", controller.RelayTask)
klingV1Router.POST("/videos/image2video", controller.RelayTask)
}
}
+3 -3
View File
@@ -501,13 +501,13 @@ func getHardcodedCompletionModelRatio(name string) (float64, bool) {
} else if strings.HasPrefix(name, "gemini-2.0") {
return 4, true
} else if strings.HasPrefix(name, "gemini-2.5-pro") { // 移除preview来增加兼容性,这里假设正式版的倍率和preview一致
return 8, true
return 8, false
} else if strings.HasPrefix(name, "gemini-2.5-flash") { // 处理不同的flash模型倍率
if strings.HasPrefix(name, "gemini-2.5-flash-preview") {
if strings.HasSuffix(name, "-nothinking") {
return 4, true
return 4, false
}
return 3.5 / 0.15, true
return 3.5 / 0.15, false
}
if strings.HasPrefix(name, "gemini-2.5-flash-lite-preview") {
return 4, true
+46 -21
View File
@@ -34,20 +34,20 @@ import LinuxDoIcon from '../common/logo/LinuxDoIcon.js';
import { useTranslation } from 'react-i18next';
const LoginForm = () => {
let navigate = useNavigate();
const { t } = useTranslation();
const [inputs, setInputs] = useState({
username: '',
password: '',
wechat_verification_code: '',
});
const { username, password } = inputs;
const [searchParams, setSearchParams] = useSearchParams();
const [submitted, setSubmitted] = useState(false);
const { username, password } = inputs;
const [userState, userDispatch] = useContext(UserContext);
const [turnstileEnabled, setTurnstileEnabled] = useState(false);
const [turnstileSiteKey, setTurnstileSiteKey] = useState('');
const [turnstileToken, setTurnstileToken] = useState('');
let navigate = useNavigate();
const [status, setStatus] = useState({});
const [showWeChatLoginModal, setShowWeChatLoginModal] = useState(false);
const [showEmailLogin, setShowEmailLogin] = useState(false);
const [wechatLoading, setWechatLoading] = useState(false);
@@ -59,7 +59,6 @@ const LoginForm = () => {
const [resetPasswordLoading, setResetPasswordLoading] = useState(false);
const [otherLoginOptionsLoading, setOtherLoginOptionsLoading] = useState(false);
const [wechatCodeSubmitLoading, setWechatCodeSubmitLoading] = useState(false);
const { t } = useTranslation();
const logo = getLogo();
const systemName = getSystemName();
@@ -69,19 +68,22 @@ const LoginForm = () => {
localStorage.setItem('aff', affCode);
}
const [status] = useState(() => {
const savedStatus = localStorage.getItem('status');
return savedStatus ? JSON.parse(savedStatus) : {};
});
useEffect(() => {
if (status.turnstile_check) {
setTurnstileEnabled(true);
setTurnstileSiteKey(status.turnstile_site_key);
}
}, [status]);
useEffect(() => {
if (searchParams.get('expired')) {
showError(t('未登录或登录已过期,请重新登录'));
}
let status = localStorage.getItem('status');
if (status) {
status = JSON.parse(status);
setStatus(status);
if (status.turnstile_check) {
setTurnstileEnabled(true);
setTurnstileSiteKey(status.turnstile_site_key);
}
}
}, []);
const onWeChatLoginClicked = () => {
@@ -356,9 +358,19 @@ const LoginForm = () => {
</Button>
</div>
<div className="mt-6 text-center text-sm">
<Text>{t('没有账户?')} <Link to="/register" className="text-blue-600 hover:text-blue-800 font-medium">{t('注册')}</Link></Text>
</div>
{!status.self_use_mode_enabled && (
<div className="mt-6 text-center text-sm">
<Text>
{t('没有账户?')}{' '}
<Link
to="/register"
className="text-blue-600 hover:text-blue-800 font-medium"
>
{t('注册')}
</Link>
</Text>
</div>
)}
</div>
</Card>
</div>
@@ -451,9 +463,19 @@ const LoginForm = () => {
</>
)}
<div className="mt-6 text-center text-sm">
<Text>{t('没有账户?')} <Link to="/register" className="text-blue-600 hover:text-blue-800 font-medium">{t('注册')}</Link></Text>
</div>
{!status.self_use_mode_enabled && (
<div className="mt-6 text-center text-sm">
<Text>
{t('没有账户?')}{' '}
<Link
to="/register"
className="text-blue-600 hover:text-blue-800 font-medium"
>
{t('注册')}
</Link>
</Text>
</div>
)}
</div>
</Card>
</div>
@@ -499,8 +521,11 @@ const LoginForm = () => {
};
return (
<div className="bg-gray-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
<div className="w-full max-w-sm">
<div className="relative overflow-hidden bg-gray-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
{/* 背景模糊晕染球 */}
<div className="blur-ball blur-ball-indigo" style={{ top: '-80px', right: '-80px', transform: 'none' }} />
<div className="blur-ball blur-ball-teal" style={{ top: '50%', left: '-120px' }} />
<div className="w-full max-w-sm mt-[64px]">
{showEmailLogin || !(status.github_oauth || status.oidc_enabled || status.wechat_login || status.linuxdo_oauth || status.telegram_oauth)
? renderEmailLoginForm()
: renderOAuthOptions()}
@@ -78,8 +78,11 @@ const PasswordResetConfirm = () => {
}
return (
<div className="bg-gray-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
<div className="w-full max-w-sm">
<div className="relative overflow-hidden bg-gray-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
{/* 背景模糊晕染球 */}
<div className="blur-ball blur-ball-indigo" style={{ top: '-80px', right: '-80px', transform: 'none' }} />
<div className="blur-ball blur-ball-teal" style={{ top: '50%', left: '-120px' }} />
<div className="w-full max-w-sm mt-[64px]">
<div className="flex flex-col items-center">
<div className="w-full max-w-md">
<div className="flex items-center justify-center mb-6 gap-2">
+5 -2
View File
@@ -78,8 +78,11 @@ const PasswordResetForm = () => {
}
return (
<div className="bg-gray-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
<div className="w-full max-w-sm">
<div className="relative overflow-hidden bg-gray-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
{/* 背景模糊晕染球 */}
<div className="blur-ball blur-ball-indigo" style={{ top: '-80px', right: '-80px', transform: 'none' }} />
<div className="blur-ball blur-ball-teal" style={{ top: '50%', left: '-120px' }} />
<div className="w-full max-w-sm mt-[64px]">
<div className="flex flex-col items-center">
<div className="w-full max-w-md">
<div className="flex items-center justify-center mb-6 gap-2">
+20 -16
View File
@@ -35,6 +35,7 @@ import { UserContext } from '../../context/User/index.js';
import { useTranslation } from 'react-i18next';
const RegisterForm = () => {
let navigate = useNavigate();
const { t } = useTranslation();
const [inputs, setInputs] = useState({
username: '',
@@ -45,15 +46,12 @@ const RegisterForm = () => {
wechat_verification_code: '',
});
const { username, password, password2 } = inputs;
const [showEmailVerification, setShowEmailVerification] = useState(false);
const [userState, userDispatch] = useContext(UserContext);
const [turnstileEnabled, setTurnstileEnabled] = useState(false);
const [turnstileSiteKey, setTurnstileSiteKey] = useState('');
const [turnstileToken, setTurnstileToken] = useState('');
const [loading, setLoading] = useState(false);
const [showWeChatLoginModal, setShowWeChatLoginModal] = useState(false);
const [showEmailRegister, setShowEmailRegister] = useState(false);
const [status, setStatus] = useState({});
const [wechatLoading, setWechatLoading] = useState(false);
const [githubLoading, setGithubLoading] = useState(false);
const [oidcLoading, setOidcLoading] = useState(false);
@@ -63,7 +61,6 @@ const RegisterForm = () => {
const [verificationCodeLoading, setVerificationCodeLoading] = useState(false);
const [otherRegisterOptionsLoading, setOtherRegisterOptionsLoading] = useState(false);
const [wechatCodeSubmitLoading, setWechatCodeSubmitLoading] = useState(false);
let navigate = useNavigate();
const logo = getLogo();
const systemName = getSystemName();
@@ -73,18 +70,22 @@ const RegisterForm = () => {
localStorage.setItem('aff', affCode);
}
const [status] = useState(() => {
const savedStatus = localStorage.getItem('status');
return savedStatus ? JSON.parse(savedStatus) : {};
});
const [showEmailVerification, setShowEmailVerification] = useState(() => {
return status.email_verification ?? false;
});
useEffect(() => {
let status = localStorage.getItem('status');
if (status) {
status = JSON.parse(status);
setStatus(status);
setShowEmailVerification(status.email_verification);
if (status.turnstile_check) {
setTurnstileEnabled(true);
setTurnstileSiteKey(status.turnstile_site_key);
}
setShowEmailVerification(status.email_verification);
if (status.turnstile_check) {
setTurnstileEnabled(true);
setTurnstileSiteKey(status.turnstile_site_key);
}
}, []);
}, [status]);
const onWeChatLoginClicked = () => {
setWechatLoading(true);
@@ -541,8 +542,11 @@ const RegisterForm = () => {
};
return (
<div className="bg-gray-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
<div className="w-full max-w-sm">
<div className="relative overflow-hidden bg-gray-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
{/* 背景模糊晕染球 */}
<div className="blur-ball blur-ball-indigo" style={{ top: '-80px', right: '-80px', transform: 'none' }} />
<div className="blur-ball blur-ball-teal" style={{ top: '50%', left: '-120px' }} />
<div className="w-full max-w-sm mt-[64px]">
{showEmailRegister || !(status.github_oauth || status.oidc_enabled || status.wechat_login || status.linuxdo_oauth || status.telegram_oauth)
? renderEmailRegisterForm()
: renderOAuthOptions()}
+1 -3
View File
@@ -11,7 +11,7 @@ import { API, getLogo, getSystemName, showError, setStatusData } from '../../hel
import { UserContext } from '../../context/User/index.js';
import { StatusContext } from '../../context/Status/index.js';
import { useLocation } from 'react-router-dom';
const { Sider, Content, Header, Footer } = Layout;
const { Sider, Content, Header } = Layout;
const PageLayout = () => {
const [userState, userDispatch] = useContext(UserContext);
@@ -94,8 +94,6 @@ const PageLayout = () => {
</Header>
<Layout
style={{
marginTop: '64px',
height: 'calc(100vh - 64px)',
overflow: styleState.isMobile ? 'visible' : 'auto',
display: 'flex',
flexDirection: 'column',
File diff suppressed because it is too large Load Diff
+267 -112
View File
@@ -20,7 +20,7 @@ import {
Tags,
} from 'lucide-react';
import { CHANNEL_OPTIONS, ITEMS_PER_PAGE } from '../../constants/index.js';
import { CHANNEL_OPTIONS, ITEMS_PER_PAGE, MODEL_TABLE_PAGE_SIZE } from '../../constants/index.js';
import {
Button,
Divider,
@@ -40,7 +40,8 @@ import {
Card,
Form,
Tabs,
TabPane
TabPane,
Select
} from '@douyinfe/semi-ui';
import {
IllustrationNoResult,
@@ -50,19 +51,13 @@ import EditChannel from '../../pages/Channel/EditChannel.js';
import {
IconTreeTriangleDown,
IconPlus,
IconRefresh,
IconSetting,
IconDescend,
IconSearch,
IconEdit,
IconDelete,
IconStop,
IconPlay,
IconMore,
IconCopy,
IconSmallTriangleRight
} from '@douyinfe/semi-icons';
import { loadChannelModels } from '../../helpers/index.js';
import { loadChannelModels, isMobile, copy } from '../../helpers';
import EditTagModal from '../../pages/Channel/EditTagModal.js';
import { useTranslation } from 'react-i18next';
import { useTableCompactMode } from '../../hooks/useTableCompactMode';
@@ -189,6 +184,11 @@ const ChannelsTable = () => {
const [visibleColumns, setVisibleColumns] = useState({});
const [showColumnSelector, setShowColumnSelector] = useState(false);
// 状态筛选 all / enabled / disabled
const [statusFilter, setStatusFilter] = useState(
localStorage.getItem('channel-status-filter') || 'all'
);
// Load saved column preferences from localStorage
useEffect(() => {
const savedColumns = localStorage.getItem('channels-table-columns');
@@ -551,7 +551,6 @@ const ChannelsTable = () => {
type='warning'
size="small"
className="!rounded-full"
icon={<IconStop />}
onClick={() => manageChannel(record.id, 'disable', record)}
>
{t('禁用')}
@@ -562,7 +561,6 @@ const ChannelsTable = () => {
type='secondary'
size="small"
className="!rounded-full"
icon={<IconPlay />}
onClick={() => manageChannel(record.id, 'enable', record)}
>
{t('启用')}
@@ -574,7 +572,6 @@ const ChannelsTable = () => {
type='tertiary'
size="small"
className="!rounded-full"
icon={<IconEdit />}
onClick={() => {
setEditingChannel(record);
setShowEdit(true);
@@ -599,19 +596,7 @@ const ChannelsTable = () => {
</Space>
);
} else {
// 标签操作的下拉菜单项
const tagMenuItems = [
{
node: 'item',
name: t('编辑'),
icon: <IconEdit />,
onClick: () => {
setShowEditTag(true);
setEditingTag(record.key);
},
},
];
// 标签操作按钮
return (
<Space wrap>
<Button
@@ -619,7 +604,6 @@ const ChannelsTable = () => {
type='secondary'
size="small"
className="!rounded-full"
icon={<IconPlay />}
onClick={() => manageTag(record.key, 'enable')}
>
{t('启用全部')}
@@ -629,24 +613,22 @@ const ChannelsTable = () => {
type='warning'
size="small"
className="!rounded-full"
icon={<IconStop />}
onClick={() => manageTag(record.key, 'disable')}
>
{t('禁用全部')}
</Button>
<Dropdown
trigger='click'
position='bottomRight'
menu={tagMenuItems}
<Button
theme='light'
type='tertiary'
size="small"
className="!rounded-full"
onClick={() => {
setShowEditTag(true);
setEditingTag(record.key);
}}
>
<Button
icon={<IconMore />}
theme='light'
type='tertiary'
size="small"
className="!rounded-full"
/>
</Dropdown>
{t('编辑')}
</Button>
</Space>
);
}
@@ -678,9 +660,11 @@ const ChannelsTable = () => {
const [modelSearchKeyword, setModelSearchKeyword] = useState('');
const [modelTestResults, setModelTestResults] = useState({});
const [testingModels, setTestingModels] = useState(new Set());
const [selectedModelKeys, setSelectedModelKeys] = useState([]);
const [isBatchTesting, setIsBatchTesting] = useState(false);
const [testQueue, setTestQueue] = useState([]);
const [isProcessingQueue, setIsProcessingQueue] = useState(false);
const [modelTablePage, setModelTablePage] = useState(1);
const [activeTypeKey, setActiveTypeKey] = useState('all');
const [typeCounts, setTypeCounts] = useState({});
const requestCounter = useRef(0);
@@ -691,6 +675,7 @@ const ChannelsTable = () => {
searchGroup: '',
searchModel: '',
};
const allSelectingRef = useRef(false);
// Filter columns based on visibility settings
const getVisibleColumns = () => {
@@ -867,12 +852,30 @@ const ChannelsTable = () => {
setChannels(channelDates);
};
const loadChannels = async (page, pageSize, idSort, enableTagMode, typeKey = activeTypeKey) => {
const loadChannels = async (
page,
pageSize,
idSort,
enableTagMode,
typeKey = activeTypeKey,
statusF,
) => {
if (statusF === undefined) statusF = statusFilter;
const { searchKeyword, searchGroup, searchModel } = getFormValues();
if (searchKeyword !== '' || searchGroup !== '' || searchModel !== '') {
setLoading(true);
await searchChannels(enableTagMode, typeKey, statusF, page, pageSize, idSort);
setLoading(false);
return;
}
const reqId = ++requestCounter.current; // 记录当前请求序号
setLoading(true);
const typeParam = (!enableTagMode && typeKey !== 'all') ? `&type=${typeKey}` : '';
const typeParam = (typeKey !== 'all') ? `&type=${typeKey}` : '';
const statusParam = statusF !== 'all' ? `&status=${statusF}` : '';
const res = await API.get(
`/api/channel/?p=${page}&page_size=${pageSize}&id_sort=${idSort}&tag_mode=${enableTagMode}${typeParam}`,
`/api/channel/?p=${page}&page_size=${pageSize}&id_sort=${idSort}&tag_mode=${enableTagMode}${typeParam}${statusParam}`,
);
if (res === undefined || reqId !== requestCounter.current) {
return;
@@ -923,7 +926,7 @@ const ChannelsTable = () => {
if (searchKeyword === '' && searchGroup === '' && searchModel === '') {
await loadChannels(activePage, pageSize, idSort, enableTagMode);
} else {
await searchChannels(enableTagMode);
await searchChannels(enableTagMode, activeTypeKey, statusFilter, activePage, pageSize, idSort);
}
};
@@ -1029,7 +1032,7 @@ const ChannelsTable = () => {
}
};
// 获取表单值的辅助函数,确保所有值都是字符串
// 获取表单值的辅助函数
const getFormValues = () => {
const formValues = formApi ? formApi.getValues() : {};
return {
@@ -1039,27 +1042,35 @@ const ChannelsTable = () => {
};
};
const searchChannels = async (enableTagMode) => {
const searchChannels = async (
enableTagMode,
typeKey = activeTypeKey,
statusF = statusFilter,
page = 1,
pageSz = pageSize,
sortFlag = idSort,
) => {
const { searchKeyword, searchGroup, searchModel } = getFormValues();
setSearching(true);
try {
if (searchKeyword === '' && searchGroup === '' && searchModel === '') {
await loadChannels(activePage - 1, pageSize, idSort, enableTagMode);
await loadChannels(page, pageSz, sortFlag, enableTagMode, typeKey, statusF);
return;
}
const typeParam = (!enableTagMode && activeTypeKey !== 'all') ? `&type=${activeTypeKey}` : '';
const typeParam = (typeKey !== 'all') ? `&type=${typeKey}` : '';
const statusParam = statusF !== 'all' ? `&status=${statusF}` : '';
const res = await API.get(
`/api/channel/search?keyword=${searchKeyword}&group=${searchGroup}&model=${searchModel}&id_sort=${idSort}&tag_mode=${enableTagMode}${typeParam}`,
`/api/channel/search?keyword=${searchKeyword}&group=${searchGroup}&model=${searchModel}&id_sort=${sortFlag}&tag_mode=${enableTagMode}&p=${page}&page_size=${pageSz}${typeParam}${statusParam}`,
);
const { success, message, data } = res.data;
if (success) {
const { items = [], type_counts = {} } = data;
const { items = [], total = 0, 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);
setChannelCount(total);
setActivePage(page);
} else {
showError(message);
}
@@ -1099,7 +1110,22 @@ const ChannelsTable = () => {
const processTestQueue = async () => {
if (!isProcessingQueue || testQueue.length === 0) return;
const { channel, model } = testQueue[0];
const { channel, model, indexInFiltered } = testQueue[0];
// 自动翻页到正在测试的模型所在页
if (currentTestChannel && currentTestChannel.id === channel.id) {
let pageNo;
if (indexInFiltered !== undefined) {
pageNo = Math.floor(indexInFiltered / MODEL_TABLE_PAGE_SIZE) + 1;
} else {
const filteredModelsList = currentTestChannel.models
.split(',')
.filter((m) => m.toLowerCase().includes(modelSearchKeyword.toLowerCase()));
const modelIdx = filteredModelsList.indexOf(model);
pageNo = modelIdx !== -1 ? Math.floor(modelIdx / MODEL_TABLE_PAGE_SIZE) + 1 : 1;
}
setModelTablePage(pageNo);
}
try {
setTestingModels(prev => new Set([...prev, model]));
@@ -1162,16 +1188,22 @@ const ChannelsTable = () => {
setIsBatchTesting(true);
const models = currentTestChannel.models
// 重置分页到第一页
setModelTablePage(1);
const filteredModels = currentTestChannel.models
.split(',')
.filter((model) =>
model.toLowerCase().includes(modelSearchKeyword.toLowerCase())
model.toLowerCase().includes(modelSearchKeyword.toLowerCase()),
);
setTestQueue(models.map(model => ({
channel: currentTestChannel,
model
})));
setTestQueue(
filteredModels.map((model, idx) => ({
channel: currentTestChannel,
model,
indexInFiltered: idx, // 记录在过滤列表中的顺序
})),
);
setIsProcessingQueue(true);
};
@@ -1185,6 +1217,8 @@ const ChannelsTable = () => {
} else {
setShowModelTestModal(false);
setModelSearchKeyword('');
setSelectedModelKeys([]);
setModelTablePage(1);
}
};
@@ -1265,32 +1299,31 @@ const ChannelsTable = () => {
};
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) => {
const { searchKeyword, searchGroup, searchModel } = getFormValues();
setActivePage(page);
loadChannels(page, pageSize, idSort, enableTagMode).then(() => { });
if (searchKeyword === '' && searchGroup === '' && searchModel === '') {
loadChannels(page, pageSize, idSort, enableTagMode).then(() => { });
} else {
searchChannels(enableTagMode, activeTypeKey, statusFilter, page, pageSize, idSort);
}
};
const handlePageSizeChange = async (size) => {
localStorage.setItem('page-size', size + '');
setPageSize(size);
setActivePage(1);
loadChannels(1, size, idSort, enableTagMode)
.then()
.catch((reason) => {
showError(reason);
});
const { searchKeyword, searchGroup, searchModel } = getFormValues();
if (searchKeyword === '' && searchGroup === '' && searchModel === '') {
loadChannels(1, size, idSort, enableTagMode)
.then()
.catch((reason) => {
showError(reason);
});
} else {
searchChannels(enableTagMode, activeTypeKey, statusFilter, 1, size, idSort);
}
};
const fetchGroups = async () => {
@@ -1471,6 +1504,7 @@ const ChannelsTable = () => {
<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
size='small'
disabled={!enableBatchDelete}
theme='light'
type='danger'
@@ -1487,6 +1521,7 @@ const ChannelsTable = () => {
</Button>
<Button
size='small'
disabled={!enableBatchDelete}
theme='light'
type='primary'
@@ -1497,11 +1532,13 @@ const ChannelsTable = () => {
</Button>
<Dropdown
size='small'
trigger='click'
render={
<Dropdown.Menu>
<Dropdown.Item>
<Button
size='small'
theme='light'
type='warning'
className="!rounded-full w-full"
@@ -1520,6 +1557,7 @@ const ChannelsTable = () => {
</Dropdown.Item>
<Dropdown.Item>
<Button
size='small'
theme='light'
type='secondary'
className="!rounded-full w-full"
@@ -1538,6 +1576,7 @@ const ChannelsTable = () => {
</Dropdown.Item>
<Dropdown.Item>
<Button
size='small'
theme='light'
type='danger'
className="!rounded-full w-full"
@@ -1556,6 +1595,7 @@ const ChannelsTable = () => {
</Dropdown.Item>
<Dropdown.Item>
<Button
size='small'
theme='light'
type='tertiary'
className="!rounded-full w-full"
@@ -1575,15 +1615,15 @@ const ChannelsTable = () => {
</Dropdown.Menu>
}
>
<Button theme='light' type='tertiary' icon={<IconSetting />} className="!rounded-full w-full md:w-auto">
<Button size='small' theme='light' type='tertiary' className="!rounded-full w-full md:w-auto">
{t('批量操作')}
</Button>
</Dropdown>
<Button
size='small'
theme='light'
type='secondary'
icon={<IconDescend />}
className="!rounded-full w-full md:w-auto"
onClick={() => setCompactMode(!compactMode)}
>
@@ -1597,11 +1637,17 @@ const ChannelsTable = () => {
{t('使用ID排序')}
</Typography.Text>
<Switch
size='small'
checked={idSort}
onChange={(v) => {
localStorage.setItem('id-sort', v + '');
setIdSort(v);
loadChannels(activePage, pageSize, v, enableTagMode);
const { searchKeyword, searchGroup, searchModel } = getFormValues();
if (searchKeyword === '' && searchGroup === '' && searchModel === '') {
loadChannels(activePage, pageSize, v, enableTagMode);
} else {
searchChannels(enableTagMode, activeTypeKey, statusFilter, activePage, pageSize, v);
}
}}
/>
</div>
@@ -1611,6 +1657,7 @@ const ChannelsTable = () => {
{t('开启批量操作')}
</Typography.Text>
<Switch
size='small'
checked={enableBatchDelete}
onChange={(v) => {
localStorage.setItem('enable-batch-delete', v + '');
@@ -1624,6 +1671,7 @@ const ChannelsTable = () => {
{t('标签聚合模式')}
</Typography.Text>
<Switch
size='small'
checked={enableTagMode}
onChange={(v) => {
localStorage.setItem('enable-tag-mode', v + '');
@@ -1633,6 +1681,27 @@ const ChannelsTable = () => {
}}
/>
</div>
{/* 状态筛选器 */}
<div className="flex items-center justify-between w-full md:w-auto">
<Typography.Text strong className="mr-2">
{t('状态筛选')}
</Typography.Text>
<Select
size='small'
value={statusFilter}
onChange={(v) => {
localStorage.setItem('channel-status-filter', v);
setStatusFilter(v);
setActivePage(1);
loadChannels(1, pageSize, idSort, enableTagMode, activeTypeKey, v);
}}
>
<Select.Option value="all">{t('全部')}</Select.Option>
<Select.Option value="enabled">{t('已启用')}</Select.Option>
<Select.Option value="disabled">{t('已禁用')}</Select.Option>
</Select>
</div>
</div>
</div>
@@ -1641,6 +1710,7 @@ const ChannelsTable = () => {
<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
size='small'
theme='light'
type='primary'
icon={<IconPlus />}
@@ -1656,9 +1726,9 @@ const ChannelsTable = () => {
</Button>
<Button
size='small'
theme='light'
type='primary'
icon={<IconRefresh />}
className="!rounded-full w-full md:w-auto"
onClick={refresh}
>
@@ -1666,9 +1736,9 @@ const ChannelsTable = () => {
</Button>
<Button
size='small'
theme='light'
type='tertiary'
icon={<IconSetting />}
onClick={() => setShowColumnSelector(true)}
className="!rounded-full w-full md:w-auto"
>
@@ -1690,9 +1760,10 @@ const ChannelsTable = () => {
>
<div className="relative w-full md:w-64">
<Form.Input
size='small'
field="searchKeyword"
prefix={<IconSearch />}
placeholder={t('搜索渠道的 ID,名称,密钥API地址 ...')}
placeholder={t('渠道ID,名称,密钥API地址')}
className="!rounded-full"
showClear
pure
@@ -1700,6 +1771,7 @@ const ChannelsTable = () => {
</div>
<div className="w-full md:w-48">
<Form.Input
size='small'
field="searchModel"
prefix={<IconSearch />}
placeholder={t('模型关键字')}
@@ -1708,8 +1780,9 @@ const ChannelsTable = () => {
pure
/>
</div>
<div className="w-full md:w-48">
<div className="w-full md:w-32">
<Form.Select
size='small'
field="searchGroup"
placeholder={t('选择分组')}
optionList={[
@@ -1728,6 +1801,7 @@ const ChannelsTable = () => {
/>
</div>
<Button
size='small'
type="primary"
htmlType="submit"
loading={loading || searching}
@@ -1736,6 +1810,7 @@ const ChannelsTable = () => {
{t('查询')}
</Button>
<Button
size='small'
theme='light'
onClick={() => {
if (formApi) {
@@ -1819,7 +1894,7 @@ const ChannelsTable = () => {
}
className="rounded-xl overflow-hidden"
size="middle"
loading={loading}
loading={loading || searching}
/>
</Card>
@@ -1855,13 +1930,73 @@ const ChannelsTable = () => {
<Modal
title={
currentTestChannel && (
<div className="flex items-center gap-2">
<Typography.Text strong className="!text-[var(--semi-color-text-0)] !text-base">
{currentTestChannel.name} {t('渠道的模型测试')}
</Typography.Text>
<Typography.Text type="tertiary" className="!text-xs flex items-center">
{t('共')} {currentTestChannel.models.split(',').length} {t('个模型')}
</Typography.Text>
<div className="flex flex-col gap-2 w-full">
<div className="flex items-center gap-2">
<Typography.Text strong className="!text-[var(--semi-color-text-0)] !text-base">
{currentTestChannel.name} {t('渠道的模型测试')}
</Typography.Text>
<Typography.Text type="tertiary" className="!text-xs flex items-center">
{t('共')} {currentTestChannel.models.split(',').length} {t('个模型')}
</Typography.Text>
</div>
{/* 搜索与操作按钮 */}
<div className="flex items-center justify-end gap-2 w-full">
<Input
placeholder={t('搜索模型...')}
value={modelSearchKeyword}
onChange={(v) => {
setModelSearchKeyword(v);
setModelTablePage(1);
}}
className="!w-full !rounded-full"
prefix={<IconSearch />}
showClear
/>
<Button
theme='light'
icon={<IconCopy />}
className="!rounded-full"
onClick={() => {
if (selectedModelKeys.length === 0) {
showError(t('请先选择模型!'));
return;
}
copy(selectedModelKeys.join(',')).then((ok) => {
if (ok) {
showSuccess(t('已复制 ${count} 个模型').replace('${count}', selectedModelKeys.length));
} else {
showError(t('复制失败,请手动复制'));
}
});
}}
>
{t('复制已选')}
</Button>
<Button
theme='light'
type='primary'
className="!rounded-full"
onClick={() => {
if (!currentTestChannel) return;
const successKeys = currentTestChannel.models
.split(',')
.filter((m) => m.toLowerCase().includes(modelSearchKeyword.toLowerCase()))
.filter((m) => {
const result = modelTestResults[`${currentTestChannel.id}-${m}`];
return result && result.success;
});
if (successKeys.length === 0) {
showInfo(t('暂无成功模型'));
}
setSelectedModelKeys(successKeys);
}}
>
{t('选择成功')}
</Button>
</div>
</div>
)
}
@@ -1911,22 +2046,11 @@ const ChannelsTable = () => {
}
maskClosable={!isBatchTesting}
className="!rounded-lg"
size="large"
size={isMobile() ? 'full-width' : 'large'}
>
<div className="max-h-[600px] overflow-y-auto">
<div className="model-test-scroll">
{currentTestChannel && (
<div>
<div className="flex items-center justify-end mb-2">
<Input
placeholder={t('搜索模型...')}
value={modelSearchKeyword}
onChange={(v) => setModelSearchKeyword(v)}
className="w-64 !rounded-full"
prefix={<IconSearch />}
showClear
/>
</div>
<Table
columns={[
{
@@ -2000,16 +2124,47 @@ const ChannelsTable = () => {
}
}
]}
dataSource={currentTestChannel.models
.split(',')
.filter((model) =>
model.toLowerCase().includes(modelSearchKeyword.toLowerCase())
)
.map((model) => ({
dataSource={(() => {
const filtered = currentTestChannel.models
.split(',')
.filter((model) =>
model.toLowerCase().includes(modelSearchKeyword.toLowerCase()),
);
const start = (modelTablePage - 1) * MODEL_TABLE_PAGE_SIZE;
const end = start + MODEL_TABLE_PAGE_SIZE;
return filtered.slice(start, end).map((model) => ({
model,
key: model
}))}
pagination={false}
key: model,
}));
})()}
rowSelection={{
selectedRowKeys: selectedModelKeys,
onChange: (keys) => {
if (allSelectingRef.current) {
allSelectingRef.current = false;
return;
}
setSelectedModelKeys(keys);
},
onSelectAll: (checked) => {
const filtered = currentTestChannel.models
.split(',')
.filter((m) => m.toLowerCase().includes(modelSearchKeyword.toLowerCase()));
allSelectingRef.current = true;
setSelectedModelKeys(checked ? filtered : []);
},
}}
pagination={{
currentPage: modelTablePage,
pageSize: MODEL_TABLE_PAGE_SIZE,
total: currentTestChannel.models
.split(',')
.filter((model) =>
model.toLowerCase().includes(modelSearchKeyword.toLowerCase()),
).length,
showSizeChanger: false,
onPageChange: (page) => setModelTablePage(page),
}}
/>
</div>
)}
+3 -62
View File
@@ -16,7 +16,6 @@ import {
Card,
Tabs,
TabPane,
Dropdown,
Empty
} from '@douyinfe/semi-ui';
import {
@@ -257,7 +256,7 @@ const ModelPricing = () => {
const [models, setModels] = useState([]);
const [loading, setLoading] = useState(true);
const [userState, userDispatch] = useContext(UserContext);
const [userState] = useContext(UserContext);
const [groupRatio, setGroupRatio] = useState({});
const [usableGroup, setUsableGroup] = useState({});
@@ -334,57 +333,6 @@ const ModelPricing = () => {
return counts;
}, [models, modelCategories]);
const renderArrow = (items, pos, handleArrowClick) => {
const style = {
width: 32,
height: 32,
margin: '0 12px',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
borderRadius: '100%',
background: 'rgba(var(--semi-grey-1), 1)',
color: 'var(--semi-color-text)',
cursor: 'pointer',
};
return (
<Dropdown
render={
<Dropdown.Menu>
{items.map(item => {
const key = item.itemKey;
const modelCount = categoryCounts[key] || 0;
return (
<Dropdown.Item
key={item.itemKey}
onClick={() => setActiveKey(item.itemKey)}
icon={modelCategories[item.itemKey]?.icon}
>
<div className="flex items-center gap-2">
{modelCategories[item.itemKey]?.label || item.itemKey}
<Tag
color={activeKey === item.itemKey ? 'red' : 'grey'}
size='small'
shape='circle'
>
{modelCount}
</Tag>
</div>
</Dropdown.Item>
);
})}
</Dropdown.Menu>
}
>
<div style={style} onClick={handleArrowClick}>
{pos === 'start' ? '←' : '→'}
</div>
</Dropdown>
);
};
// 检查分类是否有对应的模型
const availableCategories = useMemo(() => {
if (!models.length) return ['all'];
@@ -394,11 +342,9 @@ const ModelPricing = () => {
}).map(([key]) => key);
}, [models]);
// 渲染标签页
const renderTabs = () => {
return (
<Tabs
renderArrow={renderArrow}
activeKey={activeKey}
type="card"
collapsible
@@ -434,16 +380,13 @@ const ModelPricing = () => {
);
};
// 优化过滤逻辑
const filteredModels = useMemo(() => {
let result = models;
// 先按分类过滤
if (activeKey !== 'all') {
result = result.filter(model => modelCategories[activeKey].filter(model));
}
// 再按搜索词过滤
if (filteredValue.length > 0) {
const searchTerm = filteredValue[0].toLowerCase();
result = result.filter(model =>
@@ -454,7 +397,6 @@ const ModelPricing = () => {
return result;
}, [activeKey, models, filteredValue]);
// 搜索和操作区组件
const SearchAndActions = useMemo(() => (
<Card className="!rounded-xl mb-6" bordered={false}>
<div className="flex flex-wrap items-center gap-4">
@@ -485,7 +427,6 @@ const ModelPricing = () => {
</Card>
), [selectedRowKeys, t]);
// 表格组件
const ModelTable = useMemo(() => (
<Card className="!rounded-xl overflow-hidden" bordered={false}>
<Table
@@ -523,10 +464,10 @@ const ModelPricing = () => {
<div className="bg-gray-50">
<Layout>
<Layout.Content>
<div className="flex justify-center p-4 sm:p-6 md:p-8">
<div className="flex justify-center">
<div className="w-full">
{/* 主卡片容器 */}
<Card className="!rounded-2xl shadow-lg border-0">
<Card bordered={false} className="!rounded-2xl shadow-lg border-0">
{/* 顶部状态卡片 */}
<Card
className="!rounded-2xl !border-0 !shadow-md overflow-hidden mb-6"
+5 -30
View File
@@ -270,15 +270,12 @@ const RedemptionsTable = () => {
const [showEdit, setShowEdit] = useState(false);
const [compactMode, setCompactMode] = useTableCompactMode('redemptions');
// Form 初始值
const formInitValues = {
searchKeyword: '',
};
// Form API 引用
const [formApi, setFormApi] = useState(null);
// 获取表单值的辅助函数
const getFormValues = () => {
const formValues = formApi ? formApi.getValues() : {};
return {
@@ -299,14 +296,15 @@ const RedemptionsTable = () => {
setRedemptions(redeptions);
};
const loadRedemptions = async (startIdx, pageSize) => {
const loadRedemptions = async (page = 1, pageSize) => {
setLoading(true);
const res = await API.get(
`/api/redemption/?p=${startIdx}&page_size=${pageSize}`,
`/api/redemption/?p=${page}&page_size=${pageSize}`,
);
const { success, message, data } = res.data;
if (success) {
const newPageData = data.items;
setActivePage(data.page);
setActivePage(data.page <= 0 ? 1 : data.page);
setTokenCount(data.total);
setRedemptionFormat(newPageData);
} else {
@@ -339,17 +337,8 @@ const RedemptionsTable = () => {
}
};
const onPaginationChange = (e, { activePage }) => {
(async () => {
if (activePage === Math.ceil(redemptions.length / pageSize) + 1) {
await loadRedemptions(activePage - 1, pageSize);
}
setActivePage(activePage);
})();
};
useEffect(() => {
loadRedemptions(0, pageSize)
loadRedemptions(1, pageSize)
.then()
.catch((reason) => {
showError(reason);
@@ -420,20 +409,6 @@ const RedemptionsTable = () => {
setSearching(false);
};
const sortRedemption = (key) => {
if (redemptions.length === 0) return;
setLoading(true);
let sortedRedemptions = [...redemptions];
sortedRedemptions.sort((a, b) => {
return ('' + a[key]).localeCompare(b[key]);
});
if (sortedRedemptions[0].id === redemptions[0].id) {
sortedRedemptions.reverse();
}
setRedemptions(sortedRedemptions);
setLoading(false);
};
const handlePageChange = (page) => {
setActivePage(page);
const { searchKeyword } = getFormValues();
+17 -5
View File
@@ -212,7 +212,13 @@ const LogsTable = () => {
case 'generate':
return (
<Tag color='blue' size='large' shape='circle' prefixIcon={<Sparkles size={14} />}>
{t('生视频')}
{t('生视频')}
</Tag>
);
case 'textGenerate':
return (
<Tag color='blue' size='large' shape='circle' prefixIcon={<Sparkles size={14} />}>
{t('文生视频')}
</Tag>
);
default:
@@ -224,8 +230,8 @@ const LogsTable = () => {
}
};
const renderPlatform = (type) => {
switch (type) {
const renderPlatform = (platform) => {
switch (platform) {
case 'suno':
return (
<Tag color='green' size='large' shape='circle' prefixIcon={<Music size={14} />}>
@@ -234,10 +240,16 @@ const LogsTable = () => {
);
case 'kling':
return (
<Tag color='blue' size='large' shape='circle' prefixIcon={<Video size={14} />}>
<Tag color='orange' size='large' shape='circle' prefixIcon={<Video size={14} />}>
Kling
</Tag>
);
case 'jimeng':
return (
<Tag color='purple' size='large' shape='circle' prefixIcon={<Video size={14} />}>
Jimeng
</Tag>
);
default:
return (
<Tag color='white' size='large' shape='circle' prefixIcon={<HelpCircle size={14} />}>
@@ -434,7 +446,7 @@ const LogsTable = () => {
fixed: 'right',
render: (text, record, index) => {
// 仅当为视频生成任务且成功,且 fail_reason 是 URL 时显示可点击链接
const isVideoTask = record.action === 'generate';
const isVideoTask = record.action === 'generate' || record.action === 'textGenerate';
const isSuccess = record.status === 'SUCCESS';
const isUrl = typeof text === 'string' && /^https?:\/\//.test(text);
if (isSuccess && isVideoTask && isUrl) {
+1
View File
@@ -704,6 +704,7 @@ const TokensTable = () => {
<Button
theme="light"
type="danger"
icon={<IconDelete />}
className="!rounded-full w-full md:w-auto"
onClick={() => {
if (selectedKeys.length === 0) {
+7
View File
@@ -130,4 +130,11 @@ export const CHANNEL_OPTIONS = [
color: 'green',
label: '可灵',
},
{
value: 51,
color: 'blue',
label: '即梦',
},
];
export const MODEL_TABLE_PAGE_SIZE = 10;
+16 -1
View File
@@ -2,4 +2,19 @@ export const ITEMS_PER_PAGE = 10; // this value must keep same as the one define
export const DEFAULT_ENDPOINT = '/api/ratio_config';
export const TABLE_COMPACT_MODES_KEY = 'table_compact_modes';
export const TABLE_COMPACT_MODES_KEY = 'table_compact_modes';
export const API_ENDPOINTS = [
'/v1/chat/completions',
'/v1/responses',
'/v1/messages',
'/v1beta/models',
'/v1/embeddings',
'/v1/rerank',
'/v1/images/generations',
'/v1/images/edits',
'/v1/images/variations',
'/v1/audio/speech',
'/v1/audio/transcriptions',
'/v1/audio/translations'
];
+70 -70
View File
@@ -883,7 +883,7 @@ function getEffectiveRatio(groupRatio, user_group_ratio) {
? i18next.t('专属倍率')
: i18next.t('分组倍率');
const effectiveRatio = useUserGroupRatio ? user_group_ratio : groupRatio;
return {
ratio: effectiveRatio,
label: ratioLabel,
@@ -1074,25 +1074,25 @@ export function renderModelPrice(
const extraServices = [
webSearch && webSearchCallCount > 0
? i18next.t(
' + Web搜索 {{count}}次 / 1K 次 * ${{price}} * {{ratioType}} {{ratio}}',
{
count: webSearchCallCount,
price: webSearchPrice,
ratio: groupRatio,
ratioType: ratioLabel,
},
)
' + Web搜索 {{count}}次 / 1K 次 * ${{price}} * {{ratioType}} {{ratio}}',
{
count: webSearchCallCount,
price: webSearchPrice,
ratio: groupRatio,
ratioType: ratioLabel,
},
)
: '',
fileSearch && fileSearchCallCount > 0
? i18next.t(
' + 文件搜索 {{count}}次 / 1K 次 * ${{price}} * {{ratioType}} {{ratio}}',
{
count: fileSearchCallCount,
price: fileSearchPrice,
ratio: groupRatio,
ratioType: ratioLabel,
},
)
' + 文件搜索 {{count}}次 / 1K 次 * ${{price}} * {{ratioType}} {{ratio}}',
{
count: fileSearchCallCount,
price: fileSearchPrice,
ratio: groupRatio,
ratioType: ratioLabel,
},
)
: '',
].join('');
@@ -1281,10 +1281,10 @@ export function renderAudioModelPrice(
let audioPrice =
(audioInputTokens / 1000000) * inputRatioPrice * audioRatio * groupRatio +
(audioCompletionTokens / 1000000) *
inputRatioPrice *
audioRatio *
audioCompletionRatio *
groupRatio;
inputRatioPrice *
audioRatio *
audioCompletionRatio *
groupRatio;
let price = textPrice + audioPrice;
return (
<>
@@ -1340,27 +1340,27 @@ export function renderAudioModelPrice(
<p>
{cacheTokens > 0
? i18next.t(
'文字提示 {{nonCacheInput}} tokens / 1M tokens * ${{price}} + 缓存 {{cacheInput}} tokens / 1M tokens * ${{cachePrice}} + 文字补全 {{completion}} tokens / 1M tokens * ${{compPrice}} = ${{total}}',
{
nonCacheInput: inputTokens - cacheTokens,
cacheInput: cacheTokens,
cachePrice: inputRatioPrice * cacheRatio,
price: inputRatioPrice,
completion: completionTokens,
compPrice: completionRatioPrice,
total: textPrice.toFixed(6),
},
)
'文字提示 {{nonCacheInput}} tokens / 1M tokens * ${{price}} + 缓存 {{cacheInput}} tokens / 1M tokens * ${{cachePrice}} + 文字补全 {{completion}} tokens / 1M tokens * ${{compPrice}} = ${{total}}',
{
nonCacheInput: inputTokens - cacheTokens,
cacheInput: cacheTokens,
cachePrice: inputRatioPrice * cacheRatio,
price: inputRatioPrice,
completion: completionTokens,
compPrice: completionRatioPrice,
total: textPrice.toFixed(6),
},
)
: i18next.t(
'文字提示 {{input}} tokens / 1M tokens * ${{price}} + 文字补全 {{completion}} tokens / 1M tokens * ${{compPrice}} = ${{total}}',
{
input: inputTokens,
price: inputRatioPrice,
completion: completionTokens,
compPrice: completionRatioPrice,
total: textPrice.toFixed(6),
},
)}
'文字提示 {{input}} tokens / 1M tokens * ${{price}} + 文字补全 {{completion}} tokens / 1M tokens * ${{compPrice}} = ${{total}}',
{
input: inputTokens,
price: inputRatioPrice,
completion: completionTokens,
compPrice: completionRatioPrice,
total: textPrice.toFixed(6),
},
)}
</p>
<p>
{i18next.t(
@@ -1397,7 +1397,7 @@ export function renderQuotaWithPrompt(quota, digits) {
displayInCurrency = displayInCurrency === 'true';
if (displayInCurrency) {
return (
' | ' + i18next.t('等价金额') + ': ' + renderQuota(quota, digits) + ''
i18next.t('等价金额') + renderQuota(quota, digits)
);
}
return '';
@@ -1499,35 +1499,35 @@ export function renderClaudeModelPrice(
<p>
{cacheTokens > 0 || cacheCreationTokens > 0
? i18next.t(
'提示 {{nonCacheInput}} tokens / 1M tokens * ${{price}} + 缓存 {{cacheInput}} tokens / 1M tokens * ${{cachePrice}} + 缓存创建 {{cacheCreationInput}} tokens / 1M tokens * ${{cacheCreationPrice}} + 补全 {{completion}} tokens / 1M tokens * ${{compPrice}} * {{ratioType}} {{ratio}} = ${{total}}',
{
nonCacheInput: nonCachedTokens,
cacheInput: cacheTokens,
cacheRatio: cacheRatio,
cacheCreationInput: cacheCreationTokens,
cacheCreationRatio: cacheCreationRatio,
cachePrice: cacheRatioPrice,
cacheCreationPrice: cacheCreationRatioPrice,
price: inputRatioPrice,
completion: completionTokens,
compPrice: completionRatioPrice,
ratio: groupRatio,
ratioType: ratioLabel,
total: price.toFixed(6),
},
)
'提示 {{nonCacheInput}} tokens / 1M tokens * ${{price}} + 缓存 {{cacheInput}} tokens / 1M tokens * ${{cachePrice}} + 缓存创建 {{cacheCreationInput}} tokens / 1M tokens * ${{cacheCreationPrice}} + 补全 {{completion}} tokens / 1M tokens * ${{compPrice}} * {{ratioType}} {{ratio}} = ${{total}}',
{
nonCacheInput: nonCachedTokens,
cacheInput: cacheTokens,
cacheRatio: cacheRatio,
cacheCreationInput: cacheCreationTokens,
cacheCreationRatio: cacheCreationRatio,
cachePrice: cacheRatioPrice,
cacheCreationPrice: cacheCreationRatioPrice,
price: inputRatioPrice,
completion: completionTokens,
compPrice: completionRatioPrice,
ratio: groupRatio,
ratioType: ratioLabel,
total: price.toFixed(6),
},
)
: i18next.t(
'提示 {{input}} tokens / 1M tokens * ${{price}} + 补全 {{completion}} tokens / 1M tokens * ${{compPrice}} * {{ratioType}} {{ratio}} = ${{total}}',
{
input: inputTokens,
price: inputRatioPrice,
completion: completionTokens,
compPrice: completionRatioPrice,
ratio: groupRatio,
ratioType: ratioLabel,
total: price.toFixed(6),
},
)}
'提示 {{input}} tokens / 1M tokens * ${{price}} + 补全 {{completion}} tokens / 1M tokens * ${{compPrice}} * {{ratioType}} {{ratio}} = ${{total}}',
{
input: inputTokens,
price: inputRatioPrice,
completion: completionTokens,
compPrice: completionRatioPrice,
ratio: groupRatio,
ratioType: ratioLabel,
total: price.toFixed(6),
},
)}
</p>
<p>{i18next.t('仅供参考,以实际扣费为准')}</p>
</article>
+36 -12
View File
@@ -139,7 +139,7 @@
"已成功开始测试所有已启用通道,请刷新页面查看结果。": "Successfully started testing all enabled channels. Please refresh page to view results.",
"通道 ${name} 余额更新成功!": "Channel ${name} quota updated successfully!",
"已更新完毕所有已启用通道余额!": "Updated quota for all enabled channels!",
"搜索渠道的 ID,名称,密钥API地址 ...": "Search channel ID, name, key and Base URL...",
"渠道ID,名称,密钥API地址": "Channel ID, name, key, Base URL",
"名称": "Name",
"分组": "Group",
"类型": "Type",
@@ -397,7 +397,7 @@
"删除用户": "Delete User",
"添加新的用户": "Add New User",
"自定义": "Custom",
"等价金额": "Equivalent Amount",
"等价金额": "Equivalent Amount: ",
"未登录或登录已过期,请重新登录": "Not logged in or login has expired, please log in again",
"请求次数过多,请稍后再试": "Too many requests, please try again later",
"服务器内部错误,请联系管理员": "Server internal error, please contact the administrator",
@@ -428,6 +428,7 @@
"填入基础模型": "Fill in the basic model",
"填入所有模型": "Fill in all models",
"清除所有模型": "Clear all models",
"复制所有模型": "Copy all models",
"密钥": "Key",
"请输入密钥": "Please enter the key",
"批量创建": "Batch Create",
@@ -456,7 +457,7 @@
"令牌分组,默认为用户的分组": "Token group, default is the your's group",
"IP白名单": "IP whitelist",
"注意,令牌的额度仅用于限制令牌本身的最大额度使用量,实际的使用受到账户的剩余额度限制。": "Note that the quota of the token is only used to limit the maximum quota usage of the token itself, and the actual usage is limited by the remaining quota of the account.",
"设为无限额度": "Set to unlimited quota",
"无限额度": "Unlimited quota",
"更新令牌信息": "Update Token Information",
"请输入充值码!": "Please enter the recharge code!",
"请输入名称": "Please enter a name",
@@ -470,10 +471,11 @@
"请输入新的密码": "Please enter a new password",
"显示名称": "Display Name",
"请输入新的显示名称": "Please enter a new display name",
"已绑定的 GitHub 账户": "GitHub Account Bound",
"此项只读,要用户通过个人设置页面的相关绑按钮进,不可直接修改": "This item is read-only. Users need to bind through the relevant binding button on the personal settings page, and cannot be modified directly",
"已绑定的微信账户": "WeChat Account Bound",
"已绑定的邮箱账户": "Email Account Bound",
"已绑定的 GITHUB 账户": "Bound GitHub Account",
"已绑定的 WECHAT 账户": "Bound WeChat Account",
"已绑定的 EMAIL 账户": "Bound Email Account",
"已绑定的 TELEGRAM 账户": "Bound Telegram Account",
"此项只读,要用户通过个人设置页面的相关绑定按钮进行绑定,不可直接修改": "This item is read-only. Users need to bind through the relevant binding button on the personal settings page, and cannot be modified directly",
"用户信息更新成功!": "User information updated successfully!",
"使用明细(总消耗额度:{renderQuota(stat.quota)}": "Usage Details (Total Consumption Quota: {renderQuota(stat.quota)})",
"用户名称": "User Name",
@@ -515,7 +517,6 @@
"注意,系统请求的时模型名称中的点会被剔除,例如:gpt-4.1会请求为gpt-41,所以在Azure部署的时候,部署模型名称需要手动改为gpt-41": "Note that the dot in the model name requested by the system will be removed, for example: gpt-4.1 will be requested as gpt-41, so when deploying on Azure, the deployment model name needs to be manually changed to gpt-41",
"2025年5月10日后添加的渠道,不需要再在部署的时候移除模型名称中的\".\"": "After May 10, 2025, channels added do not need to remove the dot in the model name during deployment",
"模型映射必须是合法的 JSON 格式!": "Model mapping must be in valid JSON format!",
"取消无限额度": "Cancel unlimited quota",
"取消": "Cancel",
"重置": "Reset",
"请输入新的剩余额度": "Please enter the new remaining quota",
@@ -800,6 +801,7 @@
"获取无水印": "Get no watermark",
"生成图片": "Generate pictures",
"可灵": "Kling",
"即梦": "Jimeng",
"正在提交": "Submitting",
"执行中": "processing",
"平台": "platform",
@@ -1421,8 +1423,8 @@
"初始化系统": "Initialize system",
"支持众多的大模型供应商": "Supporting various LLM providers",
"统一的大模型接口网关": "The Unified LLMs API Gateway",
"更好的价格,更好的稳定性,无需订阅": "Better price, better stability, no subscription required",
"开始使用": "Get Started",
"更好的价格,更好的稳定性,只需要将模型基址替换为:": "Better price, better stability, no subscription required, just replace the model BASE URL with: ",
"获取密钥": "Get Key",
"关于我们": "About Us",
"关于项目": "About Project",
"联系我们": "Contact Us",
@@ -1458,7 +1460,8 @@
"访问限制": "Access Restrictions",
"设置令牌的访问限制": "Set token access restrictions",
"请勿过度信任此功能,IP可能被伪造": "Do not over-trust this feature, IP can be spoofed",
"勾选启用模型限制后可选择": "Select after checking to enable model restrictions",
"模型限制列表": "Model restrictions list",
"请选择该令牌支持的模型,留空支持所有模型": "Select models supported by the token, leave blank to support all models",
"非必要,不建议启用模型限制": "Not necessary, model restrictions are not recommended",
"分组信息": "Group Information",
"设置令牌的分组": "Set token grouping",
@@ -1726,5 +1729,26 @@
"放大编辑": "Expand editor",
"编辑公告内容": "Edit announcement content",
"自适应列表": "Adaptive list",
"紧凑列表": "Compact list"
"紧凑列表": "Compact list",
"仅显示矛盾倍率": "Only show conflicting ratios",
"矛盾": "Conflict",
"确认冲突项修改": "Confirm conflict item modification",
"该模型存在固定价格与倍率计费方式冲突,请确认选择": "The model has a fixed price and ratio billing method conflict, please confirm the selection",
"当前计费": "Current billing",
"修改为": "Modify to",
"状态筛选": "Status filter",
"没有模型可以复制": "No models to copy",
"模型列表已复制到剪贴板": "Model list copied to clipboard",
"复制失败": "Copy failed",
"复制已选": "Copy selected",
"选择成功": "Selection successful",
"暂无成功模型": "No successful models",
"请先选择模型!": "Please select a model first!",
"已复制 ${count} 个模型": "Copied ${count} models",
"复制失败,请手动复制": "Copy failed, please copy manually",
"快捷设置": "Quick settings",
"批量创建时会在名称后自动添加随机后缀": "When creating in batches, a random suffix will be automatically added to the name",
"额度必须大于0": "Quota must be greater than 0",
"生成数量必须大于0": "Generation quantity must be greater than 0",
"创建后可在编辑渠道时获取上游模型列表": "After creation, you can get the upstream model list when editing the channel"
}
+64
View File
@@ -375,6 +375,7 @@ code {
}
/* 隐藏卡片内容区域的滚动条 */
.model-test-scroll,
.card-content-scroll,
.model-settings-scroll,
.thinking-content-scroll,
@@ -385,6 +386,7 @@ code {
scrollbar-width: none;
}
.model-test-scroll::-webkit-scrollbar,
.card-content-scroll::-webkit-scrollbar,
.model-settings-scroll::-webkit-scrollbar,
.thinking-content-scroll::-webkit-scrollbar,
@@ -528,4 +530,66 @@ code {
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
}
/* ==================== ScrollList 定制样式 ==================== */
.semi-scrolllist,
.semi-scrolllist * {
-ms-overflow-style: none;
/* IE, Edge */
scrollbar-width: none;
/* Firefox */
background: transparent !important;
}
.semi-scrolllist::-webkit-scrollbar,
.semi-scrolllist *::-webkit-scrollbar {
width: 0 !important;
height: 0 !important;
display: none !important;
}
.semi-scrolllist-body {
padding: 1px !important;
}
.semi-scrolllist-list-outer {
padding-right: 0 !important;
}
/* ==================== Banner 背景模糊球 ==================== */
.blur-ball {
position: absolute;
width: 360px;
height: 360px;
border-radius: 50%;
filter: blur(120px);
pointer-events: none;
z-index: -1;
}
.blur-ball-indigo {
background: #6366f1;
/* indigo-500 */
top: 40px;
left: 50%;
transform: translateX(-50%);
opacity: 0.5;
}
.blur-ball-teal {
background: #14b8a6;
/* teal-400 */
top: 200px;
left: 30%;
opacity: 0.4;
}
/* 浅色主题下让模糊球更柔和 */
html:not(.dark) .blur-ball-indigo {
opacity: 0.25;
}
html:not(.dark) .blur-ball-teal {
opacity: 0.2;
}
-2
View File
@@ -5,7 +5,6 @@ import '@douyinfe/semi-ui/dist/css/semi.css';
import { UserProvider } from './context/User';
import 'react-toastify/dist/ReactToastify.css';
import { StatusProvider } from './context/Status';
import { Layout } from '@douyinfe/semi-ui';
import { ThemeProvider } from './context/Theme';
import { StyleProvider } from './context/Style/index.js';
import PageLayout from './components/layout/PageLayout.js';
@@ -15,7 +14,6 @@ import './index.css';
// initialization
const root = ReactDOM.createRoot(document.getElementById('root'));
const { Sider, Content, Header, Footer } = Layout;
root.render(
<React.StrictMode>
<StatusProvider>
+2 -2
View File
@@ -105,7 +105,7 @@ const About = () => {
);
return (
<>
<div className="mt-[64px]">
{aboutLoaded && about === '' ? (
<div className="flex justify-center items-center h-screen p-8">
<Empty
@@ -132,7 +132,7 @@ const About = () => {
)}
</>
)}
</>
</div>
);
};
+106 -121
View File
@@ -25,8 +25,9 @@ import {
ImagePreview,
Card,
Tag,
Avatar,
} from '@douyinfe/semi-ui';
import { getChannelModels } from '../../helpers';
import { getChannelModels, copy } from '../../helpers';
import {
IconSave,
IconClose,
@@ -111,6 +112,10 @@ const EditChannel = (props) => {
const [modalImageUrl, setModalImageUrl] = useState('');
const [isModalOpenurl, setIsModalOpenurl] = useState(false);
const handleInputChange = (name, value) => {
if (name === 'models' && Array.isArray(value)) {
value = Array.from(new Set(value.map((m) => (m || '').trim())));
}
if (name === 'base_url' && value.endsWith('/v1')) {
Modal.confirm({
title: '警告',
@@ -265,10 +270,14 @@ const EditChannel = (props) => {
const fetchModels = async () => {
try {
let res = await API.get(`/api/channel/models`);
let localModelOptions = res.data.data.map((model) => ({
label: model.id,
value: model.id,
}));
const localModelOptions = res.data.data.map((model) => {
const id = (model.id || '').trim();
return {
key: id,
label: id,
value: id,
};
});
setOriginModelOptions(localModelOptions);
setFullModels(res.data.data.map((model) => model.id));
setBasicModels(
@@ -300,27 +309,29 @@ const EditChannel = (props) => {
}
};
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(() => {
const modelMap = new Map();
originModelOptions.forEach(option => {
const v = (option.value || '').trim();
if (!modelMap.has(v)) {
modelMap.set(v, option);
}
});
inputs.models.forEach(model => {
const v = (model || '').trim();
if (!modelMap.has(v)) {
modelMap.set(v, {
key: v,
label: v,
value: v,
});
}
});
setModelOptions(Array.from(modelMap.values()));
}, [originModelOptions, inputs.models]);
useEffect(() => {
fetchModels().then();
@@ -403,7 +414,7 @@ useEffect(() => {
localModels.push(model);
localModelOptions.push({
key: model,
text: model,
label: model,
value: model,
});
addedModels.push(model);
@@ -442,10 +453,7 @@ useEffect(() => {
borderBottom: '1px solid var(--semi-color-border)',
padding: '24px'
}}
bodyStyle={{
backgroundColor: 'var(--semi-color-bg-0)',
padding: '0'
}}
bodyStyle={{ padding: '0' }}
visible={props.visible}
width={isMobile() ? '100%' : 600}
footer={
@@ -453,7 +461,6 @@ useEffect(() => {
<Space>
<Button
theme="solid"
size="large"
className="!rounded-full"
onClick={submit}
icon={<IconSave />}
@@ -462,7 +469,6 @@ useEffect(() => {
</Button>
<Button
theme="light"
size="large"
className="!rounded-full"
type="primary"
onClick={handleCancel}
@@ -479,20 +485,14 @@ useEffect(() => {
<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>
<div className="w-10 h-10 rounded-full bg-white/20 flex items-center justify-center mr-4 relative">
<IconServer 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>
{/* Header: Basic Info */}
<div className="flex items-center mb-2">
<Avatar size="small" color="blue" className="mr-2 shadow-md">
<IconServer size={16} />
</Avatar>
<div>
<Text className="text-lg font-medium">{t('基本信息')}</Text>
<div className="text-xs text-gray-600">{t('渠道的基本配置信息')}</div>
</div>
</div>
@@ -509,7 +509,6 @@ useEffect(() => {
filter
searchPosition='dropdown'
placeholder={t('请选择渠道类型')}
size="large"
className="!rounded-lg"
/>
</div>
@@ -525,7 +524,6 @@ useEffect(() => {
}}
value={inputs.name}
autoComplete='new-password'
size="large"
className="!rounded-lg"
/>
</div>
@@ -584,7 +582,6 @@ useEffect(() => {
}}
value={inputs.key}
autoComplete='new-password'
size="large"
className="!rounded-lg"
/>
)}
@@ -606,20 +603,14 @@ useEffect(() => {
{/* API Configuration 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>
</div>
<div className="w-10 h-10 rounded-full bg-white/20 flex items-center justify-center mr-4 relative">
<IconGlobe size="large" style={{ color: '#ffffff' }} />
</div>
<div className="relative">
<Text style={{ color: '#ffffff' }} className="text-lg font-medium">{t('API 配置')}</Text>
<div style={{ color: '#ffffff' }} className="text-sm opacity-80">{t('API 地址和相关配置')}</div>
{/* Header: API Config */}
<div className="flex items-center mb-2">
<Avatar size="small" color="green" className="mr-2 shadow-md">
<IconGlobe size={16} />
</Avatar>
<div>
<Text className="text-lg font-medium">{t('API 配置')}</Text>
<div className="text-xs text-gray-600">{t('API 地址和相关配置')}</div>
</div>
</div>
@@ -659,7 +650,6 @@ useEffect(() => {
onChange={(value) => handleInputChange('base_url', value)}
value={inputs.base_url}
autoComplete='new-password'
size="large"
className="!rounded-lg"
/>
</div>
@@ -671,7 +661,6 @@ useEffect(() => {
onChange={(value) => handleInputChange('other', value)}
value={inputs.other}
autoComplete='new-password'
size="large"
className="!rounded-lg"
/>
</div>
@@ -693,7 +682,6 @@ useEffect(() => {
onChange={(value) => handleInputChange('base_url', value)}
value={inputs.base_url}
autoComplete='new-password'
size="large"
className="!rounded-lg"
/>
</div>
@@ -717,7 +705,6 @@ useEffect(() => {
onChange={(value) => handleInputChange('base_url', value)}
value={inputs.base_url}
autoComplete='new-password'
size="large"
className="!rounded-lg"
/>
<Text type="tertiary" className="mt-1 text-xs">
@@ -735,7 +722,6 @@ useEffect(() => {
onChange={(value) => handleInputChange('base_url', value)}
value={inputs.base_url}
autoComplete='new-password'
size="large"
className="!rounded-lg"
/>
</div>
@@ -752,7 +738,6 @@ useEffect(() => {
onChange={(value) => handleInputChange('base_url', value)}
value={inputs.base_url}
autoComplete='new-password'
size="large"
className="!rounded-lg"
/>
</div>
@@ -762,20 +747,14 @@ useEffect(() => {
{/* Model Configuration 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>
</div>
<div className="w-10 h-10 rounded-full bg-white/20 flex items-center justify-center mr-4 relative">
<IconCode 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>
{/* Header: Model Config */}
<div className="flex items-center mb-2">
<Avatar size="small" color="purple" className="mr-2 shadow-md">
<IconCode size={16} />
</Avatar>
<div>
<Text className="text-lg font-medium">{t('模型配置')}</Text>
<div className="text-xs text-gray-600">{t('模型选择和映射设置')}</div>
</div>
</div>
@@ -794,7 +773,6 @@ useEffect(() => {
value={inputs.models}
autoComplete='new-password'
optionList={modelOptions}
size="large"
className="!rounded-lg"
/>
</div>
@@ -803,7 +781,6 @@ useEffect(() => {
<Button
type='primary'
onClick={() => handleInputChange('models', basicModels)}
size="large"
className="!rounded-lg"
>
{t('填入相关模型')}
@@ -811,29 +788,54 @@ useEffect(() => {
<Button
type='secondary'
onClick={() => handleInputChange('models', fullModels)}
size="large"
className="!rounded-lg"
>
{t('填入所有模型')}
</Button>
<Button
type='tertiary'
onClick={() => fetchUpstreamModelList('models')}
size="large"
className="!rounded-lg"
>
{t('获取模型列表')}
</Button>
{isEdit ? (
<Button
type='tertiary'
onClick={() => fetchUpstreamModelList('models')}
className="!rounded-lg"
>
{t('获取模型列表')}
</Button>
) : null}
<Button
type='warning'
onClick={() => handleInputChange('models', [])}
size="large"
className="!rounded-lg"
>
{t('清除所有模型')}
</Button>
<Button
type='tertiary'
onClick={() => {
if (inputs.models.length === 0) {
showInfo(t('没有模型可以复制'));
return;
}
try {
copy(inputs.models.join(','));
showSuccess(t('模型列表已复制到剪贴板'));
} catch (error) {
showError(t('复制失败'));
}
}}
className="!rounded-lg"
>
{t('复制所有模型')}
</Button>
</div>
{!isEdit && (
<Banner
type='info'
description={t('创建后可在编辑渠道时获取上游模型列表')}
className='!rounded-lg'
/>
)}
<div>
<Input
addonAfter={
@@ -844,7 +846,6 @@ useEffect(() => {
placeholder={t('输入自定义模型名称')}
value={customModel}
onChange={(value) => setCustomModel(value.trim())}
size="large"
className="!rounded-lg"
/>
</div>
@@ -878,7 +879,6 @@ useEffect(() => {
placeholder={t('不填则为模型列表第一个')}
onChange={(value) => handleInputChange('test_model', value)}
value={inputs.test_model}
size="large"
className="!rounded-lg"
/>
</div>
@@ -887,20 +887,14 @@ useEffect(() => {
{/* Advanced Settings 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, #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">
<IconSetting 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>
{/* Header: Advanced Settings */}
<div className="flex items-center mb-2">
<Avatar size="small" color="orange" className="mr-2 shadow-md">
<IconSetting size={16} />
</Avatar>
<div>
<Text className="text-lg font-medium">{t('高级设置')}</Text>
<div className="text-xs text-gray-600">{t('渠道的高级配置选项')}</div>
</div>
</div>
@@ -919,7 +913,6 @@ useEffect(() => {
value={inputs.groups}
autoComplete='new-password'
optionList={groupOptions}
size="large"
className="!rounded-lg"
/>
</div>
@@ -933,7 +926,6 @@ useEffect(() => {
onChange={(value) => handleInputChange('other', value)}
value={inputs.other}
autoComplete='new-password'
size="large"
className="!rounded-lg"
/>
</div>
@@ -975,7 +967,6 @@ useEffect(() => {
onChange={(value) => handleInputChange('other', value)}
value={inputs.other}
autoComplete='new-password'
size="large"
className="!rounded-lg"
/>
</div>
@@ -990,7 +981,6 @@ useEffect(() => {
onChange={(value) => handleInputChange('other', value)}
value={inputs.other}
autoComplete='new-password'
size="large"
className="!rounded-lg"
/>
</div>
@@ -1005,7 +995,6 @@ useEffect(() => {
onChange={(value) => handleInputChange('other', value)}
value={inputs.other}
autoComplete='new-password'
size="large"
className="!rounded-lg"
/>
</div>
@@ -1019,7 +1008,6 @@ useEffect(() => {
onChange={(value) => handleInputChange('tag', value)}
value={inputs.tag}
autoComplete='new-password'
size="large"
className="!rounded-lg"
/>
</div>
@@ -1039,7 +1027,6 @@ useEffect(() => {
}}
value={inputs.priority}
autoComplete='new-password'
size="large"
className="!rounded-lg"
/>
</div>
@@ -1059,7 +1046,6 @@ useEffect(() => {
}}
value={inputs.weight}
autoComplete='new-password'
size="large"
className="!rounded-lg"
/>
</div>
@@ -1127,7 +1113,6 @@ useEffect(() => {
placeholder={t('请输入组织org-xxx')}
onChange={(value) => handleInputChange('openai_organization', value)}
value={inputs.openai_organization}
size="large"
className="!rounded-lg"
/>
<Text type="tertiary" className="mt-1 text-xs">
+26 -52
View File
@@ -19,6 +19,7 @@ import {
TextArea,
Card,
Tag,
Avatar,
} from '@douyinfe/semi-ui';
import {
IconSave,
@@ -277,10 +278,7 @@ const EditTagModal = (props) => {
borderBottom: '1px solid var(--semi-color-border)',
padding: '24px'
}}
bodyStyle={{
backgroundColor: 'var(--semi-color-bg-0)',
padding: '0'
}}
bodyStyle={{ padding: '0' }}
visible={visible}
width={600}
onCancel={handleClose}
@@ -289,7 +287,6 @@ const EditTagModal = (props) => {
<Space>
<Button
theme="solid"
size="large"
className="!rounded-full"
onClick={handleSave}
loading={loading}
@@ -299,7 +296,6 @@ const EditTagModal = (props) => {
</Button>
<Button
theme="light"
size="large"
className="!rounded-full"
type="primary"
onClick={handleClose}
@@ -315,20 +311,14 @@ const EditTagModal = (props) => {
<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>
<div className="w-10 h-10 rounded-full bg-white/20 flex items-center justify-center mr-4 relative">
<IconBookmark 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>
{/* Header: Tag Info */}
<div className="flex items-center mb-2">
<Avatar size="small" color="blue" className="mr-2 shadow-md">
<IconBookmark size={16} />
</Avatar>
<div>
<Text className="text-lg font-medium">{t('标签信息')}</Text>
<div className="text-xs text-gray-600">{t('标签的基本配置')}</div>
</div>
</div>
@@ -345,7 +335,6 @@ const EditTagModal = (props) => {
value={inputs.new_tag}
onChange={(value) => setInputs({ ...inputs, new_tag: value })}
placeholder={t('请输入新标签,留空则解散标签')}
size="large"
className="!rounded-lg"
/>
</div>
@@ -353,20 +342,14 @@ const EditTagModal = (props) => {
</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>
</div>
<div className="w-10 h-10 rounded-full bg-white/20 flex items-center justify-center mr-4 relative">
<IconCode 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>
{/* Header: Model Config */}
<div className="flex items-center mb-2">
<Avatar size="small" color="purple" className="mr-2 shadow-md">
<IconCode size={16} />
</Avatar>
<div>
<Text className="text-lg font-medium">{t('模型配置')}</Text>
<div className="text-xs text-gray-600">{t('模型选择和映射设置')}</div>
</div>
</div>
@@ -387,7 +370,6 @@ const EditTagModal = (props) => {
onChange={(value) => handleInputChange('models', value)}
value={inputs.models}
optionList={modelOptions}
size="large"
className="!rounded-lg"
/>
</div>
@@ -402,7 +384,6 @@ const EditTagModal = (props) => {
placeholder={t('输入自定义模型名称')}
value={customModel}
onChange={(value) => setCustomModel(value.trim())}
size="large"
className="!rounded-lg"
/>
</div>
@@ -442,20 +423,14 @@ const EditTagModal = (props) => {
</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, #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">
<IconUser 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>
{/* Header: Group Settings */}
<div className="flex items-center mb-2">
<Avatar size="small" color="green" className="mr-2 shadow-md">
<IconUser size={16} />
</Avatar>
<div>
<Text className="text-lg font-medium">{t('分组设置')}</Text>
<div className="text-xs text-gray-600">{t('用户分组配置')}</div>
</div>
</div>
@@ -471,7 +446,6 @@ const EditTagModal = (props) => {
onChange={(value) => handleInputChange('groups', value)}
value={inputs.groups}
optionList={groupOptions}
size="large"
className="!rounded-lg"
/>
</div>
+2 -2
View File
@@ -3,9 +3,9 @@ import ChannelsTable from '../../components/table/ChannelsTable';
const File = () => {
return (
<>
<div className="mt-[64px]">
<ChannelsTable />
</>
</div>
);
};
+2 -2
View File
@@ -37,12 +37,12 @@ const ChatPage = () => {
return !isLoading && iframeSrc ? (
<iframe
src={iframeSrc}
style={{ width: '100%', height: '100%', border: 'none' }}
style={{ width: '100%', height: 'calc(100vh - 64px)', border: 'none', marginTop: '64px' }}
title='Token Frame'
allow='camera;microphone'
/>
) : (
<div className="fixed inset-0 w-screen h-screen flex items-center justify-center bg-white/80 z-[1000]">
<div className="fixed inset-0 w-screen h-screen flex items-center justify-center bg-white/80 z-[1000] mt-[64px]">
<div className="flex flex-col items-center">
<Spin
size="large"
+1 -1
View File
@@ -17,7 +17,7 @@ const chat2page = () => {
}
return (
<div>
<div className="mt-[64px]">
<h3>正在加载请稍候...</h3>
</div>
);
+1 -1
View File
@@ -984,7 +984,7 @@ const Detail = (props) => {
}, []);
return (
<div className="bg-gray-50 h-full">
<div className="bg-gray-50 h-full mt-[64px]">
<div className="flex items-center justify-between mb-4">
<h2 className="text-2xl font-semibold text-gray-800">{getGreeting}</h2>
<div className="flex gap-3">
+82 -31
View File
@@ -1,10 +1,11 @@
import React, { useContext, useEffect, useState } from 'react';
import { Button, Typography, Tag } from '@douyinfe/semi-ui';
import { API, showError, isMobile } from '../../helpers';
import { Button, Typography, Tag, Input, ScrollList, ScrollItem } from '@douyinfe/semi-ui';
import { API, showError, isMobile, copy, showSuccess } from '../../helpers';
import { API_ENDPOINTS } from '../../constants/common.constant';
import { StatusContext } from '../../context/Status';
import { marked } from 'marked';
import { useTranslation } from 'react-i18next';
import { IconGithubLogo, IconPlay, IconFile } from '@douyinfe/semi-icons';
import { IconGithubLogo, IconPlay, IconFile, IconCopy } from '@douyinfe/semi-icons';
import { Link } from 'react-router-dom';
import NoticeModal from '../../components/layout/NoticeModal';
import { Moonshot, OpenAI, XAI, Zhipu, Volcengine, Cohere, Claude, Gemini, Suno, Minimax, Wenxin, Spark, Qingyan, DeepSeek, Qwen, Midjourney, Grok, AzureAI, Hunyuan, Xinference } from '@lobehub/icons';
@@ -17,29 +18,12 @@ const Home = () => {
const [homePageContentLoaded, setHomePageContentLoaded] = useState(false);
const [homePageContent, setHomePageContent] = useState('');
const [noticeVisible, setNoticeVisible] = useState(false);
const isDemoSiteMode = statusState?.status?.demo_site_enabled || false;
const docsLink = statusState?.status?.docs_link || '';
useEffect(() => {
const checkNoticeAndShow = async () => {
const lastCloseDate = localStorage.getItem('notice_close_date');
const today = new Date().toDateString();
if (lastCloseDate !== today) {
try {
const res = await API.get('/api/notice');
const { success, data } = res.data;
if (success && data && data.trim() !== '') {
setNoticeVisible(true);
}
} catch (error) {
console.error('获取公告失败:', error);
}
}
};
checkNoticeAndShow();
}, []);
const serverAddress = statusState?.status?.server_address || window.location.origin;
const endpointItems = API_ENDPOINTS.map((e) => ({ value: e }));
const [endpointIndex, setEndpointIndex] = useState(0);
const isChinese = i18n.language.startsWith('zh');
const displayHomePageContent = async () => {
setHomePageContent(localStorage.getItem('home_page_content') || '');
@@ -71,10 +55,44 @@ const Home = () => {
setHomePageContentLoaded(true);
};
const handleCopyBaseURL = async () => {
const ok = await copy(serverAddress);
if (ok) {
showSuccess(t('已复制到剪切板'));
}
};
useEffect(() => {
const checkNoticeAndShow = async () => {
const lastCloseDate = localStorage.getItem('notice_close_date');
const today = new Date().toDateString();
if (lastCloseDate !== today) {
try {
const res = await API.get('/api/notice');
const { success, data } = res.data;
if (success && data && data.trim() !== '') {
setNoticeVisible(true);
}
} catch (error) {
console.error('获取公告失败:', error);
}
}
};
checkNoticeAndShow();
}, []);
useEffect(() => {
displayHomePageContent().then();
}, []);
useEffect(() => {
const timer = setInterval(() => {
setEndpointIndex((prev) => (prev + 1) % endpointItems.length);
}, 3000);
return () => clearInterval(timer);
}, [endpointItems.length]);
return (
<div className="w-full overflow-x-hidden">
<NoticeModal
@@ -86,30 +104,63 @@ const Home = () => {
<div className="w-full overflow-x-hidden">
{/* Banner 部分 */}
<div className="w-full border-b border-semi-color-border min-h-[500px] md:min-h-[600px] lg:min-h-[700px] relative overflow-x-hidden">
<div className="flex items-center justify-center h-full px-4 py-20 md:py-24 lg:py-32">
{/* 背景模糊晕染球 */}
<div className="blur-ball blur-ball-indigo" />
<div className="blur-ball blur-ball-teal" />
<div className="flex items-center justify-center h-full px-4 py-20 md:py-24 lg:py-32 mt-10">
{/* 居中内容区 */}
<div className="flex flex-col items-center justify-center text-center max-w-4xl mx-auto">
<div className="flex flex-col items-center justify-center mb-6 md:mb-8">
<h1 className="text-4xl md:text-5xl lg:text-6xl xl:text-7xl font-bold text-semi-color-text-0 leading-tight">
<h1 className={`text-4xl md:text-5xl lg:text-6xl xl:text-7xl font-bold text-semi-color-text-0 leading-tight ${isChinese ? 'tracking-wide md:tracking-wider' : ''}`}>
{i18n.language === 'en' ? (
<>
The Unified<br />
LLMs API Gateway
<span className="shine-text">LLMs API Gateway</span>
</>
) : (
t('统一的大模型接口网关')
<>
统一的<br />
<span className="shine-text">大模型接口网关</span>
</>
)}
</h1>
<p className="text-lg md:text-xl lg:text-2xl text-semi-color-text-1 mt-4 md:mt-6">
{t('更好的价格,更好的稳定性,无需订阅')}
<p className="text-base md:text-lg lg:text-xl text-semi-color-text-1 mt-4 md:mt-6 max-w-xl">
{t('更好的价格,更好的稳定性,只需要将模型基址替换为:')}
</p>
{/* BASE URL 与端点选择 */}
<div className="flex flex-col md:flex-row items-center justify-center gap-4 w-full mt-4 md:mt-6 max-w-md">
<Input
readonly
value={serverAddress}
className="flex-1 !rounded-full"
size={isMobile() ? 'default' : 'large'}
suffix={
<div className="flex items-center gap-2">
<ScrollList bodyHeight={32} style={{ border: 'unset', boxShadow: 'unset' }}>
<ScrollItem
mode="wheel"
cycled={true}
list={endpointItems}
selectedIndex={endpointIndex}
onSelect={({ index }) => setEndpointIndex(index)}
/>
</ScrollList>
<Button
type="primary"
onClick={handleCopyBaseURL}
icon={<IconCopy />}
/>
</div>
}
/>
</div>
</div>
{/* 操作按钮 */}
<div className="flex flex-row gap-4 justify-center items-center">
<Link to="/console">
<Button theme="solid" type="primary" size={isMobile() ? "default" : "large"} className="!rounded-3xl px-8 py-2" icon={<IconPlay />}>
{t('开始使用')}
{t('获取密钥')}
</Button>
</Link>
{isDemoSiteMode && statusState?.status?.version ? (
+2 -2
View File
@@ -2,9 +2,9 @@ import React from 'react';
import LogsTable from '../../components/table/LogsTable';
const Token = () => (
<>
<div className="mt-[64px]">
<LogsTable />
</>
</div>
);
export default Token;
+2 -2
View File
@@ -2,9 +2,9 @@ import React from 'react';
import MjLogsTable from '../../components/table/MjLogsTable';
const Midjourney = () => (
<>
<div className="mt-[64px]">
<MjLogsTable />
</>
</div>
);
export default Midjourney;
+1 -1
View File
@@ -6,7 +6,7 @@ import { useTranslation } from 'react-i18next';
const NotFound = () => {
const { t } = useTranslation();
return (
<div className="flex justify-center items-center h-screen p-8">
<div className="flex justify-center items-center h-screen p-8 mt-[64px]">
<Empty
image={<IllustrationNotFound style={{ width: 250, height: 250 }} />}
darkModeImage={<IllustrationNotFoundDark style={{ width: 250, height: 250 }} />}
+1 -1
View File
@@ -363,7 +363,7 @@ const Playground = () => {
}, [setMessage, saveMessagesImmediately]);
return (
<div className="h-full bg-gray-50">
<div className="h-full bg-gray-50 mt-[64px]">
<Layout style={{ height: '100%', background: 'transparent' }} className="flex flex-col md:flex-row">
{(showSettings || !styleState.isMobile) && (
<Layout.Sider
+2 -2
View File
@@ -2,9 +2,9 @@ import React from 'react';
import ModelPricing from '../../components/table/ModelPricing.js';
const Pricing = () => (
<>
<div className="mt-[64px]">
<ModelPricing />
</>
</div>
);
export default Pricing;
+145 -153
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useState, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import {
API,
@@ -7,12 +7,10 @@ import {
showError,
showSuccess,
renderQuota,
renderQuotaWithPrompt
renderQuotaWithPrompt,
} from '../../helpers';
import {
AutoComplete,
Button,
Input,
Modal,
SideSheet,
Space,
@@ -21,13 +19,14 @@ import {
Card,
Tag,
Form,
DatePicker,
Avatar,
Row,
Col,
} from '@douyinfe/semi-ui';
import {
IconCreditCard,
IconSave,
IconClose,
IconPlusCircle,
IconGift,
} from '@douyinfe/semi-icons';
@@ -37,30 +36,30 @@ const EditRedemption = (props) => {
const { t } = useTranslation();
const isEdit = props.editingRedemption.id !== undefined;
const [loading, setLoading] = useState(isEdit);
const formApiRef = useRef(null);
const originInputs = {
const getInitValues = () => ({
name: '',
quota: 100000,
count: 1,
expired_time: 0,
};
const [inputs, setInputs] = useState(originInputs);
const { name, quota, count, expired_time } = inputs;
expired_time: null,
});
const handleCancel = () => {
props.handleClose();
};
const handleInputChange = (name, value) => {
setInputs((inputs) => ({ ...inputs, [name]: value }));
};
const loadRedemption = async () => {
setLoading(true);
let res = await API.get(`/api/redemption/${props.editingRedemption.id}`);
const { success, message, data } = res.data;
if (success) {
setInputs(data);
if (data.expired_time === 0) {
data.expired_time = null;
} else {
data.expired_time = new Date(data.expired_time * 1000);
}
formApiRef.current?.setValues({ ...getInitValues(), ...data });
} else {
showError(message);
}
@@ -68,28 +67,30 @@ const EditRedemption = (props) => {
};
useEffect(() => {
if (isEdit) {
loadRedemption().then(() => {
// console.log(inputs);
});
} else {
setInputs(originInputs);
if (formApiRef.current) {
if (isEdit) {
loadRedemption();
} else {
formApiRef.current.setValues(getInitValues());
}
}
}, [props.editingRedemption.id]);
const submit = async () => {
let name = inputs.name;
if (!isEdit && inputs.name === '') {
// set default name
name = renderQuota(quota);
const submit = async (values) => {
let name = values.name;
if (!isEdit && values.name === '') {
name = renderQuota(values.quota);
}
setLoading(true);
let localInputs = inputs;
localInputs.count = parseInt(localInputs.count);
localInputs.quota = parseInt(localInputs.quota);
let localInputs = { ...values };
localInputs.count = parseInt(localInputs.count) || 0;
localInputs.quota = parseInt(localInputs.quota) || 0;
localInputs.name = name;
if (localInputs.expired_time === null || localInputs.expired_time === undefined) {
if (!localInputs.expired_time) {
localInputs.expired_time = 0;
} else {
localInputs.expired_time = Math.floor(localInputs.expired_time.getTime() / 1000);
}
let res;
if (isEdit) {
@@ -110,8 +111,8 @@ const EditRedemption = (props) => {
props.handleClose();
} else {
showSuccess(t('兑换码创建成功!'));
setInputs(originInputs);
props.refresh();
formApiRef.current?.setValues(getInitValues());
props.handleClose();
}
} else {
@@ -131,7 +132,7 @@ const EditRedemption = (props) => {
</div>
),
onOk: () => {
downloadTextAsFile(text, `${inputs.name}.txt`);
downloadTextAsFile(text, `${localInputs.name}.txt`);
},
});
}
@@ -157,10 +158,7 @@ const EditRedemption = (props) => {
borderBottom: '1px solid var(--semi-color-border)',
padding: '24px'
}}
bodyStyle={{
backgroundColor: 'var(--semi-color-bg-0)',
padding: '0'
}}
bodyStyle={{ padding: '0' }}
visible={props.visiable}
width={isMobile() ? '100%' : 600}
footer={
@@ -168,9 +166,8 @@ const EditRedemption = (props) => {
<Space>
<Button
theme="solid"
size="large"
className="!rounded-full"
onClick={submit}
onClick={() => formApiRef.current?.submitForm()}
icon={<IconSave />}
loading={loading}
>
@@ -178,7 +175,6 @@ const EditRedemption = (props) => {
</Button>
<Button
theme="light"
size="large"
className="!rounded-full"
type="primary"
onClick={handleCancel}
@@ -193,123 +189,119 @@ const EditRedemption = (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>
<div className="w-10 h-10 rounded-full bg-white/20 flex items-center justify-center mr-4 relative">
<IconGift 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>
</div>
<div className="space-y-4">
<div>
<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"
showClear
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>
<Card className="!rounded-2xl shadow-sm border-0">
<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>
<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>
<div className="flex justify-between mb-2">
<Text strong>{t('额度')}</Text>
<Text type="tertiary">{renderQuotaWithPrompt(quota)}</Text>
<Form
initValues={getInitValues()}
getFormApi={(api) => formApiRef.current = api}
onSubmit={submit}
>
{({ values }) => (
<div className="p-6 space-y-6">
<Card className="!rounded-2xl shadow-sm border-0 mb-6">
{/* Header: Basic Info */}
<div className="flex items-center mb-2">
<Avatar size="small" color="blue" className="mr-2 shadow-md">
<IconGift size={16} />
</Avatar>
<div>
<Text className="text-lg font-medium">{t('基本信息')}</Text>
<div className="text-xs text-gray-600">{t('设置兑换码的基本信息')}</div>
</div>
</div>
<AutoComplete
placeholder={t('请输入额度')}
onChange={(value) => handleInputChange('quota', value)}
value={quota}
autoComplete="new-password"
type="number"
size="large"
className="w-full !rounded-lg"
prefix={<IconCreditCard />}
data={[
{ value: 500000, label: '1$' },
{ value: 5000000, label: '10$' },
{ value: 25000000, label: '50$' },
{ value: 50000000, label: '100$' },
{ value: 250000000, label: '500$' },
{ value: 500000000, label: '1000$' },
]}
/>
</div>
{!isEdit && (
<div>
<Text strong className="block mb-2">{t('生成数量')}</Text>
<Input
placeholder={t('请输入生成数量')}
onChange={(value) => handleInputChange('count', value)}
value={count}
autoComplete="new-password"
type="number"
size="large"
className="!rounded-lg"
prefix={<IconPlusCircle />}
/>
<Row gutter={12}>
<Col span={24}>
<Form.Input
field='name'
label={t('名称')}
placeholder={t('请输入名称')}
style={{ width: '100%' }}
rules={isEdit ? [] : [{ required: true, message: t('请输入名称') }]}
showClear
/>
</Col>
<Col span={24}>
<Form.DatePicker
field='expired_time'
label={t('过期时间')}
type='dateTime'
placeholder={t('选择过期时间(可选,留空为永久)')}
style={{ width: '100%' }}
showClear
/>
</Col>
</Row>
</Card>
<Card className="!rounded-2xl shadow-sm border-0">
{/* Header: Quota Settings */}
<div className="flex items-center mb-2">
<Avatar size="small" color="green" className="mr-2 shadow-md">
<IconCreditCard size={16} />
</Avatar>
<div>
<Text className="text-lg font-medium">{t('额度设置')}</Text>
<div className="text-xs text-gray-600">{t('设置兑换码的额度和数量')}</div>
</div>
</div>
)}
<Row gutter={12}>
<Col span={12}>
<Form.AutoComplete
field='quota'
label={t('额度')}
placeholder={t('请输入额度')}
style={{ width: '100%' }}
type='number'
rules={[
{ required: true, message: t('请输入额度') },
{
validator: (rule, v) => {
const num = parseInt(v, 10);
return num > 0
? Promise.resolve()
: Promise.reject(t('额度必须大于0'));
},
},
]}
extraText={renderQuotaWithPrompt(Number(values.quota) || 0)}
data={[
{ value: 500000, label: '1$' },
{ value: 5000000, label: '10$' },
{ value: 25000000, label: '50$' },
{ value: 50000000, label: '100$' },
{ value: 250000000, label: '500$' },
{ value: 500000000, label: '1000$' },
]}
showClear
/>
</Col>
{!isEdit && (
<Col span={12}>
<Form.InputNumber
field='count'
label={t('生成数量')}
min={1}
rules={[
{ required: true, message: t('请输入生成数量') },
{
validator: (rule, v) => {
const num = parseInt(v, 10);
return num > 0
? Promise.resolve()
: Promise.reject(t('生成数量必须大于0'));
},
},
]}
style={{ width: '100%' }}
showClear
/>
</Col>
)}
</Row>
</Card>
</div>
</Card>
</div>
)}
</Form>
</Spin>
</SideSheet>
</>
+2 -2
View File
@@ -3,9 +3,9 @@ import RedemptionsTable from '../../components/table/RedemptionsTable';
const Redemption = () => {
return (
<>
<div className="mt-[64px]">
<RedemptionsTable />
</>
</div>
);
};
@@ -8,7 +8,9 @@ import {
Form,
Space,
RadioGroup,
Radio
Radio,
Checkbox,
Tag
} from '@douyinfe/semi-ui';
import {
IconDelete,
@@ -30,6 +32,7 @@ export default function ModelSettingsVisualEditor(props) {
const [loading, setLoading] = useState(false);
const [pricingMode, setPricingMode] = useState('per-token'); // 'per-token' or 'per-request'
const [pricingSubMode, setPricingSubMode] = useState('ratio'); // 'ratio' or 'token-price'
const [conflictOnly, setConflictOnly] = useState(false);
const formRef = useRef(null);
const pageSize = 10;
const quotaPerUnit = getQuotaPerUnit();
@@ -47,13 +50,19 @@ export default function ModelSettingsVisualEditor(props) {
...Object.keys(completionRatio),
]);
const modelData = Array.from(modelNames).map((name) => ({
name,
price: modelPrice[name] === undefined ? '' : modelPrice[name],
ratio: modelRatio[name] === undefined ? '' : modelRatio[name],
completionRatio:
completionRatio[name] === undefined ? '' : completionRatio[name],
}));
const modelData = Array.from(modelNames).map((name) => {
const price = modelPrice[name] === undefined ? '' : modelPrice[name];
const ratio = modelRatio[name] === undefined ? '' : modelRatio[name];
const comp = completionRatio[name] === undefined ? '' : completionRatio[name];
return {
name,
price,
ratio,
completionRatio: comp,
hasConflict: price !== '' && (ratio !== '' || comp !== ''),
};
});
setModels(modelData);
} catch (error) {
@@ -69,11 +78,13 @@ export default function ModelSettingsVisualEditor(props) {
};
// 在 return 语句之前,先处理过滤和分页逻辑
const filteredModels = models.filter((model) =>
searchText
const filteredModels = models.filter((model) => {
const keywordMatch = searchText
? model.name.toLowerCase().includes(searchText.toLowerCase())
: true,
);
: true;
const conflictMatch = conflictOnly ? model.hasConflict : true;
return keywordMatch && conflictMatch;
});
// 然后基于过滤后的数据计算分页数据
const pagedData = getPagedData(filteredModels, currentPage, pageSize);
@@ -152,6 +163,16 @@ export default function ModelSettingsVisualEditor(props) {
title: t('模型名称'),
dataIndex: 'name',
key: 'name',
render: (text, record) => (
<span>
{text}
{record.hasConflict && (
<Tag color='red' shape='circle' className='ml-2'>
{t('矛盾')}
</Tag>
)}
</span>
),
},
{
title: t('模型固定价格'),
@@ -219,9 +240,13 @@ export default function ModelSettingsVisualEditor(props) {
return;
}
setModels((prev) =>
prev.map((model) =>
model.name === name ? { ...model, [field]: value } : model,
),
prev.map((model) => {
if (model.name !== name) return model;
const updated = { ...model, [field]: value };
updated.hasConflict =
updated.price !== '' && (updated.ratio !== '' || updated.completionRatio !== '');
return updated;
}),
);
};
@@ -296,16 +321,18 @@ export default function ModelSettingsVisualEditor(props) {
if (existingModelIndex >= 0) {
// Update existing model
setModels((prev) =>
prev.map((model, index) =>
index === existingModelIndex
? {
name: values.name,
price: values.price || '',
ratio: values.ratio || '',
completionRatio: values.completionRatio || '',
}
: model,
),
prev.map((model, index) => {
if (index !== existingModelIndex) return model;
const updated = {
name: values.name,
price: values.price || '',
ratio: values.ratio || '',
completionRatio: values.completionRatio || '',
};
updated.hasConflict =
updated.price !== '' && (updated.ratio !== '' || updated.completionRatio !== '');
return updated;
}),
);
setVisible(false);
showSuccess(t('更新成功'));
@@ -317,15 +344,17 @@ export default function ModelSettingsVisualEditor(props) {
return;
}
setModels((prev) => [
{
setModels((prev) => {
const newModel = {
name: values.name,
price: values.price || '',
ratio: values.ratio || '',
completionRatio: values.completionRatio || '',
},
...prev,
]);
};
newModel.hasConflict =
newModel.price !== '' && (newModel.ratio !== '' || newModel.completionRatio !== '');
return [newModel, ...prev];
});
setVisible(false);
showSuccess(t('添加成功'));
}
@@ -426,7 +455,17 @@ export default function ModelSettingsVisualEditor(props) {
setCurrentPage(1);
}}
style={{ width: 200 }}
showClear
/>
<Checkbox
checked={conflictOnly}
onChange={(e) => {
setConflictOnly(e.target.checked);
setCurrentPage(1);
}}
>
{t('仅显示矛盾倍率')}
</Checkbox>
</Space>
<Table
columns={columns}
+199 -33
View File
@@ -1,4 +1,4 @@
import React, { useState, useCallback, useMemo } from 'react';
import React, { useState, useCallback, useMemo, useEffect } from 'react';
import {
Button,
Table,
@@ -9,6 +9,7 @@ import {
Input,
Tooltip,
Select,
Modal,
} from '@douyinfe/semi-ui';
import { IconSearch } from '@douyinfe/semi-icons';
import {
@@ -17,7 +18,7 @@ import {
AlertTriangle,
CheckCircle,
} from 'lucide-react';
import { API, showError, showSuccess, showWarning, stringToColor } from '../../../helpers';
import { API, showError, showSuccess, showWarning, stringToColor, isMobile } from '../../../helpers';
import { DEFAULT_ENDPOINT } from '../../../constants';
import { useTranslation } from 'react-i18next';
import {
@@ -26,6 +27,35 @@ import {
} from '@douyinfe/semi-illustrations';
import ChannelSelectorModal from '../../../components/settings/ChannelSelectorModal';
function ConflictConfirmModal({ t, visible, items, onOk, onCancel }) {
const columns = [
{ title: t('渠道'), dataIndex: 'channel' },
{ title: t('模型'), dataIndex: 'model' },
{
title: t('当前计费'),
dataIndex: 'current',
render: (text) => <div style={{ whiteSpace: 'pre-wrap' }}>{text}</div>,
},
{
title: t('修改为'),
dataIndex: 'newVal',
render: (text) => <div style={{ whiteSpace: 'pre-wrap' }}>{text}</div>,
},
];
return (
<Modal
title={t('确认冲突项修改')}
visible={visible}
onCancel={onCancel}
onOk={onOk}
size={isMobile() ? 'full-width' : 'large'}
>
<Table columns={columns} dataSource={items} pagination={false} size="small" />
</Modal>
);
}
export default function UpstreamRatioSync(props) {
const { t } = useTranslation();
const [modalVisible, setModalVisible] = useState(false);
@@ -56,8 +86,16 @@ export default function UpstreamRatioSync(props) {
// 倍率类型过滤
const [ratioTypeFilter, setRatioTypeFilter] = useState('');
// 冲突确认弹窗相关
const [confirmVisible, setConfirmVisible] = useState(false);
const [conflictItems, setConflictItems] = useState([]); // {channel, model, current, newVal, ratioType}
const channelSelectorRef = React.useRef(null);
useEffect(() => {
setCurrentPage(1);
}, [ratioTypeFilter, searchKeyword]);
const fetchAllChannels = async () => {
setLoading(true);
try {
@@ -155,15 +193,30 @@ export default function UpstreamRatioSync(props) {
}
};
const selectValue = (model, ratioType, value) => {
setResolutions(prev => ({
...prev,
[model]: {
...prev[model],
[ratioType]: value,
},
}));
};
function getBillingCategory(ratioType) {
return ratioType === 'model_price' ? 'price' : 'ratio';
}
const selectValue = useCallback((model, ratioType, value) => {
const category = getBillingCategory(ratioType);
setResolutions(prev => {
const newModelRes = { ...(prev[model] || {}) };
Object.keys(newModelRes).forEach((rt) => {
if (getBillingCategory(rt) !== category) {
delete newModelRes[rt];
}
});
newModelRes[ratioType] = value;
return {
...prev,
[model]: newModelRes,
};
});
}, [setResolutions]);
const applySync = async () => {
const currentRatios = {
@@ -173,19 +226,100 @@ export default function UpstreamRatioSync(props) {
ModelPrice: JSON.parse(props.options.ModelPrice || '{}'),
};
const conflicts = [];
const getLocalBillingCategory = (model) => {
if (currentRatios.ModelPrice[model] !== undefined) return 'price';
if (currentRatios.ModelRatio[model] !== undefined ||
currentRatios.CompletionRatio[model] !== undefined ||
currentRatios.CacheRatio[model] !== undefined) return 'ratio';
return null;
};
const findSourceChannel = (model, ratioType, value) => {
if (differences[model] && differences[model][ratioType]) {
const upMap = differences[model][ratioType].upstreams || {};
const entry = Object.entries(upMap).find(([_, v]) => v === value);
if (entry) return entry[0];
}
return t('未知');
};
Object.entries(resolutions).forEach(([model, ratios]) => {
const localCat = getLocalBillingCategory(model);
const newCat = 'model_price' in ratios ? 'price' : 'ratio';
if (localCat && localCat !== newCat) {
const currentDesc = localCat === 'price'
? `${t('固定价格')} : ${currentRatios.ModelPrice[model]}`
: `${t('模型倍率')} : ${currentRatios.ModelRatio[model] ?? '-'}\n${t('补全倍率')} : ${currentRatios.CompletionRatio[model] ?? '-'}`;
let newDesc = '';
if (newCat === 'price') {
newDesc = `${t('固定价格')} : ${ratios['model_price']}`;
} else {
const newModelRatio = ratios['model_ratio'] ?? '-';
const newCompRatio = ratios['completion_ratio'] ?? '-';
newDesc = `${t('模型倍率')} : ${newModelRatio}\n${t('补全倍率')} : ${newCompRatio}`;
}
const channels = Object.entries(ratios)
.map(([rt, val]) => findSourceChannel(model, rt, val))
.filter((v, idx, arr) => arr.indexOf(v) === idx)
.join(', ');
conflicts.push({
channel: channels,
model,
current: currentDesc,
newVal: newDesc,
});
}
});
if (conflicts.length > 0) {
setConflictItems(conflicts);
setConfirmVisible(true);
return;
}
await performSync(currentRatios);
};
const performSync = useCallback(async (currentRatios) => {
const finalRatios = {
ModelRatio: { ...currentRatios.ModelRatio },
CompletionRatio: { ...currentRatios.CompletionRatio },
CacheRatio: { ...currentRatios.CacheRatio },
ModelPrice: { ...currentRatios.ModelPrice },
};
Object.entries(resolutions).forEach(([model, ratios]) => {
const selectedTypes = Object.keys(ratios);
const hasPrice = selectedTypes.includes('model_price');
const hasRatio = selectedTypes.some(rt => rt !== 'model_price');
if (hasPrice) {
delete finalRatios.ModelRatio[model];
delete finalRatios.CompletionRatio[model];
delete finalRatios.CacheRatio[model];
}
if (hasRatio) {
delete finalRatios.ModelPrice[model];
}
Object.entries(ratios).forEach(([ratioType, value]) => {
const optionKey = ratioType
.split('_')
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join('');
currentRatios[optionKey][model] = parseFloat(value);
finalRatios[optionKey][model] = parseFloat(value);
});
});
setLoading(true);
try {
const updates = Object.entries(currentRatios).map(([key, value]) =>
const updates = Object.entries(finalRatios).map(([key, value]) =>
API.put('/api/option/', {
key,
value: JSON.stringify(value, null, 2),
@@ -225,7 +359,7 @@ export default function UpstreamRatioSync(props) {
} finally {
setLoading(false);
}
};
}, [resolutions, props.options, props.refresh]);
const getCurrentPageData = (dataSource) => {
const startIndex = (currentPage - 1) * pageSize;
@@ -300,6 +434,10 @@ export default function UpstreamRatioSync(props) {
const tmp = [];
Object.entries(differences).forEach(([model, ratioTypes]) => {
const hasPrice = 'model_price' in ratioTypes;
const hasOtherRatio = ['model_ratio', 'completion_ratio', 'cache_ratio'].some(rt => rt in ratioTypes);
const billingConflict = hasPrice && hasOtherRatio;
Object.entries(ratioTypes).forEach(([ratioType, diff]) => {
tmp.push({
key: `${model}_${ratioType}`,
@@ -308,6 +446,7 @@ export default function UpstreamRatioSync(props) {
current: diff.current,
upstreams: diff.upstreams,
confidence: diff.confidence || {},
billingConflict,
});
});
});
@@ -365,14 +504,25 @@ export default function UpstreamRatioSync(props) {
{
title: t('倍率类型'),
dataIndex: 'ratioType',
render: (text) => {
render: (text, record) => {
const typeMap = {
model_ratio: t('模型倍率'),
completion_ratio: t('补全倍率'),
cache_ratio: t('缓存倍率'),
model_price: t('固定价格'),
};
return <Tag color={stringToColor(text)} shape="circle">{typeMap[text] || text}</Tag>;
const baseTag = <Tag color={stringToColor(text)} shape="circle">{typeMap[text] || text}</Tag>;
if (record?.billingConflict) {
return (
<div className="flex items-center gap-1">
{baseTag}
<Tooltip position="top" content={t('该模型存在固定价格与倍率计费方式冲突,请确认选择')}>
<AlertTriangle size={14} className="text-yellow-500" />
</Tooltip>
</div>
);
}
return baseTag;
},
},
{
@@ -440,28 +590,27 @@ export default function UpstreamRatioSync(props) {
})();
const handleBulkSelect = (checked) => {
setResolutions((prev) => {
const newRes = { ...prev };
if (checked) {
filteredDataSource.forEach((row) => {
const upstreamVal = row.upstreams?.[upName];
if (upstreamVal !== null && upstreamVal !== undefined && upstreamVal !== 'same') {
if (checked) {
if (!newRes[row.model]) newRes[row.model] = {};
newRes[row.model][row.ratioType] = upstreamVal;
} else {
if (newRes[row.model]) {
delete newRes[row.model][row.ratioType];
if (Object.keys(newRes[row.model]).length === 0) {
delete newRes[row.model];
}
}
}
selectValue(row.model, row.ratioType, upstreamVal);
}
});
return newRes;
});
} else {
setResolutions((prev) => {
const newRes = { ...prev };
filteredDataSource.forEach((row) => {
if (newRes[row.model]) {
delete newRes[row.model][row.ratioType];
if (Object.keys(newRes[row.model]).length === 0) {
delete newRes[row.model];
}
}
});
return newRes;
});
}
};
return {
@@ -589,6 +738,23 @@ export default function UpstreamRatioSync(props) {
channelEndpoints={channelEndpoints}
updateChannelEndpoint={updateChannelEndpoint}
/>
<ConflictConfirmModal
t={t}
visible={confirmVisible}
items={conflictItems}
onOk={async () => {
setConfirmVisible(false);
const curRatios = {
ModelRatio: JSON.parse(props.options.ModelRatio || '{}'),
CompletionRatio: JSON.parse(props.options.CompletionRatio || '{}'),
CacheRatio: JSON.parse(props.options.CacheRatio || '{}'),
ModelPrice: JSON.parse(props.options.ModelPrice || '{}'),
};
await performSync(curRatios);
}}
onCancel={() => setConfirmVisible(false)}
/>
</>
);
}
+1 -1
View File
@@ -150,7 +150,7 @@ const Setting = () => {
}
}, [location.search]);
return (
<div>
<div className="mt-[64px]">
<Layout>
<Layout.Content>
<Tabs
+1 -1
View File
@@ -133,7 +133,7 @@ const Setup = () => {
};
return (
<div className="bg-gray-50">
<div className="bg-gray-50 mt-[64px]">
<Layout>
<Layout.Content>
<div className="flex justify-center px-4 py-8">
+2 -2
View File
@@ -2,9 +2,9 @@ import React from 'react';
import TaskLogsTable from '../../components/table/TaskLogsTable.js';
const Task = () => (
<>
<div className="mt-[64px]">
<TaskLogsTable />
</>
</div>
);
export default Task;
+223 -447
View File
@@ -1,5 +1,4 @@
import React, { useEffect, useState, useContext } from 'react';
import { useNavigate } from 'react-router-dom';
import React, { useEffect, useState, useContext, useRef } from 'react';
import {
API,
isMobile,
@@ -10,28 +9,22 @@ import {
renderQuotaWithPrompt,
} from '../../helpers';
import {
AutoComplete,
Banner,
Button,
Checkbox,
DatePicker,
Input,
Select,
SideSheet,
Space,
Spin,
TextArea,
Typography,
Card,
Tag,
Avatar,
Form,
Col,
Row,
} from '@douyinfe/semi-ui';
import {
IconClock,
IconCalendar,
IconCreditCard,
IconLink,
IconServer,
IconUserGroup,
IconSave,
IconClose,
IconPlusCircle,
@@ -45,35 +38,22 @@ const EditToken = (props) => {
const { t } = useTranslation();
const [statusState, statusDispatch] = useContext(StatusContext);
const [isEdit, setIsEdit] = useState(false);
const [loading, setLoading] = useState(isEdit);
const originInputs = {
const [loading, setLoading] = useState(false);
const formApiRef = useRef(null);
const [models, setModels] = useState([]);
const [groups, setGroups] = useState([]);
const getInitValues = () => ({
name: '',
remain_quota: isEdit ? 0 : 500000,
remain_quota: 500000,
expired_time: -1,
unlimited_quota: false,
model_limits_enabled: false,
model_limits: [],
allow_ips: '',
group: '',
};
const [inputs, setInputs] = useState(originInputs);
const {
name,
remain_quota,
expired_time,
unlimited_quota,
model_limits_enabled,
model_limits,
allow_ips,
group,
} = inputs;
const [models, setModels] = useState([]);
const [groups, setGroups] = useState([]);
const navigate = useNavigate();
const handleInputChange = (name, value) => {
setInputs((inputs) => ({ ...inputs, [name]: value }));
};
tokenCount: 1,
});
const handleCancel = () => {
props.handleClose();
@@ -86,18 +66,15 @@ const EditToken = (props) => {
seconds += day * 24 * 60 * 60;
seconds += hour * 60 * 60;
seconds += minute * 60;
if (!formApiRef.current) return;
if (seconds !== 0) {
timestamp += seconds;
setInputs({ ...inputs, expired_time: timestamp2string(timestamp) });
formApiRef.current.setValue('expired_time', timestamp2string(timestamp));
} else {
setInputs({ ...inputs, expired_time: -1 });
formApiRef.current.setValue('expired_time', -1);
}
};
const setUnlimitedQuota = () => {
setInputs({ ...inputs, unlimited_quota: !unlimited_quota });
};
const loadModels = async () => {
let res = await API.get(`/api/user/models`);
const { success, message, data } = res.data;
@@ -122,17 +99,15 @@ const EditToken = (props) => {
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' });
if (statusState?.status?.default_use_auto_group && formApiRef.current) {
formApiRef.current.setValue('group', 'auto');
}
} else {
showError(t(message));
@@ -152,7 +127,9 @@ const EditToken = (props) => {
} else {
data.model_limits = [];
}
setInputs(data);
if (formApiRef.current) {
formApiRef.current.setValues({ ...getInitValues(), ...data });
}
} else {
showError(message);
}
@@ -164,30 +141,17 @@ const EditToken = (props) => {
}, [props.editingToken.id]);
useEffect(() => {
if (!isEdit) {
setInputs(originInputs);
} else {
loadToken().then(() => {
// console.log(inputs);
});
if (formApiRef.current) {
if (!isEdit) {
formApiRef.current.setValues(getInitValues());
} else {
loadToken();
}
}
loadModels();
loadGroups();
}, [isEdit]);
// 新增 state 变量 tokenCount 来记录用户想要创建的令牌数量,默认为 1
const [tokenCount, setTokenCount] = useState(1);
// 新增处理 tokenCount 变化的函数
const handleTokenCountChange = (value) => {
// 确保用户输入的是正整数
const count = parseInt(value, 10);
if (!isNaN(count) && count > 0) {
setTokenCount(count);
}
};
// 生成一个随机的四位字母数字字符串
const generateRandomSuffix = () => {
const characters =
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
@@ -200,11 +164,10 @@ const EditToken = (props) => {
return result;
};
const submit = async () => {
const submit = async (values) => {
setLoading(true);
if (isEdit) {
// 编辑令牌的逻辑保持不变
let localInputs = { ...inputs };
let { tokenCount: _tc, ...localInputs } = values;
localInputs.remain_quota = parseInt(localInputs.remain_quota);
if (localInputs.expired_time !== -1) {
let time = Date.parse(localInputs.expired_time);
@@ -216,6 +179,7 @@ const EditToken = (props) => {
localInputs.expired_time = Math.ceil(time / 1000);
}
localInputs.model_limits = localInputs.model_limits.join(',');
localInputs.model_limits_enabled = localInputs.model_limits.length > 0;
let res = await API.put(`/api/token/`, {
...localInputs,
id: parseInt(props.editingToken.id),
@@ -229,16 +193,12 @@ const EditToken = (props) => {
showError(t(message));
}
} else {
// 处理新增多个令牌的情况
let successCount = 0; // 记录成功创建的令牌数量
for (let i = 0; i < tokenCount; i++) {
let localInputs = { ...inputs };
// 检查用户是否填写了令牌名称
const baseName = inputs.name.trim() === '' ? 'default' : inputs.name;
if (i !== 0 || inputs.name.trim() === '') {
// 如果创建多个令牌(i !== 0)或者用户没有填写名称,则添加随机后缀
const count = parseInt(values.tokenCount, 10) || 1;
let successCount = 0;
for (let i = 0; i < count; i++) {
let { tokenCount: _tc, ...localInputs } = values;
const baseName = values.name.trim() === '' ? 'default' : values.name.trim();
if (i !== 0 || values.name.trim() === '') {
localInputs.name = `${baseName}-${generateRandomSuffix()}`;
} else {
localInputs.name = baseName;
@@ -255,17 +215,16 @@ const EditToken = (props) => {
localInputs.expired_time = Math.ceil(time / 1000);
}
localInputs.model_limits = localInputs.model_limits.join(',');
localInputs.model_limits_enabled = localInputs.model_limits.length > 0;
let res = await API.post(`/api/token/`, localInputs);
const { success, message } = res.data;
if (success) {
successCount++;
} else {
showError(t(message));
break; // 如果创建失败,终止循环
break;
}
}
if (successCount > 0) {
showSuccess(t('令牌创建成功,请在列表页面点击复制获取令牌!'));
props.refresh();
@@ -273,8 +232,7 @@ const EditToken = (props) => {
}
}
setLoading(false);
setInputs(originInputs); // 重置表单
setTokenCount(1); // 重置数量为默认值
formApiRef.current?.setValues(getInitValues());
};
return (
@@ -300,10 +258,7 @@ const EditToken = (props) => {
borderBottom: '1px solid var(--semi-color-border)',
padding: '24px',
}}
bodyStyle={{
backgroundColor: 'var(--semi-color-bg-0)',
padding: '0',
}}
bodyStyle={{ padding: '0' }}
visible={props.visiable}
width={isMobile() ? '100%' : 600}
footer={
@@ -311,9 +266,8 @@ const EditToken = (props) => {
<Space>
<Button
theme='solid'
size='large'
className='!rounded-full'
onClick={submit}
onClick={() => formApiRef.current?.submitForm()}
icon={<IconSave />}
loading={loading}
>
@@ -321,7 +275,6 @@ const EditToken = (props) => {
</Button>
<Button
theme='light'
size='large'
className='!rounded-full'
type='primary'
onClick={handleCancel}
@@ -336,370 +289,193 @@ 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>
<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>
</div>
<div className='space-y-4'>
<div>
<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'
showClear
required
/>
</div>
<div>
<Text strong className='block mb-2'>
{t('过期时间')}
</Text>
<div className='mb-2'>
<DatePicker
placeholder={t('请选择过期时间')}
onChange={(value) =>
handleInputChange('expired_time', value)
}
value={expired_time}
autoComplete='new-password'
type='dateTime'
className='w-full !rounded-lg'
size='large'
prefix={<IconCalendar />}
/>
</div>
<div className='flex flex-wrap gap-2'>
<Button
theme='light'
type='primary'
onClick={() => setExpiredTime(0, 0, 0, 0)}
className='!rounded-full'
>
{t('永不过期')}
</Button>
<Button
theme='light'
type='tertiary'
onClick={() => setExpiredTime(0, 0, 1, 0)}
className='!rounded-full'
icon={<IconClock />}
>
{t('一小时')}
</Button>
<Button
theme='light'
type='tertiary'
onClick={() => setExpiredTime(0, 1, 0, 0)}
className='!rounded-full'
icon={<IconCalendar />}
>
{t('一天')}
</Button>
<Button
theme='light'
type='tertiary'
onClick={() => setExpiredTime(1, 0, 0, 0)}
className='!rounded-full'
icon={<IconCalendar />}
>
{t('一个月')}
</Button>
</div>
</div>
</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>
</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>
<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'
/>
<div className='space-y-4'>
<div>
<div className='flex justify-between mb-2'>
<Text strong>{t('额度')}</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'
prefix={<IconCreditCard />}
data={[
{ value: 500000, label: '1$' },
{ value: 5000000, label: '10$' },
{ value: 25000000, label: '50$' },
{ value: 50000000, label: '100$' },
{ value: 250000000, label: '500$' },
{ value: 500000000, label: '1000$' },
]}
disabled={unlimited_quota}
/>
</div>
{!isEdit && (
<div>
<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'
prefix={<IconPlusCircle />}
data={[
{ value: 10, label: t('10个') },
{ value: 20, label: t('20个') },
{ value: 30, label: t('30个') },
{ value: 100, label: t('100个') },
]}
disabled={unlimited_quota}
/>
</div>
)}
<div className='flex justify-end'>
<Button
theme='light'
type={unlimited_quota ? 'danger' : 'warning'}
onClick={setUnlimitedQuota}
className='!rounded-full'
>
{unlimited_quota ? t('取消无限额度') : t('设为无限额度')}
</Button>
</div>
</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>
</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>
<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>
<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'
rows={4}
/>
<Text type='tertiary' className='mt-1 block text-xs'>
{t('请勿过度信任此功能,IP可能被伪造')}
</Text>
</div>
<div>
<Form
key={isEdit ? 'edit' : 'new'}
initValues={getInitValues()}
getFormApi={(api) => (formApiRef.current = api)}
onSubmit={submit}
>
{({ values }) => (
<div className='p-6 space-y-6'>
{/* 基本信息 */}
<Card className='!rounded-2xl shadow-sm border-0'>
<div className='flex items-center mb-2'>
<Checkbox
checked={model_limits_enabled}
onChange={(e) =>
handleInputChange(
'model_limits_enabled',
e.target.checked,
)
}
>
<Text strong>{t('模型限制')}</Text>
</Checkbox>
<Avatar size='small' color='blue' className='mr-2 shadow-md'>
<IconPlusCircle size={16} />
</Avatar>
<div>
<Text className='text-lg font-medium'>{t('基本信息')}</Text>
<div className='text-xs text-gray-600'>{t('设置令牌的基本信息')}</div>
</div>
</div>
<Select
placeholder={
model_limits_enabled
? t('请选择该渠道所支持的模型')
: t('勾选启用模型限制后可选择')
}
onChange={(value) => handleInputChange('model_limits', value)}
value={inputs.model_limits}
multiple
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>
</div>
</div>
</Card>
<Row gutter={12}>
<Col span={24}>
<Form.Input
field='name'
label={t('名称')}
placeholder={t('请输入名称')}
rules={[{ required: true, message: t('请输入名称') }]}
showClear
/>
</Col>
<Col span={24}>
{groups.length > 0 ? (
<Form.Select
field='group'
label={t('令牌分组')}
placeholder={t('令牌分组,默认为用户的分组')}
optionList={groups}
renderOptionItem={renderGroupOption}
/>
) : (
<Form.Select
placeholder={t('管理员未设置用户可选分组')}
disabled
label={t('令牌分组')}
/>
)}
</Col>
<Col span={10}>
<Form.DatePicker
field='expired_time'
label={t('过期时间')}
type='dateTime'
placeholder={t('请选择过期时间')}
style={{ width: '100%' }}
rules={[{ required: true, message: t('请选择过期时间') }]}
/>
</Col>
<Col span={14} className='flex flex-col justify-end'>
<Form.Slot label={t('快捷设置')}>
<Space wrap>
<Button
theme='light'
type='primary'
onClick={() => setExpiredTime(0, 0, 0, 0)}
className='!rounded-full'
>
{t('永不过期')}
</Button>
<Button
theme='light'
type='tertiary'
onClick={() => setExpiredTime(1, 0, 0, 0)}
className='!rounded-full'
>
{t('一个月')}
</Button>
<Button
theme='light'
type='tertiary'
onClick={() => setExpiredTime(0, 1, 0, 0)}
className='!rounded-full'
>
{t('一天')}
</Button>
<Button
theme='light'
type='tertiary'
onClick={() => setExpiredTime(0, 0, 1, 0)}
className='!rounded-full'
>
{t('一小时')}
</Button>
</Space>
</Form.Slot>
</Col>
{!isEdit && (
<Col span={24}>
<Form.InputNumber
field='tokenCount'
label={t('新建数量')}
min={1}
extraText={t('批量创建时会在名称后自动添加随机后缀')}
rules={[{ required: true, message: t('请输入新建数量') }]}
/>
</Col>
)}
</Row>
</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>
</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>
<div className='relative'>
<Text
style={{ color: '#ffffff' }}
className='text-lg font-medium'
>
{t('分组信息')}
</Text>
<div
style={{ color: '#ffffff' }}
className='text-sm opacity-80'
>
{t('设置令牌的分组')}
{/* 额度设置 */}
<Card className='!rounded-2xl shadow-sm border-0'>
<div className='flex items-center mb-2'>
<Avatar size='small' color='green' className='mr-2 shadow-md'>
<IconCreditCard size={16} />
</Avatar>
<div>
<Text className='text-lg font-medium'>{t('额度设置')}</Text>
<div className='text-xs text-gray-600'>{t('设置令牌可用额度和数量')}</div>
</div>
</div>
</div>
</div>
<Row gutter={12}>
<Col span={10}>
<Form.AutoComplete
field='remain_quota'
label={t('额度')}
placeholder={t('请输入额度')}
type='number'
disabled={values.unlimited_quota}
extraText={renderQuotaWithPrompt(values.remain_quota)}
rules={values.unlimited_quota ? [] : [{ required: true, message: t('请输入额度') }]}
data={[
{ value: 500000, label: '1$' },
{ value: 5000000, label: '10$' },
{ value: 25000000, label: '50$' },
{ value: 50000000, label: '100$' },
{ value: 250000000, label: '500$' },
{ value: 500000000, label: '1000$' },
]}
/>
</Col>
<Col span={14} className='flex justify-end'>
<Form.Switch field='unlimited_quota' label={t('无限额度')} size='large' />
</Col>
</Row>
<Banner
type='warning'
description={t('注意,令牌的额度仅用于限制令牌本身的最大额度使用量,实际的使用受到账户的剩余额度限制。')}
className='mb-4 !rounded-lg'
/>
</Card>
<div>
<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'
prefix={<IconUserGroup />}
optionList={groups}
/>
) : (
<Select
placeholder={t('管理员未设置用户可选分组')}
disabled={true}
size='large'
className='w-full !rounded-lg'
prefix={<IconUserGroup />}
/>
)}
{/* 访问限制 */}
<Card className='!rounded-2xl shadow-sm border-0'>
<div className='flex items-center mb-2'>
<Avatar size='small' color='purple' className='mr-2 shadow-md'>
<IconLink size={16} />
</Avatar>
<div>
<Text className='text-lg font-medium'>{t('访问限制')}</Text>
<div className='text-xs text-gray-600'>{t('设置令牌的访问限制')}</div>
</div>
</div>
<Row gutter={12}>
<Col span={24}>
<Form.TextArea
field='allow_ips'
label={t('IP白名单')}
placeholder={t('允许的IP,一行一个,不填写则不限制')}
rows={4}
extraText={t('请勿过度信任此功能,IP可能被伪造')}
/>
</Col>
<Col span={24}>
<Form.Select
field='model_limits'
label={t('模型限制列表')}
placeholder={t('请选择该令牌支持的模型,留空支持所有模型')}
multiple
optionList={models}
maxTagCount={3}
extraText={t('非必要,不建议启用模型限制')}
/>
</Col>
</Row>
</Card>
</div>
</Card>
</div>
)}
</Form>
</Spin>
</SideSheet>
);
+2 -2
View File
@@ -3,9 +3,9 @@ import TokensTable from '../../components/table/TokensTable';
const Token = () => {
return (
<>
<div className="mt-[64px]">
<TokensTable />
</>
</div>
);
};
+2 -2
View File
@@ -382,7 +382,7 @@ 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 mt-[64px]'>
{/* 划转模态框 */}
<Modal
title={
@@ -931,7 +931,7 @@ const TopUp = () => {
<Title heading={6}>{t('邀请链接')}</Title>
<Input
value={affLink}
readOnly
readonly
size='large'
suffix={
<Button
+68 -108
View File
@@ -1,22 +1,22 @@
import React, { useState } from 'react';
import React, { useState, useRef } from 'react';
import { API, isMobile, showError, showSuccess } from '../../helpers';
import {
Button,
Input,
SideSheet,
Space,
Spin,
Typography,
Card,
Tag
Tag,
Avatar,
Form,
Row,
Col,
} from '@douyinfe/semi-ui';
import {
IconUser,
IconSave,
IconClose,
IconKey,
IconUserAdd,
IconEdit,
} from '@douyinfe/semi-icons';
import { useTranslation } from 'react-i18next';
@@ -24,32 +24,23 @@ const { Text, Title } = Typography;
const AddUser = (props) => {
const { t } = useTranslation();
const originInputs = {
const formApiRef = useRef(null);
const [loading, setLoading] = useState(false);
const getInitValues = () => ({
username: '',
display_name: '',
password: '',
remark: '',
};
const [inputs, setInputs] = useState(originInputs);
const [loading, setLoading] = useState(false);
const { username, display_name, password, remark } = inputs;
});
const handleInputChange = (name, value) => {
setInputs((inputs) => ({ ...inputs, [name]: value }));
};
const submit = async () => {
const submit = async (values) => {
setLoading(true);
if (inputs.username === '' || inputs.password === '') {
setLoading(false);
showError(t('用户名和密码不能为空!'));
return;
}
const res = await API.post(`/api/user/`, inputs);
const res = await API.post(`/api/user/`, values);
const { success, message } = res.data;
if (success) {
showSuccess(t('用户账户创建成功!'));
setInputs(originInputs);
formApiRef.current?.setValues(getInitValues());
props.refresh();
props.handleClose();
} else {
@@ -78,10 +69,7 @@ const AddUser = (props) => {
borderBottom: '1px solid var(--semi-color-border)',
padding: '24px'
}}
bodyStyle={{
backgroundColor: 'var(--semi-color-bg-0)',
padding: '0'
}}
bodyStyle={{ padding: '0' }}
visible={props.visible}
width={isMobile() ? '100%' : 600}
footer={
@@ -89,9 +77,8 @@ const AddUser = (props) => {
<Space>
<Button
theme="solid"
size="large"
className="!rounded-full"
onClick={submit}
onClick={() => formApiRef.current?.submitForm()}
icon={<IconSave />}
loading={loading}
>
@@ -99,7 +86,6 @@ const AddUser = (props) => {
</Button>
<Button
theme="light"
size="large"
className="!rounded-full"
type="primary"
onClick={handleCancel}
@@ -114,86 +100,60 @@ const AddUser = (props) => {
onCancel={() => handleCancel()}
>
<Spin spinning={loading}>
<div className="p-6">
<Card className="!rounded-2xl shadow-sm border-0">
<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">
<IconUserAdd 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>
</div>
<div className="space-y-4">
<div>
<Text strong className="block mb-2">{t('用户名')}</Text>
<Input
placeholder={t('请输入用户名')}
onChange={(value) => handleInputChange('username', value)}
value={username}
autoComplete="off"
size="large"
className="!rounded-lg"
prefix={<IconUser />}
showClear
required
/>
<Form
initValues={getInitValues()}
getFormApi={(api) => formApiRef.current = api}
onSubmit={submit}
onSubmitFail={(errs) => {
const first = Object.values(errs)[0];
if (first) showError(Array.isArray(first) ? first[0] : first);
formApiRef.current?.scrollToError();
}}
>
<div className="p-6 space-y-6">
<Card className="!rounded-2xl shadow-sm border-0">
<div className="flex items-center mb-2">
<Avatar size="small" color="blue" className="mr-2 shadow-md">
<IconUserAdd size={16} />
</Avatar>
<div>
<Text className="text-lg font-medium">{t('用户信息')}</Text>
<div className="text-xs text-gray-600">{t('创建新用户账户')}</div>
</div>
</div>
<div>
<Text strong className="block mb-2">{t('显示名称')}</Text>
<Input
placeholder={t('请输入显示名称')}
onChange={(value) => handleInputChange('display_name', value)}
value={display_name}
autoComplete="off"
size="large"
className="!rounded-lg"
prefix={<IconUser />}
showClear
/>
</div>
<div>
<Text strong className="block mb-2">{t('密码')}</Text>
<Input
type="password"
placeholder={t('请输入密码')}
onChange={(value) => handleInputChange('password', value)}
value={password}
autoComplete="off"
size="large"
className="!rounded-lg"
prefix={<IconKey />}
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>
<Row gutter={12}>
<Col span={24}>
<Form.Input
field='username'
label={t('用户名')}
placeholder={t('请输入用户名')}
rules={[{ required: true, message: t('请输入用户名') }]} />
</Col>
<Col span={24}>
<Form.Input
field='display_name'
label={t('显示名称')}
placeholder={t('请输入显示名称')} />
</Col>
<Col span={24}>
<Form.Input
field='password'
label={t('密码')}
type='password'
placeholder={t('请输入密码')}
rules={[{ required: true, message: t('请输入密码') }]} />
</Col>
<Col span={24}>
<Form.Input
field='remark'
label={t('备注')}
placeholder={t('请输入备注(仅管理员可见)')} />
</Col>
</Row>
</Card>
</div>
</Form>
</Spin>
</SideSheet>
</>
+217 -335
View File
@@ -1,96 +1,85 @@
import React, { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { API, isMobile, showError, showSuccess, renderQuota, renderQuotaWithPrompt } from '../../helpers';
import React, { useEffect, useState, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import {
API,
isMobile,
showError,
showSuccess,
renderQuota,
renderQuotaWithPrompt,
} from '../../helpers';
import {
Button,
Input,
Modal,
Select,
SideSheet,
Space,
Spin,
Typography,
Card,
Tag,
Form,
Avatar,
Row,
Col,
Input,
} from '@douyinfe/semi-ui';
import {
IconUser,
IconSave,
IconClose,
IconKey,
IconCreditCard,
IconLink,
IconUserGroup,
IconPlus,
IconEdit,
} from '@douyinfe/semi-icons';
import { useTranslation } from 'react-i18next';
const { Text, Title } = Typography;
const EditUser = (props) => {
const { t } = useTranslation();
const userId = props.editingUser.id;
const [loading, setLoading] = useState(true);
const [addQuotaModalOpen, setIsModalOpen] = useState(false);
const [addQuotaLocal, setAddQuotaLocal] = useState('');
const [inputs, setInputs] = useState({
const [addQuotaLocal, setAddQuotaLocal] = useState('0');
const [groupOptions, setGroupOptions] = useState([]);
const formApiRef = useRef(null);
const isEdit = Boolean(userId);
const getInitValues = () => ({
username: '',
display_name: '',
password: '',
github_id: '',
oidc_id: '',
wechat_id: '',
telegram_id: '',
email: '',
quota: 0,
group: 'default',
remark: '',
});
const [groupOptions, setGroupOptions] = useState([]);
const {
username,
display_name,
password,
github_id,
oidc_id,
wechat_id,
telegram_id,
email,
quota,
group,
remark,
} = inputs;
const handleInputChange = (name, value) => {
setInputs((inputs) => ({ ...inputs, [name]: value }));
};
const fetchGroups = async () => {
try {
let res = await API.get(`/api/group/`);
setGroupOptions(
res.data.data.map((group) => ({
label: group,
value: group,
})),
res.data.data.map((g) => ({ label: g, value: g }))
);
} catch (error) {
showError(error.message);
} catch (e) {
showError(e.message);
}
};
const navigate = useNavigate();
const handleCancel = () => {
props.handleClose();
};
const handleCancel = () => props.handleClose();
const loadUser = async () => {
setLoading(true);
let res = undefined;
if (userId) {
res = await API.get(`/api/user/${userId}`);
} else {
res = await API.get(`/api/user/self`);
}
const url = userId ? `/api/user/${userId}` : `/api/user/self`;
const res = await API.get(url);
const { success, message, data } = res.data;
if (success) {
data.password = '';
setInputs(data);
formApiRef.current?.setValues({ ...getInitValues(), ...data });
} else {
showError(message);
}
@@ -98,27 +87,23 @@ const EditUser = (props) => {
};
useEffect(() => {
loadUser().then();
if (userId) {
fetchGroups().then();
}
loadUser();
if (userId) fetchGroups();
}, [props.editingUser.id]);
const submit = async () => {
/* ----------------------- submit ----------------------- */
const submit = async (values) => {
setLoading(true);
let res = undefined;
let payload = { ...values };
if (typeof payload.quota === 'string') payload.quota = parseInt(payload.quota) || 0;
if (userId) {
let data = { ...inputs, id: parseInt(userId) };
if (typeof data.quota === 'string') {
data.quota = parseInt(data.quota);
}
res = await API.put(`/api/user/`, data);
} else {
res = await API.put(`/api/user/self`, inputs);
payload.id = parseInt(userId);
}
const url = userId ? `/api/user/` : `/api/user/self`;
const res = await API.put(url, payload);
const { success, message } = res.data;
if (success) {
showSuccess('用户信息更新成功!');
showSuccess(t('用户信息更新成功!'));
props.refresh();
props.handleClose();
} else {
@@ -127,58 +112,48 @@ const EditUser = (props) => {
setLoading(false);
};
/* --------------------- quota helper -------------------- */
const addLocalQuota = () => {
let newQuota = parseInt(quota) + parseInt(addQuotaLocal);
setInputs((inputs) => ({ ...inputs, quota: newQuota }));
const current = parseInt(formApiRef.current?.getValue('quota') || 0);
const delta = parseInt(addQuotaLocal) || 0;
formApiRef.current?.setValue('quota', current + delta);
};
const openAddQuotaModal = () => {
setAddQuotaLocal('0');
setIsModalOpen(true);
};
const { t } = useTranslation();
/* --------------------------- UI --------------------------- */
return (
<>
<SideSheet
placement={'right'}
placement='right'
title={
<Space>
<Tag color="blue" shape="circle">{t('编辑')}</Tag>
<Title heading={4} className="m-0">
{t('编辑用户')}
<Tag color='blue' shape='circle'>
{t(isEdit ? '编辑' : '新建')}
</Tag>
<Title heading={4} className='m-0'>
{isEdit ? t('编辑用户') : t('创建用户')}
</Title>
</Space>
}
headerStyle={{
borderBottom: '1px solid var(--semi-color-border)',
padding: '24px'
}}
bodyStyle={{
backgroundColor: 'var(--semi-color-bg-0)',
padding: '0'
}}
headerStyle={{ borderBottom: '1px solid var(--semi-color-border)', padding: '24px' }}
bodyStyle={{ padding: 0 }}
visible={props.visible}
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"
onClick={submit}
theme='solid'
className='!rounded-full'
onClick={() => formApiRef.current?.submitForm()}
icon={<IconSave />}
loading={loading}
>
{t('提交')}
</Button>
<Button
theme="light"
size="large"
className="!rounded-full"
type="primary"
theme='light'
className='!rounded-full'
type='primary'
onClick={handleCancel}
icon={<IconClose />}
>
@@ -188,249 +163,154 @@ const EditUser = (props) => {
</div>
}
closeIcon={null}
onCancel={() => handleCancel()}
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>
<div className="w-10 h-10 rounded-full bg-white/20 flex items-center justify-center mr-4 relative">
<IconUser 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>
</div>
<div className="space-y-4">
<div>
<Text strong className="block mb-2">{t('用户名')}</Text>
<Input
placeholder={t('请输入新的用户名')}
onChange={(value) => handleInputChange('username', value)}
value={username}
autoComplete="new-password"
size="large"
className="!rounded-lg"
showClear
/>
</div>
<div>
<Text strong className="block mb-2">{t('密码')}</Text>
<Input
type="password"
placeholder={t('请输入新的密码,最短 8 位')}
onChange={(value) => handleInputChange('password', value)}
value={password}
autoComplete="new-password"
size="large"
className="!rounded-lg"
prefix={<IconKey />}
/>
</div>
<div>
<Text strong className="block mb-2">{t('显示名称')}</Text>
<Input
placeholder={t('请输入新的显示名称')}
onChange={(value) => handleInputChange('display_name', value)}
value={display_name}
autoComplete="new-password"
size="large"
className="!rounded-lg"
showClear
/>
</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>
{userId && (
<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">
<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>
</div>
<div className="space-y-4">
<div>
<Text strong className="block mb-2">{t('分组')}</Text>
<Select
placeholder={t('请选择分组')}
search
allowAdditions
additionLabel={t(
'请在系统设置页面编辑分组倍率以添加新的分组:',
)}
onChange={(value) => handleInputChange('group', value)}
value={inputs.group}
autoComplete="new-password"
optionList={groupOptions}
size="large"
className="w-full !rounded-lg"
prefix={<IconUserGroup />}
/>
</div>
<div>
<div className="flex justify-between mb-2">
<Text strong>{t('剩余额度')}</Text>
<Text type="tertiary">{renderQuotaWithPrompt(quota)}</Text>
<Form
initValues={getInitValues()}
getFormApi={(api) => (formApiRef.current = api)}
onSubmit={submit}
>
{({ values }) => (
<div className='p-6 space-y-6'>
{/* 基本信息 */}
<Card className='!rounded-2xl shadow-sm border-0'>
<div className='flex items-center mb-2'>
<Avatar size='small' color='blue' className='mr-2 shadow-md'>
<IconUser size={16} />
</Avatar>
<div>
<Text className='text-lg font-medium'>{t('基本信息')}</Text>
<div className='text-xs text-gray-600'>{t('用户的基本账户信息')}</div>
</div>
<div className="flex gap-2">
<Input
placeholder={t('请输入新的剩余额度')}
onChange={(value) => handleInputChange('quota', value)}
value={quota}
type="number"
autoComplete="new-password"
size="large"
className="flex-1 !rounded-lg"
prefix={<IconCreditCard />}
</div>
<Row gutter={12}>
<Col span={24}>
<Form.Input
field='username'
label={t('用户名')}
placeholder={t('请输入新的用户名')}
rules={[{ required: true, message: t('请输入用户名') }]}
showClear
/>
<Button
onClick={openAddQuotaModal}
size="large"
className="!rounded-lg"
icon={<IconPlus />}
>
{t('添加额度')}
</Button>
</Col>
<Col span={24}>
<Form.Input
field='password'
label={t('密码')}
placeholder={t('请输入新的密码,最短 8 位')}
mode='password'
showClear
/>
</Col>
<Col span={24}>
<Form.Input
field='display_name'
label={t('显示名称')}
placeholder={t('请输入新的显示名称')}
showClear
/>
</Col>
<Col span={24}>
<Form.Input
field='remark'
label={t('备注')}
placeholder={t('请输入备注(仅管理员可见)')}
showClear
/>
</Col>
</Row>
</Card>
{/* 权限设置 */}
{userId && (
<Card className='!rounded-2xl shadow-sm border-0'>
<div className='flex items-center mb-2'>
<Avatar size='small' color='green' className='mr-2 shadow-md'>
<IconUserGroup size={16} />
</Avatar>
<div>
<Text className='text-lg font-medium'>{t('权限设置')}</Text>
<div className='text-xs text-gray-600'>{t('用户分组和额度管理')}</div>
</div>
</div>
<Row gutter={12}>
<Col span={24}>
<Form.Select
field='group'
label={t('分组')}
placeholder={t('请选择分组')}
optionList={groupOptions}
allowAdditions
search
rules={[{ required: true, message: t('请选择分组') }]}
/>
</Col>
<Col span={10}>
<Form.InputNumber
field='quota'
label={t('剩余额度')}
placeholder={t('请输入新的剩余额度')}
min={0}
step={500000}
extraText={renderQuotaWithPrompt(values.quota || 0)}
rules={[{ required: true, message: t('请输入额度') }]}
style={{ width: '100%' }}
/>
</Col>
<Col span={14}>
<Form.Slot label={t('添加额度')}>
<Button
icon={<IconPlus />}
onClick={() => setIsModalOpen(true)}
/>
</Form.Slot>
</Col>
</Row>
</Card>
)}
{/* 绑定信息 */}
<Card className='!rounded-2xl shadow-sm border-0'>
<div className='flex items-center mb-2'>
<Avatar size='small' color='purple' className='mr-2 shadow-md'>
<IconLink size={16} />
</Avatar>
<div>
<Text className='text-lg font-medium'>{t('绑定信息')}</Text>
<div className='text-xs text-gray-600'>{t('第三方账户绑定状态(只读)')}</div>
</div>
</div>
</div>
</Card>
<Row gutter={12}>
{['github_id', 'oidc_id', 'wechat_id', 'email', 'telegram_id'].map((field) => (
<Col span={24} key={field}>
<Form.Input
field={field}
label={t(`已绑定的 ${field.replace('_id', '').toUpperCase()} 账户`)}
readonly
placeholder={t('此项只读,需要用户通过个人设置页面的相关绑定按钮进行绑定,不可直接修改')}
/>
</Col>
))}
</Row>
</Card>
</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">
<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>
</div>
<div className="space-y-4">
<div>
<Text strong className="block mb-2">{t('已绑定的 GitHub 账户')}</Text>
<Input
value={github_id}
autoComplete="new-password"
placeholder={t(
'此项只读,需要用户通过个人设置页面的相关绑定按钮进行绑定,不可直接修改',
)}
readonly
size="large"
className="!rounded-lg"
/>
</div>
<div>
<Text strong className="block mb-2">{t('已绑定的 OIDC 账户')}</Text>
<Input
value={oidc_id}
placeholder={t(
'此项只读,需要用户通过个人设置页面的相关绑定按钮进行绑定,不可直接修改',
)}
readonly
size="large"
className="!rounded-lg"
/>
</div>
<div>
<Text strong className="block mb-2">{t('已绑定的微信账户')}</Text>
<Input
value={wechat_id}
autoComplete="new-password"
placeholder={t(
'此项只读,需要用户通过个人设置页面的相关绑定按钮进行绑定,不可直接修改',
)}
readonly
size="large"
className="!rounded-lg"
/>
</div>
<div>
<Text strong className="block mb-2">{t('已绑定的邮箱账户')}</Text>
<Input
value={email}
autoComplete="new-password"
placeholder={t(
'此项只读,需要用户通过个人设置页面的相关绑定按钮进行绑定,不可直接修改',
)}
readonly
size="large"
className="!rounded-lg"
/>
</div>
<div>
<Text strong className="block mb-2">{t('已绑定的 Telegram 账户')}</Text>
<Input
value={telegram_id}
autoComplete="new-password"
placeholder={t(
'此项只读,需要用户通过个人设置页面的相关绑定按钮进行绑定,不可直接修改',
)}
readonly
size="large"
className="!rounded-lg"
/>
</div>
</div>
</Card>
</div>
</Form>
</Spin>
</SideSheet>
{/* 添加额度模态框 */}
<Modal
centered={true}
centered
visible={addQuotaModalOpen}
onOk={() => {
addLocalQuota();
@@ -439,28 +319,30 @@ const EditUser = (props) => {
onCancel={() => setIsModalOpen(false)}
closable={null}
title={
<div className="flex items-center">
<IconPlus className="mr-2" />
<div className='flex items-center'>
<IconPlus className='mr-2' />
{t('添加额度')}
</div>
}
>
<div className="mb-4">
<Text type="secondary" className="block mb-2">
{`${t('新额度')}${renderQuota(quota)} + ${renderQuota(addQuotaLocal)} = ${renderQuota(quota + parseInt(addQuotaLocal || 0))}`}
</Text>
<div className='mb-4'>
{
(() => {
const current = formApiRef.current?.getValue('quota') || 0;
return (
<Text type='secondary' className='block mb-2'>
{`${t('新额度')}${renderQuota(current)} + ${renderQuota(addQuotaLocal)} = ${renderQuota(current + parseInt(addQuotaLocal || 0))}`}
</Text>
);
})()
}
</div>
<Input
placeholder={t('需要添加的额度(支持负数)')}
onChange={(value) => {
setAddQuotaLocal(value);
}}
type='number'
value={addQuotaLocal}
type="number"
autoComplete="new-password"
size="large"
className="!rounded-lg"
prefix={<IconCreditCard />}
onChange={setAddQuotaLocal}
showClear
/>
</Modal>
</>
+2 -2
View File
@@ -3,9 +3,9 @@ import UsersTable from '../../components/table/UsersTable';
const User = () => {
return (
<>
<div className="mt-[64px]">
<UsersTable />
</>
</div>
);
};