Compare commits

...

105 Commits

Author SHA1 Message Date
CalciumIon
9a54b345c5 feat: support gemini-2.0-flash-thinking #639 #637 2024-12-20 13:20:07 +08:00
CalciumIon
2d865eb735 refactor: Improve channel status update logic and clean up code
- Simplified conditional checks in UpdateChannelStatusById function in channel.go to enhance readability.
- Commented out unused image number check in relay-gemini.go for clarity.
- Updated JSON field in en.json for currency consistency, changing "元" from "RMB/CNY" to "CNY" and added a space in "实付金额:" for formatting.
2024-12-17 15:33:16 +08:00
Calcium-Ion
70e7860838 Merge pull request #631 from xqx333/main
fix
2024-12-17 14:39:39 +08:00
xqx333
1f4fc2d590 Update channel.go 2024-12-17 14:30:31 +08:00
Calcium-Ion
c76021e9a1 Merge pull request #630 from xqx333/main
修复自动禁用会对数据库进行多次更新的问题
2024-12-17 12:22:27 +08:00
xqx333
173c9bc669 Update channel.go 2024-12-17 12:11:24 +08:00
xqx333
739c0cc334 Update cache.go 2024-12-17 12:10:05 +08:00
CalciumIon
4163713714 refactor: Update SystemInstructions type in GeminiChatRequest and adjust handling in CovertGemini2OpenAI
- Changed SystemInstructions from *GeminiPart to *GeminiChatContent in GeminiChatRequest for improved structure.
- Updated CovertGemini2OpenAI function to accommodate the new SystemInstructions type, ensuring proper handling of message content.
2024-12-16 22:41:23 +08:00
CalciumIon
76acfdc9e2 fix: Correct JSON field name for SystemInstructions in GeminiChatRequest
- Updated the JSON field name from "system_instructions" to "system_instruction" to ensure consistency and accuracy in the data structure.
2024-12-16 22:12:56 +08:00
CalciumIon
fe6f3d7978 feat: Enhance Home component to support language messaging
- Added language messaging functionality to the iframe in the Home component.
- This update ensures that the iframe receives the current language setting, improving localization support.
2024-12-16 21:10:46 +08:00
Calcium-Ion
1f70904fc5 Merge pull request #628 from Calcium-Ion/pr482-merge
merge 428
2024-12-16 21:05:58 +08:00
CalciumIon
fd86de19b5 feat: Enhance HeaderBar to support language change messaging
- Added functionality to post a message to the iframe when the language is changed.
- This update improves localization support by ensuring that the iframe content updates according to the selected language.
2024-12-16 21:05:02 +08:00
CalciumIon
c8f437c13a Merge remote-tracking branch 'guoruqiang/main' into pr482-merge
# Conflicts:
#	README.md
#	web/src/components/HeaderBar.js
#	web/src/components/SiderBar.js
2024-12-16 20:56:53 +08:00
CalciumIon
e6c6bbdef9 feat: support gemini SystemInstructions #408 2024-12-16 20:19:29 +08:00
CalciumIon
aab3887d2c Update README.md 2024-12-16 18:12:17 +08:00
Calcium-Ion
25ab1d400f Merge pull request #625 from QAbot-zh/fix/bindwechat
fix bindWeChat tips
2024-12-15 20:59:47 +08:00
Q.A.zh
6c995d4bd9 fix bindWeChat tips 2024-12-15 12:53:16 +00:00
Calcium-Ion
fb9f50ce79 Merge pull request #624 from Calcium-Ion/channel-setting
feat: Add collapsible section for available models in PersonalSettings
2024-12-15 16:32:09 +08:00
CalciumIon
e8444edce7 feat: Add collapsible section for available models in PersonalSettings 2024-12-15 16:31:00 +08:00
Calcium-Ion
c1a5b0e1bb Merge pull request #623 from Calcium-Ion/channel-setting
feat: implement channel settings configuration
2024-12-15 15:55:13 +08:00
CalciumIon
f2809917f8 feat: implement channel settings configuration
fix #620
2024-12-15 15:52:41 +08:00
CalciumIon
a4c43bb83b feat: Enhance Operation Settings with Group and Model Ratio Management
- Added new components for GroupRatioSettings and ModelRatioSettings to manage group and model ratios.
- Integrated tabs in OperationSetting to switch between model and visual ratio settings.
- Updated translations for new settings and improved existing ones in the English locale file.
- Refactored ModelSettingsVisualEditor to support dynamic pricing and ratio configurations.

This update improves the user interface for managing operational settings, enhancing usability and localization support.
2024-12-14 22:13:31 +08:00
Calcium-Ion
6ca68651ff Merge pull request #618 from HynoR/feat/modeledit
feat: 可视化模型定价编辑器
2024-12-14 21:32:05 +08:00
Calcium-Ion
e2f6444b83 Merge pull request #617 from kingxjs/main
Available models could not be populated when adding a new channel
2024-12-14 21:31:05 +08:00
Calcium-Ion
8e2b6d0aad Merge pull request #622 from Calcium-Ion/i18n-fix
feat: Enhance i18n support
2024-12-14 14:12:27 +08:00
CalciumIon
41a7cee98e feat: Refactor App and ChannelsTable components for improved i18n support
- Removed redundant user and status loading logic from the App component, centralizing it in the PageLayout component for better maintainability.
- Enhanced the ChannelsTable component by integrating translation functions for various UI elements, ensuring consistent localization of titles and modal messages.
- Updated the English locale file with new translation keys for sub-channel modifications, improving the overall localization coverage.
- Streamlined the code structure in multiple components to enhance readability and performance.
2024-12-14 14:09:30 +08:00
CalciumIon
68b87736b6 feat: Enhance i18n support in Home component and update translations
- Integrated translation functions in the Home component to support dynamic localization for various UI elements, improving accessibility for users in different languages.
- Added new translation keys for "Telegram authentication", "Linux DO authentication", and "License" in the English locale file, expanding the localization coverage.
- Updated existing text elements to utilize translation functions, ensuring consistency in language display across the application.
2024-12-14 12:58:10 +08:00
CalciumIon
b86aeb9150 feat: Implement status loading in App component and refactor SiderBar
- Added a new function to load status data from the API in the App component, enhancing the application's ability to display real-time status updates.
- Integrated error handling for API calls to improve user feedback in case of connection issues.
- Removed the redundant status loading logic from the SiderBar component, streamlining the code and ensuring a single source of truth for status management.
- Updated the useEffect hook in SiderBar to maintain sidebar collapse state based on local storage, improving user experience.
2024-12-14 12:57:56 +08:00
CalciumIon
cfdf6e48f1 Update README 2024-12-13 23:48:18 +08:00
CalciumIon
68b7e0e96a refactor: Remove unused translation function calls in LogsTable component
- Eliminated unnecessary calls to the translation function in the LogsTable component, streamlining the code and improving performance.
- This change enhances readability and reduces potential overhead from unused localization logic.
2024-12-13 22:34:10 +08:00
CalciumIon
84130cdf50 Update README.en.md 2024-12-13 21:21:28 +08:00
CalciumIon
d54cab2ab9 Add README.en.md 2024-12-13 20:21:34 +08:00
CalciumIon
7aa8b88c89 Update README.md 2024-12-13 20:15:50 +08:00
CalciumIon
a485db5065 fix: Refine sider visibility logic in HeaderBar component
- Updated the click handler in HeaderBar to correctly toggle the visibility of the sider based on the current item selection.
- Ensured that the sider is hidden when navigating to the home item and displayed conditionally for other items, improving the user interface responsiveness.
2024-12-13 19:28:09 +08:00
Calcium-Ion
5f06feb9a1 feat: support i18n
feat: support i18n
2024-12-13 19:24:15 +08:00
CalciumIon
221d7b5c99 feat: Integrate i18n support and enhance UI text localization
- Added internationalization (i18n) support across various components, enabling dynamic language switching and improved user experience.
- Updated multiple components to utilize translation functions for labels, buttons, and messages, ensuring consistent language display.
- Enhanced the user interface by refining text elements in the ChannelsTable, LogsTable, and various settings pages, improving clarity and accessibility.
- Adjusted CSS styles for better responsiveness and layout consistency across different screen sizes.
2024-12-13 19:03:14 +08:00
HynoR
3587f2c6e9 feat: 增加价格和倍率的互斥验证,优化模型名称输入提示 2024-12-13 14:45:49 +08:00
HynoR
498590d9fd feat: 优化模型设置可视化编辑器,增强输入验证和提示信息 2024-12-13 14:42:02 +08:00
HynoR
8504e07245 feat: 添加保存功能并优化模型数据提交逻辑 2024-12-13 14:29:43 +08:00
TAKO
2eddb93432 Merge branch 'Calcium-Ion:main' into feat/modeledit 2024-12-13 14:11:31 +08:00
HynoR
369ecf365a feat: 添加模型设置可视化编辑器组件 2024-12-13 14:10:38 +08:00
迷糊虫
36bf4b3926 Update EditChannel.js
Fixes an issue with the OpenAI models interface where it fails to get a list of models.
2024-12-13 13:36:03 +08:00
CalciumIon
2a6f3ad27f fix: Adjust inner padding style in PageLayout component
- Updated the overflowY style to always be 'auto', ensuring consistent scrolling behavior.
- Maintained conditional inner padding based on the styleState, enhancing layout responsiveness.
2024-12-12 23:34:14 +08:00
CalciumIon
cd21aa1c56 feat: init i18n 2024-12-12 23:32:55 +08:00
CalciumIon
16599a900b fix: Refine sider visibility and inner padding logic in StyleProvider component
- Consolidated the logic for managing sider visibility and inner padding based on the current pathname, improving responsiveness to navigation changes.
- Ensured that the sider is hidden on specific paths and adjusted inner padding accordingly, enhancing the user interface experience on both mobile and desktop views.
2024-12-12 20:52:22 +08:00
CalciumIon
d241e4fe29 fix: Update label truncation logic in Playground and adjust sider visibility in HeaderBar
- Modified the group label truncation in the Playground component to shorten labels exceeding 16 characters for better mobile display.
- Corrected the conditional rendering logic in the HeaderBar to toggle the sider visibility based on its current state, enhancing user interface responsiveness.
2024-12-12 20:39:49 +08:00
CalciumIon
e17f36e7b7 fix: Correct inner padding and sider visibility logic in HeaderBar, PageLayout, and SiderBar components
- Updated the click handler in HeaderBar to toggle inner padding and sider visibility correctly based on the selected item.
- Adjusted the conditional rendering of SiderBar in PageLayout to ensure it displays when the sider is shown.
- Refined the inner padding logic in SiderBar to maintain consistent behavior when selecting items.
- Introduced a new function in Style context to manage sider visibility based on the current pathname, enhancing responsiveness to navigation changes.
2024-12-12 20:31:40 +08:00
CalciumIon
263547ebb7 refactor: Simplify average calculations in Detail component
- Streamlined the calculation of average RPM and average TPM by removing unnecessary function calls and directly applying the `toFixed(3)` method within the JSX.
- Improved code readability and maintainability by reducing the number of lines and enhancing clarity in the calculation logic.
2024-12-12 19:21:08 +08:00
CalciumIon
5d338337a0 feat: 兼容OpenAI格式下设置gemini模型联网搜索 #615 2024-12-12 17:58:25 +08:00
CalciumIon
b1fb595610 feat: add model gemini-2.0-flash-exp 2024-12-12 17:21:37 +08:00
CalciumIon
44512d3c28 feat: Enhance group label display in Playground component
- Updated the group selection input to truncate long labels on mobile devices, ensuring better readability and a cleaner interface.
- Implemented a conditional label adjustment that shortens group names exceeding 18 characters, appending '...' for clarity.
2024-12-12 16:35:13 +08:00
Calcium-Ion
430d5fcd6a Merge pull request #616 from Calcium-Ion/panel
feat: 完善数据看板功能
2024-12-12 16:19:27 +08:00
CalciumIon
6625563f80 feat: Enhance quota data handling and CSS styling
- Updated the `increaseQuotaData` function to include `tokenUsed` parameter for better quota tracking.
- Modified the `GetAllQuotaDates` function to sum `token_used` alongside `count` and `quota` for comprehensive data retrieval.
- Improved CSS styles for better layout responsiveness, including padding adjustments for navigation elements and description cards.
2024-12-12 16:18:14 +08:00
CalciumIon
b2d36b946d feat: Update SiderBar and Detail components for improved navigation and data visualization
- Removed the '模型价格' (Pricing) link from the SiderBar for a cleaner interface.
- Added a new '数据看板' (Data Dashboard) link to the SiderBar, enhancing navigation options.
- Refactored the Detail component to include user context and style context for better state management.
- Introduced new state variables to track token consumption and updated data handling for charts.
- Enhanced the layout with additional cards and tabs for displaying user quota and usage statistics.
- Improved data processing logic for pie and line charts, ensuring accurate representation of user data.
2024-12-12 16:11:17 +08:00
CalciumIon
ab4c9fdb8f feat: Enhance color mapping and chart rendering in Detail component
- Added base and extended color palettes for improved model color mapping.
- Introduced a new `modelToColor` function to dynamically assign colors based on model names.
- Updated the Detail component to utilize the new color mapping for pie and line charts.
- Refactored chart data handling to support dynamic color assignment and improved data visualization.
- Cleaned up unused state variables and optimized data loading logic for better performance.
2024-12-12 14:56:16 +08:00
CalciumIon
79de02b05f chore: Update dependencies and refactor JSON handling #614
- Removed the `bytedance/sonic` dependency and replaced its usage with the standard `encoding/json` package for JSON marshalling in `relay-text.go`.
- Updated `go.mod` to reflect the removal of `sonic` and adjusted the version of `sonic/loader`.
- Cleaned up `go.sum` to ensure consistency with the updated dependencies.
2024-12-12 14:14:24 +08:00
Calcium-Ion
0455f30d16 Merge pull request #613 from Calcium-Ion/mobile
feat: Add pricing link to HeaderBar component
2024-12-11 23:14:45 +08:00
Calcium-Ion
21d4dcadab Merge pull request #612 from Calcium-Ion/mobile
feat: Refactor style management for inner padding in layout components
2024-12-11 23:14:10 +08:00
CalciumIon
f0d9c89659 feat: Add pricing link to HeaderBar component
- Introduced a new '定价' (Pricing) item in the HeaderBar navigation for better accessibility to pricing information.
- Updated routing to include the new '/pricing' path.
- Adjusted user display in the HeaderBar for mobile responsiveness, hiding the username on smaller screens for a cleaner interface.
2024-12-11 23:13:46 +08:00
CalciumIon
28fa77cc92 feat: Refactor style management for inner padding in layout components
- Updated HeaderBar, PageLayout, and SiderBar components to manage inner padding state based on selected items.
- Replaced `isChatPage` state with `shouldInnerPadding` in Style context for better clarity and functionality.
- Enhanced user experience by dynamically adjusting content padding based on navigation selections.
2024-12-11 23:08:52 +08:00
Calcium-Ion
db84b26e2d Merge pull request #611 from Calcium-Ion/mobile
feat: 前端美化
2024-12-11 21:41:09 +08:00
CalciumIon
024cdb08df feat: Update model lists and enhance model retrieval in Adaptor
- Refactored ModelList in the gemini constant to include new models and remove outdated ones.
- Modified the GetModelList function in the Adaptor to consolidate model lists from multiple sources, ensuring a comprehensive and updated list is returned.
- Commented out deprecated models in the vertex constants for clarity and future reference.
2024-12-11 21:39:41 +08:00
CalciumIon
89136dfa9e feat: Add filtering and search functionality to model selection in EditChannel and EditTagModal
- Implemented filter and search position options in the model selection dropdowns for both EditChannel and EditTagModal components.
- Enhanced user experience by allowing users to easily find and select models from a potentially large list.
2024-12-11 21:33:30 +08:00
CalciumIon
5f0322b672 feat: Add custom model input functionality in EditTagModal
- Introduced a new input field for adding custom model names in the EditTagModal component.
- Implemented logic to handle the addition of custom models, including validation to prevent duplicates.
- Enhanced user experience by providing feedback when attempting to add existing models.
- Updated state management to reflect changes in the model options dynamically.
2024-12-11 21:31:29 +08:00
CalciumIon
379b08f691 feat: Update user group handling in Playground component
- Enhanced the Playground component to prioritize the user's group by moving it to the front of the local group options if it exists.
- Improved user experience by ensuring the default group selection reflects the user's current group, if available.
2024-12-11 21:25:50 +08:00
CalciumIon
afb7b661ee feat: Implement chat page state management in layout and sidebar
- Added `isChatPage` state to the Style context to manage chat page layout.
- Updated `PageLayout` component to adjust padding based on the chat page state.
- Enhanced `SiderBar` component to dispatch chat page state changes when chat-related items are selected.
2024-12-11 21:17:46 +08:00
CalciumIon
60710d6c68 feat: Add renderModelPriceSimple function and update LogsTable component
- Introduced a new helper function `renderModelPriceSimple` to simplify the rendering of model price information.
- Updated the `LogsTable` component to utilize `renderModelPriceSimple`, enhancing the display of model pricing and grouping information.
- Removed the previous implementation of `renderModelPrice` from the `LogsTable` for cleaner code.
2024-12-11 21:06:26 +08:00
CalciumIon
77b8d918de refactor: Simplify PersonalSetting component layout
- Moved footer content from the Card component to a separate Descriptions component for better structure.
- Maintained the display of user quota, historical consumption, and request count while improving readability.
2024-12-11 20:36:44 +08:00
Calcium-Ion
69f57728b2 Merge pull request #610 from Calcium-Ion/mobile
feat: Update dependencies and restructure Playground component
2024-12-11 18:28:11 +08:00
CalciumIon
7cab9d7c8a feat: Update dependencies and restructure Playground component
- Upgraded @douyinfe/semi-ui from version 2.63.1 to 2.69.1 in package.json.
- Updated pnpm-lock.yaml to reflect new dependency versions and lockfile format.
- Moved Playground component to a new directory structure under pages.
- Enhanced Playground component with new features and improved user experience.
2024-12-11 18:27:30 +08:00
Calcium-Ion
e5dc21d56b Merge pull request #609 from Calcium-Ion/mobile
feat: 界面美化
2024-12-11 17:33:32 +08:00
CalciumIon
713de36ecd feat: Enhance EditRedemption component with default name handling 2024-12-11 17:28:59 +08:00
CalciumIon
64e085dc4c feat: 首页优化 2024-12-11 17:19:03 +08:00
CalciumIon
3622c664b6 feat: 侧边栏移动端优化 2024-12-11 16:11:27 +08:00
CalciumIon
18a8216a43 feat: 优化playground搜索模型功能 2024-12-10 23:48:55 +08:00
CalciumIon
5d1087a6a9 fix: 编辑标签文字错误 2024-12-09 23:45:12 +08:00
CalciumIon
cf8b30edfa fix: edit channel weight and priority 2024-12-09 21:26:17 +08:00
CalciumIon
56ccb30a94 fix: 渠道标签开启下使用ID排序出错 2024-12-09 20:38:03 +08:00
CalciumIon
2c79811cb1 feat: update playground roleConfig 2024-12-09 15:03:04 +08:00
Calcium-Ion
1e1a22e7b3 Merge pull request #605 from jochne/patch-1
Update relay-xunfei.go
2024-12-08 18:50:56 +08:00
jochne
70b5a7fd88 Update relay-xunfei.go
按照讯飞的最新文档,Spark Lite请求地址,对应的domain参数为lite
参考来源:https://www.xfyun.cn/doc/spark/Web.html#_1-接口说明
2024-12-08 01:04:43 +08:00
CalciumIon
dd293f80ae fix: telegram register 2024-12-07 18:08:51 +08:00
Calcium-Ion
904a1858e4 Merge pull request #600 from wzxjohn/upstream
feat: support Azure Comm Service SMTP
2024-12-07 15:24:17 +08:00
wzxjohn
568d4e3f71 feat: support Azure Comm Service SMTP 2024-12-07 00:37:11 +08:00
Calcium-Ion
3eca58093f Merge pull request #597 from daggeryu/patch-3
fix 关键词搜索加标签聚合时,大于1个空标签渠道无法展开的问题
2024-12-06 22:10:08 +08:00
CalciumIon
aa82adc5a9 feat: 兼容渠道搜索下标签聚合功能 2024-12-06 22:03:50 +08:00
CalciumIon
195ab1fdd5 feat: add gemini tool_calls finish reason 2024-12-06 14:31:27 +08:00
daggeryu
b9007ced90 fix 关键词搜索加标签聚合时,大于1个空标签渠道无法展开的问题 2024-12-05 23:21:20 +08:00
Calcium-Ion
8c42ea19b9 Merge pull request #596 from daggeryu/patch-2
fix: 关键词搜索时无法展开测试模型
2024-12-05 22:53:05 +08:00
Calcium-Ion
9ce8940d11 删除无用代码 2024-12-05 22:52:49 +08:00
daggeryu
3aa591785d fix: 关键词搜索时无法展开测试模型 2024-12-05 22:41:55 +08:00
Calcium-Ion
a7b5d684cc Update README.md 2024-12-05 18:07:36 +08:00
CalciumIon
be556a23cc feat: add deepseek channel type 2024-12-05 17:50:08 +08:00
CalciumIon
98373f486e feat: update go-epay 2024-12-05 17:45:54 +08:00
CalciumIon
45bf496183 Update go.mod 2024-12-05 15:25:42 +08:00
G.RQ
b0d655e2b9 Merge branch 'Calcium-Ion:main' into main 2024-09-25 14:40:51 +08:00
GuoRuqiang
e4ccaddf4a 撤回修改 2024-09-25 06:39:32 +00:00
GuoRuqiang
b139588aa1 更新LoginForm 提示 2024-09-25 06:38:17 +00:00
G.RQ
ee17e307f0 Update README.md
增加主要变更说明15. 支持使用路由/chat2link 进入聊天界面
2024-09-22 23:09:45 +08:00
GuoRuqiang
574d7a0914 使用postMessage向iframe传参theme-mode,实现切换子页面主题的功能
子页面的js示例
```
<script>
    // 接收父页面的主题模式
    window.addEventListener('message', function(event) {
        if (event.data.themeMode) {
            var theme = event.data.themeMode;
            // 测试是否正确接受到theme-mode的值
            // console.log('Received theme mode from parent:', theme);
            applyTheme(theme);
        }
    });

    // 定义一个函数来应用主题
    function applyTheme(theme) {
        var body = document.body;
        if (theme === 'dark') {
            body.classList.add("dark-mode");
            document.getElementById("darkModeToggle").checked = true;
        } else {
            body.classList.remove("dark-mode");
            document.getElementById("darkModeToggle").checked = false;
        }
    }
</script>
```
2024-09-22 14:09:03 +00:00
GuoRuqiang
edce1f7046 手动合并upstream 2024-09-22 14:04:15 +00:00
GuoRuqiang
bab718e9bc 聊天按钮适配移动端 2024-09-20 04:45:33 +00:00
G.RQ
509a1a0bac Merge branch 'Calcium-Ion:main' into main 2024-09-19 15:19:46 +08:00
GuoRuqiang
0b2ae561bf Merge branch 'Calcium-Ion:main' into main 2024-09-18 20:53:24 +08:00
GuoRuqiang
016e071d50 update HeaderBar 2024-09-18 10:29:25 +00:00
89 changed files with 9421 additions and 5211 deletions

150
README.en.md Normal file
View File

@@ -0,0 +1,150 @@
<div align="center">
![new-api](/web/public/logo.png)
# New API
🍥 Next Generation LLM Gateway and AI Asset Management System
<a href="https://trendshift.io/repositories/8227" target="_blank"><img src="https://trendshift.io/api/badge/repositories/8227" alt="Calcium-Ion%2Fnew-api | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
<p align="center">
<a href="https://raw.githubusercontent.com/Calcium-Ion/new-api/main/LICENSE">
<img src="https://img.shields.io/github/license/Calcium-Ion/new-api?color=brightgreen" alt="license">
</a>
<a href="https://github.com/Calcium-Ion/new-api/releases/latest">
<img src="https://img.shields.io/github/v/release/Calcium-Ion/new-api?color=brightgreen&include_prereleases" alt="release">
</a>
<a href="https://github.com/users/Calcium-Ion/packages/container/package/new-api">
<img src="https://img.shields.io/badge/docker-ghcr.io-blue" alt="docker">
</a>
<a href="https://hub.docker.com/r/CalciumIon/new-api">
<img src="https://img.shields.io/badge/docker-dockerHub-blue" alt="docker">
</a>
<a href="https://goreportcard.com/report/github.com/Calcium-Ion/new-api">
<img src="https://goreportcard.com/badge/github.com/Calcium-Ion/new-api" alt="GoReportCard">
</a>
</p>
</div>
## 📝 Project Description
> [!NOTE]
> This is an open-source project developed based on [One API](https://github.com/songquanpeng/one-api)
> [!IMPORTANT]
> - Users must comply with OpenAI's [Terms of Use](https://openai.com/policies/terms-of-use) and relevant laws and regulations. Not to be used for illegal purposes.
> - This project is for personal learning only. Stability is not guaranteed, and no technical support is provided.
## ✨ Key Features
1. 🎨 New UI interface (some interfaces pending update)
2. 🌍 Multi-language support (work in progress)
3. 🎨 Added [Midjourney-Proxy(Plus)](https://github.com/novicezk/midjourney-proxy) interface support, [Integration Guide](Midjourney.md)
4. 💰 Online recharge support, configurable in system settings:
- [x] EasyPay
5. 🔍 Query usage quota by key:
- Works with [neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool)
6. 📑 Configurable items per page in pagination
7. 🔄 Compatible with original One API database (one-api.db)
8. 💵 Support per-request model pricing, configurable in System Settings - Operation Settings
9. ⚖️ Support channel **weighted random** selection
10. 📈 Data dashboard (console)
11. 🔒 Configurable model access per token
12. 🤖 Telegram authorization login support:
1. System Settings - Configure Login Registration - Allow Telegram Login
2. Send /setdomain command to [@Botfather](https://t.me/botfather)
3. Select your bot, then enter http(s)://your-website/login
4. Telegram Bot name is the bot username without @
13. 🎵 Added [Suno API](https://github.com/Suno-API/Suno-API) interface support, [Integration Guide](Suno.md)
14. 🔄 Support for Rerank models, compatible with Cohere and Jina, can integrate with Dify, [Integration Guide](Rerank.md)
15.**[OpenAI Realtime API](https://platform.openai.com/docs/guides/realtime/integration)** - Support for OpenAI's Realtime API, including Azure channels
## Model Support
This version additionally supports:
1. Third-party model **gps** (gpt-4-gizmo-*)
2. [Midjourney-Proxy(Plus)](https://github.com/novicezk/midjourney-proxy) interface, [Integration Guide](Midjourney.md)
3. Custom channels with full API URL support
4. [Suno API](https://github.com/Suno-API/Suno-API) interface, [Integration Guide](Suno.md)
5. Rerank models, supporting [Cohere](https://cohere.ai/) and [Jina](https://jina.ai/), [Integration Guide](Rerank.md)
6. Dify
You can add custom models gpt-4-gizmo-* in channels. These are third-party models and cannot be called with official OpenAI keys.
## Additional Configurations Beyond One API
- `GENERATE_DEFAULT_TOKEN`: Generate initial token for new users, default `false`
- `STREAMING_TIMEOUT`: Set streaming response timeout, default 60 seconds
- `DIFY_DEBUG`: Output workflow and node info to client for Dify channel, default `true`
- `FORCE_STREAM_OPTION`: Override client stream_options parameter, default `true`
- `GET_MEDIA_TOKEN`: Calculate image tokens, default `true`
- `GET_MEDIA_TOKEN_NOT_STREAM`: Calculate image tokens in non-stream mode, default `true`
- `UPDATE_TASK`: Update async tasks (Midjourney, Suno), default `true`
- `GEMINI_MODEL_MAP`: Specify Gemini model versions (v1/v1beta), format: "model:version", comma-separated
- `COHERE_SAFETY_SETTING`: Cohere model [safety settings](https://docs.cohere.com/docs/safety-modes#overview), options: `NONE`, `CONTEXTUAL`, `STRICT`, default `NONE`
## Deployment
> [!TIP]
> Latest Docker image: `calciumion/new-api:latest`
> Default account: root, password: 123456
> Update command:
> ```
> docker run --rm -v /var/run/docker.sock:/var/run/docker.sock containrrr/watchtower -cR
> ```
### Requirements
- Local database (default): SQLite (Docker deployment must mount `/data` directory)
- Remote database: MySQL >= 5.7.8, PgSQL >= 9.6
### Docker Deployment
### Using Docker Compose (Recommended)
```shell
# Clone project
git clone https://github.com/Calcium-Ion/new-api.git
cd new-api
# Edit docker-compose.yml as needed
# Start
docker-compose up -d
```
### Direct Docker Image Usage
```shell
# SQLite deployment:
docker run --name new-api -d --restart always -p 3000:3000 -e TZ=Asia/Shanghai -v /home/ubuntu/data/new-api:/data calciumion/new-api:latest
# MySQL deployment (add -e SQL_DSN="root:123456@tcp(localhost:3306)/oneapi"), modify database connection parameters as needed
# Example:
docker run --name new-api -d --restart always -p 3000:3000 -e SQL_DSN="root:123456@tcp(localhost:3306)/oneapi" -e TZ=Asia/Shanghai -v /home/ubuntu/data/new-api:/data calciumion/new-api:latest
```
## Channel Retry
Channel retry is implemented, configurable in `Settings->Operation Settings->General Settings`. **Cache recommended**.
First retry uses same priority, second retry uses next priority, and so on.
### Cache Configuration
1. `REDIS_CONN_STRING`: Use Redis as cache
+ Example: `REDIS_CONN_STRING=redis://default:redispw@localhost:49153`
2. `MEMORY_CACHE_ENABLED`: Enable memory cache, default `false`
+ Example: `MEMORY_CACHE_ENABLED=true`
### Why Some Errors Don't Retry
Error codes 400, 504, 524 won't retry
### To Enable Retry for 400
In `Channel->Edit`, set `Status Code Override` to:
```json
{
"400": "500"
}
```
## Integration Guides
- [Midjourney Integration](Midjourney.md)
- [Suno Integration](Suno.md)
## Related Projects
- [One API](https://github.com/songquanpeng/one-api): Original project
- [Midjourney-Proxy](https://github.com/novicezk/midjourney-proxy): Midjourney interface support
- [chatnio](https://github.com/Deeptrain-Community/chatnio): Next-gen AI B/C solution
- [neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool): Query usage quota by key
## 🌟 Star History
[![Star History Chart](https://api.star-history.com/svg?repos=Calcium-Ion/new-api&type=Date)](https://star-history.com/#Calcium-Ion/new-api&Date)

111
README.md
View File

@@ -1,68 +1,79 @@
<p align="right">
<strong>中文</strong> | <a href="./README.en.md">English</a>
</p>
<div align="center">
![new-api](/web/public/logo.png)
# New API
🍥新一代大模型网关与AI资产管理系统
<a href="https://trendshift.io/repositories/8227" target="_blank"><img src="https://trendshift.io/api/badge/repositories/8227" alt="Calcium-Ion%2Fnew-api | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
<p align="center">
<a href="https://raw.githubusercontent.com/Calcium-Ion/new-api/main/LICENSE">
<img src="https://img.shields.io/github/license/Calcium-Ion/new-api?color=brightgreen" alt="license">
</a>
<a href="https://github.com/Calcium-Ion/new-api/releases/latest">
<img src="https://img.shields.io/github/v/release/Calcium-Ion/new-api?color=brightgreen&include_prereleases" alt="release">
</a>
<a href="https://github.com/users/Calcium-Ion/packages/container/package/new-api">
<img src="https://img.shields.io/badge/docker-ghcr.io-blue" alt="docker">
</a>
<a href="https://hub.docker.com/r/CalciumIon/new-api">
<img src="https://img.shields.io/badge/docker-dockerHub-blue" alt="docker">
</a>
<a href="https://goreportcard.com/report/github.com/Calcium-Ion/new-api">
<img src="https://goreportcard.com/badge/github.com/Calcium-Ion/new-api" alt="GoReportCard">
</a>
</p>
</div>
> [!NOTE]
## 📝 项目说明
> [!NOTE]
> 本项目为开源项目,在[One API](https://github.com/songquanpeng/one-api)的基础上进行二次开发
> [!IMPORTANT]
> 使用者必须在遵循 OpenAI 的[使用条款](https://openai.com/policies/terms-of-use)以及**法律法规**的情况下使用,不得用于非法用途。
> 本项目仅供个人学习使用,不保证稳定性,且不提供任何技术支持。
> 根据[《生成式人工智能服务管理暂行办法》](http://www.cac.gov.cn/2023-07/13/c_1690898327029107.htm)的要求,请勿对中国地区公众提供一切未经备案的生成式人工智能服务。
> [!IMPORTANT]
> - 使用者必须在遵循 OpenAI 的[使用条款](https://openai.com/policies/terms-of-use)以及**法律法规**的情况下使用,不得用于非法用途。
> - 本项目仅供个人学习使用,不保证稳定性,且不提供任何技术支持。
> - 根据[《生成式人工智能服务管理暂行办法》](http://www.cac.gov.cn/2023-07/13/c_1690898327029107.htm)的要求,请勿对中国地区公众提供一切未经备案的生成式人工智能服务。
> [!TIP]
> 最新版Docker镜像`calciumion/new-api:latest`
> 默认账号root 密码123456
> 更新指令:
> ```
> docker run --rm -v /var/run/docker.sock:/var/run/docker.sock containrrr/watchtower -cR
> ```
## ✨ 主要特性
## 主要变更
此分叉版本的主要变更如下:
1. 全新的UI界面部分界面还待更新
2. 添加[Midjourney-Proxy(Plus)](https://github.com/novicezk/midjourney-proxy)接口的支持,[对接文档](Midjourney.md)
3. 支持在线充值功能,可在系统设置中设置,当前支持的支付接口:
+ [x] 易支付
4. 支持用key查询使用额度:
+ 配合项目[neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool)可实现用key查询使用
5. 渠道显示已使用额度,支持指定组织访问
6. 分页支持选择每页显示数量
7. 兼容原版One API的数据库可直接使用原版数据库one-api.db
8. 支持模型按次数收费,可在 系统设置-运营设置 中设置
9. 支持渠道**加权随机**
10. 数据看板
11. 可设置令牌能调用的模型
12. 支持Telegram授权登录。
1. 🎨 全新的UI界面部分界面还待更新
2. 🌍 多语言支持(待完善)
3. 🎨 添加[Midjourney-Proxy(Plus)](https://github.com/novicezk/midjourney-proxy)接口支持,[对接文档](Midjourney.md)
4. 💰 支持在线充值功能,可在系统设置中设置:
- [x] 易支付
5. 🔍 支持用key查询使用额度
- 配合项目[neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool)可实现用key查询使用
6. 📑 分页支持选择每页显示数量
7. 🔄 兼容原版One API的数据库可直接使用原版数据库one-api.db
8. 💵 支持模型按次数收费,可在 系统设置-运营设置 中设置
9. ⚖️ 支持渠道**加权随机**
10. 📈 数据看板(控制台)
11. 🔒 可设置令牌能调用的模型
12. 🤖 支持Telegram授权登录
1. 系统设置-配置登录注册-允许通过Telegram登录
2. 对[@Botfather](https://t.me/botfather)输入指令/setdomain
3. 选择你的bot然后输入http(s)://你的网站地址/login
4. Telegram Bot 名称是bot username 去掉@后的字符串
13. 添加 [Suno API](https://github.com/Suno-API/Suno-API)接口支持,[对接文档](Suno.md)
14. 支持Rerank模型目前兼容Cohere和Jina可接入Dify[对接文档](Rerank.md)
15. **[OpenAI Realtime API](https://platform.openai.com/docs/guides/realtime/integration)** - 支持OpenAI的Realtime API支持Azure渠道
13. 🎵 添加 [Suno API](https://github.com/Suno-API/Suno-API)接口支持,[对接文档](Suno.md)
14. 🔄 支持Rerank模型目前兼容Cohere和Jina可接入Dify[对接文档](Rerank.md)
15. **[OpenAI Realtime API](https://platform.openai.com/docs/guides/realtime/integration)** - 支持OpenAI的Realtime API支持Azure渠道
16. 支持使用路由/chat2link 进入聊天界面
## 模型支持
此版本额外支持以下模型:
1. 第三方模型 **gps** gpt-4-gizmo-*
2. 智谱glm-4vglm-4v识图
3. Anthropic Claude 3
4. [Ollama](https://github.com/ollama/ollama?tab=readme-ov-file),添加渠道时,密钥可以随便填写,默认的请求地址是[http://localhost:11434](http://localhost:11434),如果需要修改请在渠道中修改
5. [Midjourney-Proxy(Plus)](https://github.com/novicezk/midjourney-proxy)接口[对接文档](Midjourney.md)
6. [零一万物](https://platform.lingyiwanwu.com/)
7. 自定义渠道,支持填入完整调用地址
8. [Suno API](https://github.com/Suno-API/Suno-API) 接口,[对接文档](Suno.md)
9. Rerank模型目前支持[Cohere](https://cohere.ai/)和[Jina](https://jina.ai/)[对接文档](Rerank.md)
10. Dify
11. Vertex AI目前兼容ClaudeGeminiLlama3.1
2. [Midjourney-Proxy(Plus)](https://github.com/novicezk/midjourney-proxy)接口,[对接文档](Midjourney.md)
3. 自定义渠道,支持填入完整调用地址
4. [Suno API](https://github.com/Suno-API/Suno-API) 接口,[对接文档](Suno.md)
5. Rerank模型目前支持[Cohere](https://cohere.ai/)和[Jina](https://jina.ai/)[对接文档](Rerank.md)
6. Dify
您可以在渠道中添加自定义模型gpt-4-gizmo-*此模型并非OpenAI官方模型而是第三方模型使用官方key无法调用。
@@ -77,6 +88,14 @@
- `GEMINI_MODEL_MAP`Gemini模型指定版本(v1/v1beta),使用“模型:版本”指定,","分隔,例如:-e GEMINI_MODEL_MAP="gemini-1.5-pro-latest:v1beta,gemini-1.5-pro-001:v1beta",为空则使用默认配置(v1beta)
- `COHERE_SAFETY_SETTING`Cohere模型[安全设置](https://docs.cohere.com/docs/safety-modes#overview),可选值为 `NONE`, `CONTEXTUAL``STRICT`,默认为 `NONE`
## 部署
> [!TIP]
> 最新版Docker镜像`calciumion/new-api:latest`
> 默认账号root 密码123456
> 更新指令:
> ```
> docker run --rm -v /var/run/docker.sock:/var/run/docker.sock containrrr/watchtower -cR
> ```
### 部署要求
- 本地数据库默认SQLiteDocker 部署默认使用 SQLite必须挂载 `/data` 目录到宿主机)
- 远程数据库MySQL 版本 >= 5.7.8PgSQL 版本 >= 9.6
@@ -152,6 +171,10 @@ docker run --name new-api -d --restart always -p 3000:3000 -e SQL_DSN="root:1234
- [chatnio](https://github.com/Deeptrain-Community/chatnio):下一代 AI 一站式 B/C 端解决方案
- [neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool)用key查询使用额度
## Star History
其他基于New API的项目
- [new-api-horizon](https://github.com/Calcium-Ion/new-api-horizon)New API高性能优化版并支持Claude格式
- [VoAPI](https://github.com/VoAPI/VoAPI)基于New API的闭源项目
## 🌟 Star History
[![Star History Chart](https://api.star-history.com/svg?repos=Calcium-Ion/new-api&type=Date)](https://star-history.com/#Calcium-Ion/new-api&Date)

View File

@@ -229,6 +229,7 @@ const (
ChannelTypeSiliconFlow = 40
ChannelTypeVertexAi = 41
ChannelTypeMistral = 42
ChannelTypeDeepSeek = 43
ChannelTypeDummy // this one is only for count, do not add any channel after this
@@ -254,7 +255,7 @@ var ChannelBaseURLs = []string{
"https://open.bigmodel.cn", // 16
"https://dashscope.aliyuncs.com", // 17
"", // 18
"https://api.360.cn", // 19
"https://api.360.cn", // 19
"https://openrouter.ai/api", // 20
"https://api.aiproxy.io", // 21
"https://fastgpt.run/api/openapi", // 22
@@ -278,4 +279,5 @@ var ChannelBaseURLs = []string{
"https://api.siliconflow.cn", //40
"", //41
"https://api.mistral.ai", //42
"https://api.deepseek.com", //43
}

View File

@@ -10,22 +10,22 @@ import (
)
func generateMessageID() (string, error) {
split := strings.Split(SMTPAccount, "@")
split := strings.Split(SMTPFrom, "@")
if len(split) < 2 {
return "", fmt.Errorf("invalid SMTP account")
}
domain := strings.Split(SMTPAccount, "@")[1]
domain := strings.Split(SMTPFrom, "@")[1]
return fmt.Sprintf("<%d.%s@%s>", time.Now().UnixNano(), GetRandomString(12), domain), nil
}
func SendEmail(subject string, receiver string, content string) error {
if SMTPFrom == "" { // for compatibility
SMTPFrom = SMTPAccount
}
id, err2 := generateMessageID()
if err2 != nil {
return err2
}
if SMTPFrom == "" { // for compatibility
SMTPFrom = SMTPAccount
}
if SMTPServer == "" && SMTPAccount == "" {
return fmt.Errorf("SMTP 服务器未配置")
}
@@ -79,11 +79,11 @@ func SendEmail(subject string, receiver string, content string) error {
if err != nil {
return err
}
} else if isOutlookServer(SMTPAccount) {
} else if isOutlookServer(SMTPAccount) || SMTPServer == "smtp.azurecomm.net" {
auth = LoginAuth(SMTPAccount, SMTPToken)
err = smtp.SendMail(addr, auth, SMTPAccount, to, mail)
err = smtp.SendMail(addr, auth, SMTPFrom, to, mail)
} else {
err = smtp.SendMail(addr, auth, SMTPAccount, to, mail)
err = smtp.SendMail(addr, auth, SMTPFrom, to, mail)
}
return err
}

View File

@@ -0,0 +1,5 @@
package constant
var (
ForceFormat = "force_format" // ForceFormat 强制格式化为OpenAI格式
)

View File

@@ -0,0 +1,6 @@
package constant
var (
FinishReasonStop = "stop"
FinishReasonToolCalls = "tool_calls"
)

View File

@@ -153,6 +153,8 @@ func buildTestRequest(model string) *dto.GeneralOpenAIRequest {
}
if strings.HasPrefix(model, "o1-") {
testRequest.MaxCompletionTokens = 1
} else if strings.HasPrefix(model, "gemini-2.0-flash-thinking") {
testRequest.MaxTokens = 2
} else {
testRequest.MaxTokens = 1
}

View File

@@ -63,7 +63,7 @@ func GetAllChannels(c *gin.Context) {
}
for _, tag := range tags {
if tag != nil && *tag != "" {
tagChannel, err := model.GetChannelsByTag(*tag)
tagChannel, err := model.GetChannelsByTag(*tag, idSort)
if err == nil {
channelData = append(channelData, tagChannel...)
}
@@ -168,18 +168,40 @@ func SearchChannels(c *gin.Context) {
group := c.Query("group")
modelKeyword := c.Query("model")
idSort, _ := strconv.ParseBool(c.Query("id_sort"))
channels, err := model.SearchChannels(keyword, group, modelKeyword, idSort)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
enableTagMode, _ := strconv.ParseBool(c.Query("tag_mode"))
channelData := make([]*model.Channel, 0)
if enableTagMode {
tags, err := model.SearchTags(keyword, group, modelKeyword, idSort)
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...)
}
}
}
} else {
channels, err := model.SearchChannels(keyword, group, modelKeyword, idSort)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
channelData = channels
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": channels,
"data": channelData,
})
return
}

View File

@@ -85,14 +85,13 @@ func RequestEpay(c *gin.Context) {
c.JSON(200, gin.H{"message": "error", "data": "充值金额过低"})
return
}
var payType epay.PurchaseType
payType := "wxpay"
if req.PaymentMethod == "zfb" {
payType = epay.Alipay
payType = "alipay"
}
if req.PaymentMethod == "wx" {
req.PaymentMethod = "wxpay"
payType = epay.WechatPay
payType = "wxpay"
}
callBackAddress := service.GetCallbackAddress()
returnUrl, _ := url.Parse(constant.ServerAddress + "/log")

33
go.mod
View File

@@ -1,25 +1,22 @@
module one-api
// +heroku goVersion go1.18
go 1.21
toolchain go1.22.4
go 1.23.4
require (
github.com/Calcium-Ion/go-epay v0.0.2
github.com/Calcium-Ion/go-epay v0.0.4
github.com/andybalholm/brotli v1.1.1
github.com/anknown/ahocorasick v0.0.0-20190904063843-d75dbd5169c0
github.com/aws/aws-sdk-go-v2 v1.26.1
github.com/aws/aws-sdk-go-v2/credentials v1.17.11
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.7.4
github.com/bytedance/gopkg v0.0.0-20220118071334-3db87571198b
github.com/bytedance/sonic v1.12.4
github.com/gin-contrib/cors v1.4.0
github.com/gin-contrib/cors v1.7.2
github.com/gin-contrib/gzip v0.0.6
github.com/gin-contrib/sessions v0.0.5
github.com/gin-contrib/static v0.0.1
github.com/gin-gonic/gin v1.9.1
github.com/go-playground/validator/v10 v10.19.0
github.com/go-playground/validator/v10 v10.20.0
github.com/go-redis/redis/v8 v8.11.5
github.com/golang-jwt/jwt v3.2.2+incompatible
github.com/google/uuid v1.6.0
@@ -30,8 +27,8 @@ require (
github.com/pkoukk/tiktoken-go v0.1.7
github.com/samber/lo v1.39.0
github.com/shirou/gopsutil v3.21.11+incompatible
golang.org/x/crypto v0.26.0
golang.org/x/image v0.15.0
golang.org/x/crypto v0.27.0
golang.org/x/image v0.23.0
gorm.io/driver/mysql v1.4.3
gorm.io/driver/postgres v1.5.2
gorm.io/driver/sqlite v1.4.3
@@ -44,7 +41,8 @@ require (
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.5 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.5 // indirect
github.com/aws/smithy-go v1.20.2 // indirect
github.com/bytedance/sonic/loader v0.2.1 // indirect
github.com/bytedance/sonic v1.11.6 // indirect
github.com/bytedance/sonic/loader v0.1.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudwego/base64x v0.1.4 // indirect
github.com/cloudwego/iasm v0.2.0 // indirect
@@ -62,9 +60,9 @@ require (
github.com/gorilla/securecookie v1.1.1 // indirect
github.com/gorilla/sessions v1.2.1 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
github.com/jackc/pgx/v5 v5.5.1 // indirect
github.com/jackc/puddle/v2 v2.2.1 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.7.1 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/json-iterator/go v1.1.12 // indirect
@@ -75,19 +73,18 @@ require (
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.0.8 // indirect
github.com/stretchr/testify v1.9.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.1 // indirect
github.com/tklauser/go-sysconf v0.3.12 // indirect
github.com/tklauser/numcpus v0.6.1 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.11 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
github.com/yusufpapurcu/wmi v1.2.3 // indirect
golang.org/x/arch v0.12.0 // indirect
golang.org/x/exp v0.0.0-20240404231335-c0f41cb1a7a0 // indirect
golang.org/x/net v0.28.0 // indirect
golang.org/x/sync v0.8.0 // indirect
golang.org/x/sync v0.10.0 // indirect
golang.org/x/sys v0.27.0 // indirect
golang.org/x/text v0.17.0 // indirect
golang.org/x/text v0.21.0 // indirect
google.golang.org/protobuf v1.34.2 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

60
go.sum
View File

@@ -1,5 +1,5 @@
github.com/Calcium-Ion/go-epay v0.0.2 h1:3knFBuaBFpHzsGeGQU/QxUqZSHh5s0+jGo0P62pJzWc=
github.com/Calcium-Ion/go-epay v0.0.2/go.mod h1:cxo/ZOg8ClvE3VAnCmEzbuyAZINSq7kFEN9oHj5WQ2U=
github.com/Calcium-Ion/go-epay v0.0.4 h1:C96M7WfRLadcIVscWzwLiYs8etI1wrDmtFMuK2zP22A=
github.com/Calcium-Ion/go-epay v0.0.4/go.mod h1:cxo/ZOg8ClvE3VAnCmEzbuyAZINSq7kFEN9oHj5WQ2U=
github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=
github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=
github.com/anknown/ahocorasick v0.0.0-20190904063843-d75dbd5169c0 h1:onfun1RA+KcxaMk1lfrRnwCd1UUuOjJM/lri5eM1qMs=
@@ -22,11 +22,10 @@ github.com/aws/smithy-go v1.20.2 h1:tbp628ireGtzcHDDmLT/6ADHidqnwgF57XOXZe6tp4Q=
github.com/aws/smithy-go v1.20.2/go.mod h1:krry+ya/rV9RDcV/Q16kpu6ypI4K2czasz0NC3qS14E=
github.com/bytedance/gopkg v0.0.0-20220118071334-3db87571198b h1:LTGVFpNmNHhj0vhOlfgWueFJ32eK9blaIlHR2ciXOT0=
github.com/bytedance/gopkg v0.0.0-20220118071334-3db87571198b/go.mod h1:2ZlV9BaUH4+NXIBF0aMdKKAnHTzqH+iMU4KUjAbL23Q=
github.com/bytedance/sonic v1.12.4 h1:9Csb3c9ZJhfUWeMtpCDCq6BUoH5ogfDFLUgQ/jG+R0k=
github.com/bytedance/sonic v1.12.4/go.mod h1:B8Gt/XvtZ3Fqj+iSKMypzymZxw/FVwgIGKzMzT9r/rk=
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/bytedance/sonic/loader v0.2.1 h1:1GgorWTqf12TA8mma4DDSbaQigE2wOgQo7iCjjJv3+E=
github.com/bytedance/sonic/loader v0.2.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
@@ -45,8 +44,8 @@ github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWo
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
github.com/gin-contrib/cors v1.4.0 h1:oJ6gwtUl3lqV0WEIwM/LxPF1QZ5qe2lGWdY2+bz7y0g=
github.com/gin-contrib/cors v1.4.0/go.mod h1:bs9pNM0x/UsmHPBWT2xZz9ROh8xYjYkiURUfmBoMlcs=
github.com/gin-contrib/cors v1.7.2 h1:oLDHxdg8W/XDoN/8zamqk/Drgt4oVZDvaV0YmvVICQw=
github.com/gin-contrib/cors v1.7.2/go.mod h1:SUJVARKgQ40dmrzgXEVxj2m7Ig1v1qIboQkPDTQ9t2E=
github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4=
github.com/gin-contrib/gzip v0.0.6/go.mod h1:QOJlmV2xmayAjkNS2Y8NQsMneuRShOU/kjovCXNuzzk=
github.com/gin-contrib/sessions v0.0.5 h1:CATtfHmLMQrMNpJRgzjWXD7worTh7g7ritsQfmF+0jE=
@@ -74,8 +73,8 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.2.0/go.mod h1:uOYAAleCW8F/7oMFd6aG0GOhaH6EGOAJShg8Id5JGkI=
github.com/go-playground/validator/v10 v10.10.0/go.mod h1:74x4gJWsvQexRdW8Pn3dXSGrTK4nAUsbPlLADvpJkos=
github.com/go-playground/validator/v10 v10.19.0 h1:ol+5Fu+cSq9JD7SoSqe04GMI92cbn0+wvQ3bZ8b/AU4=
github.com/go-playground/validator/v10 v10.19.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI=
github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo=
github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE=
@@ -103,12 +102,12 @@ github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWm
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.5.1 h1:5I9etrGkLrN+2XPCsi6XLlV5DITbSL/xBZdmAxFcXPI=
github.com/jackc/pgx/v5 v5.5.1/go.mod h1:Ig06C2Vu0t5qXC60W8sqIthScaEnFvojjj9dSljmHRA=
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.7.1 h1:x7SYsPBYDkHDksogeSmZZ5xzThcTgRz++I5E+ePFUcs=
github.com/jackc/pgx/v5 v5.7.1/go.mod h1:e7O26IywZZ+naJtWWos6i6fvWK+29etgITqrqHLfoZA=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jinzhu/copier v0.4.0 h1:w3ciUoD19shMCRargcpm0cm91ytaBhDvuRpz1ODO/U8=
github.com/jinzhu/copier v0.4.0/go.mod h1:DfbEm0FYsaqBcKcFuvmOZb218JkPGtvSHsKg8S8hyyg=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
@@ -159,8 +158,8 @@ github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042
github.com/onsi/gomega v1.18.1 h1:M1GfJqGRrBrrGGsbxzV5dqM2U2ApXefZCQpkukxYRLE=
github.com/onsi/gomega v1.18.1/go.mod h1:0q+aL8jAiMXy9hbwj2mr5GziHiwhAIQpFmmtT5hitRs=
github.com/pelletier/go-toml/v2 v2.0.1/go.mod h1:r9LEWfGN8R5k0VXJ+0BkIe7MYkRdwZOjgMj2KwnJFUo=
github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ=
github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4=
github.com/pelletier/go-toml/v2 v2.2.1 h1:9TA9+T8+8CUCO2+WYnDLCgrYi9+omqKXyjDtosvtEhg=
github.com/pelletier/go-toml/v2 v2.2.1/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
@@ -178,6 +177,7 @@ github.com/shirou/gopsutil v3.21.11+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMT
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
@@ -185,7 +185,7 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU=
@@ -198,27 +198,28 @@ github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVM
github.com/ugorji/go v1.2.7/go.mod h1:nF9osbDWLy6bDVv/Rtoh6QgnvNDpmCalQV5urGCCS6M=
github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY=
github.com/ugorji/go/codec v1.2.7/go.mod h1:WGN1fab3R1fzQlVQTkfxVtIBhWDRqOviHU95kRgeqEY=
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/yusufpapurcu/wmi v1.2.3 h1:E1ctvB7uKFMOJw3fdOW32DwGE9I7t++CRUEMKvFoFiw=
github.com/yusufpapurcu/wmi v1.2.3/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.12.0 h1:UsYJhbzPYGsT0HbEdmYcqtCv8UNGvnaL561NnIUvaKg=
golang.org/x/arch v0.12.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw=
golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54=
golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A=
golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70=
golang.org/x/exp v0.0.0-20240404231335-c0f41cb1a7a0 h1:985EYyeCOxTpcgOTJpflJUwOeEz0CQOdPt73OzpE9F8=
golang.org/x/exp v0.0.0-20240404231335-c0f41cb1a7a0/go.mod h1:/lliqkxwWAhPjf5oSOIJup2XcqJaw8RGS6k3TGEc7GI=
golang.org/x/image v0.15.0 h1:kOELfmgrmJlw4Cdb7g/QGuB3CvDrXbqEIww/pNtNBm8=
golang.org/x/image v0.15.0/go.mod h1:HUYqC05R2ZcZ3ejNQsIHQDQiwWM4JBqmm6MKANTp4LE=
golang.org/x/image v0.23.0 h1:HseQ7c2OpPKTPVzNjG5fwJsOTCiiwS4QdsYi5XU6H68=
golang.org/x/image v0.23.0/go.mod h1:wJJBTdLfCCf3tiHa1fNxpZmUI4mmoZvwMCPP0ddoNKY=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.28.0 h1:a9JDOJc5GMUJ0+UDqmLT86WiEy7iWyIhz8gz8E4e5hE=
golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -235,8 +236,8 @@ golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9sn
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc=
golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
@@ -269,3 +270,4 @@ gorm.io/gorm v1.24.0/go.mod h1:DVrVomtaYTbqs7gB/x2uVvqnXzv0nqjB396B8cG4dBA=
gorm.io/gorm v1.25.0 h1:+KtYtb2roDz14EQe4bla8CbQlmb9dN3VejSai3lprfU=
gorm.io/gorm v1.25.0/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=

View File

@@ -1,529 +0,0 @@
{
"%.6f 额度": "%.6f quota",
"%d 点额度": "%d point quota",
"尚未实现": "Not yet implemented",
"余额不足": "Insufficient balance",
"危险操作": "Hazardous operations",
"输入你的账户名": "Enter your account name",
"确认删除": "Confirm Delete",
"确认绑定": "Confirm Binding",
"您正在删除自己的帐户,将清空所有数据且不可恢复": "You are deleting your account, all data will be cleared and unrecoverable.",
"\"通道「%s」#%d已被禁用\"": "\"Channel %s (#%d) has been disabled\"",
"通道「%s」#%d已被禁用原因%s": "Channel %s (#%d) has been disabled, reason: %s",
"测试已在运行中": "Test is already running",
"响应时间 %.2fs 超过阈值 %.2fs": "Response time %.2fs exceeds threshold %.2fs",
"通道测试完成": "Channel test completed",
"通道测试完成,如果没有收到禁用通知,说明所有通道都正常": "Channel test completed, if you have not received the disable notification, it means that all channels are normal",
"无法连接至 GitHub 服务器,请稍后重试!": "Unable to connect to GitHub server, please try again later!",
"返回值非法,用户字段为空,请稍后重试!": "The return value is illegal, the user field is empty, please try again later!",
"管理员未开启通过 GitHub 登录以及注册": "The administrator did not turn on login and registration via GitHub",
"管理员关闭了新用户注册": "The administrator has turned off new user registration",
"用户已被封禁": "User has been banned",
"该 GitHub 账户已被绑定": "The GitHub account has been bound",
"邮箱地址已被占用": "Email address is occupied",
"%s邮箱验证邮件": "%s Email verification email",
"<p>您好,你正在进行%s邮箱验证。</p>": "<p>Hello, you are verifying %s email.</p>",
"<p>您的验证码为: <strong>%s</strong></p>": "<p>Your verification code is: <strong>%s</strong></p>",
"<p>验证码 %d 分钟内有效,如果不是本人操作,请忽略。</p>": "<p>The verification code is valid within %d minutes. If it is not your operation, please ignore it.</p>",
"无效的参数": "Invalid parameter",
"该邮箱地址未注册": "The email address is not registered",
"%s密码重置": "%s Password reset",
"<p>您好,你正在进行%s密码重置。</p>": "<p>Hello, you are resetting %s password.</p>",
"<p>点击<a href='%s'>此处</a>进行密码重置。</p>": "<p>Click <a href='%s'>here</a> to reset your password.</p>",
"<p>重置链接 %d 分钟内有效,如果不是本人操作,请忽略。</p>": "<p>The reset link is valid within %d minutes. If it is not your operation, please ignore it.</p>",
"重置链接非法或已过期": "Reset link is illegal or expired",
"无法启用 GitHub OAuth请先填入 GitHub Client ID 以及 GitHub Client Secret": "Unable to enable GitHub OAuth, please fill in GitHub Client ID and GitHub Client Secret first!",
"无法启用微信登录,请先填入微信登录相关配置信息!": "Unable to enable WeChat login, please fill in the relevant configuration information for WeChat login first!",
"无法启用 Turnstile 校验,请先填入 Turnstile 校验相关配置信息!": "Unable to enable Turnstile verification, please fill in the relevant configuration information for Turnstile verification first!",
"兑换码名称长度必须在1-20之间": "The length of the redemption code name must be between 1-20",
"兑换码个数必须大于0": "The number of redemption codes must be greater than 0",
"一次兑换码批量生成的个数不能大于 100": "The number of redemption codes generated in a batch cannot be greater than 100",
"通过令牌「%s」使用模型 %s 消耗 %s模型倍率 %.2f,分组倍率 %.2f": "Using model %s with token %s consumes %s (model rate %.2f, group rate %.2f)",
"当前分组上游负载已饱和,请稍后再试": "The current group load is saturated, please try again later",
"令牌名称过长": "Token name is too long",
"令牌已过期,无法启用,请先修改令牌过期时间,或者设置为永不过期": "The token has expired and cannot be enabled. Please modify the expiration time of the token, or set it to never expire.",
"令牌可用额度已用尽,无法启用,请先修改令牌剩余额度,或者设置为无限额度": "The available quota of the token has been used up and cannot be enabled. Please modify the remaining quota of the token, or set it to unlimited quota",
"管理员关闭了密码登录": "The administrator has turned off password login",
"无法保存会话信息,请重试": "Unable to save session information, please try again",
"管理员关闭了通过密码进行注册,请使用第三方账户验证的形式进行注册": "The administrator has turned off registration via password. Please use the form of third-party account verification to register",
"输入不合法 ": "Input is illegal ",
"管理员开启了邮箱验证,请输入邮箱地址和验证码": "The administrator has turned on email verification, please enter the email address and verification code",
"验证码错误或已过期": "Verification code error or expired",
"无权获取同级或更高等级用户的信息": "No permission to get information of users at the same level or higher",
"请重试,系统生成的 UUID 竟然重复了!": "Please try again, the system-generated UUID is actually duplicated!",
"输入不合法": "Input is illegal",
"无权更新同权限等级或更高权限等级的用户信息": "No permission to update user information with the same permission level or higher permission level",
"管理员将用户额度从 %s修改为 %s": "The administrator changed the user quota from %s to %s",
"无权删除同权限等级或更高权限等级的用户": "No permission to delete users with the same permission level or higher permission level",
"无法创建权限大于等于自己的用户": "Unable to create users with permissions greater than or equal to your own",
"用户不存在": "User does not exist",
"无法禁用超级管理员用户": "Unable to disable super administrator user",
"无法删除超级管理员用户": "Unable to delete super administrator user",
"普通管理员用户无法提升其他用户为管理员": "Ordinary administrator users cannot promote other users to administrators",
"该用户已经是管理员": "The user is already an administrator",
"无法降级超级管理员用户": "Unable to downgrade super administrator user",
"该用户已经是普通用户": "The user is already an ordinary user",
"管理员未开启通过微信登录以及注册": "The administrator has not enabled login and registration via WeChat",
"该微信账号已被绑定": "The WeChat account has been bound",
"无权进行此操作,未登录且未提供 access token": "No permission to perform this operation, not logged in and no access token provided",
"无权进行此操作access token 无效": "No permission to perform this operation, access token is invalid",
"无权进行此操作,权限不足": "No permission to perform this operation, insufficient permissions",
"普通用户不支持指定渠道": "Ordinary users do not support specifying channels",
"无效的渠道 ID": "Invalid channel ID",
"该渠道已被禁用": "The channel has been disabled",
"无效的请求": "Invalid request",
"无可用渠道": "No available channels",
"Turnstile token 为空": "Turnstile token is empty",
"Turnstile 校验失败,请刷新重试!": "Turnstile verification failed, please refresh and try again!",
"id 为空!": "id is empty!",
"未提供兑换码": "No redemption code provided",
"无效的 user id": "Invalid user id",
"无效的兑换码": "Invalid redemption code",
"该兑换码已被使用": "The redemption code has been used",
"通过兑换码充值 %s": "Recharge %s through redemption code",
"未提供令牌": "No token provided",
"该令牌状态不可用": "The token status is not available",
"该令牌已过期": "The token has expired",
"该令牌额度已用尽": "The token quota has been used up",
"无效的令牌": "Invalid token",
"id 或 userId 为空!": "id or userId is empty!",
"quota 不能为负数!": "quota cannot be negative!",
"令牌额度不足": "Insufficient token quota",
"用户额度不足": "Insufficient user quota",
"您的额度即将用尽": "Your quota is about to run out",
"您的额度已用尽": "Your quota has been used up",
"%s当前剩余额度为 %d为了不影响您的使用请及时充值。<br/>充值链接:<a href='%s'>%s</a>": "%s, the current remaining quota is %d, in order not to affect your use, please recharge in time. <br/> Recharge link: <a href='%s'>%s</a>",
"affCode 为空!": "affCode is empty!",
"新用户注册赠送 %s": "New user registration gives %s",
"使用邀请码赠送 %s": "Use invitation code to give %s",
"邀请用户赠送 %s": "Invite users to give %s",
"用户名或密码为空": "Username or password is empty",
"用户名或密码错误,或用户已被封禁": "Username or password is wrong, or user has been banned",
"email 为空!": "email is empty!",
"GitHub id 为空!": "GitHub id is empty!",
"WeChat id 为空!": "WeChat id is empty!",
"username 为空!": "username is empty!",
"邮箱地址或密码为空!": "Email address or password is empty!",
"OpenAI 接口聚合管理,支持多种渠道包括 Azure可用于二次分发管理 key仅单可执行文件已打包好 Docker 镜像,一键部署,开箱即用": "OpenAI interface aggregation management, supports multiple channels including Azure, can be used for secondary distribution management key, only single executable file, Docker image has been packaged, one-click deployment, out of the box",
"未知类型": "Unknown type",
"不支持": "Not supported",
"操作成功完成!": "Operation completed successfully!",
"已启用": "Enabled",
"已禁用": "Disabled",
"未知状态": "Unknown status",
" 秒": "s",
" 分钟 ": " m ",
" 小时 ": " h ",
" 天 ": " d ",
" 个月 ": " M ",
" 年 ": " y ",
"未测试": "Not tested",
"通道 ${name} 测试成功,耗时 ${time.toFixed(2)} 秒。": "Channel ${name} test succeeded, time consumed ${time.toFixed(2)} s.",
"已成功开始测试所有已启用通道,请刷新页面查看结果。": "All enabled channels have been successfully tested, please refresh the page to view the results.",
"通道 ${name} 余额更新成功!": "Channel ${name} balance updated successfully!",
"已更新完毕所有已启用通道余额!": "The balance of all enabled channels has been updated!",
"搜索渠道的 ID名称和密钥 ...": "Search for channel ID, name and key ...",
"名称": "Name",
"分组": "Group",
"类型": "Type",
"状态": "Status",
"响应时间": "Response time",
"余额": "Balance",
"操作": "Operation",
"未更新": "Not updated",
"测试": "Test",
"更新余额": "Update balance",
"删除": "Delete",
"删除渠道 {channel.name}": "Delete channel {channel.name}",
"禁用": "Disable",
"启用": "Enable",
"编辑": "Edit",
"添加新的渠道": "Add a new channel",
"测试所有已启用通道": "Test all enabled channels",
"更新所有已启用通道余额": "Update the balance of all enabled channels",
"刷新": "Refresh",
"处理中...": "Processing...",
"绑定成功!": "Binding succeeded!",
"登录成功!": "Login succeeded!",
"操作失败,重定向至登录界面中...": "Operation failed, redirecting to the login page...",
"出现错误,第 ${count} 次重试中...": "An error occurred, retrying for the ${count} time...",
"首页": "Home",
"渠道": "Channel",
"令牌": "Token",
"兑换": "Redeem",
"充值": "Recharge",
"用户": "User",
"日志": "Log",
"设置": "Settings",
"关于": "About",
"聊天": "Chat",
"注销成功!": "Logout succeeded!",
"注销": "Logout",
"登录": "Login",
"注册": "Register",
"加载{name}中...": "Loading {name}...",
"未登录或登录已过期,请重新登录!": "Not logged in or login has expired, please log in again!",
"用户登录": "User login",
"\"用户名\"": "\"Username\"",
"\"密码\"": "\"Password\"",
"忘记密码?": "Forget password?",
"点击重置": "Click to reset",
" 没有账户?": "; No account?",
"点击注册": "Click to register",
"微信扫码关注公众号,输入「验证码」获取验证码(三分钟内有效)": "Scan the QR code of WeChat to follow the official account, enter \"verification code\" to get the verification code (valid within three minutes)",
"\"验证码\"": "\"Verification code\"",
"全部用户": "All users",
"当前用户": "Current user",
"'全部'": "'All'",
"'充值'": "'Recharge'",
"'消费'": "'Consumption'",
"'管理'": "'Management'",
"'系统'": "'System'",
" 充值 ": " Recharge ",
" 消费 ": " Consumption ",
" 管理 ": " Management ",
" 系统 ": " System ",
" 未知 ": " Unknown ",
"时间": "Time",
"详情": "Details",
"选择模式": "Select mode",
"选择明细分类": "Select details category",
"模型倍率不是合法的 JSON 字符串": "Model rate is not a valid JSON string",
"分组倍率不是合法的 JSON 字符串": "Group rate is not a valid JSON string",
"通用设置": "General Settings",
"充值链接": "Recharge Link",
"例如发卡网站的购买链接": "For example, the purchase link of the card issuing website",
"聊天页面链接": "Chat Page Link",
"例如 ChatGPT Next Web 的部署地址": "For example, the deployment address of ChatGPT Next Web",
"单位美元额度": "Unit Dollar Quota",
"一单位货币能兑换的额度": "Quota that can be exchanged for one unit of currency",
"启用额度消费日志记录": "Enable quota consumption log recording",
"以货币形式显示额度": "Display quota in the form of currency",
"相关 API 显示令牌额度而非用户额度": "Related API displays token quota instead of user quota",
"保存通用设置": "Save General Settings",
"监控设置": "Monitoring Settings",
"最长响应时间": "Longest Response Time",
"单位秒": "Unit in seconds",
"当运行通道全部测试时": "When all operating channels are tested",
"超过此时间将自动禁用通道": "Channels will be automatically disabled if this time is exceeded",
"额度提醒阈值": "Quota reminder threshold",
"低于此额度时将发送邮件提醒用户": "Email will be sent to remind users when the quota is below this",
"失败时自动禁用通道": "Automatically disable the channel when it fails",
"保存监控设置": "Save Monitoring Settings",
"额度设置": "Quota Settings",
"新用户初始额度": "Initial quota for new users",
"例如": "For example",
"请求预扣费额度": "Request for pre-deducted quota",
"请求结束后多退少补": "Refund more or less after the request ends",
"邀请新用户奖励额度": "Invite new users to reward quota",
"新用户使用邀请码奖励额度": "New user rewards quota using invitation code",
"保存额度设置": "Save Quota Settings",
"倍率设置": "Rate Settings",
"模型倍率": "Model rate",
"为一个 JSON 文本": "Is a JSON text",
"键为模型名称": "Key is model name",
"值为倍率": "Value is the rate",
"分组倍率": "Group rate",
"键为分组名称": "Key is group name",
"保存倍率设置": "Save Rate Settings",
"已是最新版本": "Is the latest version",
"检查更新": "Check for updates",
"公告": "Announcement",
"在此输入新的公告内容,支持 Markdown & HTML 代码": "Enter the new announcement content here, supports Markdown & HTML code",
"保存公告": "Save Announcement",
"个性化设置": "Personalization Settings",
"系统名称": "System Name",
"在此输入系统名称": "Enter the system name here",
"设置系统名称": "Set system name",
"图片地址": "Image URL",
"在此输入 Logo 图片地址": "Enter the Logo image URL here",
"首页内容": "Home Page Content",
"在此输入首页内容,支持 Markdown & HTML 代码,设置后首页的状态信息将不再显示。如果输入的是一个链接,则会使用该链接作为 iframe 的 src 属性,这允许你设置任意网页作为首页": "Enter the homepage content here, supports Markdown & HTML code. Once set, the status information of the homepage will not be displayed. If a link is entered, it will be used as the src attribute of the iframe, allowing you to set any webpage as the homepage.",
"保存首页内容": "Save Home Page Content",
"在此输入新的关于内容,支持 Markdown & HTML 代码。如果输入的是一个链接,则会使用该链接作为 iframe 的 src 属性,这允许你设置任意网页作为关于页面": "Enter new about content here, supports Markdown & HTML code. If a link is entered, it will be used as the src attribute of the iframe, allowing you to set any webpage as the about page.",
"保存关于": "Save About",
"移除 One API 的版权标识必须首先获得授权,项目维护需要花费大量精力,如果本项目对你有意义,请主动支持本项目": "Removal of One API copyright mark must first be authorized. Project maintenance requires a lot of effort. If this project is meaningful to you, please actively support it.",
"页脚": "Footer",
"在此输入新的页脚,留空则使用默认页脚,支持 HTML 代码": "Enter the new footer here, leave blank to use the default footer, supports HTML code.",
"设置页脚": "Set Footer",
"新版本": "New Version",
"关闭": "Close",
"密码已重置并已复制到剪贴板": "Password has been reset and copied to clipboard",
"密码重置确认": "Password Reset Confirmation",
"邮箱地址": "Email Address",
"提交": "Submit",
"请稍后几秒重试": "Please retry in a few seconds",
"正在检查用户环境": "Checking user environment",
"重置邮件发送成功": "Reset mail sent successfully",
"请检查邮箱": "Please check your email",
"密码重置": "Password Reset",
"令牌已重置并已复制到剪贴板": "Token has been reset and copied to clipboard",
"邀请链接已复制到剪切板": "Invitation link has been copied to clipboard",
"微信账户绑定成功": "WeChat account binding succeeded",
"验证码发送成功": "Verification code sent successfully",
"邮箱账户绑定成功": "Email account binding succeeded",
"注意": "Note",
"此处生成的令牌用于系统管理": "The token generated here is used for system management",
"而非用于请求 OpenAI 相关的服务": "Not for requesting OpenAI related services",
"请知悉": "Please be aware",
"更新个人信息": "Update Personal Information",
"生成系统访问令牌": "Generate System Access Token",
"复制邀请链接": "Copy Invitation Link",
"账号绑定": "Account Binding",
"绑定微信账号": "Bind WeChat Account",
"微信扫码关注公众号": "Scan the QR code with WeChat to follow the official account",
"输入": "Enter",
"验证码": "Verification Code",
"获取验证码": "Get Verification Code",
"三分钟内有效": "Valid for three minutes",
"绑定": "Bind",
"绑定 GitHub 账号": "Bind GitHub Account",
"绑定邮箱地址": "Bind Email Address",
"输入邮箱地址": "Enter Email Address",
"未使用": "Unused",
"已使用": "Used",
"操作成功完成": "Operation successfully completed",
"搜索兑换码的 ID 和名称": "Search for ID and name",
"额度": "Quota",
"创建时间": "Creation Time",
"兑换时间": "Redemption Time",
"尚未兑换": "Not yet redeemed",
"已复制到剪贴板": "Copied to clipboard",
"无法复制到剪贴板": "Unable to copy to clipboard",
"请手动复制": "Please copy manually",
"已将兑换码填入搜索框": "The voucher code has been filled into the search box",
"复制": "Copy",
"添加新的兑换码": "Add a new voucher",
"密码长度不得小于 8 位": "Password length must not be less than 8 characters",
"两次输入的密码不一致": "The two passwords entered do not match",
"注册成功": "Registration succeeded",
"请稍后几秒重试Turnstile 正在检查用户环境": "Please retry in a few seconds, Turnstile is checking user environment",
"验证码发送成功,请检查你的邮箱": "Verification code sent successfully, please check your email",
"新用户注册": "New User Registration",
"输入用户名,最长 12 位": "Enter username, up to 12 characters",
"输入密码,最短 8 位,最长 20 位": "Enter password, at least 8 characters and up to 20 characters",
"输入验证码": "Enter Verification Code",
"已有账户": "Already have an account",
"点击登录": "Click to log in",
"服务器地址": "Server Address",
"更新服务器地址": "Update Server Address",
"配置登录注册": "Configure Login/Registration",
"允许通过密码进行登录": "Allow login via password",
"允许通过密码进行注册": "Allow registration via password",
"通过密码注册时需要进行邮箱验证": "Email verification is required when registering via password",
"允许通过 GitHub 账户登录 & 注册": "Allow login & registration via GitHub account",
"允许通过微信登录 & 注册": "Allow login & registration via WeChat",
"允许新用户注册(此项为否时,新用户将无法以任何方式进行注册": "Allow new user registration (if this option is off, new users will not be able to register in any way",
"启用 Turnstile 用户校验": "Enable Turnstile user verification",
"配置 SMTP": "Configure SMTP",
"用以支持系统的邮件发送": "To support the system email sending",
"SMTP 服务器地址": "SMTP Server Address",
"例如smtp.qq.com": "For example: smtp.qq.com",
"SMTP 端口": "SMTP Port",
"默认: 587": "Default: 587",
"SMTP 账户": "SMTP Account",
"通常是邮箱地址": "Usually an email address",
"发送者邮箱": "Sender email",
"通常和邮箱地址保持一致": "Usually consistent with the email address",
"SMTP 访问凭证": "SMTP Access Credential",
"敏感信息不会发送到前端显示": "Sensitive information will not be displayed in the frontend",
"保存 SMTP 设置": "Save SMTP Settings",
"配置 GitHub OAuth App": "Configure GitHub OAuth App",
"配置 Linuxdo OAuth App": "Configure Linuxdo OAuth App",
"用以支持通过 GitHub 进行登录注册": "To support login & registration via GitHub",
"点击此处": "Click here",
"管理你的 GitHub OAuth App": "Manage your GitHub OAuth App",
"输入你注册的 GitHub OAuth APP 的 ID": "Enter your registered GitHub OAuth APP ID",
"保存 GitHub OAuth 设置": "Save GitHub OAuth Settings",
"配置 WeChat Server": "Configure WeChat Server",
"用以支持通过微信进行登录注册": "To support login & registration via WeChat",
"了解 WeChat Server": "Learn about WeChat Server",
"WeChat Server 访问凭证": "WeChat Server Access Credential",
"微信公众号二维码图片链接": "WeChat Public Account QR Code Image Link",
"输入一个图片链接": "Enter an image link",
"保存 WeChat Server 设置": "Save WeChat Server Settings",
"配置 Turnstile": "Configure Turnstile",
"用以支持用户校验": "To support user verification",
"管理你的 Turnstile Sites推荐选择 Invisible Widget Type": "Manage your Turnstile Sites, recommend selecting Invisible Widget Type",
"输入你注册的 Turnstile Site Key": "Enter your registered Turnstile Site Key",
"保存 Turnstile 设置": "Save Turnstile Settings",
"已过期": "Expired",
"已耗尽": "Exhausted",
"搜索令牌的名称 ...": "Search for the name of the token...",
"已用额度": "Quota used",
"剩余额度": "Remaining quota",
"过期时间": "Expiration time",
"无": "None",
"无限制": "Unlimited",
"永不过期": "Never expires",
"无法复制到剪贴板,请手动复制,已将令牌填入搜索框": "Unable to copy to clipboard, please copy manually, the token has been entered into the search box",
"删除令牌": "Delete Token",
"添加新的令牌": "Add New Token",
"普通用户": "Regular User",
"管理员": "Admin",
"超级管理员": "Super Admin",
"未知身份": "Unknown Identity",
"已激活": "Activated",
"已封禁": "Banned",
"搜索用户的 ID用户名显示名称以及邮箱地址 ...": "Search user ID, username, display name, and email address...",
"用户名": "Username",
"统计信息": "Statistics",
"用户角色": "User Role",
"未绑定邮箱地址": "Email not bound",
"请求次数": "Number of Requests",
"提升": "Promote",
"降级": "Demote",
"删除用户": "Delete User",
"添加新的用户": "Add New User",
"自定义": "Custom",
"等价金额": "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",
"本站仅作演示之用,无服务端": "This site is for demonstration purposes only, no server-side",
"超级管理员未设置充值链接!": "Super administrator has not set the recharge link!",
"错误:": "Error: ",
"新版本可用:${data.version},请使用快捷键 Shift + F5 刷新页面": "New version available: ${data.version}, please refresh the page using shortcut Shift + F5",
"无法正常连接至服务器": "Unable to connect to the server normally",
"管理渠道": "Manage Channels",
"系统状况": "System Status",
"系统信息": "System Information",
"系统信息总览": "System Information Overview",
"版本": "Version",
"源码": "Source Code",
"启动时间": "Startup Time",
"系统配置": "System Configuration",
"系统配置总览": "System Configuration Overview",
"邮箱验证": "Email Verification",
"未启用": "Not Enabled",
"GitHub 身份验证": "GitHub Authentication",
"微信身份验证": "WeChat Authentication",
"Turnstile 用户校验": "Turnstile User Verification",
"创建新的渠道": "Create New Channel",
"镜像": "Mirror",
"请输入镜像站地址格式为https://domain.com可不填不填则使用渠道默认值": "Please enter the mirror site address, the format is: https://domain.com, it can be left blank, if left blank, the default value of the channel will be used",
"模型": "Model",
"请选择该通道所支持的模型": "Please select the model supported by the channel",
"填入基础模型": "Fill in the basic model",
"填入所有模型": "Fill in all models",
"清除所有模型": "Clear all models",
"密钥": "Key",
"请输入密钥": "Please enter the key",
"批量创建": "Batch Create",
"更新渠道信息": "Update Channel Information",
"我的令牌": "My Tokens",
"管理兑换码": "Manage Redeem Codes",
"兑换码": "Redeem Code",
"管理用户": "Manage Users",
"额度明细": "Quota Details",
"个人设置": "Personal Settings",
"运营设置": "Operation Settings",
"系统设置": "System Settings",
"其他设置": "Other Settings",
"项目仓库地址": "Project Repository Address",
"可在设置页面设置关于内容,支持 HTML & Markdown": "You can set the content about in the settings page, support HTML & Markdown",
"由{' '}": "built by{' '}",
"构建,源代码遵循{' '}": ", the source code licensed under{' '}",
"MIT 协议": "MIT License",
"充值额度": "Recharge Quota",
"获取兑换码": "Get Redeem Code",
"一个月后过期": "Expires after one month",
"一天后过期": "Expires after one day",
"一小时后过期": "Expires after one hour",
"一分钟后过期": "Expires after one minute",
"创建新的令牌": "Create New Token",
"注意,令牌的额度仅用于限制令牌本身的最大额度使用量,实际的使用受到账户的剩余额度限制。": "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",
"更新令牌信息": "Update Token Information",
"请输入充值码!": "Please enter the recharge code!",
"请输入名称": "Please enter a name",
"请输入密钥,一行一个": "Please enter the key, one per line",
"请输入额度": "Please enter the quota",
"令牌创建成功": "Token created successfully",
"令牌更新成功": "Token updated successfully",
"充值成功!": "Recharge successful!",
"更新用户信息": "Update User Information",
"请输入新的用户名": "Please enter a new username",
"密码": "Password",
"请输入新的密码": "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",
"用户信息更新成功!": "User information updated successfully!",
"模型倍率 %.2f,分组倍率 %.2f": "model rate %.2f, group rate %.2f",
"使用明细(总消耗额度:{renderQuota(stat.quota)}": "Usage Details (Total Consumption Quota: {renderQuota(stat.quota)})",
"用户名称": "User Name",
"令牌名称": "Token Name",
"留空则查询全部用户": "Leave blank to query all users",
"留空则查询全部令牌": "Leave blank to query all tokens",
"模型名称": "Model Name",
"留空则查询全部模型": "Leave blank to query all models",
"起始时间": "Start Time",
"结束时间": "End Time",
"查询": "Query",
"提示": "Prompt",
"补全": "Completion",
"消耗额度": "Used Quota",
"可选值": "Optional Values",
"渠道不存在:%d": "Channel does not exist: %d",
"数据库一致性已被破坏,请联系管理员": "Database consistency has been broken, please contact the administrator",
"使用近似的方式估算 token 数以减少计算量": "Estimate the number of tokens in an approximate way to reduce computational load",
"请填写ChannelName和ChannelKey": "Please fill in the ChannelName and ChannelKey!",
"请至少选择一个Model": "Please select at least one Model!",
"加载首页内容失败": "Failed to load the homepage content",
"加载关于内容失败": "Failed to load the About content",
"兑换码更新成功!": "Redemption code updated successfully!",
"兑换码创建成功!": "Redemption code created successfully!",
"用户账户创建成功!": "User account created successfully!",
"生成数量": "Generate quantity",
"请输入生成数量": "Please enter the quantity to generate",
"创建新用户账户": "Create new user account",
"渠道更新成功!": "Channel updated successfully!",
"渠道创建成功!": "Channel created successfully!",
"请选择分组": "Please select a group",
"更新兑换码信息": "Update redemption code information",
"创建新的兑换码": "Create a new redemption code",
"请在系统设置页面编辑分组倍率以添加新的分组:": "Please edit the group ratio in the system settings page to add a new group:",
"未找到所请求的页面": "The requested page was not found",
"过期时间格式错误!": "Expiration time format error!",
"请输入过期时间,格式为 yyyy-MM-dd HH:mm:ss-1 表示无限制": "Please enter the expiration time, the format is yyyy-MM-dd HH:mm:ss, -1 means no limit",
"此项可选,为一个 JSON 文本,键为用户请求的模型名称,值为要替换的模型名称,例如:": "This is optional, it's a JSON text, the key is the model name requested by the user, and the value is the model name to be replaced, for example:",
"此项可选,输入镜像站地址,格式为:": "This is optional, enter the mirror site address, the format is:",
"模型映射": "Model mapping",
"请输入默认 API 版本例如2023-03-15-preview该配置可以被实际的请求查询参数所覆盖": "Please enter the default API version, for example: 2023-03-15-preview, this configuration can be overridden by the actual request query parameters",
"默认": "Default",
"图片演示": "Image demo",
"参数替换为你的部署名称(模型名称中的点会被剔除)": "Replace the parameter with your deployment name (dots in the model name will be removed)",
"模型映射必须是合法的 JSON 格式!": "Model mapping must be in valid JSON format!",
"取消无限额度": "Cancel unlimited quota",
"取消": "Cancel",
"请输入新的剩余额度": "Please enter the new remaining quota",
"请输入单个兑换码中包含的额度": "Please enter the quota included in a single redemption code",
"请输入用户名": "Please enter username",
"请输入显示名称": "Please enter display name",
"请输入密码": "Please enter password",
"模型部署名称必须和模型名称保持一致": "The model deployment name must be consistent with the model name",
",因为 One API 会把请求体中的 model": ", because One API will take the model in the request body",
"请输入 AZURE_OPENAI_ENDPOINT": "Please enter AZURE_OPENAI_ENDPOINT",
"请输入自定义渠道的 Base URL": "Please enter the Base URL of the custom channel",
"Homepage URL 填": "Fill in the Homepage URL",
"Authorization callback URL 填": "Fill in the Authorization callback URL",
"请为通道命名": "Please name the channel",
"此项可选,用于修改请求体中的模型名称,为一个 JSON 字符串,键为请求中模型名称,值为要替换的模型名称,例如:": "This is optional, used to modify the model name in the request body, it's a JSON string, the key is the model name in the request, and the value is the model name to be replaced, for example:",
"模型重定向": "Model redirection",
"请输入渠道对应的鉴权密钥": "Please enter the authentication key corresponding to the channel",
"注意,": "Note that, ",
",图片演示。": "related image demo.",
"令牌创建成功,请在列表页面点击复制获取令牌!": "Token created successfully, please click copy on the list page to get the token!",
"代理": "Proxy",
"此项可选,用于通过代理站来进行 API 调用请输入代理站地址格式为https://domain.com": "This is optional, used to make API calls through the proxy site, please enter the proxy site address, the format is: https://domain.com",
"取消密码登录将导致所有未绑定其他登录方式的用户(包括管理员)无法通过密码登录,确认取消?": "Canceling password login will cause all users (including administrators) who have not bound other login methods to be unable to log in via password, confirm cancel?",
"按照如下格式输入:": "Enter in the following format:",
"模型版本": "Model version",
"请输入星火大模型版本注意是接口地址中的版本号例如v2.1": "Please enter the version of the Starfire model, note that it is the version number in the interface address, for example: v2.1",
"点击查看": "click to view",
"请确保已在 Azure 上创建了 gpt-35-turbo 模型,并且 apiVersion 已正确填写!": "Please make sure that the gpt-35-turbo model has been created on Azure, and the apiVersion has been filled in correctly!"
}

View File

@@ -1,61 +0,0 @@
import argparse
import json
import os
def list_file_paths(path):
file_paths = []
for root, dirs, files in os.walk(path):
if "node_modules" in dirs:
dirs.remove("node_modules")
if "build" in dirs:
dirs.remove("build")
if "i18n" in dirs:
dirs.remove("i18n")
for file in files:
file_path = os.path.join(root, file)
if file_path.endswith("png") or file_path.endswith("ico") or file_path.endswith("db") or file_path.endswith("exe"):
continue
file_paths.append(file_path)
for dir in dirs:
dir_path = os.path.join(root, dir)
file_paths += list_file_paths(dir_path)
return file_paths
def replace_keys_in_repository(repo_path, json_file_path):
with open(json_file_path, 'r', encoding="utf-8") as json_file:
key_value_pairs = json.load(json_file)
pairs = []
for key, value in key_value_pairs.items():
pairs.append((key, value))
pairs.sort(key=lambda x: len(x[0]), reverse=True)
files = list_file_paths(repo_path)
print('Total files: {}'.format(len(files)))
for file_path in files:
replace_keys_in_file(file_path, pairs)
def replace_keys_in_file(file_path, pairs):
try:
with open(file_path, 'r', encoding="utf-8") as file:
content = file.read()
for key, value in pairs:
content = content.replace(key, value)
with open(file_path, 'w', encoding="utf-8") as file:
file.write(content)
except UnicodeDecodeError:
print('UnicodeDecodeError: {}'.format(file_path))
if __name__ == "__main__":
parser = argparse.ArgumentParser(description='Replace keys in repository.')
parser.add_argument('--repository_path', help='Path to repository')
parser.add_argument('--json_file_path', help='Path to JSON file')
args = parser.parse_args()
replace_keys_in_repository(args.repository_path, args.json_file_path)

View File

@@ -33,7 +33,7 @@ var indexPage []byte
func main() {
err := godotenv.Load(".env")
if err != nil {
common.SysLog("Can't load .env file")
common.SysError("failed to load .env file: " + err.Error())
}
common.SetupLogger()

View File

@@ -213,6 +213,7 @@ func SetupContextForSelectedChannel(c *gin.Context, channel *model.Channel, mode
c.Set("channel_id", channel.Id)
c.Set("channel_name", channel.Name)
c.Set("channel_type", channel.Type)
c.Set("channel_setting", channel.GetSetting())
if nil != channel.OpenAIOrganization && "" != *channel.OpenAIOrganization {
c.Set("channel_organization", *channel.OpenAIOrganization)
}

View File

@@ -342,3 +342,14 @@ func CacheGetChannel(id int) (*Channel, error) {
}
return c, nil
}
func CacheUpdateChannelStatus(id int, status int) {
if (!common.MemoryCacheEnabled) {
return
}
channelSyncLock.Lock()
defer channelSyncLock.Unlock()
if channel, ok := channelsIDM[id]; ok {
channel.Status = status
}
}

View File

@@ -4,6 +4,7 @@ import (
"encoding/json"
"one-api/common"
"strings"
"sync"
"gorm.io/gorm"
)
@@ -34,6 +35,7 @@ type Channel struct {
AutoBan *int `json:"auto_ban" gorm:"default:1"`
OtherInfo string `json:"other_info"`
Tag *string `json:"tag" gorm:"index"`
Setting string `json:"setting" gorm:"type:text"`
}
func (channel *Channel) GetModels() []string {
@@ -100,9 +102,13 @@ func GetAllChannels(startIdx int, num int, selectAll bool, idSort bool) ([]*Chan
return channels, err
}
func GetChannelsByTag(tag string) ([]*Channel, error) {
func GetChannelsByTag(tag string, idSort bool) ([]*Channel, error) {
var channels []*Channel
err := DB.Where("tag = ?", tag).Find(&channels).Error
order := "priority desc"
if idSort {
order = "id desc"
}
err := DB.Where("tag = ?", tag).Order(order).Find(&channels).Error
return channels, err
}
@@ -285,7 +291,25 @@ func (channel *Channel) Delete() error {
return err
}
var channelStatusLock sync.Mutex
func UpdateChannelStatusById(id int, status int, reason string) {
if common.MemoryCacheEnabled {
channelStatusLock.Lock()
channelCache, _ := CacheGetChannel(id)
// 如果缓存渠道存在,且状态已是目标状态,直接返回
if channelCache != nil && channelCache.Status == status {
channelStatusLock.Unlock()
return
}
// 如果缓存渠道不存在(说明已经被禁用),且要设置的状态不为启用,直接返回
if channelCache == nil && status != common.ChannelStatusEnabled {
channelStatusLock.Unlock()
return
}
CacheUpdateChannelStatus(id, status)
channelStatusLock.Unlock()
}
err := UpdateAbilityStatus(id, status == common.ChannelStatusEnabled)
if err != nil {
common.SysError("failed to update ability status: " + err.Error())
@@ -362,7 +386,7 @@ func EditChannelByTag(tag string, newTag *string, modelMapping *string, models *
return err
}
if shouldReCreateAbilities {
channels, err := GetChannelsByTag(updatedTag)
channels, err := GetChannelsByTag(updatedTag, false)
if err == nil {
for _, channel := range channels {
err = channel.UpdateAbilities()
@@ -410,3 +434,78 @@ func GetPaginatedTags(offset int, limit int) ([]*string, error) {
err := DB.Model(&Channel{}).Select("DISTINCT tag").Where("tag != ''").Offset(offset).Limit(limit).Find(&tags).Error
return tags, err
}
func SearchTags(keyword string, group string, model string, idSort bool) ([]*string, error) {
var tags []*string
keyCol := "`key`"
groupCol := "`group`"
modelsCol := "`models`"
// 如果是 PostgreSQL使用双引号
if common.UsingPostgreSQL {
keyCol = `"key"`
groupCol = `"group"`
modelsCol = `"models"`
}
order := "priority desc"
if idSort {
order = "id desc"
}
// 构造基础查询
baseQuery := DB.Model(&Channel{}).Omit(keyCol)
// 构造WHERE子句
var whereClause string
var args []interface{}
if group != "" && group != "null" {
var groupCondition string
if common.UsingMySQL {
groupCondition = `CONCAT(',', ` + groupCol + `, ',') LIKE ?`
} else {
// sqlite, PostgreSQL
groupCondition = `(',' || ` + groupCol + ` || ',') LIKE ?`
}
whereClause = "(id = ? OR name LIKE ? OR " + keyCol + " = ?) AND " + modelsCol + ` LIKE ? AND ` + groupCondition
args = append(args, common.String2Int(keyword), "%"+keyword+"%", keyword, "%"+model+"%", "%,"+group+",%")
} else {
whereClause = "(id = ? OR name LIKE ? OR " + keyCol + " = ?) AND " + modelsCol + " LIKE ?"
args = append(args, common.String2Int(keyword), "%"+keyword+"%", keyword, "%"+model+"%")
}
subQuery := baseQuery.Where(whereClause, args...).
Select("tag").
Where("tag != ''").
Order(order)
err := DB.Table("(?) as sub", subQuery).
Select("DISTINCT tag").
Find(&tags).Error
if err != nil {
return nil, err
}
return tags, nil
}
func (channel *Channel) GetSetting() map[string]interface{} {
setting := make(map[string]interface{})
if channel.Setting != "" {
err := json.Unmarshal([]byte(channel.Setting), &setting)
if err != nil {
common.SysError("failed to unmarshal setting: " + err.Error())
}
}
return setting
}
func (channel *Channel) SetSetting(setting map[string]interface{}) {
settingBytes, err := json.Marshal(setting)
if err != nil {
common.SysError("failed to marshal setting: " + err.Error())
return
}
channel.Setting = string(settingBytes)
}

View File

@@ -85,7 +85,7 @@ func SaveQuotaDataCache() {
//quotaDataDB.Count += quotaData.Count
//quotaDataDB.Quota += quotaData.Quota
//DB.Table("quota_data").Save(quotaDataDB)
increaseQuotaData(quotaData.UserID, quotaData.Username, quotaData.ModelName, quotaData.Count, quotaData.Quota, quotaData.CreatedAt)
increaseQuotaData(quotaData.UserID, quotaData.Username, quotaData.ModelName, quotaData.Count, quotaData.Quota, quotaData.CreatedAt, quotaData.TokenUsed)
} else {
DB.Table("quota_data").Create(quotaData)
}
@@ -94,11 +94,12 @@ func SaveQuotaDataCache() {
common.SysLog(fmt.Sprintf("保存数据看板数据成功,共保存%d条数据", size))
}
func increaseQuotaData(userId int, username string, modelName string, count int, quota int, createdAt int64) {
func increaseQuotaData(userId int, username string, modelName string, count int, quota int, createdAt int64, tokenUsed int) {
err := DB.Table("quota_data").Where("user_id = ? and username = ? and model_name = ? and created_at = ?",
userId, username, modelName, createdAt).Updates(map[string]interface{}{
"count": gorm.Expr("count + ?", count),
"quota": gorm.Expr("quota + ?", quota),
"count": gorm.Expr("count + ?", count),
"quota": gorm.Expr("quota + ?", quota),
"token_used": gorm.Expr("token_used + ?", tokenUsed),
}).Error
if err != nil {
common.SysLog(fmt.Sprintf("increaseQuotaData error: %s", err))
@@ -127,6 +128,6 @@ func GetAllQuotaDates(startTime int64, endTime int64, username string) (quotaDat
// 从quota_data表中查询数据
// only select model_name, sum(count) as count, sum(quota) as quota, model_name, created_at from quota_data group by model_name, created_at;
//err = DB.Table("quota_data").Where("created_at >= ? and created_at <= ?", startTime, endTime).Find(&quotaDatas).Error
err = DB.Table("quota_data").Select("model_name, sum(count) as count, sum(quota) as quota, created_at").Where("created_at >= ? and created_at <= ?", startTime, endTime).Group("model_name, created_at").Find(&quotaDatas).Error
err = DB.Table("quota_data").Select("model_name, sum(count) as count, sum(quota) as quota, sum(token_used) as token_used, created_at").Where("created_at >= ? and created_at <= ?", startTime, endTime).Group("model_name, created_at").Find(&quotaDatas).Error
return quotaDatas, err
}

View File

@@ -9,8 +9,8 @@ import (
"io"
"net/http"
"one-api/common"
"one-api/constant"
"one-api/dto"
relaycommon "one-api/relay/common"
"one-api/service"
"strings"
"sync"
@@ -75,7 +75,7 @@ func streamResponseBaidu2OpenAI(baiduResponse *BaiduChatStreamResponse) *dto.Cha
var choice dto.ChatCompletionsStreamResponseChoice
choice.Delta.SetContentString(baiduResponse.Result)
if baiduResponse.IsEnd {
choice.FinishReason = &relaycommon.StopFinishReason
choice.FinishReason = &constant.FinishReasonStop
}
response := dto.ChatCompletionsStreamResponse{
Id: baiduResponse.Id,

View File

@@ -0,0 +1,71 @@
package deepseek
import (
"errors"
"fmt"
"github.com/gin-gonic/gin"
"io"
"net/http"
"one-api/dto"
"one-api/relay/channel"
"one-api/relay/channel/openai"
relaycommon "one-api/relay/common"
)
type Adaptor struct {
}
func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) {
//TODO implement me
return nil, errors.New("not implemented")
}
func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) {
//TODO implement me
return nil, errors.New("not implemented")
}
func (a *Adaptor) Init(info *relaycommon.RelayInfo) {
}
func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
return fmt.Sprintf("%s/chat/completions", info.BaseUrl), nil
}
func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) error {
channel.SetupApiRequestHeader(info, c, req)
req.Set("Authorization", "Bearer "+info.ApiKey)
return nil
}
func (a *Adaptor) ConvertRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeneralOpenAIRequest) (any, error) {
if request == nil {
return nil, errors.New("request is nil")
}
return request, nil
}
func (a *Adaptor) ConvertRerankRequest(c *gin.Context, relayMode int, request dto.RerankRequest) (any, error) {
return nil, nil
}
func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (any, error) {
return channel.DoApiRequest(a, c, info, requestBody)
}
func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *dto.OpenAIErrorWithStatusCode) {
if info.IsStream {
err, usage = openai.OaiStreamHandler(c, resp, info)
} else {
err, usage = openai.OpenaiHandler(c, resp, info.PromptTokens, info.UpstreamModelName)
}
return
}
func (a *Adaptor) GetModelList() []string {
return ModelList
}
func (a *Adaptor) GetChannelName() string {
return ChannelName
}

View File

@@ -0,0 +1,7 @@
package deepseek
var ModelList = []string{
"deepseek-chat", "deepseek-coder",
}
var ChannelName = "deepseek"

View File

@@ -5,9 +5,11 @@ const (
)
var ModelList = []string{
"gemini-1.0-pro-latest", "gemini-1.0-pro-001", "gemini-1.5-pro-latest", "gemini-1.5-flash-latest", "gemini-ultra",
"gemini-1.0-pro-vision-latest", "gemini-1.0-pro-vision-001", "gemini-1.5-pro-exp-0827", "gemini-1.5-flash-exp-0827",
"gemini-exp-1114",
"gemini-1.5-pro-latest", "gemini-1.5-flash-latest",
"gemini-1.5-pro-exp-0827", "gemini-1.5-flash-exp-0827",
"gemini-exp-1114", "gemini-exp-1121", "gemini-exp-1206",
"gemini-2.0-flash-exp",
"gemini-2.0-flash-thinking-exp-1219",
}
var ChannelName = "google gemini"

View File

@@ -1,10 +1,11 @@
package gemini
type GeminiChatRequest struct {
Contents []GeminiChatContent `json:"contents"`
SafetySettings []GeminiChatSafetySettings `json:"safety_settings,omitempty"`
GenerationConfig GeminiChatGenerationConfig `json:"generation_config,omitempty"`
Tools []GeminiChatTools `json:"tools,omitempty"`
Contents []GeminiChatContent `json:"contents"`
SafetySettings []GeminiChatSafetySettings `json:"safety_settings,omitempty"`
GenerationConfig GeminiChatGenerationConfig `json:"generation_config,omitempty"`
Tools []GeminiChatTools `json:"tools,omitempty"`
SystemInstructions *GeminiChatContent `json:"system_instruction,omitempty"`
}
type GeminiInlineData struct {
@@ -34,6 +35,7 @@ type GeminiChatSafetySettings struct {
}
type GeminiChatTools struct {
GoogleSearch any `json:"googleSearch,omitempty"`
FunctionDeclarations any `json:"functionDeclarations,omitempty"`
}

View File

@@ -4,14 +4,16 @@ import (
"bufio"
"encoding/json"
"fmt"
"github.com/gin-gonic/gin"
"io"
"net/http"
"one-api/common"
"one-api/constant"
"one-api/dto"
relaycommon "one-api/relay/common"
"one-api/service"
"strings"
"github.com/gin-gonic/gin"
)
// Setting safety to the lowest possible values since Gemini is already powerless enough
@@ -35,6 +37,10 @@ func CovertGemini2OpenAI(textRequest dto.GeneralOpenAIRequest) *GeminiChatReques
Category: "HARM_CATEGORY_DANGEROUS_CONTENT",
Threshold: common.GeminiSafetySetting,
},
{
Category: "HARM_CATEGORY_CIVIC_INTEGRITY",
Threshold: common.GeminiSafetySetting,
},
},
GenerationConfig: GeminiChatGenerationConfig{
Temperature: textRequest.Temperature,
@@ -44,13 +50,25 @@ func CovertGemini2OpenAI(textRequest dto.GeneralOpenAIRequest) *GeminiChatReques
}
if textRequest.Tools != nil {
functions := make([]dto.FunctionCall, 0, len(textRequest.Tools))
googleSearch := false
for _, tool := range textRequest.Tools {
if tool.Function.Name == "googleSearch" {
googleSearch = true
continue
}
functions = append(functions, tool.Function)
}
geminiRequest.Tools = []GeminiChatTools{
{
FunctionDeclarations: functions,
},
if len(functions) > 0 {
geminiRequest.Tools = []GeminiChatTools{
{
FunctionDeclarations: functions,
},
}
}
if googleSearch {
geminiRequest.Tools = append(geminiRequest.Tools, GeminiChatTools{
GoogleSearch: make(map[string]string),
})
}
} else if textRequest.Functions != nil {
geminiRequest.Tools = []GeminiChatTools{
@@ -59,30 +77,40 @@ func CovertGemini2OpenAI(textRequest dto.GeneralOpenAIRequest) *GeminiChatReques
},
}
}
shouldAddDummyModelMessage := false
//shouldAddDummyModelMessage := false
for _, message := range textRequest.Messages {
if message.Role == "system" {
geminiRequest.SystemInstructions = &GeminiChatContent{
Parts: []GeminiPart{
{
Text: message.StringContent(),
},
},
}
continue
}
content := GeminiChatContent{
Role: message.Role,
Parts: []GeminiPart{
{
Text: message.StringContent(),
},
},
//Parts: []GeminiPart{
// {
// Text: message.StringContent(),
// },
//},
}
openaiContent := message.ParseContent()
var parts []GeminiPart
imageNum := 0
for _, part := range openaiContent {
if part.Type == dto.ContentTypeText {
parts = append(parts, GeminiPart{
Text: part.Text,
})
} else if part.Type == dto.ContentTypeImageURL {
imageNum += 1
if imageNum > GeminiVisionMaxImageNum {
continue
}
//if imageNum > GeminiVisionMaxImageNum {
// continue
//}
// 判断是否是url
if strings.HasPrefix(part.ImageUrl.(dto.MessageImageUrl).Url, "http") {
// 是url获取图片的类型和base64编码的数据
@@ -114,26 +142,25 @@ func CovertGemini2OpenAI(textRequest dto.GeneralOpenAIRequest) *GeminiChatReques
content.Role = "model"
}
// Converting system prompt to prompt from user for the same reason
if content.Role == "system" {
content.Role = "user"
shouldAddDummyModelMessage = true
}
//if content.Role == "system" {
// content.Role = "user"
// shouldAddDummyModelMessage = true
//}
geminiRequest.Contents = append(geminiRequest.Contents, content)
// If a system message is the last message, we need to add a dummy model message to make gemini happy
if shouldAddDummyModelMessage {
geminiRequest.Contents = append(geminiRequest.Contents, GeminiChatContent{
Role: "model",
Parts: []GeminiPart{
{
Text: "Okay",
},
},
})
shouldAddDummyModelMessage = false
}
//
//// If a system message is the last message, we need to add a dummy model message to make gemini happy
//if shouldAddDummyModelMessage {
// geminiRequest.Contents = append(geminiRequest.Contents, GeminiChatContent{
// Role: "model",
// Parts: []GeminiPart{
// {
// Text: "Okay",
// },
// },
// })
// shouldAddDummyModelMessage = false
//}
}
return &geminiRequest
}
@@ -186,13 +213,18 @@ func responseGeminiChat2OpenAI(response *GeminiChatResponse) *dto.OpenAITextResp
Role: "assistant",
Content: content,
},
FinishReason: relaycommon.StopFinishReason,
FinishReason: constant.FinishReasonStop,
}
if len(candidate.Content.Parts) > 0 {
if candidate.Content.Parts[0].FunctionCall != nil {
choice.FinishReason = constant.FinishReasonToolCalls
choice.Message.ToolCalls = getToolCalls(&candidate)
} else {
choice.Message.SetStringContent(candidate.Content.Parts[0].Text)
var texts []string
for _, part := range candidate.Content.Parts {
texts = append(texts, part.Text)
}
choice.Message.SetStringContent(strings.Join(texts, "\n"))
}
}
fullTextResponse.Choices = append(fullTextResponse.Choices, choice)
@@ -262,7 +294,7 @@ func GeminiChatStreamHandler(c *gin.Context, resp *http.Response, info *relaycom
}
}
response := service.GenerateStopResponse(id, createAt, info.UpstreamModelName, relaycommon.StopFinishReason)
response := service.GenerateStopResponse(id, createAt, info.UpstreamModelName, constant.FinishReasonStop)
service.ObjectData(c, response)
usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens

View File

@@ -5,9 +5,6 @@ import (
"bytes"
"encoding/json"
"fmt"
"github.com/bytedance/gopkg/util/gopool"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"io"
"net/http"
"one-api/common"
@@ -19,9 +16,33 @@ import (
"strings"
"sync"
"time"
"github.com/bytedance/gopkg/util/gopool"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
)
func sendStreamData(c *gin.Context, data string, forceFormat bool) error {
if data == "" {
return nil
}
if forceFormat {
var lastStreamResponse dto.ChatCompletionsStreamResponse
if err := json.Unmarshal(common.StringToByteSlice(data), &lastStreamResponse); err != nil {
return err
}
return service.ObjectData(c, lastStreamResponse)
}
return service.StringData(c, data)
}
func OaiStreamHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (*dto.OpenAIErrorWithStatusCode, *dto.Usage) {
if resp == nil || resp.Body == nil {
common.LogError(c, "invalid response or response body")
return service.OpenAIErrorWrapper(fmt.Errorf("invalid response"), "invalid_response", http.StatusInternalServerError), nil
}
containStreamUsage := false
var responseId string
var createAt int64 = 0
@@ -31,6 +52,13 @@ func OaiStreamHandler(c *gin.Context, resp *http.Response, info *relaycommon.Rel
var responseTextBuilder strings.Builder
var usage = &dto.Usage{}
var streamItems []string // store stream items
var forceFormat bool
if info.ChannelType == common.ChannelTypeCustom {
if forceFmt, ok := info.ChannelSetting["force_format"].(bool); ok {
forceFormat = forceFmt
}
}
toolCount := 0
scanner := bufio.NewScanner(resp.Body)
@@ -62,7 +90,7 @@ func OaiStreamHandler(c *gin.Context, resp *http.Response, info *relaycommon.Rel
data = data[6:]
if !strings.HasPrefix(data, "[DONE]") {
if lastStreamData != "" {
err := service.StringData(c, lastStreamData)
err := sendStreamData(c, lastStreamData, forceFormat)
if err != nil {
common.LogError(c, "streaming error: "+err.Error())
}
@@ -105,7 +133,7 @@ func OaiStreamHandler(c *gin.Context, resp *http.Response, info *relaycommon.Rel
}
}
if shouldSendLastResp {
service.StringData(c, lastStreamData)
sendStreamData(c, lastStreamData, forceFormat)
}
// 计算token
@@ -375,6 +403,10 @@ func getTextFromJSON(body []byte) (string, error) {
}
func OpenaiRealtimeHandler(c *gin.Context, info *relaycommon.RelayInfo) (*dto.OpenAIErrorWithStatusCode, *dto.RealtimeUsage) {
if info == nil || info.ClientWs == nil || info.TargetWs == nil {
return service.OpenAIErrorWrapper(fmt.Errorf("invalid websocket connection"), "invalid_connection", http.StatusBadRequest), nil
}
info.IsStream = true
clientConn := info.ClientWs
targetConn := info.TargetWs
@@ -390,6 +422,11 @@ func OpenaiRealtimeHandler(c *gin.Context, info *relaycommon.RelayInfo) (*dto.Op
sumUsage := &dto.RealtimeUsage{}
gopool.Go(func() {
defer func() {
if r := recover(); r != nil {
errChan <- fmt.Errorf("panic in client reader: %v", r)
}
}()
for {
select {
case <-c.Done():
@@ -445,6 +482,11 @@ func OpenaiRealtimeHandler(c *gin.Context, info *relaycommon.RelayInfo) (*dto.Op
})
gopool.Go(func() {
defer func() {
if r := recover(); r != nil {
errChan <- fmt.Errorf("panic in target reader: %v", r)
}
}()
for {
select {
case <-c.Done():
@@ -568,6 +610,10 @@ func OpenaiRealtimeHandler(c *gin.Context, info *relaycommon.RelayInfo) (*dto.Op
}
func preConsumeUsage(ctx *gin.Context, info *relaycommon.RelayInfo, usage *dto.RealtimeUsage, totalUsage *dto.RealtimeUsage) error {
if usage == nil || totalUsage == nil {
return fmt.Errorf("invalid usage pointer")
}
totalUsage.TotalTokens += usage.TotalTokens
totalUsage.InputTokens += usage.InputTokens
totalUsage.OutputTokens += usage.OutputTokens

View File

@@ -7,8 +7,8 @@ import (
"io"
"net/http"
"one-api/common"
"one-api/constant"
"one-api/dto"
relaycommon "one-api/relay/common"
"one-api/service"
)
@@ -63,7 +63,7 @@ func streamResponsePaLM2OpenAI(palmResponse *PaLMChatResponse) *dto.ChatCompleti
if len(palmResponse.Candidates) > 0 {
choice.Delta.SetContentString(palmResponse.Candidates[0].Content)
}
choice.FinishReason = &relaycommon.StopFinishReason
choice.FinishReason = &constant.FinishReasonStop
var response dto.ChatCompletionsStreamResponse
response.Object = "chat.completion.chunk"
response.Model = "palm2"

View File

@@ -12,8 +12,8 @@ import (
"io"
"net/http"
"one-api/common"
"one-api/constant"
"one-api/dto"
relaycommon "one-api/relay/common"
"one-api/service"
"strconv"
"strings"
@@ -81,7 +81,7 @@ func streamResponseTencent2OpenAI(TencentResponse *TencentChatResponse) *dto.Cha
var choice dto.ChatCompletionsStreamResponseChoice
choice.Delta.SetContentString(TencentResponse.Choices[0].Delta.Content)
if TencentResponse.Choices[0].FinishReason == "stop" {
choice.FinishReason = &relaycommon.StopFinishReason
choice.FinishReason = &constant.FinishReasonStop
}
response.Choices = append(response.Choices, choice)
}

View File

@@ -176,7 +176,20 @@ func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycom
}
func (a *Adaptor) GetModelList() []string {
return ModelList
var modelList []string
for i, s := range ModelList {
modelList = append(modelList, s)
ModelList[i] = s
}
for i, s := range claude.ModelList {
modelList = append(modelList, s)
claude.ModelList[i] = s
}
for i, s := range gemini.ModelList {
modelList = append(modelList, s)
gemini.ModelList[i] = s
}
return modelList
}
func (a *Adaptor) GetChannelName() string {

View File

@@ -1,13 +1,13 @@
package vertex
var ModelList = []string{
"claude-3-sonnet-20240229",
"claude-3-opus-20240229",
"claude-3-haiku-20240307",
"claude-3-5-sonnet-20240620",
//"claude-3-sonnet-20240229",
//"claude-3-opus-20240229",
//"claude-3-haiku-20240307",
//"claude-3-5-sonnet-20240620",
//"gemini-1.5-pro-latest", "gemini-1.5-flash-latest",
"gemini-1.5-pro-001", "gemini-1.5-flash-001", "gemini-pro", "gemini-pro-vision",
//"gemini-1.5-pro-001", "gemini-1.5-flash-001", "gemini-pro", "gemini-pro-vision",
"meta/llama3-405b-instruct-maas",
}

View File

@@ -12,8 +12,8 @@ import (
"net/http"
"net/url"
"one-api/common"
"one-api/constant"
"one-api/dto"
relaycommon "one-api/relay/common"
"one-api/service"
"strings"
"time"
@@ -67,7 +67,7 @@ func responseXunfei2OpenAI(response *XunfeiChatResponse) *dto.OpenAITextResponse
Role: "assistant",
Content: content,
},
FinishReason: relaycommon.StopFinishReason,
FinishReason: constant.FinishReasonStop,
}
fullTextResponse := dto.OpenAITextResponse{
Object: "chat.completion",
@@ -89,7 +89,7 @@ func streamResponseXunfei2OpenAI(xunfeiResponse *XunfeiChatResponse) *dto.ChatCo
var choice dto.ChatCompletionsStreamResponseChoice
choice.Delta.SetContentString(xunfeiResponse.Payload.Choices.Text[0].Content)
if xunfeiResponse.Payload.Choices.Status == 2 {
choice.FinishReason = &relaycommon.StopFinishReason
choice.FinishReason = &constant.FinishReasonStop
}
response := dto.ChatCompletionsStreamResponse{
Object: "chat.completion.chunk",
@@ -245,7 +245,7 @@ func xunfeiMakeRequest(textRequest dto.GeneralOpenAIRequest, domain, authUrl, ap
func apiVersion2domain(apiVersion string) string {
switch apiVersion {
case "v1.1":
return "general"
return "lite"
case "v2.1":
return "generalv2"
case "v3.1":

View File

@@ -8,8 +8,8 @@ import (
"io"
"net/http"
"one-api/common"
"one-api/constant"
"one-api/dto"
relaycommon "one-api/relay/common"
"one-api/service"
"strings"
"sync"
@@ -139,7 +139,7 @@ func streamResponseZhipu2OpenAI(zhipuResponse string) *dto.ChatCompletionsStream
func streamMetaResponseZhipu2OpenAI(zhipuResponse *ZhipuStreamMetaResponse) (*dto.ChatCompletionsStreamResponse, *dto.Usage) {
var choice dto.ChatCompletionsStreamResponseChoice
choice.Delta.SetContentString("")
choice.FinishReason = &relaycommon.StopFinishReason
choice.FinishReason = &constant.FinishReasonStop
response := dto.ChatCompletionsStreamResponse{
Id: zhipuResponse.RequestId,
Object: "chat.completion.chunk",

View File

@@ -1,13 +1,14 @@
package common
import (
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"one-api/common"
"one-api/dto"
"one-api/relay/constant"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
)
type RelayInfo struct {
@@ -43,6 +44,7 @@ type RelayInfo struct {
RealtimeTools []dto.RealTimeTool
IsFirstRequest bool
AudioUsage bool
ChannelSetting map[string]interface{}
}
func GenRelayInfoWs(c *gin.Context, ws *websocket.Conn) *RelayInfo {
@@ -57,6 +59,7 @@ func GenRelayInfoWs(c *gin.Context, ws *websocket.Conn) *RelayInfo {
func GenRelayInfo(c *gin.Context) *RelayInfo {
channelType := c.GetInt("channel_type")
channelId := c.GetInt("channel_id")
channelSetting := c.GetStringMap("channel_setting")
tokenId := c.GetInt("token_id")
tokenKey := c.GetString("token_key")
@@ -87,6 +90,7 @@ func GenRelayInfo(c *gin.Context) *RelayInfo {
ApiVersion: c.GetString("api_version"),
ApiKey: strings.TrimPrefix(c.Request.Header.Get("Authorization"), "Bearer "),
Organization: c.GetString("channel_organization"),
ChannelSetting: channelSetting,
}
if strings.HasPrefix(c.Request.URL.Path, "/pg") {
info.IsPlayground = true

View File

@@ -10,8 +10,6 @@ import (
"strings"
)
var StopFinishReason = "stop"
func GetFullRequestURL(baseURL string, requestURL string, channelType int) string {
fullRequestURL := fmt.Sprintf("%s%s", baseURL, requestURL)

View File

@@ -26,6 +26,7 @@ const (
APITypeSiliconFlow
APITypeVertexAi
APITypeMistral
APITypeDeepSeek
APITypeDummy // this one is only for count, do not add any channel after this
)
@@ -75,6 +76,8 @@ func ChannelType2APIType(channelType int) (int, bool) {
apiType = APITypeVertexAi
case common.ChannelTypeMistral:
apiType = APITypeMistral
case common.ChannelTypeDeepSeek:
apiType = APITypeDeepSeek
}
if apiType == -1 {
return APITypeOpenAI, false

View File

@@ -18,8 +18,6 @@ import (
"strings"
"time"
"github.com/bytedance/sonic"
"github.com/gin-gonic/gin"
)
@@ -180,7 +178,7 @@ func TextHelper(c *gin.Context) (openaiErr *dto.OpenAIErrorWithStatusCode) {
if err != nil {
return service.OpenAIErrorWrapperLocal(err, "convert_request_failed", http.StatusInternalServerError)
}
jsonData, err := sonic.Marshal(convertedRequest)
jsonData, err := json.Marshal(convertedRequest)
if err != nil {
return service.OpenAIErrorWrapperLocal(err, "json_marshal_failed", http.StatusInternalServerError)
}

View File

@@ -9,6 +9,7 @@ import (
"one-api/relay/channel/claude"
"one-api/relay/channel/cloudflare"
"one-api/relay/channel/cohere"
"one-api/relay/channel/deepseek"
"one-api/relay/channel/dify"
"one-api/relay/channel/gemini"
"one-api/relay/channel/jina"
@@ -71,6 +72,8 @@ func GetAdaptor(apiType int) channel.Adaptor {
return &vertex.Adaptor{}
case constant.APITypeMistral:
return &mistral.Adaptor{}
case constant.APITypeDeepSeek:
return &deepseek.Adaptor{}
}
return nil
}

View File

@@ -5,7 +5,7 @@
"type": "module",
"dependencies": {
"@douyinfe/semi-icons": "^2.63.1",
"@douyinfe/semi-ui": "^2.63.1",
"@douyinfe/semi-ui": "^2.69.1",
"@visactor/react-vchart": "~1.8.8",
"@visactor/vchart": "~1.8.8",
"@visactor/vchart-semi-theme": "~1.8.8",
@@ -23,7 +23,10 @@
"react-turnstile": "^1.0.5",
"semantic-ui-offline": "^2.5.0",
"semantic-ui-react": "^2.1.3",
"sse": "github:mpetazzoni/sse.js"
"sse": "github:mpetazzoni/sse.js",
"i18next": "^23.16.8",
"react-i18next": "^13.0.0",
"i18next-browser-languagedetector": "^7.2.0"
},
"scripts": {
"dev": "vite",

5224
web/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -24,40 +24,18 @@ import { Layout } from '@douyinfe/semi-ui';
import Midjourney from './pages/Midjourney';
import Pricing from './pages/Pricing/index.js';
import Task from "./pages/Task/index.js";
import Playground from './components/Playground.js';
import Playground from './pages/Playground/Playground.js';
import OAuth2Callback from "./components/OAuth2Callback.js";
import { useTranslation } from 'react-i18next';
import { StatusContext } from './context/Status';
import { setStatusData } from './helpers/data.js';
import { API, showError } from './helpers';
const Home = lazy(() => import('./pages/Home'));
const Detail = lazy(() => import('./pages/Detail'));
const About = lazy(() => import('./pages/About'));
function App() {
const [userState, userDispatch] = useContext(UserContext);
// const [statusState, statusDispatch] = useContext(StatusContext);
const loadUser = () => {
let user = localStorage.getItem('user');
if (user) {
let data = JSON.parse(user);
userDispatch({ type: 'login', payload: data });
}
};
useEffect(() => {
loadUser();
let systemName = getSystemName();
if (systemName) {
document.title = systemName;
}
let logo = getLogo();
if (logo) {
let linkElement = document.querySelector("link[rel~='icon']");
if (linkElement) {
linkElement.href = logo;
}
}
}, []);
return (
<>
<Routes>

View File

@@ -36,43 +36,111 @@ import { IconList, IconTreeTriangleDown } from '@douyinfe/semi-icons';
import { loadChannelModels } from './utils.js';
import EditTagModal from '../pages/Channel/EditTagModal.js';
import TextNumberInput from './custom/TextNumberInput.js';
import { useTranslation } from 'react-i18next';
function renderTimestamp(timestamp) {
return <>{timestamp2string(timestamp)}</>;
}
let type2label = undefined;
function renderType(type) {
if (!type2label) {
type2label = new Map();
for (let i = 0; i < CHANNEL_OPTIONS.length; i++) {
type2label[CHANNEL_OPTIONS[i].value] = CHANNEL_OPTIONS[i];
}
type2label[0] = { value: 0, text: '未知类型', color: 'grey' };
}
return (
<Tag size="large" color={type2label[type]?.color}>
{type2label[type]?.text}
</Tag>
);
}
function renderTagType(type) {
return (
<Tag
color='light-blue'
prefixIcon={<IconList />}
size='large'
shape='circle'
type='light'
>
标签聚合
</Tag>
);
}
const ChannelsTable = () => {
const { t } = useTranslation();
let type2label = undefined;
const renderType = (type) => {
if (!type2label) {
type2label = new Map();
for (let i = 0; i < CHANNEL_OPTIONS.length; i++) {
type2label[CHANNEL_OPTIONS[i].value] = CHANNEL_OPTIONS[i];
}
type2label[0] = { value: 0, text: t('未知类型'), color: 'grey' };
}
return (
<Tag size="large" color={type2label[type]?.color}>
{type2label[type]?.text}
</Tag>
);
};
const renderTagType = () => {
return (
<Tag
color='light-blue'
prefixIcon={<IconList />}
size='large'
shape='circle'
type='light'
>
{t('标签聚合')}
</Tag>
);
};
const renderStatus = (status) => {
switch (status) {
case 1:
return (
<Tag size="large" color="green">
{t('已启用')}
</Tag>
);
case 2:
return (
<Tag size="large" color="yellow">
{t('已禁用')}
</Tag>
);
case 3:
return (
<Tag size="large" color="yellow">
{t('自动禁用')}
</Tag>
);
default:
return (
<Tag size="large" color="grey">
{t('未知状态')}
</Tag>
);
}
};
const renderResponseTime = (responseTime) => {
let time = responseTime / 1000;
time = time.toFixed(2) + t(' 秒');
if (responseTime === 0) {
return (
<Tag size="large" color="grey">
{t('未测试')}
</Tag>
);
} else if (responseTime <= 1000) {
return (
<Tag size="large" color="green">
{time}
</Tag>
);
} else if (responseTime <= 3000) {
return (
<Tag size="large" color="lime">
{time}
</Tag>
);
} else if (responseTime <= 5000) {
return (
<Tag size="large" color="yellow">
{time}
</Tag>
);
} else {
return (
<Tag size="large" color="red">
{time}
</Tag>
);
}
};
const columns = [
// {
// title: '',
@@ -80,15 +148,15 @@ const ChannelsTable = () => {
// className: 'checkbox',
// },
{
title: 'ID',
title: t('ID'),
dataIndex: 'id'
},
{
title: '名称',
title: t('名称'),
dataIndex: 'name'
},
{
title: '分组',
title: t('分组'),
dataIndex: 'group',
render: (text, record, index) => {
return (
@@ -103,18 +171,18 @@ const ChannelsTable = () => {
}
},
{
title: '类型',
title: t('类型'),
dataIndex: 'type',
render: (text, record, index) => {
if (record.children === undefined) {
return <>{renderType(text)}</>;
} else {
return <>{renderTagType(0)}</>;
return <>{renderTagType()}</>;
}
}
},
{
title: '状态',
title: t('状态'),
dataIndex: 'status',
render: (text, record, index) => {
if (text === 3) {
@@ -126,7 +194,7 @@ const ChannelsTable = () => {
let time = otherInfo['status_time'];
return (
<div>
<Tooltip content={'原因:' + reason + ',时间:' + timestamp2string(time)}>
<Tooltip content={t('原因:') + reason + t(',时间:') + timestamp2string(time)}>
{renderStatus(text)}
</Tooltip>
</div>
@@ -137,26 +205,26 @@ const ChannelsTable = () => {
}
},
{
title: '响应时间',
title: t('响应时间'),
dataIndex: 'response_time',
render: (text, record, index) => {
return <div>{renderResponseTime(text)}</div>;
}
},
{
title: '已用/剩余',
title: t('已用/剩余'),
dataIndex: 'expired_time',
render: (text, record, index) => {
if (record.children === undefined) {
return (
<div>
<Space spacing={1}>
<Tooltip content={'已用额度'}>
<Tooltip content={t('已用额度')}>
<Tag color="white" type="ghost" size="large">
{renderQuota(record.used_quota)}
</Tag>
</Tooltip>
<Tooltip content={'剩余额度' + record.balance + ',点击更新'}>
<Tooltip content={t('剩余额度') + record.balance + t(',点击更新')}>
<Tag
color="white"
type="ghost"
@@ -172,7 +240,7 @@ const ChannelsTable = () => {
</div>
);
} else {
return <Tooltip content={'已用额度'}>
return <Tooltip content={t('已用额度')}>
<Tag color="white" type="ghost" size="large">
{renderQuota(record.used_quota)}
</Tag>
@@ -181,7 +249,7 @@ const ChannelsTable = () => {
}
},
{
title: '优先级',
title: t('优先级'),
dataIndex: 'priority',
render: (text, record, index) => {
if (record.children === undefined) {
@@ -208,8 +276,8 @@ const ChannelsTable = () => {
keepFocus={true}
onBlur={(e) => {
Modal.warning({
title: '修改子渠道优先级',
content: '确定要修改所有子渠道优先级为 ' + e.target.value + ' 吗?',
title: t('修改子渠道优先级'),
content: t('确定要修改所有子渠道优先级为 ') + e.target.value + t(' 吗?'),
onOk: () => {
if (e.target.value === '') {
return;
@@ -230,7 +298,7 @@ const ChannelsTable = () => {
}
},
{
title: '权重',
title: t('权重'),
dataIndex: 'weight',
render: (text, record, index) => {
if (record.children === undefined) {
@@ -257,8 +325,8 @@ const ChannelsTable = () => {
keepFocus={true}
onBlur={(e) => {
Modal.warning({
title: '修改子渠道权重',
content: '确定要修改所有子渠道权重为 ' + e.target.value + ' 吗?',
title: t('修改子渠道权重'),
content: t('确定要修改所有子渠道权重为 ') + e.target.value + t(' 吗?'),
onOk: () => {
if (e.target.value === '') {
return;
@@ -287,7 +355,7 @@ const ChannelsTable = () => {
<div>
<SplitButtonGroup
style={{ marginRight: 1 }}
aria-label="测试单个渠道操作项目组"
aria-label={t('测试单个渠道操作项目组')}
>
<Button
theme="light"
@@ -295,7 +363,7 @@ const ChannelsTable = () => {
testChannel(record, '');
}}
>
测试
{t('测试')}
</Button>
<Dropdown
trigger="click"
@@ -309,10 +377,9 @@ const ChannelsTable = () => {
></Button>
</Dropdown>
</SplitButtonGroup>
{/*<Button theme='light' type='primary' style={{marginRight: 1}} onClick={()=>testChannel(record)}>测试</Button>*/}
<Popconfirm
title="确定是否要删除此渠道?"
content="此修改将不可逆"
title={t('确定是否要删除此渠道?')}
content={t('此修改将不可逆')}
okType={'danger'}
position={'left'}
onConfirm={() => {
@@ -322,7 +389,7 @@ const ChannelsTable = () => {
}}
>
<Button theme="light" type="danger" style={{ marginRight: 1 }}>
删除
{t('删除')}
</Button>
</Popconfirm>
{record.status === 1 ? (
@@ -334,7 +401,7 @@ const ChannelsTable = () => {
manageChannel(record.id, 'disable', record);
}}
>
禁用
{t('禁用')}
</Button>
) : (
<Button
@@ -345,7 +412,7 @@ const ChannelsTable = () => {
manageChannel(record.id, 'enable', record);
}}
>
启用
{t('启用')}
</Button>
)}
<Button
@@ -357,11 +424,11 @@ const ChannelsTable = () => {
setShowEdit(true);
}}
>
编辑
{t('编辑')}
</Button>
<Popconfirm
title="确定是否要复制此渠道?"
content="复制渠道的所有信息"
title={t('确定是否要复制此渠道?')}
content={t('复制渠道的所有信息')}
okType={'danger'}
position={'left'}
onConfirm={async () => {
@@ -369,7 +436,7 @@ const ChannelsTable = () => {
}}
>
<Button theme="light" type="primary" style={{ marginRight: 1 }}>
复制
{t('复制')}
</Button>
</Popconfirm>
</div>
@@ -385,7 +452,7 @@ const ChannelsTable = () => {
manageTag(record.key, 'enable');
}}
>
启用全部
{t('启用全部')}
</Button>
<Button
theme="light"
@@ -395,7 +462,7 @@ const ChannelsTable = () => {
manageTag(record.key, 'disable');
}}
>
禁用全部
{t('禁用全部')}
</Button>
<Button
theme="light"
@@ -406,7 +473,7 @@ const ChannelsTable = () => {
setEditingTag(record.key);
}}
>
编辑
{t('编辑')}
</Button>
</>
);
@@ -484,7 +551,7 @@ const ChannelsTable = () => {
if (!enableTagMode) {
channelDates.push(channels[i]);
} else {
let tag = channels[i].tag;
let tag = channels[i].tag?channels[i].tag:"";
// find from channelTags
let tagIndex = channelTags[tag];
let tagChannelDates = undefined;
@@ -579,25 +646,25 @@ const ChannelsTable = () => {
const copySelectedChannel = async (record) => {
const channelToCopy = record
channelToCopy.name += '_复制';
channelToCopy.name += t('_复制');
channelToCopy.created_time = null;
channelToCopy.balance = 0;
channelToCopy.used_quota = 0;
if (!channelToCopy) {
showError('渠道未找到,请刷新页面后重试。');
showError(t('渠道未找到,请刷新页面后重试。'));
return;
}
try {
const newChannel = { ...channelToCopy, id: undefined };
const response = await API.post('/api/channel/', newChannel);
if (response.data.success) {
showSuccess('渠道复制成功');
showSuccess(t('渠道复制成功'));
await refresh();
} else {
showError(response.data.message);
}
} catch (error) {
showError('渠道复制失败: ' + error.message);
showError(t('渠道复制失败: ') + error.message);
}
};
@@ -656,7 +723,7 @@ const ChannelsTable = () => {
}
const { success, message } = res.data;
if (success) {
showSuccess('操作成功完成!');
showSuccess(t('操作成功完成!'));
let channel = res.data.data;
let newChannels = [...channels];
if (action === 'delete') {
@@ -703,72 +770,7 @@ const ChannelsTable = () => {
}
};
const renderStatus = (status) => {
switch (status) {
case 1:
return (
<Tag size="large" color="green">
已启用
</Tag>
);
case 2:
return (
<Tag size="large" color="yellow">
已禁用
</Tag>
);
case 3:
return (
<Tag size="large" color="yellow">
自动禁用
</Tag>
);
default:
return (
<Tag size="large" color="grey">
未知状态
</Tag>
);
}
};
const renderResponseTime = (responseTime) => {
let time = responseTime / 1000;
time = time.toFixed(2) + ' 秒';
if (responseTime === 0) {
return (
<Tag size="large" color="grey">
未测试
</Tag>
);
} else if (responseTime <= 1000) {
return (
<Tag size="large" color="green">
{time}
</Tag>
);
} else if (responseTime <= 3000) {
return (
<Tag size="large" color="lime">
{time}
</Tag>
);
} else if (responseTime <= 5000) {
return (
<Tag size="large" color="yellow">
{time}
</Tag>
);
} else {
return (
<Tag size="large" color="red">
{time}
</Tag>
);
}
};
const searchChannels = async (searchKeyword, searchGroup, searchModel) => {
const searchChannels = async (searchKeyword, searchGroup, searchModel, enableTagMode) => {
if (searchKeyword === '' && searchGroup === '' && searchModel === '') {
await loadChannels(0, pageSize, idSort, enableTagMode);
setActivePage(1);
@@ -780,12 +782,7 @@ const ChannelsTable = () => {
);
const { success, message, data } = res.data;
if (success) {
if (enableTagMode) {
setChannelFormat(data, enableTagMode);
} else {
setChannels(data.map(channel => ({...channel, key: '' + channel.id})));
setChannelCount(data.length);
}
setChannelFormat(data, enableTagMode);
setActivePage(1);
} else {
showError(message);
@@ -799,7 +796,7 @@ const ChannelsTable = () => {
if (success) {
record.response_time = time * 1000;
record.test_time = Date.now() / 1000;
showInfo(`通道 ${record.name} 测试成功,耗时 ${time.toFixed(2)} 秒。`);
showInfo(t('通道 ${name} 测试成功,耗时 ${time.toFixed(2)} 秒。').replace('${name}', record.name).replace('${time.toFixed(2)}', time.toFixed(2)));
} else {
showError(message);
}
@@ -809,7 +806,7 @@ const ChannelsTable = () => {
const res = await API.get(`/api/channel/test`);
const { success, message } = res.data;
if (success) {
showInfo('已成功开始测试所有通道,请刷新页面查看结果。');
showInfo(t('已成功开始测试所有已启用通道,请刷新页面查看结果。'));
} else {
showError(message);
}
@@ -819,7 +816,7 @@ const ChannelsTable = () => {
const res = await API.delete(`/api/channel/disabled`);
const { success, message, data } = res.data;
if (success) {
showSuccess(`已删除所有禁用渠道,共计 ${data}`);
showSuccess(t('已删除所有禁用渠道,共计 ${data} 个').replace('${data}', data));
await refresh();
} else {
showError(message);
@@ -832,7 +829,7 @@ const ChannelsTable = () => {
if (success) {
record.balance = balance;
record.balance_updated_time = Date.now() / 1000;
showInfo(`通道 ${record.name} 余额更新成功!`);
showInfo(t('通道 ${name} 余额更新成功!').replace('${name}', record.name));
} else {
showError(message);
}
@@ -843,7 +840,7 @@ const ChannelsTable = () => {
const res = await API.get(`/api/channel/update_balance`);
const { success, message } = res.data;
if (success) {
showInfo('已更新完毕所有已启用通道余额!');
showInfo(t('已更新完毕所有已启用通道余额!'));
} else {
showError(message);
}
@@ -852,7 +849,7 @@ const ChannelsTable = () => {
const batchDeleteChannels = async () => {
if (selectedChannels.length === 0) {
showError('请先选择要删除的通道!');
showError(t('请先选择要删除的通道!'));
return;
}
setLoading(true);
@@ -863,7 +860,7 @@ const ChannelsTable = () => {
const res = await API.post(`/api/channel/batch`, { ids: ids });
const { success, message, data } = res.data;
if (success) {
showSuccess(`已删除 ${data} 个通道!`);
showSuccess(t('已删除 ${data} 个通道!').replace('${data}', data));
await refresh();
} else {
showError(message);
@@ -875,7 +872,7 @@ const ChannelsTable = () => {
const res = await API.post(`/api/channel/fix`);
const { success, message, data } = res.data;
if (success) {
showSuccess(`已修复 ${data} 个通道!`);
showSuccess(t('已修复 ${data} 个通道!').replace('${data}', data));
await refresh();
} else {
showError(message);
@@ -987,7 +984,7 @@ const ChannelsTable = () => {
/>
<Form
onSubmit={() => {
searchChannels(searchKeyword, searchGroup, searchModel);
searchChannels(searchKeyword, searchGroup, searchModel, enableTagMode);
}}
labelPosition="left"
>
@@ -995,8 +992,8 @@ const ChannelsTable = () => {
<Space>
<Form.Input
field="search_keyword"
label="搜索渠道关键词"
placeholder="ID名称和密钥 ..."
label={t('搜索渠道关键词')}
placeholder={t('搜索渠道的 ID名称和密钥 ...')}
value={searchKeyword}
loading={searching}
onChange={(v) => {
@@ -1005,8 +1002,8 @@ const ChannelsTable = () => {
/>
<Form.Input
field="search_model"
label="模型"
placeholder="模型关键字"
label={t('模型')}
placeholder={t('模型关键字')}
value={searchModel}
loading={searching}
onChange={(v) => {
@@ -1015,22 +1012,22 @@ const ChannelsTable = () => {
/>
<Form.Select
field="group"
label="分组"
optionList={[{ label: '选择分组', value: null }, ...groupOptions]}
label={t('分组')}
optionList={[{ label: t('选择分组'), value: null }, ...groupOptions]}
initValue={null}
onChange={(v) => {
setSearchGroup(v);
searchChannels(searchKeyword, v, searchModel);
searchChannels(searchKeyword, v, searchModel, enableTagMode);
}}
/>
<Button
label="查询"
label={t('查询')}
type="primary"
htmlType="submit"
className="btn-margin-right"
style={{ marginRight: 8 }}
>
查询
{t('查询')}
</Button>
</Space>
</div>
@@ -1047,12 +1044,12 @@ const ChannelsTable = () => {
<Space
style={{ pointerEvents: 'auto', marginTop: isMobile() ? 0 : 45 }}
>
<Typography.Text strong>使用ID排序</Typography.Text>
<Typography.Text strong>{t('使用ID排序')}</Typography.Text>
<Switch
checked={idSort}
label="使用ID排序"
uncheckedText="关"
aria-label="是否用ID排序"
label={t('使用ID排序')}
uncheckedText={t('关')}
aria-label={t('是否用ID排序')}
onChange={(v) => {
localStorage.setItem('id-sort', v + '');
setIdSort(v);
@@ -1074,35 +1071,35 @@ const ChannelsTable = () => {
setShowEdit(true);
}}
>
添加渠道
{t('添加渠道')}
</Button>
<Popconfirm
title="确定?"
title={t('确定?')}
okType={'warning'}
onConfirm={testAllChannels}
position={isMobile() ? 'top' : 'top'}
>
<Button theme="light" type="warning" style={{ marginRight: 8 }}>
测试所有通道
{t('测试所有通道')}
</Button>
</Popconfirm>
<Popconfirm
title="确定?"
title={t('确定?')}
okType={'secondary'}
onConfirm={updateAllChannelsBalance}
>
<Button theme="light" type="secondary" style={{ marginRight: 8 }}>
更新所有已启用通道余额
{t('更新所有已启用通道余额')}
</Button>
</Popconfirm>
<Popconfirm
title="确定是否要删除禁用通道?"
content="此修改将不可逆"
title={t('确定是否要删除禁用通道?')}
content={t('此修改将不可逆')}
okType={'danger'}
onConfirm={deleteAllDisabledChannels}
>
<Button theme="light" type="danger" style={{ marginRight: 8 }}>
删除禁用通道
{t('删除禁用通道')}
</Button>
</Popconfirm>
@@ -1112,24 +1109,24 @@ const ChannelsTable = () => {
style={{ marginRight: 8 }}
onClick={refresh}
>
刷新
{t('刷新')}
</Button>
</Space>
</div>
<div style={{ marginTop: 20 }}>
<Space>
<Typography.Text strong>开启批量删除</Typography.Text>
<Typography.Text strong>{t('开启批量删除')}</Typography.Text>
<Switch
label="开启批量删除"
uncheckedText="关"
aria-label="是否开启批量删除"
label={t('开启批量删除')}
uncheckedText={t('关')}
aria-label={t('是否开启批量删除')}
onChange={(v) => {
setEnableBatchDelete(v);
}}
></Switch>
<Popconfirm
title="确定是否要删除所选通道?"
content="此修改将不可逆"
title={t('确定是否要删除所选通道?')}
content={t('此修改将不可逆')}
okType={'danger'}
onConfirm={batchDeleteChannels}
disabled={!enableBatchDelete}
@@ -1141,33 +1138,32 @@ const ChannelsTable = () => {
type="danger"
style={{ marginRight: 8 }}
>
删除所选通道
{t('删除所选通道')}
</Button>
</Popconfirm>
<Popconfirm
title="确定是否要修复数据库一致性?"
content="进行该操作时,可能导致渠道访问错误,请仅在数据库出现问题时使用"
title={t('确定是否要修复数据库一致性?')}
content={t('进行该操作时,可能导致渠道访问错误,请仅在数据库出现问题时使用')}
okType={'warning'}
onConfirm={fixChannelsAbilities}
position={'top'}
>
<Button theme="light" type="secondary" style={{ marginRight: 8 }}>
修复数据库一致性
{t('修复数据库一致性')}
</Button>
</Popconfirm>
</Space>
</div>
<div style={{ marginTop: 20 }}>
<Space>
<Typography.Text strong>标签聚合模式</Typography.Text>
<Typography.Text strong>{t('标签聚合模式')}</Typography.Text>
<Switch
checked={enableTagMode}
label="标签聚合模式"
uncheckedText="关"
aria-label="是否启用标签聚合"
label={t('标签聚合模式')}
uncheckedText={t('关')}
aria-label={t('是否启用标签聚合')}
onChange={(v) => {
setEnableTagMode(v);
// 切换模式时重新加载数据
loadChannels(0, pageSize, idSort, v);
}}
/>

View File

@@ -1,9 +1,10 @@
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { getFooterHTML, getSystemName } from '../helpers';
import { Layout, Tooltip } from '@douyinfe/semi-ui';
const FooterBar = () => {
const { t } = useTranslation();
const systemName = getSystemName();
const [footer, setFooter] = useState(getFooterHTML());
let remainCheckTimes = 5;
@@ -24,7 +25,7 @@ const FooterBar = () => {
>
New API {import.meta.env.VITE_REACT_APP_VERSION}{' '}
</a>
{' '}
{t('由')}{' '}
<a
href='https://github.com/Calcium-Ion'
target='_blank'
@@ -32,7 +33,7 @@ const FooterBar = () => {
>
Calcium-Ion
</a>{' '}
开发基于{' '}
{t('开发,基于')}{' '}
<a
href='https://github.com/songquanpeng/one-api'
target='_blank'

View File

@@ -2,6 +2,7 @@ import React, { useContext, useEffect, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { UserContext } from '../context/User';
import { useSetTheme, useTheme } from '../context/Theme';
import { useTranslation } from 'react-i18next';
import { API, getLogo, getSystemName, isMobile, showSuccess } from '../helpers';
import '../index.css';
@@ -9,70 +10,62 @@ import '../index.css';
import fireworks from 'react-fireworks';
import {
IconClose,
IconHelpCircle,
IconHome,
IconHomeStroked,
IconKey,
IconHomeStroked, IconIndentLeft,
IconComment,
IconKey, IconMenu,
IconNoteMoneyStroked,
IconPriceTag,
IconUser
IconUser,
IconLanguage
} from '@douyinfe/semi-icons';
import { Avatar, Dropdown, Layout, Nav, Switch } from '@douyinfe/semi-ui';
import { Avatar, Button, Dropdown, Layout, Nav, Switch } from '@douyinfe/semi-ui';
import { stringToColor } from '../helpers/render';
import Text from '@douyinfe/semi-ui/lib/es/typography/text';
// HeaderBar Buttons
let headerButtons = [
{
text: '关于',
itemKey: 'about',
to: '/about',
icon: <IconHelpCircle />,
},
];
let buttons = [
{
text: '首页',
itemKey: 'home',
to: '/',
// icon: <IconHomeStroked />,
},
// {
// text: 'Playground',
// itemKey: 'playground',
// to: '/playground',
// // icon: <IconNoteMoneyStroked />,
// },
];
if (localStorage.getItem('chat_link')) {
headerButtons.splice(1, 0, {
name: '聊天',
to: '/chat',
icon: 'comments',
});
}
import { StyleContext } from '../context/Style/index.js';
const HeaderBar = () => {
const { t, i18n } = useTranslation();
const [userState, userDispatch] = useContext(UserContext);
const [styleState, styleDispatch] = useContext(StyleContext);
let navigate = useNavigate();
const [currentLang, setCurrentLang] = useState(i18n.language);
const [showSidebar, setShowSidebar] = useState(false);
const systemName = getSystemName();
const logo = getLogo();
const currentDate = new Date();
// enable fireworks on new year(1.1 and 2.9-2.24)
const isNewYear =
(currentDate.getMonth() === 0 && currentDate.getDate() === 1) ||
(currentDate.getMonth() === 1 &&
currentDate.getDate() >= 9 &&
currentDate.getDate() <= 24);
(currentDate.getMonth() === 0 && currentDate.getDate() === 1);
let buttons = [
{
text: t('首页'),
itemKey: 'home',
to: '/',
},
{
text: t('控制台'),
itemKey: 'detail',
to: '/',
},
{
text: t('定价'),
itemKey: 'pricing',
to: '/pricing',
},
{
text: t('关于'),
itemKey: 'about',
to: '/about',
},
];
async function logout() {
setShowSidebar(false);
await API.get('/api/user/logout');
showSuccess('注销成功!');
showSuccess(t('注销成功!'));
userDispatch({ type: 'logout' });
localStorage.removeItem('user');
navigate('/login');
@@ -95,49 +88,101 @@ const HeaderBar = () => {
useEffect(() => {
if (theme === 'dark') {
document.body.setAttribute('theme-mode', 'dark');
} else {
document.body.removeAttribute('theme-mode');
}
// 发送当前主题模式给子页面
const iframe = document.querySelector('iframe');
if (iframe) {
iframe.contentWindow.postMessage({ themeMode: theme }, '*');
}
if (isNewYear) {
console.log('Happy New Year!');
}
}, []);
}, [theme]);
useEffect(() => {
const handleLanguageChanged = (lng) => {
setCurrentLang(lng);
const iframe = document.querySelector('iframe');
if (iframe) {
iframe.contentWindow.postMessage({ lang: lng }, '*');
}
};
i18n.on('languageChanged', handleLanguageChanged);
return () => {
i18n.off('languageChanged', handleLanguageChanged);
};
}, [i18n]);
const handleLanguageChange = (lang) => {
i18n.changeLanguage(lang);
};
return (
<>
<Layout>
<div style={{ width: '100%' }}>
<Nav
className={'topnav'}
mode={'horizontal'}
// bodyStyle={{ height: 100 }}
renderWrapper={({ itemElement, isSubNav, isInSubNav, props }) => {
const routerMap = {
about: '/about',
login: '/login',
register: '/register',
pricing: '/pricing',
detail: '/detail',
home: '/',
chat: '/chat',
};
return (
<Link
style={{ textDecoration: 'none' }}
to={routerMap[props.itemKey]}
>
{itemElement}
</Link>
<div onClick={(e) => {
if (props.itemKey === 'home') {
styleDispatch({ type: 'SET_INNER_PADDING', payload: false });
styleDispatch({ type: 'SET_SIDER', payload: false });
} else {
styleDispatch({ type: 'SET_INNER_PADDING', payload: true });
if (!styleState.isMobile) {
styleDispatch({ type: 'SET_SIDER', payload: true });
}
}
}}>
<Link
className="header-bar-text"
style={{ textDecoration: 'none' }}
to={routerMap[props.itemKey]}
>
{itemElement}
</Link>
</div>
);
}}
selectedKeys={[]}
// items={headerButtons}
onSelect={(key) => {}}
header={isMobile()?{
header={styleState.isMobile?{
logo: (
<img src={logo} alt='logo' style={{ marginRight: '0.75em' }} />
<>
{
!styleState.showSider ?
<Button icon={<IconMenu />} theme="light" aria-label={t('展开侧边栏')} onClick={
() => styleDispatch({ type: 'SET_SIDER', payload: true })
} />:
<Button icon={<IconIndentLeft />} theme="light" aria-label={t('闭侧边栏')} onClick={
() => styleDispatch({ type: 'SET_SIDER', payload: false })
} />
}
</>
),
}:{
logo: (
<img src={logo} alt='logo' />
),
text: systemName,
}}
items={buttons}
footer={
@@ -157,27 +202,49 @@ const HeaderBar = () => {
<Nav.Item itemKey={'new-year'} text={'🏮'} />
</Dropdown>
)}
<Nav.Item itemKey={'about'} icon={<IconHelpCircle />} />
{/* <Nav.Item itemKey={'about'} icon={<IconHelpCircle />} /> */}
<>
{!isMobile() && (
<Switch
checkedText='🌞'
size={'large'}
checked={theme === 'dark'}
uncheckedText='🌙'
onChange={(checked) => {
setTheme(checked);
}}
/>
)}
<Switch
checkedText='🌞'
size={styleState.isMobile?'default':'large'}
checked={theme === 'dark'}
uncheckedText='🌙'
onChange={(checked) => {
setTheme(checked);
}}
/>
</>
<Dropdown
position='bottomRight'
render={
<Dropdown.Menu>
<Dropdown.Item
onClick={() => handleLanguageChange('zh')}
type={currentLang === 'zh' ? 'primary' : 'tertiary'}
>
中文
</Dropdown.Item>
<Dropdown.Item
onClick={() => handleLanguageChange('en')}
type={currentLang === 'en' ? 'primary' : 'tertiary'}
>
English
</Dropdown.Item>
</Dropdown.Menu>
}
>
<Nav.Item
itemKey={'language'}
icon={<IconLanguage />}
/>
</Dropdown>
{userState.user ? (
<>
<Dropdown
position='bottomRight'
render={
<Dropdown.Menu>
<Dropdown.Item onClick={logout}>退出</Dropdown.Item>
<Dropdown.Item onClick={logout}>{t('退出')}</Dropdown.Item>
</Dropdown.Menu>
}
>
@@ -188,21 +255,25 @@ const HeaderBar = () => {
>
{userState.user.username[0]}
</Avatar>
<span>{userState.user.username}</span>
{styleState.isMobile?null:<Text>{userState.user.username}</Text>}
</Dropdown>
</>
) : (
<>
<Nav.Item
itemKey={'login'}
text={'登录'}
// icon={<IconKey />}
/>
<Nav.Item
itemKey={'register'}
text={'注册'}
text={!styleState.isMobile?t('登录'):null}
icon={<IconUser />}
/>
{
!styleState.isMobile && (
<Nav.Item
itemKey={'register'}
text={t('注册')}
icon={<IconKey />}
/>
)
}
</>
)}
</>

View File

@@ -28,6 +28,7 @@ import { IconGithubLogo, IconAlarm } from '@douyinfe/semi-icons';
import WeChatIcon from './WeChatIcon';
import { setUserData } from '../helpers/data.js';
import LinuxDoIcon from './LinuxDoIcon.js';
import { useTranslation } from 'react-i18next';
const LoginForm = () => {
const [inputs, setInputs] = useState({
@@ -45,6 +46,7 @@ const LoginForm = () => {
let navigate = useNavigate();
const [status, setStatus] = useState({});
const [showWeChatLoginModal, setShowWeChatLoginModal] = useState(false);
const { t } = useTranslation();
const logo = getLogo();
@@ -55,7 +57,7 @@ const LoginForm = () => {
useEffect(() => {
if (searchParams.get('expired')) {
showError('未登录或登录已过期,请重新登录');
showError(t('未登录或登录已过期,请重新登录'));
}
let status = localStorage.getItem('status');
if (status) {
@@ -182,20 +184,20 @@ const LoginForm = () => {
<div style={{ width: 500 }}>
<Card>
<Title heading={2} style={{ textAlign: 'center' }}>
用户登录
{t('用户登录')}
</Title>
<Form>
<Form.Input
field={'username'}
label={'用户名'}
placeholder='用户名'
label={t('用户名/邮箱')}
placeholder={t('用户名/邮箱')}
name='username'
onChange={(value) => handleChange('username', value)}
/>
<Form.Input
field={'password'}
label={'密码'}
placeholder='密码'
label={t('密码')}
placeholder={t('密码')}
name='password'
type='password'
onChange={(value) => handleChange('password', value)}
@@ -209,7 +211,7 @@ const LoginForm = () => {
htmlType={'submit'}
onClick={handleSubmit}
>
登录
{t('登录')}
</Button>
</Form>
<div
@@ -220,10 +222,10 @@ const LoginForm = () => {
}}
>
<Text>
没有账号请先 <Link to='/register'>注册账号</Link>
{t('没有账户?')} <Link to='/register'>{t('点击注册')}</Link>
</Text>
<Text>
忘记密码 <Link to='/reset'>点击重置</Link>
{t('忘记密码?')} <Link to='/reset'>{t('点击重置')}</Link>
</Text>
</div>
{status.github_oauth ||
@@ -232,7 +234,7 @@ const LoginForm = () => {
status.linuxdo_oauth ? (
<>
<Divider margin='12px' align='center'>
第三方登录
{t('第三方登录')}
</Divider>
<div
style={{
@@ -296,12 +298,12 @@ const LoginForm = () => {
<></>
)}
<Modal
title='微信扫码登录'
title={t('微信扫码登录')}
visible={showWeChatLoginModal}
maskClosable={true}
onOk={onSubmitWeChatVerificationCode}
onCancel={() => setShowWeChatLoginModal(false)}
okText={'登录'}
okText={t('登录')}
size={'small'}
centered={true}
>
@@ -316,14 +318,14 @@ const LoginForm = () => {
</div>
<div style={{ textAlign: 'center' }}>
<p>
微信扫码关注公众号输入验证码获取验证码三分钟内有效
{t('微信扫码关注公众号,输入「验证码」获取验证码(三分钟内有效)')}
</p>
</div>
<Form size='large'>
<Form.Input
field={'wechat_verification_code'}
placeholder='验证码'
label={'验证码'}
placeholder={t('验证码')}
label={t('验证码')}
value={inputs.wechat_verification_code}
onChange={(value) =>
handleChange('wechat_verification_code', value)

View File

@@ -1,4 +1,5 @@
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
API,
copy,
@@ -25,7 +26,7 @@ import {
import { ITEMS_PER_PAGE } from '../constants';
import {
renderAudioModelPrice,
renderModelPrice,
renderModelPrice, renderModelPriceSimple,
renderNumber,
renderQuota,
stringToColor
@@ -40,8 +41,8 @@ function renderTimestamp(timestamp) {
}
const MODE_OPTIONS = [
{ key: 'all', text: '全部用户', value: 'all' },
{ key: 'self', text: '当前用户', value: 'self' },
{ key: 'all', text: 'all', value: 'all' },
{ key: 'self', text: 'current user', value: 'self' },
];
const colors = [
@@ -62,123 +63,92 @@ const colors = [
'yellow',
];
function renderType(type) {
switch (type) {
case 1:
const LogsTable = () => {
const { t } = useTranslation();
function renderType(type) {
switch (type) {
case 1:
return <Tag color='cyan' size='large'>{t('充值')}</Tag>;
case 2:
return <Tag color='lime' size='large'>{t('消费')}</Tag>;
case 3:
return <Tag color='orange' size='large'>{t('管理')}</Tag>;
case 4:
return <Tag color='purple' size='large'>{t('系统')}</Tag>;
default:
return <Tag color='black' size='large'>{t('未知')}</Tag>;
}
}
function renderIsStream(bool) {
if (bool) {
return <Tag color='blue' size='large'>{t('流')}</Tag>;
} else {
return <Tag color='purple' size='large'>{t('非流')}</Tag>;
}
}
function renderUseTime(type) {
const time = parseInt(type);
if (time < 101) {
return (
<Tag color='cyan' size='large'>
<Tag color='green' size='large'>
{' '}
充值{' '}
{time} s{' '}
</Tag>
);
case 2:
return (
<Tag color='lime' size='large'>
{' '}
消费{' '}
</Tag>
);
case 3:
} else if (time < 300) {
return (
<Tag color='orange' size='large'>
{' '}
管理{' '}
{time} s{' '}
</Tag>
);
case 4:
} else {
return (
<Tag color='purple' size='large'>
<Tag color='red' size='large'>
{' '}
系统{' '}
{time} s{' '}
</Tag>
);
default:
}
}
function renderFirstUseTime(type) {
let time = parseFloat(type) / 1000.0;
time = time.toFixed(1);
if (time < 3) {
return (
<Tag color='black' size='large'>
<Tag color='green' size='large'>
{' '}
未知{' '}
{time} s{' '}
</Tag>
);
}
}
} else if (time < 10) {
return (
<Tag color='orange' size='large'>
{' '}
{time} s{' '}
</Tag>
);
} else {
return (
<Tag color='red' size='large'>
{' '}
{time} s{' '}
</Tag>
);
}
}
function renderIsStream(bool) {
if (bool) {
return (
<Tag color='blue' size='large'>
</Tag>
);
} else {
return (
<Tag color='purple' size='large'>
非流
</Tag>
);
}
}
function renderUseTime(type) {
const time = parseInt(type);
if (time < 101) {
return (
<Tag color='green' size='large'>
{' '}
{time} s{' '}
</Tag>
);
} else if (time < 300) {
return (
<Tag color='orange' size='large'>
{' '}
{time} s{' '}
</Tag>
);
} else {
return (
<Tag color='red' size='large'>
{' '}
{time} s{' '}
</Tag>
);
}
}
function renderFirstUseTime(type) {
let time = parseFloat(type) / 1000.0;
time = time.toFixed(1);
if (time < 3) {
return (
<Tag color='green' size='large'>
{' '}
{time} s{' '}
</Tag>
);
} else if (time < 10) {
return (
<Tag color='orange' size='large'>
{' '}
{time} s{' '}
</Tag>
);
} else {
return (
<Tag color='red' size='large'>
{' '}
{time} s{' '}
</Tag>
);
}
}
const LogsTable = () => {
const columns = [
{
title: '时间',
title: t('时间'),
dataIndex: 'timestamp2string',
},
{
title: '渠道',
title: t('渠道'),
dataIndex: 'channel',
className: isAdmin() ? 'tableShow' : 'tableHiddle',
render: (text, record, index) => {
@@ -204,7 +174,7 @@ const LogsTable = () => {
},
},
{
title: '用户',
title: t('用户'),
dataIndex: 'username',
className: isAdmin() ? 'tableShow' : 'tableHiddle',
render: (text, record, index) => {
@@ -226,7 +196,7 @@ const LogsTable = () => {
},
},
{
title: '令牌',
title: t('令牌'),
dataIndex: 'token_name',
render: (text, record, index) => {
return record.type === 0 || record.type === 2 ? (
@@ -239,7 +209,7 @@ const LogsTable = () => {
}}
>
{' '}
{text}{' '}
{t(text)}{' '}
</Tag>
</div>
) : (
@@ -248,14 +218,14 @@ const LogsTable = () => {
},
},
{
title: '类型',
title: t('类型'),
dataIndex: 'type',
render: (text, record, index) => {
return <>{renderType(text)}</>;
},
},
{
title: '模型',
title: t('模型'),
dataIndex: 'model_name',
render: (text, record, index) => {
return record.type === 0 || record.type === 2 ? (
@@ -277,7 +247,7 @@ const LogsTable = () => {
},
},
{
title: '用时/首字',
title: t('用时/首字'),
dataIndex: 'use_time',
render: (text, record, index) => {
if (record.is_stream) {
@@ -304,7 +274,7 @@ const LogsTable = () => {
},
},
{
title: '提示',
title: t('提示'),
dataIndex: 'prompt_tokens',
render: (text, record, index) => {
return record.type === 0 || record.type === 2 ? (
@@ -315,7 +285,7 @@ const LogsTable = () => {
},
},
{
title: '补全',
title: t('补全'),
dataIndex: 'completion_tokens',
render: (text, record, index) => {
return parseInt(text) > 0 &&
@@ -327,7 +297,7 @@ const LogsTable = () => {
},
},
{
title: '花费',
title: t('花费'),
dataIndex: 'quota',
render: (text, record, index) => {
return record.type === 0 || record.type === 2 ? (
@@ -338,11 +308,11 @@ const LogsTable = () => {
},
},
{
title: '重试',
title: t('重试'),
dataIndex: 'retry',
className: isAdmin() ? 'tableShow' : 'tableHiddle',
render: (text, record, index) => {
let content = '渠道' + record.channel;
let content = t('渠道') + `${record.channel}`;
if (record.other !== '') {
let other = JSON.parse(record.other);
if (other === null) {
@@ -357,7 +327,7 @@ const LogsTable = () => {
// channel id array
let useChannel = other.admin_info.use_channel;
let useChannelStr = useChannel.join('->');
content = `渠道${useChannelStr}`;
content = t('渠道') + `${useChannelStr}`;
}
}
}
@@ -365,7 +335,7 @@ const LogsTable = () => {
},
},
{
title: '详情',
title: t('详情'),
dataIndex: 'content',
render: (text, record, index) => {
let other = getLogOther(record.other);
@@ -386,14 +356,11 @@ const LogsTable = () => {
);
}
// let content = renderModelPrice(
// record.prompt_tokens,
// record.completion_tokens,
// other.model_ratio,
// other.model_price,
// other.completion_ratio,
// other.group_ratio,
// );
let content = renderModelPriceSimple(
other.model_ratio,
other.model_price,
other.group_ratio,
);
return (
<Paragraph
ellipsis={{
@@ -401,7 +368,7 @@ const LogsTable = () => {
}}
style={{ maxWidth: 240 }}
>
调用消费
{content}
</Paragraph>
);
},
@@ -496,13 +463,13 @@ const LogsTable = () => {
const { success, message, data } = res.data;
if (success) {
Modal.info({
title: '用户信息',
title: t('用户信息'),
content: (
<div style={{ padding: 12 }}>
<p>用户名: {data.username}</p>
<p>余额: {renderQuota(data.quota)}</p>
<p>已用额度{renderQuota(data.used_quota)}</p>
<p>请求次数{renderNumber(data.request_count)}</p>
<p>{t('用户名')}: {data.username}</p>
<p>{t('余额')}: {renderQuota(data.quota)}</p>
<p>{t('已用额度')}{renderQuota(data.used_quota)}</p>
<p>{t('请求次数')}{renderNumber(data.request_count)}</p>
</div>
),
centered: true,
@@ -540,26 +507,26 @@ const LogsTable = () => {
}
if (other?.ws || other?.audio) {
expandDataLocal.push({
key: '语音输入',
key: t('语音输入'),
value: other.audio_input,
});
expandDataLocal.push({
key: '语音输出',
key: t('语音输出'),
value: other.audio_output,
});
expandDataLocal.push({
key: '文字输入',
key: t('文字输入'),
value: other.text_input,
});
expandDataLocal.push({
key: '文字输出',
key: t('文字输出'),
value: other.text_output,
});
}
expandDataLocal.push({
key: '日志详情',
key: t('日志详情'),
value: logs[i].content,
})
});
if (logs[i].type === 2) {
let content = '';
if (other?.ws || other?.audio) {
@@ -586,7 +553,7 @@ const LogsTable = () => {
);
}
expandDataLocal.push({
key: '计费过程',
key: t('计费过程'),
value: content,
});
}
@@ -679,7 +646,7 @@ const LogsTable = () => {
<Spin spinning={loadingStat}>
<Space>
<Tag color='green' size='large' style={{ padding: 15 }}>
总消耗额度: {renderQuota(stat.quota)}
{t('总消耗额度')}: {renderQuota(stat.quota)}
</Tag>
<Tag color='blue' size='large' style={{ padding: 15 }}>
RPM: {stat.rpm}
@@ -694,25 +661,25 @@ const LogsTable = () => {
<>
<Form.Input
field='token_name'
label='令牌名称'
label={t('令牌名称')}
style={{ width: 176 }}
value={token_name}
placeholder={'可选值'}
placeholder={t('可选值')}
name='token_name'
onChange={(value) => handleInputChange(value, 'token_name')}
/>
<Form.Input
field='model_name'
label='模型名称'
label={t('模型名称')}
style={{ width: 176 }}
value={model_name}
placeholder='可选值'
placeholder={t('可选值')}
name='model_name'
onChange={(value) => handleInputChange(value, 'model_name')}
/>
<Form.DatePicker
field='start_timestamp'
label='起始时间'
label={t('起始时间')}
style={{ width: 272 }}
initValue={start_timestamp}
value={start_timestamp}
@@ -723,7 +690,7 @@ const LogsTable = () => {
<Form.DatePicker
field='end_timestamp'
fluid
label='结束时间'
label={t('结束时间')}
style={{ width: 272 }}
initValue={end_timestamp}
value={end_timestamp}
@@ -735,26 +702,26 @@ const LogsTable = () => {
<>
<Form.Input
field='channel'
label='渠道 ID'
label={t('渠道 ID')}
style={{ width: 176 }}
value={channel}
placeholder='可选值'
placeholder={t('可选值')}
name='channel'
onChange={(value) => handleInputChange(value, 'channel')}
/>
<Form.Input
field='username'
label='用户名称'
label={t('用户名称')}
style={{ width: 176 }}
value={username}
placeholder={'可选值'}
placeholder={t('可选值')}
name='username'
onChange={(value) => handleInputChange(value, 'username')}
/>
</>
)}
<Button
label='查询'
label={t('查询')}
type='primary'
htmlType='submit'
className='btn-margin-right'
@@ -762,7 +729,7 @@ const LogsTable = () => {
loading={loading}
style={{ marginTop: 24 }}
>
查询
{t('查询')}
</Button>
<Form.Section></Form.Section>
</>
@@ -776,11 +743,11 @@ const LogsTable = () => {
loadLogs(0, pageSize, parseInt(value));
}}
>
<Select.Option value='0'>全部</Select.Option>
<Select.Option value='1'>充值</Select.Option>
<Select.Option value='2'>消费</Select.Option>
<Select.Option value='3'>管理</Select.Option>
<Select.Option value='4'>系统</Select.Option>
<Select.Option value='0'>{t('全部')}</Select.Option>
<Select.Option value='1'>{t('充值')}</Select.Option>
<Select.Option value='2'>{t('消费')}</Select.Option>
<Select.Option value='3'>{t('管理')}</Select.Option>
<Select.Option value='4'>{t('系统')}</Select.Option>
</Select>
</div>
<Table

View File

@@ -21,6 +21,7 @@ import {
Typography,
} from '@douyinfe/semi-ui';
import { ITEMS_PER_PAGE } from '../constants';
import { useTranslation } from 'react-i18next';
const colors = [
'amber',
@@ -40,247 +41,241 @@ const colors = [
'yellow',
];
function renderType(type) {
switch (type) {
case 'IMAGINE':
return (
<Tag color='blue' size='large'>
绘图
</Tag>
);
case 'UPSCALE':
return (
<Tag color='orange' size='large'>
放大
</Tag>
);
case 'VARIATION':
return (
<Tag color='purple' size='large'>
变换
</Tag>
);
case 'HIGH_VARIATION':
return (
<Tag color='purple' size='large'>
强变换
</Tag>
);
case 'LOW_VARIATION':
return (
<Tag color='purple' size='large'>
弱变换
</Tag>
);
case 'PAN':
return (
<Tag color='cyan' size='large'>
平移
</Tag>
);
case 'DESCRIBE':
return (
<Tag color='yellow' size='large'>
图生文
</Tag>
);
case 'BLEND':
return (
<Tag color='lime' size='large'>
图混合
</Tag>
);
case 'UPLOAD':
return (
<Tag color='blue' size='large'>
上传文件
</Tag>
);
case 'SHORTEN':
return (
<Tag color='pink' size='large'>
缩词
</Tag>
);
case 'REROLL':
return (
<Tag color='indigo' size='large'>
重绘
</Tag>
);
case 'INPAINT':
return (
<Tag color='violet' size='large'>
局部重绘-提交
</Tag>
);
case 'ZOOM':
return (
<Tag color='teal' size='large'>
变焦
</Tag>
);
case 'CUSTOM_ZOOM':
return (
<Tag color='teal' size='large'>
自定义变焦-提交
</Tag>
);
case 'MODAL':
return (
<Tag color='green' size='large'>
窗口处理
</Tag>
);
case 'SWAP_FACE':
return (
<Tag color='light-green' size='large'>
换脸
</Tag>
);
default:
return (
<Tag color='white' size='large'>
未知
</Tag>
);
}
}
function renderCode(code) {
switch (code) {
case 1:
return (
<Tag color='green' size='large'>
已提交
</Tag>
);
case 21:
return (
<Tag color='lime' size='large'>
等待中
</Tag>
);
case 22:
return (
<Tag color='orange' size='large'>
重复提交
</Tag>
);
case 0:
return (
<Tag color='yellow' size='large'>
未提交
</Tag>
);
default:
return (
<Tag color='white' size='large'>
未知
</Tag>
);
}
}
function renderStatus(type) {
// Ensure all cases are string literals by adding quotes.
switch (type) {
case 'SUCCESS':
return (
<Tag color='green' size='large'>
成功
</Tag>
);
case 'NOT_START':
return (
<Tag color='grey' size='large'>
未启动
</Tag>
);
case 'SUBMITTED':
return (
<Tag color='yellow' size='large'>
队列中
</Tag>
);
case 'IN_PROGRESS':
return (
<Tag color='blue' size='large'>
执行中
</Tag>
);
case 'FAILURE':
return (
<Tag color='red' size='large'>
失败
</Tag>
);
case 'MODAL':
return (
<Tag color='yellow' size='large'>
窗口等待
</Tag>
);
default:
return (
<Tag color='white' size='large'>
未知
</Tag>
);
}
}
const renderTimestamp = (timestampInSeconds) => {
const date = new Date(timestampInSeconds * 1000); // 从秒转换为毫秒
const year = date.getFullYear(); // 获取年份
const month = ('0' + (date.getMonth() + 1)).slice(-2); // 获取月份从0开始需要+1并保证两位数
const day = ('0' + date.getDate()).slice(-2); // 获取日期,并保证两位数
const hours = ('0' + date.getHours()).slice(-2); // 获取小时,并保证两位数
const minutes = ('0' + date.getMinutes()).slice(-2); // 获取分钟,并保证两位数
const seconds = ('0' + date.getSeconds()).slice(-2); // 获取秒钟,并保证两位数
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`; // 格式化输出
};
// 修改renderDuration函数以包含颜色逻辑
function renderDuration(submit_time, finishTime) {
// 确保startTime和finishTime都是有效的时间戳
if (!submit_time || !finishTime) return 'N/A';
// 将时间戳转换为Date对象
const start = new Date(submit_time);
const finish = new Date(finishTime);
// 计算时间差(毫秒)
const durationMs = finish - start;
// 将时间差转换为秒,并保留一位小数
const durationSec = (durationMs / 1000).toFixed(1);
// 设置颜色大于60秒则为红色小于等于60秒则为绿色
const color = durationSec > 60 ? 'red' : 'green';
// 返回带有样式的颜色标签
return (
<Tag color={color} size="large">
{durationSec}
</Tag>
);
}
const LogsTable = () => {
const { t } = useTranslation();
const [isModalOpen, setIsModalOpen] = useState(false);
const [modalContent, setModalContent] = useState('');
function renderType(type) {
switch (type) {
case 'IMAGINE':
return (
<Tag color='blue' size='large'>
{t('绘图')}
</Tag>
);
case 'UPSCALE':
return (
<Tag color='orange' size='large'>
{t('放大')}
</Tag>
);
case 'VARIATION':
return (
<Tag color='purple' size='large'>
{t('变换')}
</Tag>
);
case 'HIGH_VARIATION':
return (
<Tag color='purple' size='large'>
{t('强变换')}
</Tag>
);
case 'LOW_VARIATION':
return (
<Tag color='purple' size='large'>
{t('弱变换')}
</Tag>
);
case 'PAN':
return (
<Tag color='cyan' size='large'>
{t('平移')}
</Tag>
);
case 'DESCRIBE':
return (
<Tag color='yellow' size='large'>
{t('图生文')}
</Tag>
);
case 'BLEND':
return (
<Tag color='lime' size='large'>
{t('图混合')}
</Tag>
);
case 'UPLOAD':
return (
<Tag color='blue' size='large'>
上传文件
</Tag>
);
case 'SHORTEN':
return (
<Tag color='pink' size='large'>
{t('缩词')}
</Tag>
);
case 'REROLL':
return (
<Tag color='indigo' size='large'>
{t('重绘')}
</Tag>
);
case 'INPAINT':
return (
<Tag color='violet' size='large'>
{t('局部重绘-提交')}
</Tag>
);
case 'ZOOM':
return (
<Tag color='teal' size='large'>
{t('变焦')}
</Tag>
);
case 'CUSTOM_ZOOM':
return (
<Tag color='teal' size='large'>
{t('自定义变焦-提交')}
</Tag>
);
case 'MODAL':
return (
<Tag color='green' size='large'>
{t('窗口处理')}
</Tag>
);
case 'SWAP_FACE':
return (
<Tag color='light-green' size='large'>
{t('换脸')}
</Tag>
);
default:
return (
<Tag color='white' size='large'>
{t('未知')}
</Tag>
);
}
}
function renderCode(code) {
switch (code) {
case 1:
return (
<Tag color='green' size='large'>
{t('已提交')}
</Tag>
);
case 21:
return (
<Tag color='lime' size='large'>
{t('等待中')}
</Tag>
);
case 22:
return (
<Tag color='orange' size='large'>
{t('重复提交')}
</Tag>
);
case 0:
return (
<Tag color='yellow' size='large'>
{t('未提交')}
</Tag>
);
default:
return (
<Tag color='white' size='large'>
{t('未知')}
</Tag>
);
}
}
function renderStatus(type) {
switch (type) {
case 'SUCCESS':
return (
<Tag color='green' size='large'>
{t('成功')}
</Tag>
);
case 'NOT_START':
return (
<Tag color='grey' size='large'>
{t('未启动')}
</Tag>
);
case 'SUBMITTED':
return (
<Tag color='yellow' size='large'>
{t('队列中')}
</Tag>
);
case 'IN_PROGRESS':
return (
<Tag color='blue' size='large'>
{t('执行中')}
</Tag>
);
case 'FAILURE':
return (
<Tag color='red' size='large'>
{t('失败')}
</Tag>
);
case 'MODAL':
return (
<Tag color='yellow' size='large'>
{t('窗口等待')}
</Tag>
);
default:
return (
<Tag color='white' size='large'>
{t('未知')}
</Tag>
);
}
}
const renderTimestamp = (timestampInSeconds) => {
const date = new Date(timestampInSeconds * 1000); // 从秒转换为毫秒
const year = date.getFullYear(); // 获取年份
const month = ('0' + (date.getMonth() + 1)).slice(-2); // 获取月份从0开始需要+1并保证两位数
const day = ('0' + date.getDate()).slice(-2); // 获取日期,并保证两位数
const hours = ('0' + date.getHours()).slice(-2); // 获取小时,并保证两位数
const minutes = ('0' + date.getMinutes()).slice(-2); // 获取分钟,并保证两位数
const seconds = ('0' + date.getSeconds()).slice(-2); // 获取秒钟,并保证两位数
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`; // 格式化输出
};
// 修改renderDuration函数以包含颜色逻辑
function renderDuration(submit_time, finishTime) {
if (!submit_time || !finishTime) return 'N/A';
const start = new Date(submit_time);
const finish = new Date(finishTime);
const durationMs = finish - start;
const durationSec = (durationMs / 1000).toFixed(1);
const color = durationSec > 60 ? 'red' : 'green';
return (
<Tag color={color} size="large">
{durationSec} {t('秒')}
</Tag>
);
}
const columns = [
{
title: '提交时间',
title: t('提交时间'),
dataIndex: 'submit_time',
render: (text, record, index) => {
return <div>{renderTimestamp(text / 1000)}</div>;
},
},
{
title: '花费时间',
title: t('花费时间'),
dataIndex: 'finish_time', // 以finish_time作为dataIndex
key: 'finish_time',
render: (finish, record) => {
@@ -289,7 +284,7 @@ const LogsTable = () => {
},
},
{
title: '渠道',
title: t('渠道'),
dataIndex: 'channel_id',
className: isAdmin() ? 'tableShow' : 'tableHiddle',
render: (text, record, index) => {
@@ -310,21 +305,21 @@ const LogsTable = () => {
},
},
{
title: '类型',
title: t('类型'),
dataIndex: 'action',
render: (text, record, index) => {
return <div>{renderType(text)}</div>;
},
},
{
title: '任务ID',
title: t('任务ID'),
dataIndex: 'mj_id',
render: (text, record, index) => {
return <div>{text}</div>;
},
},
{
title: '提交结果',
title: t('提交结果'),
dataIndex: 'code',
className: isAdmin() ? 'tableShow' : 'tableHiddle',
render: (text, record, index) => {
@@ -332,7 +327,7 @@ const LogsTable = () => {
},
},
{
title: '任务状态',
title: t('任务状态'),
dataIndex: 'status',
className: isAdmin() ? 'tableShow' : 'tableHiddle',
render: (text, record, index) => {
@@ -340,7 +335,7 @@ const LogsTable = () => {
},
},
{
title: '进度',
title: t('进度'),
dataIndex: 'progress',
render: (text, record, index) => {
return (
@@ -363,11 +358,11 @@ const LogsTable = () => {
},
},
{
title: '结果图片',
title: t('结果图片'),
dataIndex: 'image_url',
render: (text, record, index) => {
if (!text) {
return '无';
return t('无');
}
return (
<Button
@@ -376,7 +371,7 @@ const LogsTable = () => {
setIsModalOpenurl(true); // 打开模态框
}}
>
查看图片
{t('查看图片')}
</Button>
);
},
@@ -387,7 +382,7 @@ const LogsTable = () => {
render: (text, record, index) => {
// 如果text未定义返回替代文本例如空字符串''或其他
if (!text) {
return '无';
return t('无');
}
return (
@@ -410,7 +405,7 @@ const LogsTable = () => {
render: (text, record, index) => {
// 如果text未定义返回替代文本例如空字符串''或其他
if (!text) {
return '无';
return t('无');
}
return (
@@ -428,12 +423,12 @@ const LogsTable = () => {
},
},
{
title: '失败原因',
title: t('失败原因'),
dataIndex: 'fail_reason',
render: (text, record, index) => {
// 如果text未定义返回替代文本例如空字符串''或其他
if (!text) {
return '无';
return t('无');
}
return (
@@ -565,7 +560,7 @@ const LogsTable = () => {
{isAdminUser && showBanner ? (
<Banner
type='info'
description='当前未开启Midjourney回调部分项目可能无法获得绘图结果可在运营设置中开启。'
description={t('当前未开启Midjourney回调部分项目可能无法获得绘图结果可在运营设置中开启。')}
/>
) : (
<></>
@@ -574,25 +569,25 @@ const LogsTable = () => {
<>
<Form.Input
field='channel_id'
label='渠道 ID'
label={t('渠道 ID')}
style={{ width: 176 }}
value={channel_id}
placeholder={'可选值'}
placeholder={t('可选值')}
name='channel_id'
onChange={(value) => handleInputChange(value, 'channel_id')}
/>
<Form.Input
field='mj_id'
label='任务 ID'
label={t('任务 ID')}
style={{ width: 176 }}
value={mj_id}
placeholder='可选值'
placeholder={t('可选值')}
name='mj_id'
onChange={(value) => handleInputChange(value, 'mj_id')}
/>
<Form.DatePicker
field='start_timestamp'
label='起始时间'
label={t('起始时间')}
style={{ width: 272 }}
initValue={start_timestamp}
value={start_timestamp}
@@ -603,7 +598,7 @@ const LogsTable = () => {
<Form.DatePicker
field='end_timestamp'
fluid
label='结束时间'
label={t('结束时间')}
style={{ width: 272 }}
initValue={end_timestamp}
value={end_timestamp}
@@ -614,13 +609,13 @@ const LogsTable = () => {
<Form.Section>
<Button
label='查询'
label={t('查询')}
type='primary'
htmlType='submit'
className='btn-margin-right'
onClick={refresh}
>
查询
{t('查询')}
</Button>
</Form.Section>
</>
@@ -635,6 +630,12 @@ const LogsTable = () => {
total: logCount,
pageSizeOpts: [10, 20, 50, 100],
onPageChange: handlePageChange,
formatPageText: (page) =>
t('第 {{start}} - {{end}} 条,共 {{total}} 条', {
start: page.currentStart,
end: page.currentEnd,
total: logCount
}),
}}
loading={loading}
/>

View File

@@ -1,5 +1,6 @@
import React, { useContext, useEffect, useRef, useMemo, useState } from 'react';
import { API, copy, showError, showInfo, showSuccess } from '../helpers';
import { useTranslation } from 'react-i18next';
import {
Banner,
@@ -23,65 +24,8 @@ import {
import { UserContext } from '../context/User/index.js';
import Text from '@douyinfe/semi-ui/lib/es/typography/text';
function renderQuotaType(type) {
// Ensure all cases are string literals by adding quotes.
switch (type) {
case 1:
return (
<Tag color='teal' size='large'>
按次计费
</Tag>
);
case 0:
return (
<Tag color='violet' size='large'>
按量计费
</Tag>
);
default:
return '未知';
}
}
function renderAvailable(available) {
return available ? (
<Popover
content={
<div style={{ padding: 8 }}>您的分组可以使用该模型</div>
}
position='top'
key={available}
style={{
backgroundColor: 'rgba(var(--semi-blue-4),1)',
borderColor: 'rgba(var(--semi-blue-4),1)',
color: 'var(--semi-color-white)',
borderWidth: 1,
borderStyle: 'solid',
}}
>
<IconVerify style={{ color: 'green' }} size="large" />
</Popover>
) : (
<Popover
content={
<div style={{ padding: 8 }}>您的分组无权使用该模型</div>
}
position='top'
key={available}
style={{
backgroundColor: 'rgba(var(--semi-blue-4),1)',
borderColor: 'rgba(var(--semi-blue-4),1)',
color: 'var(--semi-color-white)',
borderWidth: 1,
borderStyle: 'solid',
}}
>
<IconUploadError style={{ color: '#FFA54F' }} size="large" />
</Popover>
);
}
const ModelPricing = () => {
const { t } = useTranslation();
const [filteredValue, setFilteredValue] = useState([]);
const compositionRef = useRef({ isComposition: false });
const [selectedRowKeys, setSelectedRowKeys] = useState([]);
@@ -115,10 +59,68 @@ const ModelPricing = () => {
const newFilteredValue = value ? [value] : [];
setFilteredValue(newFilteredValue);
};
function renderQuotaType(type) {
// Ensure all cases are string literals by adding quotes.
switch (type) {
case 1:
return (
<Tag color='teal' size='large'>
{t('按次计费')}
</Tag>
);
case 0:
return (
<Tag color='violet' size='large'>
{t('按量计费')}
</Tag>
);
default:
return t('未知');
}
}
function renderAvailable(available) {
return available ? (
<Popover
content={
<div style={{ padding: 8 }}>{t('您的分组可以使用该模型')}</div>
}
position='top'
key={available}
style={{
backgroundColor: 'rgba(var(--semi-blue-4),1)',
borderColor: 'rgba(var(--semi-blue-4),1)',
color: 'var(--semi-color-white)',
borderWidth: 1,
borderStyle: 'solid',
}}
>
<IconVerify style={{ color: 'green' }} size="large" />
</Popover>
) : (
<Popover
content={
<div style={{ padding: 8 }}>{t('您的分组无权使用该模型')}</div>
}
position='top'
key={available}
style={{
backgroundColor: 'rgba(var(--semi-blue-4),1)',
borderColor: 'rgba(var(--semi-blue-4),1)',
color: 'var(--semi-color-white)',
borderWidth: 1,
borderStyle: 'solid',
}}
>
<IconUploadError style={{ color: '#FFA54F' }} size="large" />
</Popover>
);
}
const columns = [
{
title: '可用性',
title: t('可用性'),
dataIndex: 'available',
render: (text, record, index) => {
// if record.enable_groups contains selectedGroup, then available is true
@@ -127,20 +129,8 @@ const ModelPricing = () => {
sorter: (a, b) => a.available - b.available,
},
{
title: (
<Space>
<span>模型名称</span>
<Input
placeholder='模糊搜索'
style={{ width: 200 }}
onCompositionStart={handleCompositionStart}
onCompositionEnd={handleCompositionEnd}
onChange={handleChange}
showClear
/>
</Space>
),
dataIndex: 'model_name', // 以finish_time作为dataIndex
title: t('模型名称'),
dataIndex: 'model_name',
render: (text, record, index) => {
return (
<>
@@ -161,7 +151,7 @@ const ModelPricing = () => {
filteredValue,
},
{
title: '计费类型',
title: t('计费类型'),
dataIndex: 'quota_type',
render: (text, record, index) => {
return renderQuotaType(parseInt(text));
@@ -169,7 +159,7 @@ const ModelPricing = () => {
sorter: (a, b) => a.quota_type - b.quota_type,
},
{
title: '可用分组',
title: t('可用分组'),
dataIndex: 'enable_groups',
render: (text, record, index) => {
// enable_groups is a string array
@@ -193,7 +183,10 @@ const ModelPricing = () => {
size='large'
onClick={() => {
setSelectedGroup(group);
showInfo('当前查看的分组为:' + group + ',倍率为:' + groupRatio[group]);
showInfo(t('当前查看的分组为:{{group}},倍率为:{{ratio}}', {
group: group,
ratio: groupRatio[group]
}));
}}
>
{group}
@@ -208,10 +201,13 @@ const ModelPricing = () => {
{
title: () => (
<span style={{'display':'flex','alignItems':'center'}}>
倍率
{t('倍率')}
<Popover
content={
<div style={{ padding: 8 }}>倍率是为了方便换算不同价格的模型<br/>点击查看倍率说明</div>
<div style={{ padding: 8 }}>
{t('倍率是为了方便换算不同价格的模型')}<br/>
{t('点击查看倍率说明')}
</div>
}
position='top'
style={{
@@ -237,18 +233,18 @@ const ModelPricing = () => {
let completionRatio = parseFloat(record.completion_ratio.toFixed(3));
content = (
<>
<Text>模型{record.quota_type === 0 ? text : '无'}</Text>
<Text>{t('模型倍率')}{record.quota_type === 0 ? text : t('无')}</Text>
<br />
<Text>补全{record.quota_type === 0 ? completionRatio : '无'}</Text>
<Text>{t('补全倍率')}{record.quota_type === 0 ? completionRatio : t('无')}</Text>
<br />
<Text>分组{groupRatio[selectedGroup]}</Text>
<Text>{t('分组倍率')}{groupRatio[selectedGroup]}</Text>
</>
);
return <div>{content}</div>;
},
},
{
title: '模型价格',
title: t('模型价格'),
dataIndex: 'model_price',
render: (text, record, index) => {
let content = text;
@@ -261,14 +257,14 @@ const ModelPricing = () => {
groupRatio[selectedGroup];
content = (
<>
<Text>提示 ${inputRatioPrice} / 1M tokens</Text>
<Text>{t('提示')} ${inputRatioPrice} / 1M tokens</Text>
<br />
<Text>补全 ${completionRatioPrice} / 1M tokens</Text>
<Text>{t('补全')} ${completionRatioPrice} / 1M tokens</Text>
</>
);
} else {
let price = parseFloat(text) * groupRatio[selectedGroup];
content = <>模型价格${price}</>;
content = <>${t('模型价格')}${price}</>;
}
return <div>{content}</div>;
},
@@ -349,41 +345,62 @@ const ModelPricing = () => {
type="success"
fullMode={false}
closeIcon="null"
description={`您的默认分组为:${userState.user.group},分组倍率为:${groupRatio[userState.user.group]}`}
description={t('您的默认分组为:{{group}},分组倍率为:{{ratio}}', {
group: userState.user.group,
ratio: groupRatio[userState.user.group]
})}
/>
) : (
<Banner
type='warning'
fullMode={false}
closeIcon="null"
description={`您还未登陆,显示的价格为默认分组倍率: ${groupRatio['default']}`}
description={t('您还未登陆,显示的价格为默认分组倍率: {{ratio}}', {
ratio: groupRatio['default']
})}
/>
)}
<br/>
<Banner
type="info"
fullMode={false}
description={<div>按量计费费用 = 分组倍率 × 模型倍率 × 提示token数 + 补全token数 × 补全倍率/ 500000 单位美元</div>}
description={<div>{t('按量计费费用 = 分组倍率 × 模型倍率 × 提示token数 + 补全token数 × 补全倍率)/ 500000 (单位:美元)')}</div>}
closeIcon="null"
/>
<br/>
<Button
theme='light'
type='tertiary'
style={{width: 150}}
onClick={() => {
copyText(selectedRowKeys);
}}
disabled={selectedRowKeys == ""}
>
复制选中模型
</Button>
<Space style={{ marginBottom: 16 }}>
<Input
placeholder={t('模糊搜索模型名称')}
style={{ width: 200 }}
onCompositionStart={handleCompositionStart}
onCompositionEnd={handleCompositionEnd}
onChange={handleChange}
showClear
/>
<Button
theme='light'
type='tertiary'
style={{width: 150}}
onClick={() => {
copyText(selectedRowKeys);
}}
disabled={selectedRowKeys == ""}
>
{t('复制选中模型')}
</Button>
</Space>
<Table
style={{ marginTop: 5 }}
columns={columns}
dataSource={models}
loading={loading}
pagination={{
formatPageText: (page) =>
t('第 {{start}} - {{end}} 条,共 {{total}} 条', {
start: page.currentStart,
end: page.currentEnd,
total: models.length
}),
pageSize: models.length,
showSizeChanger: false,
}}

View File

@@ -1,5 +1,5 @@
import React, { useEffect, useState } from 'react';
import { Card, Spin } from '@douyinfe/semi-ui';
import { Card, Spin, Tabs } from '@douyinfe/semi-ui';
import SettingsGeneral from '../pages/Setting/Operation/SettingsGeneral.js';
import SettingsDrawing from '../pages/Setting/Operation/SettingsDrawing.js';
import SettingsSensitiveWords from '../pages/Setting/Operation/SettingsSensitiveWords.js';
@@ -8,11 +8,17 @@ import SettingsDataDashboard from '../pages/Setting/Operation/SettingsDataDashbo
import SettingsMonitoring from '../pages/Setting/Operation/SettingsMonitoring.js';
import SettingsCreditLimit from '../pages/Setting/Operation/SettingsCreditLimit.js';
import SettingsMagnification from '../pages/Setting/Operation/SettingsMagnification.js';
import ModelSettingsVisualEditor from '../pages/Setting/Operation/ModelSettingsVisualEditor.js';
import GroupRatioSettings from '../pages/Setting/Operation/GroupRatioSettings.js';
import ModelRatioSettings from '../pages/Setting/Operation/ModelRatioSettings.js';
import { API, showError, showSuccess } from '../helpers';
import SettingsChats from '../pages/Setting/Operation/SettingsChats.js';
import { useTranslation } from 'react-i18next';
const OperationSetting = () => {
const { t } = useTranslation();
let [inputs, setInputs] = useState({
QuotaForNewUser: 0,
QuotaForInviter: 0,
@@ -137,9 +143,20 @@ const OperationSetting = () => {
<Card style={{ marginTop: '10px' }}>
<SettingsChats options={inputs} refresh={onRefresh} />
</Card>
{/* 倍率设置 */}
{/* 分组倍率设置 */}
<Card style={{ marginTop: '10px' }}>
<SettingsMagnification options={inputs} refresh={onRefresh} />
<GroupRatioSettings options={inputs} refresh={onRefresh} />
</Card>
{/* 合并模型倍率设置和可视化倍率设置 */}
<Card style={{ marginTop: '10px' }}>
<Tabs type="line">
<Tabs.TabPane tab={t('模型倍率设置')} itemKey="model">
<ModelRatioSettings options={inputs} refresh={onRefresh} />
</Tabs.TabPane>
<Tabs.TabPane tab={t('可视化倍率设置')} itemKey="visual">
<ModelSettingsVisualEditor options={inputs} refresh={onRefresh} />
</Tabs.TabPane>
</Tabs>
</Card>
</Spin>
</>

View File

@@ -2,8 +2,10 @@ import React, { useEffect, useRef, useState } from 'react';
import { Banner, Button, Col, Form, Row } from '@douyinfe/semi-ui';
import { API, showError, showSuccess } from '../helpers';
import { marked } from 'marked';
import { useTranslation } from 'react-i18next';
const OtherSetting = () => {
const { t } = useTranslation();
let [inputs, setInputs] = useState({
Notice: '',
SystemName: '',
@@ -54,10 +56,10 @@ const OtherSetting = () => {
try {
setLoadingInput((loadingInput) => ({ ...loadingInput, Notice: true }));
await updateOption('Notice', inputs.Notice);
showSuccess('公告已更新');
showSuccess(t('公告已更新'));
} catch (error) {
console.error('公告更新失败', error);
showError('公告更新失败');
console.error(t('公告更新失败'), error);
showError(t('公告更新失败'));
} finally {
setLoadingInput((loadingInput) => ({ ...loadingInput, Notice: false }));
}
@@ -72,10 +74,10 @@ const OtherSetting = () => {
SystemName: true,
}));
await updateOption('SystemName', inputs.SystemName);
showSuccess('系统名称已更新');
showSuccess(t('系统名称已更新'));
} catch (error) {
console.error('系统名称更新失败', error);
showError('系统名称更新失败');
console.error(t('系统名称更新失败'), error);
showError(t('系统名称更新失败'));
} finally {
setLoadingInput((loadingInput) => ({
...loadingInput,
@@ -193,17 +195,17 @@ const OtherSetting = () => {
getFormApi={(formAPI) => (formAPISettingGeneral.current = formAPI)}
style={{ marginBottom: 15 }}
>
<Form.Section text={'通用设置'}>
<Form.Section text={t('通用设置')}>
<Form.TextArea
label={'公告'}
placeholder={'在此输入新的公告内容,支持 Markdown & HTML 代码'}
label={t('公告')}
placeholder={t('在此输入新的公告内容,支持 Markdown & HTML 代码')}
field={'Notice'}
onChange={handleInputChange}
style={{ fontFamily: 'JetBrains Mono, Consolas' }}
autosize={{ minRows: 6, maxRows: 12 }}
/>
<Button onClick={submitNotice} loading={loadingInput['Notice']}>
设置公告
{t('设置公告')}
</Button>
</Form.Section>
</Form>
@@ -213,10 +215,10 @@ const OtherSetting = () => {
getFormApi={(formAPI) => (formAPIPersonalization.current = formAPI)}
style={{ marginBottom: 15 }}
>
<Form.Section text={'个性化设置'}>
<Form.Section text={t('个性化设置')}>
<Form.Input
label={'系统名称'}
placeholder={'在此输入系统名称'}
label={t('系统名称')}
placeholder={t('在此输入系统名称')}
field={'SystemName'}
onChange={handleInputChange}
/>
@@ -224,22 +226,20 @@ const OtherSetting = () => {
onClick={submitSystemName}
loading={loadingInput['SystemName']}
>
设置系统名称
{t('设置系统名称')}
</Button>
<Form.Input
label={'Logo 图片地址'}
placeholder={'在此输入 Logo 图片地址'}
label={t('Logo 图片地址')}
placeholder={t('在此输入 Logo 图片地址')}
field={'Logo'}
onChange={handleInputChange}
/>
<Button onClick={submitLogo} loading={loadingInput['Logo']}>
设置 Logo
{t('设置 Logo')}
</Button>
<Form.TextArea
label={'首页内容'}
placeholder={
'在此输入首页内容,支持 Markdown & HTML 代码,设置后首页的状态信息将不再显示。如果输入的是一个链接,则会使用该链接作为 iframe 的 src 属性,这允许你设置任意网页作为首页。'
}
label={t('首页内容')}
placeholder={t('在此输入首页内容,支持 Markdown & HTML 代码,设置后首页的状态信息将不再显示。如果输入的是一个链接,则会使用该链接作为 iframe 的 src 属性,这允许你设置任意网页作为首页')}
field={'HomePageContent'}
onChange={handleInputChange}
style={{ fontFamily: 'JetBrains Mono, Consolas' }}
@@ -249,39 +249,35 @@ const OtherSetting = () => {
onClick={() => submitOption('HomePageContent')}
loading={loadingInput['HomePageContent']}
>
设置首页内容
{t('设置首页内容')}
</Button>
<Form.TextArea
label={'关于'}
placeholder={
'在此输入新的关于内容,支持 Markdown & HTML 代码。如果输入的是一个链接,则会使用该链接作为 iframe 的 src 属性,这允许你设置任意网页作为关于页面。'
}
label={t('关于')}
placeholder={t('在此输入新的关于内容,支持 Markdown & HTML 代码。如果输入的是一个链接,则会使用该链接作为 iframe 的 src 属性,这允许你设置任意网页作为关于页面')}
field={'About'}
onChange={handleInputChange}
style={{ fontFamily: 'JetBrains Mono, Consolas' }}
autosize={{ minRows: 6, maxRows: 12 }}
/>
<Button onClick={submitAbout} loading={loadingInput['About']}>
设置关于
{t('设置关于')}
</Button>
{/* */}
<Banner
fullMode={false}
type='info'
description='移除 One API 的版权标识必须首先获得授权,项目维护需要花费大量精力,如果本项目对你有意义,请主动支持本项目'
description={t('移除 One API 的版权标识必须首先获得授权,项目维护需要花费大量精力,如果本项目对你有意义,请主动支持本项目')}
closeIcon={null}
style={{ marginTop: 15 }}
/>
<Form.Input
label={'页脚'}
placeholder={
'在此输入新的页脚,留空则使用默认页脚,支持 HTML 代码'
}
label={t('页脚')}
placeholder={t('在此输入新的页脚,留空则使用默认页脚,支持 HTML 代码')}
field={'Footer'}
onChange={handleInputChange}
/>
<Button onClick={submitFooter} loading={loadingInput['Footer']}>
设置页脚
{t('设置页脚')}
</Button>
</Form.Section>
</Form>

View File

@@ -0,0 +1,92 @@
import HeaderBar from './HeaderBar.js';
import { Layout } from '@douyinfe/semi-ui';
import SiderBar from './SiderBar.js';
import App from '../App.js';
import FooterBar from './Footer.js';
import { ToastContainer } from 'react-toastify';
import React, { useContext, useEffect } from 'react';
import { StyleContext } from '../context/Style/index.js';
import { useTranslation } from 'react-i18next';
import { API, getLogo, getSystemName, showError } from '../helpers/index.js';
import { setStatusData } from '../helpers/data.js';
import { UserContext } from '../context/User/index.js';
import { StatusContext } from '../context/Status/index.js';
const { Sider, Content, Header, Footer } = Layout;
const PageLayout = () => {
const [userState, userDispatch] = useContext(UserContext);
const [statusState, statusDispatch] = useContext(StatusContext);
const [styleState, styleDispatch] = useContext(StyleContext);
const { i18n } = useTranslation();
const loadUser = () => {
let user = localStorage.getItem('user');
if (user) {
let data = JSON.parse(user);
userDispatch({ type: 'login', payload: data });
}
};
const loadStatus = async () => {
try {
const res = await API.get('/api/status');
const { success, data } = res.data;
if (success) {
statusDispatch({ type: 'set', payload: data });
setStatusData(data);
} else {
showError('Unable to connect to server');
}
} catch (error) {
showError('Failed to load status');
}
};
useEffect(() => {
loadUser();
loadStatus().catch(console.error);
let systemName = getSystemName();
if (systemName) {
document.title = systemName;
}
let logo = getLogo();
if (logo) {
let linkElement = document.querySelector("link[rel~='icon']");
if (linkElement) {
linkElement.href = logo;
}
}
// 从localStorage获取上次使用的语言
const savedLang = localStorage.getItem('i18nextLng');
if (savedLang) {
i18n.changeLanguage(savedLang);
}
}, [i18n]);
return (
<Layout style={{ height: '100vh', display: 'flex', flexDirection: 'column' }}>
<Header>
<HeaderBar />
</Header>
<Layout style={{ flex: 1, overflow: 'hidden' }}>
<Sider>
{styleState.showSider ? <SiderBar /> : null}
</Sider>
<Layout>
<Content
style={{ overflowY: 'auto', padding: styleState.shouldInnerPadding? '24px': '0' }}
>
<App />
</Content>
<Layout.Footer>
<FooterBar />
</Layout.Footer>
</Layout>
</Layout>
<ToastContainer />
</Layout>
)
}
export default PageLayout;

View File

@@ -25,6 +25,7 @@ import {
Space,
Tag,
Typography,
Collapsible,
} from '@douyinfe/semi-ui';
import {
getQuotaPerUnit,
@@ -33,10 +34,12 @@ import {
stringToColor,
} from '../helpers/render';
import TelegramLoginButton from 'react-telegram-login';
import { useTranslation } from 'react-i18next';
const PersonalSetting = () => {
const [userState, userDispatch] = useContext(UserContext);
let navigate = useNavigate();
const { t } = useTranslation();
const [inputs, setInputs] = useState({
wechat_verification_code: '',
@@ -62,6 +65,8 @@ const PersonalSetting = () => {
const [models, setModels] = useState([]);
const [openTransfer, setOpenTransfer] = useState(false);
const [transferAmount, setTransferAmount] = useState(0);
const [isModelsExpanded, setIsModelsExpanded] = useState(false);
const MODELS_DISPLAY_COUNT = 10; // 默认显示的模型数量
useEffect(() => {
// let user = localStorage.getItem('user');
@@ -110,7 +115,7 @@ const PersonalSetting = () => {
if (success) {
setSystemToken(data);
await copy(data);
showSuccess(`令牌已重置并已复制到剪贴板`);
showSuccess(t('令牌已重置并已复制到剪贴板'));
} else {
showError(message);
}
@@ -151,18 +156,18 @@ const PersonalSetting = () => {
const handleAffLinkClick = async (e) => {
e.target.select();
await copy(e.target.value);
showSuccess(`邀请链接已复制到剪切板`);
showSuccess(t('邀请链接已复制到剪切板'));
};
const handleSystemTokenClick = async (e) => {
e.target.select();
await copy(e.target.value);
showSuccess(`系统令牌已复制到剪切板`);
showSuccess(t('系统令牌已复制到剪切板'));
};
const deleteAccount = async () => {
if (inputs.self_account_deletion_confirmation !== userState.user.username) {
showError('请输入你的账户名以确认删除!');
showError(t('请输入你的账户名以确认删除!'));
return;
}
@@ -170,7 +175,7 @@ const PersonalSetting = () => {
const {success, message} = res.data;
if (success) {
showSuccess('账户已删除!');
showSuccess(t('账户已删除!'));
await API.get('/api/user/logout');
userDispatch({type: 'logout'});
localStorage.removeItem('user');
@@ -187,7 +192,7 @@ const PersonalSetting = () => {
);
const {success, message} = res.data;
if (success) {
showSuccess('微信账户绑定成功!');
showSuccess(t('微信账户绑定成功!'));
setShowWeChatBindModal(false);
} else {
showError(message);
@@ -196,7 +201,7 @@ const PersonalSetting = () => {
const changePassword = async () => {
if (inputs.set_new_password !== inputs.set_new_password_confirmation) {
showError('两次输入的密码不一致!');
showError(t('两次输入的密码不一致!'));
return;
}
const res = await API.put(`/api/user/self`, {
@@ -204,7 +209,7 @@ const PersonalSetting = () => {
});
const {success, message} = res.data;
if (success) {
showSuccess('密码修改成功!');
showSuccess(t('密码修改成功!'));
setShowWeChatBindModal(false);
} else {
showError(message);
@@ -214,7 +219,7 @@ const PersonalSetting = () => {
const transfer = async () => {
if (transferAmount < getQuotaPerUnit()) {
showError('划转金额最低为' + renderQuota(getQuotaPerUnit()));
showError(t('划转金额最低为') + ' ' + renderQuota(getQuotaPerUnit()));
return;
}
const res = await API.post(`/api/user/aff_transfer`, {
@@ -232,7 +237,7 @@ const PersonalSetting = () => {
const sendVerificationCode = async () => {
if (inputs.email === '') {
showError('请输入邮箱!');
showError(t('请输入邮箱!'));
return;
}
setDisableButton(true);
@@ -246,7 +251,7 @@ const PersonalSetting = () => {
);
const {success, message} = res.data;
if (success) {
showSuccess('验证码发送成功,请检查邮箱!');
showSuccess(t('验证码发送成功,请检查邮箱!'));
} else {
showError(message);
}
@@ -255,7 +260,7 @@ const PersonalSetting = () => {
const bindEmail = async () => {
if (inputs.email_verification_code === '') {
showError('请输入邮箱验证码!');
showError(t('请输入邮箱验证码!'));
return;
}
setLoading(true);
@@ -264,7 +269,7 @@ const PersonalSetting = () => {
);
const {success, message} = res.data;
if (success) {
showSuccess('邮箱账户绑定成功!');
showSuccess(t('邮箱账户绑定成功!'));
setShowEmailBindModal(false);
userState.user.email = inputs.email;
} else {
@@ -287,10 +292,10 @@ const PersonalSetting = () => {
const copyText = async (text) => {
if (await copy(text)) {
showSuccess('已复制:' + text);
showSuccess(t('已复制:') + text);
} else {
// setSearchKeyword(text);
Modal.error({title: '无法复制到剪贴板,请手动复制', content: text});
Modal.error({title: t('无法复制到剪贴板,请手动复制'), content: text});
}
};
@@ -299,7 +304,7 @@ const PersonalSetting = () => {
<Layout>
<Layout.Content>
<Modal
title='请输入要划转的数量'
title={t('请输入要划转的数量')}
visible={openTransfer}
onOk={transfer}
onCancel={handleCancel}
@@ -308,7 +313,7 @@ const PersonalSetting = () => {
centered={true}
>
<div style={{marginTop: 20}}>
<Typography.Text>{`可用额度${renderQuotaWithPrompt(userState?.user?.aff_quota)}`}</Typography.Text>
<Typography.Text>{t('可用额度')}{renderQuotaWithPrompt(userState?.user?.aff_quota)}</Typography.Text>
<Input
style={{marginTop: 5}}
value={userState?.user?.aff_quota}
@@ -317,8 +322,7 @@ const PersonalSetting = () => {
</div>
<div style={{marginTop: 20}}>
<Typography.Text>
{`划转额度${renderQuotaWithPrompt(transferAmount)} 最低` +
renderQuota(getQuotaPerUnit())}
{t('划转额度')}{renderQuotaWithPrompt(transferAmount)} {t('最低') + renderQuota(getQuotaPerUnit())}
</Typography.Text>
<div>
<InputNumber
@@ -348,9 +352,9 @@ const PersonalSetting = () => {
title={<Typography.Text>{getUsername()}</Typography.Text>}
description={
isRoot() ? (
<Tag color='red'>管理员</Tag>
<Tag color='red'>{t('管理员')}</Tag>
) : (
<Tag color='blue'>普通用户</Tag>
<Tag color='blue'>{t('普通用户')}</Tag>
)
}
></Card.Meta>
@@ -364,41 +368,97 @@ const PersonalSetting = () => {
</>
}
footer={
<Descriptions row>
<Descriptions.Item itemKey='当前余额'>
{renderQuota(userState?.user?.quota)}
</Descriptions.Item>
<Descriptions.Item itemKey='历史消耗'>
{renderQuota(userState?.user?.used_quota)}
</Descriptions.Item>
<Descriptions.Item itemKey='请求次数'>
{userState.user?.request_count}
</Descriptions.Item>
</Descriptions>
<>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Typography.Title heading={6}>{t('可用模型')}</Typography.Title>
</div>
<div style={{marginTop: 10}}>
{models.length <= MODELS_DISPLAY_COUNT ? (
<Space wrap>
{models.map((model) => (
<Tag
key={model}
color='cyan'
onClick={() => {
copyText(model);
}}
>
{model}
</Tag>
))}
</Space>
) : (
<>
<Collapsible isOpen={isModelsExpanded}>
<Space wrap>
{models.map((model) => (
<Tag
key={model}
color='cyan'
onClick={() => {
copyText(model);
}}
>
{model}
</Tag>
))}
<Tag
color='blue'
type="light"
style={{ cursor: 'pointer' }}
onClick={() => setIsModelsExpanded(false)}
>
{t('收起')}
</Tag>
</Space>
</Collapsible>
{!isModelsExpanded && (
<Space wrap>
{models.slice(0, MODELS_DISPLAY_COUNT).map((model) => (
<Tag
key={model}
color='cyan'
onClick={() => {
copyText(model);
}}
>
{model}
</Tag>
))}
<Tag
color='blue'
type="light"
style={{ cursor: 'pointer' }}
onClick={() => setIsModelsExpanded(true)}
>
{t('更多')} {models.length - MODELS_DISPLAY_COUNT} {t('个模型')}
</Tag>
</Space>
)}
</>
)}
</div>
</>
}
>
<Typography.Title heading={6}>可用模型</Typography.Title>
<div style={{marginTop: 10}}>
<Space wrap>
{models.map((model) => (
<Tag
key={model}
color='cyan'
onClick={() => {
copyText(model);
}}
>
{model}
</Tag>
))}
</Space>
</div>
<Descriptions row>
<Descriptions.Item itemKey={t('当前余额')}>
{renderQuota(userState?.user?.quota)}
</Descriptions.Item>
<Descriptions.Item itemKey={t('历史消耗')}>
{renderQuota(userState?.user?.used_quota)}
</Descriptions.Item>
<Descriptions.Item itemKey={t('请求次数')}>
{userState.user?.request_count}
</Descriptions.Item>
</Descriptions>
</Card>
<Card
style={{marginTop: 10}}
footer={
<div>
<Typography.Text>邀请链接</Typography.Text>
<Typography.Text>{t('邀请链接')}</Typography.Text>
<Input
style={{marginTop: 10}}
value={affLink}
@@ -408,35 +468,35 @@ const PersonalSetting = () => {
</div>
}
>
<Typography.Title heading={6}>邀请信息</Typography.Title>
<Typography.Title heading={6}>{t('邀请信息')}</Typography.Title>
<div style={{marginTop: 10}}>
<Descriptions row>
<Descriptions.Item itemKey='待使用收益'>
<span style={{color: 'rgba(var(--semi-red-5), 1)'}}>
{renderQuota(userState?.user?.aff_quota)}
</span>
<Descriptions.Item itemKey={t('待使用收益')}>
<span style={{color: 'rgba(var(--semi-red-5), 1)'}}>
{renderQuota(userState?.user?.aff_quota)}
</span>
<Button
type={'secondary'}
onClick={() => setOpenTransfer(true)}
size={'small'}
style={{marginLeft: 10}}
>
划转
{t('划转')}
</Button>
</Descriptions.Item>
<Descriptions.Item itemKey='总收益'>
<Descriptions.Item itemKey={t('总收益')}>
{renderQuota(userState?.user?.aff_history_quota)}
</Descriptions.Item>
<Descriptions.Item itemKey='邀请人数'>
<Descriptions.Item itemKey={t('邀请人数')}>
{userState?.user?.aff_count}
</Descriptions.Item>
</Descriptions>
</div>
</Card>
<Card style={{marginTop: 10}}>
<Typography.Title heading={6}>个人信息</Typography.Title>
<Typography.Title heading={6}>{t('个人信息')}</Typography.Title>
<div style={{marginTop: 20}}>
<Typography.Text strong>邮箱</Typography.Text>
<Typography.Text strong>{t('邮箱')}</Typography.Text>
<div
style={{display: 'flex', justifyContent: 'space-between'}}
>
@@ -445,7 +505,7 @@ const PersonalSetting = () => {
value={
userState.user && userState.user.email !== ''
? userState.user.email
: '未绑定'
: t('未绑定')
}
readonly={true}
></Input>
@@ -457,14 +517,14 @@ const PersonalSetting = () => {
}}
>
{userState.user && userState.user.email !== ''
? '修改绑定'
: '绑定邮箱'}
? t('修改绑定')
: t('绑定邮箱')}
</Button>
</div>
</div>
</div>
<div style={{marginTop: 10}}>
<Typography.Text strong>微信</Typography.Text>
<Typography.Text strong>{t('微信')}</Typography.Text>
<div
style={{display: 'flex', justifyContent: 'space-between'}}
>
@@ -472,8 +532,8 @@ const PersonalSetting = () => {
<Input
value={
userState.user && userState.user.wechat_id !== ''
? '已绑定'
: '未绑定'
? t('已绑定')
: t('未绑定')
}
readonly={true}
></Input>
@@ -485,13 +545,13 @@ const PersonalSetting = () => {
!status.wechat_login
}
>
{status.wechat_login ? '绑定' : '未启用'}
{status.wechat_login ? t('绑定') : t('未启用')}
</Button>
</div>
</div>
</div>
<div style={{marginTop: 10}}>
<Typography.Text strong>GitHub</Typography.Text>
<Typography.Text strong>{t('GitHub')}</Typography.Text>
<div
style={{display: 'flex', justifyContent: 'space-between'}}
>
@@ -500,7 +560,7 @@ const PersonalSetting = () => {
value={
userState.user && userState.user.github_id !== ''
? userState.user.github_id
: '未绑定'
: t('未绑定')
}
readonly={true}
></Input>
@@ -515,13 +575,13 @@ const PersonalSetting = () => {
!status.github_oauth
}
>
{status.github_oauth ? '绑定' : '未启用'}
{status.github_oauth ? t('绑定') : t('未启用')}
</Button>
</div>
</div>
</div>
<div style={{marginTop: 10}}>
<Typography.Text strong>Telegram</Typography.Text>
<Typography.Text strong>{t('Telegram')}</Typography.Text>
<div
style={{display: 'flex', justifyContent: 'space-between'}}
>
@@ -530,7 +590,7 @@ const PersonalSetting = () => {
value={
userState.user && userState.user.telegram_id !== ''
? userState.user.telegram_id
: '未绑定'
: t('未绑定')
}
readonly={true}
></Input>
@@ -538,7 +598,7 @@ const PersonalSetting = () => {
<div>
{status.telegram_oauth ? (
userState.user.telegram_id !== '' ? (
<Button disabled={true}>已绑定</Button>
<Button disabled={true}>{t('已绑定')}</Button>
) : (
<TelegramLoginButton
dataAuthUrl='/api/oauth/telegram/bind'
@@ -546,13 +606,13 @@ const PersonalSetting = () => {
/>
)
) : (
<Button disabled={true}>未启用</Button>
<Button disabled={true}>{t('未启用')}</Button>
)}
</div>
</div>
</div>
<div style={{marginTop: 10}}>
<Typography.Text strong>LinuxDO</Typography.Text>
<Typography.Text strong>{t('LinuxDO')}</Typography.Text>
<div
style={{display: 'flex', justifyContent: 'space-between'}}
>
@@ -561,7 +621,7 @@ const PersonalSetting = () => {
value={
userState.user && userState.user.linux_do_id !== ''
? userState.user.linux_do_id
: '未绑定'
: t('未绑定')
}
readonly={true}
></Input>
@@ -576,7 +636,7 @@ const PersonalSetting = () => {
!status.linuxdo_oauth
}
>
{status.linuxdo_oauth ? '绑定' : '未启用'}
{status.linuxdo_oauth ? t('绑定') : t('未启用')}
</Button>
</div>
</div>
@@ -584,14 +644,14 @@ const PersonalSetting = () => {
<div style={{marginTop: 10}}>
<Space>
<Button onClick={generateAccessToken}>
生成系统访问令牌
{t('生成系统访问令牌')}
</Button>
<Button
onClick={() => {
setShowChangePasswordModal(true);
}}
>
修改密码
{t('修改密码')}
</Button>
<Button
type={'danger'}
@@ -599,7 +659,7 @@ const PersonalSetting = () => {
setShowAccountDeleteModal(true);
}}
>
删除个人账户
{t('删除个人账户')}
</Button>
</Space>
@@ -617,7 +677,7 @@ const PersonalSetting = () => {
setShowWeChatBindModal(true);
}}
>
绑定微信账号
{t('绑定微信账号')}
</Button>
)}
<Modal
@@ -641,7 +701,7 @@ const PersonalSetting = () => {
}
/>
<Button color='' fluid size='large' onClick={bindWeChat}>
绑定
{t('绑定')}
</Button>
</Modal>
</div>
@@ -655,7 +715,7 @@ const PersonalSetting = () => {
centered={true}
maskClosable={false}
>
<Typography.Title heading={6}>绑定邮箱地址</Typography.Title>
<Typography.Title heading={6}>{t('绑定邮箱地址')}</Typography.Title>
<div
style={{
marginTop: 20,
@@ -747,7 +807,7 @@ const PersonalSetting = () => {
<div style={{marginTop: 20}}>
<Input
name='set_new_password'
placeholder='新密码'
placeholder={t('新密码')}
value={inputs.set_new_password}
onChange={(value) =>
handleInputChange('set_new_password', value)
@@ -756,7 +816,7 @@ const PersonalSetting = () => {
<Input
style={{marginTop: 20}}
name='set_new_password_confirmation'
placeholder='确认新密码'
placeholder={t('确认新密码')}
value={inputs.set_new_password_confirmation}
onChange={(value) =>
handleInputChange('set_new_password_confirmation', value)

View File

@@ -19,55 +19,55 @@ import {
Tag,
} from '@douyinfe/semi-ui';
import EditRedemption from '../pages/Redemption/EditRedemption';
import { useTranslation } from 'react-i18next';
function renderTimestamp(timestamp) {
return <>{timestamp2string(timestamp)}</>;
}
function renderStatus(status) {
switch (status) {
case 1:
return (
<Tag color='green' size='large'>
未使用
</Tag>
);
case 2:
return (
<Tag color='red' size='large'>
{' '}
已禁用{' '}
</Tag>
);
case 3:
return (
<Tag color='grey' size='large'>
{' '}
已使用{' '}
</Tag>
);
default:
return (
<Tag color='black' size='large'>
{' '}
未知状态{' '}
</Tag>
);
}
}
const RedemptionsTable = () => {
const { t } = useTranslation();
const renderStatus = (status) => {
switch (status) {
case 1:
return (
<Tag color='green' size='large'>
{t('未使用')}
</Tag>
);
case 2:
return (
<Tag color='red' size='large'>
{t('已禁用')}
</Tag>
);
case 3:
return (
<Tag color='grey' size='large'>
{t('已使用')}
</Tag>
);
default:
return (
<Tag color='black' size='large'>
{t('未知状态')}
</Tag>
);
}
};
const columns = [
{
title: 'ID',
title: t('ID'),
dataIndex: 'id',
},
{
title: '名称',
title: t('名称'),
dataIndex: 'name',
},
{
title: '状态',
title: t('状态'),
dataIndex: 'status',
key: 'status',
render: (text, record, index) => {
@@ -75,24 +75,24 @@ const RedemptionsTable = () => {
},
},
{
title: '额度',
title: t('额度'),
dataIndex: 'quota',
render: (text, record, index) => {
return <div>{renderQuota(parseInt(text))}</div>;
},
},
{
title: '创建时间',
title: t('创建时间'),
dataIndex: 'created_time',
render: (text, record, index) => {
return <div>{renderTimestamp(text)}</div>;
},
},
{
title: '兑换人ID',
title: t('兑换人ID'),
dataIndex: 'used_user_id',
render: (text, record, index) => {
return <div>{text === 0 ? '无' : text}</div>;
return <div>{text === 0 ? t('无') : text}</div>;
},
},
{
@@ -102,7 +102,7 @@ const RedemptionsTable = () => {
<div>
<Popover content={record.key} style={{ padding: 20 }} position='top'>
<Button theme='light' type='tertiary' style={{ marginRight: 1 }}>
查看
{t('查看')}
</Button>
</Popover>
<Button
@@ -113,11 +113,11 @@ const RedemptionsTable = () => {
await copyText(record.key);
}}
>
复制
{t('复制')}
</Button>
<Popconfirm
title='确定是否要删除此兑换码?'
content='此修改将不可逆'
title={t('确定是否要删除此兑换码?')}
content={t('此修改将不可逆')}
okType={'danger'}
position={'left'}
onConfirm={() => {
@@ -127,7 +127,7 @@ const RedemptionsTable = () => {
}}
>
<Button theme='light' type='danger' style={{ marginRight: 1 }}>
删除
{t('删除')}
</Button>
</Popconfirm>
{record.status === 1 ? (
@@ -139,7 +139,7 @@ const RedemptionsTable = () => {
manageRedemption(record.id, 'disable', record);
}}
>
禁用
{t('禁用')}
</Button>
) : (
<Button
@@ -151,7 +151,7 @@ const RedemptionsTable = () => {
}}
disabled={record.status === 3}
>
启用
{t('启用')}
</Button>
)}
<Button
@@ -164,7 +164,7 @@ const RedemptionsTable = () => {
}}
disabled={record.status !== 1}
>
编辑
{t('编辑')}
</Button>
</div>
),
@@ -239,10 +239,10 @@ const RedemptionsTable = () => {
const copyText = async (text) => {
if (await copy(text)) {
showSuccess('已复制到剪贴板!');
showSuccess(t('已复制到剪贴板!'));
} else {
// setSearchKeyword(text);
Modal.error({ title: '无法复制到剪贴板,请手动复制', content: text });
Modal.error({ title: t('无法复制到剪贴板,请手动复制'), content: text });
}
};
@@ -286,7 +286,7 @@ const RedemptionsTable = () => {
}
const { success, message } = res.data;
if (success) {
showSuccess('操作成功完成!');
showSuccess(t('操作成功完成!'));
let redemption = res.data.data;
let newRedemptions = [...redemptions];
// let realIdx = (activePage - 1) * ITEMS_PER_PAGE + idx;
@@ -381,11 +381,11 @@ const RedemptionsTable = () => {
></EditRedemption>
<Form onSubmit={searchRedemptions}>
<Form.Input
label='搜索关键字'
label={t('搜索关键字')}
field='keyword'
icon='search'
iconPosition='left'
placeholder='关键字(id或者名称)'
placeholder={t('关键字(id或者名称)')}
value={searchKeyword}
loading={searching}
onChange={handleKeywordChange}
@@ -404,14 +404,14 @@ const RedemptionsTable = () => {
setShowEdit(true);
}}
>
添加兑换码
{t('添加兑换码')}
</Button>
<Button
label='复制所选兑换码'
label={t('复制所选兑换码')}
type='warning'
onClick={async () => {
if (selectedKeys.length === 0) {
showError('请至少选择一个兑换码!');
showError(t('请至少选择一个兑换码!'));
return;
}
let keys = '';
@@ -421,7 +421,7 @@ const RedemptionsTable = () => {
await copyText(keys);
}}
>
复制所选兑换码到剪贴板
{t('复制所选兑换码到剪贴板')}
</Button>
</div>
@@ -436,7 +436,11 @@ const RedemptionsTable = () => {
// showSizeChanger: true,
// pageSizeOptions: [10, 20, 50, 100],
formatPageText: (page) =>
`${page.currentStart} - ${page.currentEnd} 条,共 ${redemptions.length}`,
t('第 {{start}} - {{end}} 条,共 {{total}} 条', {
start: page.currentStart,
end: page.currentEnd,
total: redemptions.length
}),
// onPageSizeChange: (size) => {
// setPageSize(size);
// setActivePage(1);

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react';
import React, { useContext, useEffect, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { API, getLogo, showError, showInfo, showSuccess, updateAPI } from '../helpers';
import Turnstile from 'react-turnstile';
@@ -11,8 +11,11 @@ import LinuxDoIcon from './LinuxDoIcon.js';
import WeChatIcon from './WeChatIcon.js';
import TelegramLoginButton from 'react-telegram-login/src';
import { setUserData } from '../helpers/data.js';
import { UserContext } from '../context/User/index.js';
import { useTranslation } from 'react-i18next';
const RegisterForm = () => {
const { t } = useTranslation();
const [inputs, setInputs] = useState({
username: '',
password: '',
@@ -22,6 +25,7 @@ const RegisterForm = () => {
});
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('');
@@ -133,6 +137,38 @@ const RegisterForm = () => {
setLoading(false);
};
const onTelegramLoginClicked = async (response) => {
const fields = [
'id',
'first_name',
'last_name',
'username',
'photo_url',
'auth_date',
'hash',
'lang',
];
const params = {};
fields.forEach((field) => {
if (response[field]) {
params[field] = response[field];
}
});
const res = await API.get(`/api/oauth/telegram/login`, { params });
const { success, message, data } = res.data;
if (success) {
userDispatch({ type: 'login', payload: data });
localStorage.setItem('user', JSON.stringify(data));
showSuccess('登录成功!');
setUserData(data);
updateAPI();
navigate('/');
} else {
showError(message);
}
};
return (
<div>
<Layout>
@@ -148,28 +184,28 @@ const RegisterForm = () => {
<div style={{ width: 500 }}>
<Card>
<Title heading={2} style={{ textAlign: 'center' }}>
新用户注册
{t('新用户注册')}
</Title>
<Form size="large">
<Form.Input
field={'username'}
label={'用户名'}
placeholder="用户名"
label={t('用户名')}
placeholder={t('用户名')}
name="username"
onChange={(value) => handleChange('username', value)}
/>
<Form.Input
field={'password'}
label={'密码'}
placeholder="密码,最短 8 位,最长 20 位"
label={t('密码')}
placeholder={t('输入密码,最短 8 位,最长 20 位')}
name="password"
type="password"
onChange={(value) => handleChange('password', value)}
/>
<Form.Input
field={'password2'}
label={'确认密码'}
placeholder="确认密码"
label={t('确认密码')}
placeholder={t('确认密码')}
name="password2"
type="password"
onChange={(value) => handleChange('password2', value)}
@@ -178,21 +214,21 @@ const RegisterForm = () => {
<>
<Form.Input
field={'email'}
label={'邮箱'}
placeholder="输入邮箱地址"
label={t('邮箱')}
placeholder={t('输入邮箱地址')}
onChange={(value) => handleChange('email', value)}
name="email"
type="email"
suffix={
<Button onClick={sendVerificationCode} disabled={loading}>
获取验证码
{t('获取验证码')}
</Button>
}
/>
<Form.Input
field={'verification_code'}
label={'验证码'}
placeholder="输入验证码"
label={t('验证码')}
placeholder={t('输入验证码')}
onChange={(value) => handleChange('verification_code', value)}
name="verification_code"
/>
@@ -208,7 +244,7 @@ const RegisterForm = () => {
htmlType={'submit'}
onClick={handleSubmit}
>
注册
{t('注册')}
</Button>
</Form>
<div
@@ -219,9 +255,9 @@ const RegisterForm = () => {
}}
>
<Text>
已有账户
{t('已有账户?')}
<Link to="/login">
点击登录
{t('点击登录')}
</Link>
</Text>
</div>
@@ -231,7 +267,7 @@ const RegisterForm = () => {
status.linuxdo_oauth ? (
<>
<Divider margin='12px' align='center'>
第三方登录
{t('第三方登录')}
</Divider>
<div
style={{
@@ -296,12 +332,12 @@ const RegisterForm = () => {
)}
</Card>
<Modal
title='微信扫码登录'
title={t('微信扫码登录')}
visible={showWeChatLoginModal}
maskClosable={true}
onOk={onSubmitWeChatVerificationCode}
onCancel={() => setShowWeChatLoginModal(false)}
okText={'登录'}
okText={t('登录')}
size={'small'}
centered={true}
>
@@ -316,14 +352,14 @@ const RegisterForm = () => {
</div>
<div style={{ textAlign: 'center' }}>
<p>
微信扫码关注公众号输入验证码获取验证码三分钟内有效
{t('微信扫码关注公众号,输入「验证码」获取验证码(三分钟内有效)')}
</p>
</div>
<Form size='large'>
<Form.Input
field={'wechat_verification_code'}
placeholder='验证码'
label={'验证码'}
placeholder={t('验证码')}
label={t('验证码')}
value={inputs.wechat_verification_code}
onChange={(value) =>
handleChange('wechat_verification_code', value)

View File

@@ -2,6 +2,7 @@ import React, { useContext, useEffect, useMemo, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { UserContext } from '../context/User';
import { StatusContext } from '../context/Status';
import { useTranslation } from 'react-i18next';
import {
API,
@@ -31,14 +32,16 @@ import { Avatar, Dropdown, Layout, Nav, Switch } from '@douyinfe/semi-ui';
import { setStatusData } from '../helpers/data.js';
import { stringToColor } from '../helpers/render.js';
import { useSetTheme, useTheme } from '../context/Theme/index.js';
import { StyleContext } from '../context/Style/index.js';
// HeaderBar Buttons
const SiderBar = () => {
const [userState, userDispatch] = useContext(UserContext);
const { t } = useTranslation();
const [styleState, styleDispatch] = useContext(StyleContext);
const [statusState, statusDispatch] = useContext(StatusContext);
const defaultIsCollapsed =
isMobile() || localStorage.getItem('default_collapse_sidebar') === 'true';
localStorage.getItem('default_collapse_sidebar') === 'true';
const [selectedKeys, setSelectedKeys] = useState(['home']);
const [isCollapsed, setIsCollapsed] = useState(defaultIsCollapsed);
@@ -73,62 +76,26 @@ const SiderBar = () => {
icon: <IconCommentStroked />,
},
{
text: '模型价格',
itemKey: 'pricing',
to: '/pricing',
icon: <IconPriceTag />,
},
{
text: '渠道',
text: t('渠道'),
itemKey: 'channel',
to: '/channel',
icon: <IconLayers />,
className: isAdmin() ? 'semi-navigation-item-normal' : 'tableHiddle',
},
{
text: '聊天',
text: t('聊天'),
itemKey: 'chat',
// to: '/chat',
items: chatItems,
icon: <IconComment />,
// className: localStorage.getItem('chat_link')
// ? 'semi-navigation-item-normal'
// : 'tableHiddle',
},
{
text: '令牌',
text: t('令牌'),
itemKey: 'token',
to: '/token',
icon: <IconKey />,
},
{
text: '兑换码',
itemKey: 'redemption',
to: '/redemption',
icon: <IconGift />,
className: isAdmin() ? 'semi-navigation-item-normal' : 'tableHiddle',
},
{
text: '钱包',
itemKey: 'topup',
to: '/topup',
icon: <IconCreditCard />,
},
{
text: '用户管理',
itemKey: 'user',
to: '/user',
icon: <IconUser />,
className: isAdmin() ? 'semi-navigation-item-normal' : 'tableHiddle',
},
{
text: '日志',
itemKey: 'log',
to: '/log',
icon: <IconHistogram />,
},
{
text: '数据看板',
text: t('数据看板'),
itemKey: 'detail',
to: '/detail',
icon: <IconCalendarClock />,
@@ -138,7 +105,33 @@ const SiderBar = () => {
: 'tableHiddle',
},
{
text: '绘图',
text: t('兑换码'),
itemKey: 'redemption',
to: '/redemption',
icon: <IconGift />,
className: isAdmin() ? 'semi-navigation-item-normal' : 'tableHiddle',
},
{
text: t('钱包'),
itemKey: 'topup',
to: '/topup',
icon: <IconCreditCard />,
},
{
text: t('用户管理'),
itemKey: 'user',
to: '/user',
icon: <IconUser />,
className: isAdmin() ? 'semi-navigation-item-normal' : 'tableHiddle',
},
{
text: t('日志'),
itemKey: 'log',
to: '/log',
icon: <IconHistogram />,
},
{
text: t('绘图'),
itemKey: 'midjourney',
to: '/midjourney',
icon: <IconImage />,
@@ -148,7 +141,7 @@ const SiderBar = () => {
: 'tableHiddle',
},
{
text: '异步任务',
text: t('异步任务'),
itemKey: 'task',
to: '/task',
icon: <IconChecklistStroked />,
@@ -158,53 +151,30 @@ const SiderBar = () => {
: 'tableHiddle',
},
{
text: '设置',
text: t('设置'),
itemKey: 'setting',
to: '/setting',
icon: <IconSetting />,
},
// {
// text: '关于',
// itemKey: 'about',
// to: '/about',
// icon: <IconAt/>
// }
],
[
localStorage.getItem('enable_data_export'),
localStorage.getItem('enable_drawing'),
localStorage.getItem('enable_task'),
localStorage.getItem('chat_link'), chatItems,
localStorage.getItem('chat_link'),
chatItems,
isAdmin(),
t,
],
);
const loadStatus = async () => {
const res = await API.get('/api/status');
if (res === undefined) {
return;
}
const { success, data } = res.data;
if (success) {
statusDispatch({ type: 'set', payload: data });
setStatusData(data);
} else {
showError('无法正常连接至服务器!');
}
};
useEffect(() => {
loadStatus().then(() => {
setIsCollapsed(
isMobile() ||
localStorage.getItem('default_collapse_sidebar') === 'true',
);
});
let localKey = window.location.pathname.split('/')[1];
if (localKey === '') {
localKey = 'home';
}
setSelectedKeys([localKey]);
let chatLink = localStorage.getItem('chat_link');
if (!chatLink) {
let chats = localStorage.getItem('chats');
@@ -232,6 +202,8 @@ const SiderBar = () => {
}
}
}
setIsCollapsed(localStorage.getItem('default_collapse_sidebar') === 'true');
}, []);
return (
@@ -239,7 +211,6 @@ const SiderBar = () => {
<Nav
style={{ maxWidth: 220, height: '100%' }}
defaultIsCollapsed={
isMobile() ||
localStorage.getItem('default_collapse_sidebar') === 'true'
}
isCollapsed={isCollapsed}
@@ -280,21 +251,15 @@ const SiderBar = () => {
}}
items={headerButtons}
onSelect={(key) => {
if (key.itemKey.toString().startsWith('chat')) {
styleDispatch({ type: 'SET_INNER_PADDING', payload: false });
} else {
styleDispatch({ type: 'SET_INNER_PADDING', payload: true });
}
setSelectedKeys([key.itemKey]);
}}
footer={
<>
{isMobile() && (
<Switch
checkedText='🌞'
size={'small'}
checked={theme === 'dark'}
uncheckedText='🌙'
onChange={(checked) => {
setTheme(checked);
}}
/>
)}
</>
}
>

View File

@@ -23,67 +23,66 @@ import {
import { IconTreeTriangleDown } from '@douyinfe/semi-icons';
import EditToken from '../pages/Token/EditToken';
import { useTranslation } from 'react-i18next';
function renderTimestamp(timestamp) {
return <>{timestamp2string(timestamp)}</>;
}
function renderStatus(status, model_limits_enabled = false) {
switch (status) {
case 1:
if (model_limits_enabled) {
return (
<Tag color='green' size='large'>
已启用限制模型
</Tag>
);
} else {
return (
<Tag color='green' size='large'>
已启用
</Tag>
);
}
case 2:
return (
<Tag color='red' size='large'>
{' '}
已禁用{' '}
</Tag>
);
case 3:
return (
<Tag color='yellow' size='large'>
{' '}
已过期{' '}
</Tag>
);
case 4:
return (
<Tag color='grey' size='large'>
{' '}
已耗尽{' '}
</Tag>
);
default:
return (
<Tag color='black' size='large'>
{' '}
未知状态{' '}
</Tag>
);
}
}
const TokensTable = () => {
const { t } = useTranslation();
const renderStatus = (status, model_limits_enabled = false) => {
switch (status) {
case 1:
if (model_limits_enabled) {
return (
<Tag color='green' size='large'>
{t('已启用:限制模型')}
</Tag>
);
} else {
return (
<Tag color='green' size='large'>
{t('已启用')}
</Tag>
);
}
case 2:
return (
<Tag color='red' size='large'>
{t('已禁用')}
</Tag>
);
case 3:
return (
<Tag color='yellow' size='large'>
{t('已过期')}
</Tag>
);
case 4:
return (
<Tag color='grey' size='large'>
{t('已耗尽')}
</Tag>
);
default:
return (
<Tag color='black' size='large'>
{t('未知状态')}
</Tag>
);
}
};
const columns = [
{
title: '名称',
title: t('名称'),
dataIndex: 'name',
},
{
title: '状态',
title: t('状态'),
dataIndex: 'status',
key: 'status',
render: (text, record, index) => {
@@ -96,21 +95,21 @@ const TokensTable = () => {
},
},
{
title: '已用额度',
title: t('已用额度'),
dataIndex: 'used_quota',
render: (text, record, index) => {
return <div>{renderQuota(parseInt(text))}</div>;
},
},
{
title: '剩余额度',
title: t('剩余额度'),
dataIndex: 'remain_quota',
render: (text, record, index) => {
return (
<div>
{record.unlimited_quota ? (
<Tag size={'large'} color={'white'}>
无限制
{t('无限制')}
</Tag>
) : (
<Tag size={'large'} color={'light-blue'}>
@@ -122,19 +121,19 @@ const TokensTable = () => {
},
},
{
title: '创建时间',
title: t('创建时间'),
dataIndex: 'created_time',
render: (text, record, index) => {
return <div>{renderTimestamp(text)}</div>;
},
},
{
title: '过期时间',
title: t('过期时间'),
dataIndex: 'expired_time',
render: (text, record, index) => {
return (
<div>
{record.expired_time === -1 ? '永不过期' : renderTimestamp(text)}
{record.expired_time === -1 ? t('永不过期') : renderTimestamp(text)}
</div>
);
},
@@ -199,7 +198,7 @@ const TokensTable = () => {
} catch (e) {
console.log(e);
showError('聊天链接配置错误,请联系管理员');
showError(t('聊天链接配置错误,请联系管理员'));
}
}
return (
@@ -210,7 +209,7 @@ const TokensTable = () => {
position='top'
>
<Button theme='light' type='tertiary' style={{ marginRight: 1 }}>
查看
{t('查看')}
</Button>
</Popover>
<Button
@@ -221,24 +220,24 @@ const TokensTable = () => {
await copyText('sk-' + record.key);
}}
>
复制
{t('复制')}
</Button>
<SplitButtonGroup
style={{ marginRight: 1 }}
aria-label='项目操作按钮组'
aria-label={t('项目操作按钮组')}
>
<Button
theme='light'
style={{ color: 'rgba(var(--semi-teal-7), 1)' }}
onClick={() => {
if (chatsArray.length === 0) {
showError('请联系管理员配置聊天链接');
showError(t('请联系管理员配置聊天链接'));
} else {
onOpenLink('default', chats[0][Object.keys(chats[0])[0]], record);
}
}}
>
聊天
{t('聊天')}
</Button>
<Dropdown
trigger='click'
@@ -256,8 +255,8 @@ const TokensTable = () => {
</Dropdown>
</SplitButtonGroup>
<Popconfirm
title='确定是否要删除此令牌?'
content='此修改将不可逆'
title={t('确定是否要删除此令牌?')}
content={t('此修改将不可逆')}
okType={'danger'}
position={'left'}
onConfirm={() => {
@@ -267,7 +266,7 @@ const TokensTable = () => {
}}
>
<Button theme='light' type='danger' style={{ marginRight: 1 }}>
删除
{t('删除')}
</Button>
</Popconfirm>
{record.status === 1 ? (
@@ -279,7 +278,7 @@ const TokensTable = () => {
manageToken(record.id, 'disable', record);
}}
>
禁用
{t('禁用')}
</Button>
) : (
<Button
@@ -290,7 +289,7 @@ const TokensTable = () => {
manageToken(record.id, 'enable', record);
}}
>
启用
{t('启用')}
</Button>
)}
<Button
@@ -302,7 +301,7 @@ const TokensTable = () => {
setShowEdit(true);
}}
>
编辑
{t('编辑')}
</Button>
</div>
);
@@ -371,10 +370,10 @@ const TokensTable = () => {
const copyText = async (text) => {
if (await copy(text)) {
showSuccess('已复制到剪贴板!');
showSuccess(t('已复制到剪贴板!'));
} else {
Modal.error({
title: '无法复制到剪贴板,请手动复制',
title: t('无法复制到剪贴板,请手动复制'),
content: text,
size: 'large',
});
@@ -395,37 +394,6 @@ const TokensTable = () => {
let encodedServerAddress = encodeURIComponent(serverAddress);
url = url.replaceAll('{address}', encodedServerAddress);
url = url.replaceAll('{key}', 'sk-' + record.key);
// console.log(url);
// const chatLink = localStorage.getItem('chat_link');
// const mjLink = localStorage.getItem('chat_link2');
// let defaultUrl;
//
// if (chatLink) {
// defaultUrl =
// chatLink + `/#/?settings={"key":"sk-${key}","url":"${serverAddress}"}`;
// }
// let url;
// switch (type) {
// case 'ama':
// url = `ama://set-api-key?server=${encodedServerAddress}&key=sk-${key}`;
// break;
// case 'opencat':
// url = `opencat://team/join?domain=${encodedServerAddress}&token=sk-${key}`;
// break;
// case 'lobe':
// url = `https://chat-preview.lobehub.com/?settings={"keyVaults":{"openai":{"apiKey":"sk-${key}","baseURL":"${encodedServerAddress}/v1"}}}`;
// break;
// case 'next-mj':
// url =
// mjLink + `/#/?settings={"key":"sk-${key}","url":"${serverAddress}"}`;
// break;
// default:
// if (!chatLink) {
// showError('管理员未设置聊天链接');
// return;
// }
// url = defaultUrl;
// }
window.open(url, '_blank');
};
@@ -571,29 +539,29 @@ const TokensTable = () => {
>
<Form.Input
field='keyword'
label='搜索关键字'
placeholder='令牌名称'
label={t('搜索关键字')}
placeholder={t('令牌名称')}
value={searchKeyword}
loading={searching}
onChange={handleKeywordChange}
/>
<Form.Input
field='token'
label='Key'
placeholder='密钥'
label={t('密钥')}
placeholder={t('密钥')}
value={searchToken}
loading={searching}
onChange={handleSearchTokenChange}
/>
<Button
label='查询'
label={t('查询')}
type='primary'
htmlType='submit'
className='btn-margin-right'
onClick={searchTokens}
style={{ marginRight: 8 }}
>
查询
{t('查询')}
</Button>
</Form>
<Divider style={{margin:'15px 0'}}/>
@@ -609,14 +577,14 @@ const TokensTable = () => {
setShowEdit(true);
}}
>
添加令牌
{t('添加令牌')}
</Button>
<Button
label='复制所选令牌'
label={t('复制所选令牌')}
type='warning'
onClick={async () => {
if (selectedKeys.length === 0) {
showError('请至少选择一个令牌!');
showError(t('请至少选择一个令牌!'));
return;
}
let keys = '';
@@ -627,7 +595,7 @@ const TokensTable = () => {
await copyText(keys);
}}
>
复制所选令牌到剪贴板
{t('复制所选令牌到剪贴板')}
</Button>
</div>
@@ -642,7 +610,11 @@ const TokensTable = () => {
showSizeChanger: true,
pageSizeOptions: [10, 20, 50, 100],
formatPageText: (page) =>
`${page.currentStart} - ${page.currentEnd} 条,共 ${tokens.length}`,
t('第 {{start}} - {{end}} 条,共 {{total}} 条', {
start: page.currentStart,
end: page.currentEnd,
total: tokens.length
}),
onPageSizeChange: (size) => {
setPageSize(size);
setActivePage(1);

View File

@@ -13,67 +13,69 @@ import { ITEMS_PER_PAGE } from '../constants';
import { renderGroup, renderNumber, renderQuota } from '../helpers/render';
import AddUser from '../pages/User/AddUser';
import EditUser from '../pages/User/EditUser';
function renderRole(role) {
switch (role) {
case 1:
return <Tag size='large'>普通用户</Tag>;
case 10:
return (
<Tag color='yellow' size='large'>
管理员
</Tag>
);
case 100:
return (
<Tag color='orange' size='large'>
超级管理员
</Tag>
);
default:
return (
<Tag color='red' size='large'>
未知身份
</Tag>
);
}
}
import { useTranslation } from 'react-i18next';
const UsersTable = () => {
const { t } = useTranslation();
function renderRole(role) {
switch (role) {
case 1:
return <Tag size='large'>{t('普通用户')}</Tag>;
case 10:
return (
<Tag color='yellow' size='large'>
{t('管理员')}
</Tag>
);
case 100:
return (
<Tag color='orange' size='large'>
{t('超级管理员')}
</Tag>
);
default:
return (
<Tag color='red' size='large'>
{t('未知身份')}
</Tag>
);
}
}
const columns = [
{
title: 'ID',
dataIndex: 'id',
},
{
title: '用户名',
title: t('用户名'),
dataIndex: 'username',
},
{
title: '分组',
title: t('分组'),
dataIndex: 'group',
render: (text, record, index) => {
return <div>{renderGroup(text)}</div>;
},
},
{
title: '统计信息',
title: t('统计信息'),
dataIndex: 'info',
render: (text, record, index) => {
return (
<div>
<Space spacing={1}>
<Tooltip content={'剩余额度'}>
<Tooltip content={t('剩余额度')}>
<Tag color='white' size='large'>
{renderQuota(record.quota)}
</Tag>
</Tooltip>
<Tooltip content={'已用额度'}>
<Tooltip content={t('已用额度')}>
<Tag color='white' size='large'>
{renderQuota(record.used_quota)}
</Tag>
</Tooltip>
<Tooltip content={'调用次数'}>
<Tooltip content={t('调用次数')}>
<Tag color='white' size='large'>
{renderNumber(record.request_count)}
</Tag>
@@ -84,26 +86,26 @@ const UsersTable = () => {
},
},
{
title: '邀请信息',
title: t('邀请信息'),
dataIndex: 'invite',
render: (text, record, index) => {
return (
<div>
<Space spacing={1}>
<Tooltip content={'邀请人数'}>
<Tooltip content={t('邀请人数')}>
<Tag color='white' size='large'>
{renderNumber(record.aff_count)}
</Tag>
</Tooltip>
<Tooltip content={'邀请总收益'}>
<Tooltip content={t('邀请总收益')}>
<Tag color='white' size='large'>
{renderQuota(record.aff_history_quota)}
</Tag>
</Tooltip>
<Tooltip content={'邀请人ID'}>
<Tooltip content={t('邀请人ID')}>
{record.inviter_id === 0 ? (
<Tag color='white' size='large'>
{t('无')}
</Tag>
) : (
<Tag color='white' size='large'>
@@ -117,20 +119,20 @@ const UsersTable = () => {
},
},
{
title: '角色',
title: t('角色'),
dataIndex: 'role',
render: (text, record, index) => {
return <div>{renderRole(text)}</div>;
},
},
{
title: '状态',
title: t('状态'),
dataIndex: 'status',
render: (text, record, index) => {
return (
<div>
{record.DeletedAt !== null ? (
<Tag color='red'>已注销</Tag>
<Tag color='red'>{t('已注销')}</Tag>
) : (
renderStatus(text)
)}
@@ -148,29 +150,25 @@ const UsersTable = () => {
) : (
<>
<Popconfirm
title='确定?'
title={t('确定?')}
okType={'warning'}
onConfirm={() => {
manageUser(record.id, 'promote', record);
}}
>
<Button theme='light' type='warning' style={{ marginRight: 1 }}>
提升
{t('提升')}
</Button>
</Popconfirm>
<Popconfirm
title='确定?'
title={t('确定?')}
okType={'warning'}
onConfirm={() => {
manageUser(record.id, 'demote', record);
}}
>
<Button
theme='light'
type='secondary'
style={{ marginRight: 1 }}
>
降级
<Button theme='light' type='secondary' style={{ marginRight: 1 }}>
{t('降级')}
</Button>
</Popconfirm>
{record.status === 1 ? (
@@ -182,7 +180,7 @@ const UsersTable = () => {
manageUser(record.id, 'disable', record);
}}
>
禁用
{t('禁用')}
</Button>
) : (
<Button
@@ -194,7 +192,7 @@ const UsersTable = () => {
}}
disabled={record.status === 3}
>
启用
{t('启用')}
</Button>
)}
<Button
@@ -206,11 +204,11 @@ const UsersTable = () => {
setShowEditUser(true);
}}
>
编辑
{t('编辑')}
</Button>
<Popconfirm
title='确定是否要注销此用户?'
content='相当于删除用户,此修改将不可逆'
title={t('确定是否要注销此用户?')}
content={t('相当于删除用户,此修改将不可逆')}
okType={'danger'}
position={'left'}
onConfirm={() => {
@@ -220,7 +218,7 @@ const UsersTable = () => {
}}
>
<Button theme='light' type='danger' style={{ marginRight: 1 }}>
注销
{t('注销')}
</Button>
</Popconfirm>
</>
@@ -327,17 +325,17 @@ const UsersTable = () => {
const renderStatus = (status) => {
switch (status) {
case 1:
return <Tag size='large'>已激活</Tag>;
return <Tag size='large'>{t('已激活')}</Tag>;
case 2:
return (
<Tag size='large' color='red'>
已封禁
{t('已封禁')}
</Tag>
);
default:
return (
<Tag size='large' color='grey'>
未知状态
{t('未知状态')}
</Tag>
);
}
@@ -452,41 +450,41 @@ const UsersTable = () => {
>
<div style={{ display: 'flex' }}>
<Space>
<Form.Input
label='搜索关键字'
icon='search'
field='keyword'
iconPosition='left'
placeholder='搜索用户的 ID用户名显示名称以及邮箱地址 ...'
value={searchKeyword}
loading={searching}
onChange={(value) => handleKeywordChange(value)}
/>
<Form.Select
field='group'
label='分组'
optionList={groupOptions}
onChange={(value) => {
setSearchGroup(value);
searchUsers(searchKeyword, value);
}}
/>
<Button
label='查询'
type='primary'
htmlType='submit'
className='btn-margin-right'
>
查询
</Button>
<Form.Input
label={t('搜索关键字')}
icon='search'
field='keyword'
iconPosition='left'
placeholder={t('搜索用户的 ID用户名显示名称以及邮箱地址 ...')}
value={searchKeyword}
loading={searching}
onChange={(value) => handleKeywordChange(value)}
/>
<Form.Select
field='group'
label={t('分组')}
optionList={groupOptions}
onChange={(value) => {
setSearchGroup(value);
searchUsers(searchKeyword, value);
}}
/>
<Button
theme='light'
type='primary'
onClick={() => {
setShowAddUser(true);
}}
label={t('查询')}
type='primary'
htmlType='submit'
className='btn-margin-right'
>
添加用户
{t('查询')}
</Button>
<Button
theme='light'
type='primary'
onClick={() => {
setShowAddUser(true);
}}
>
{t('添加用户')}
</Button>
</Space>
</div>
@@ -496,6 +494,12 @@ const UsersTable = () => {
columns={columns}
dataSource={pageData}
pagination={{
formatPageText: (page) =>
t('第 {{start}} - {{end}} 条,共 {{total}} 条', {
start: page.currentStart,
end: page.currentEnd,
total: users.length
}),
currentPage: activePage,
pageSize: ITEMS_PER_PAGE,
total: userCount,

View File

@@ -44,13 +44,6 @@ export const CHANNEL_OPTIONS = [
color: 'teal',
label: 'Azure OpenAI'
},
{
key: 24,
text: 'Google Gemini',
value: 24,
color: 'orange',
label: 'Google Gemini'
},
{
key: 34,
text: 'Cohere',
@@ -58,6 +51,8 @@ export const CHANNEL_OPTIONS = [
color: 'purple',
label: 'Cohere'
},
{ key: 39, text: 'Cloudflare', value: 39, color: 'grey', label: 'Cloudflare' },
{ key: 43, text: 'DeepSeek', value: 43, color: 'blue', label: 'DeepSeek' },
{
key: 15,
text: '百度文心千帆',
@@ -93,6 +88,13 @@ export const CHANNEL_OPTIONS = [
color: 'purple',
label: '智谱 GLM-4V'
},
{
key: 24,
text: 'Google Gemini',
value: 24,
color: 'orange',
label: 'Google Gemini'
},
{
key: 11,
text: 'Google PaLM2',
@@ -100,7 +102,6 @@ export const CHANNEL_OPTIONS = [
color: 'orange',
label: 'Google PaLM2'
},
{ key: 39, text: 'Cloudflare', value: 39, color: 'grey', label: 'Cloudflare' },
{ key: 25, text: 'Moonshot', value: 25, color: 'green', label: 'Moonshot' },
{ key: 19, text: '360 智脑', value: 19, color: 'blue', label: '360 智脑' },
{ key: 23, text: '腾讯混元', value: 23, color: 'teal', label: '腾讯混元' },

View File

@@ -0,0 +1,80 @@
// contexts/User/index.jsx
import React, { useState, useEffect } from 'react';
import { isMobile } from '../../helpers/index.js';
export const StyleContext = React.createContext({
dispatch: () => null,
});
export const StyleProvider = ({ children }) => {
const [state, setState] = useState({
isMobile: false,
showSider: false,
shouldInnerPadding: false,
});
const dispatch = (action) => {
if ('type' in action) {
switch (action.type) {
case 'TOGGLE_SIDER':
setState(prev => ({ ...prev, showSider: !prev.showSider }));
break;
case 'SET_SIDER':
setState(prev => ({ ...prev, showSider: action.payload }));
break;
case 'SET_MOBILE':
setState(prev => ({ ...prev, isMobile: action.payload }));
break;
case 'SET_INNER_PADDING':
setState(prev => ({ ...prev, shouldInnerPadding: action.payload }));
break;
default:
setState(prev => ({ ...prev, ...action }));
}
} else {
setState(prev => ({ ...prev, ...action }));
}
};
useEffect(() => {
const updateIsMobile = () => {
dispatch({ type: 'SET_MOBILE', payload: isMobile() });
};
updateIsMobile();
const updateShowSider = () => {
// check pathname
const pathname = window.location.pathname;
if (pathname === '' || pathname === '/' || pathname.includes('/home') || pathname.includes('/chat')) {
dispatch({ type: 'SET_SIDER', payload: false });
dispatch({ type: 'SET_INNER_PADDING', payload: false });
} else {
dispatch({ type: 'SET_SIDER', payload: true });
dispatch({ type: 'SET_INNER_PADDING', payload: true });
}
if (isMobile()) {
dispatch({ type: 'SET_SIDER', payload: false });
}
};
updateShowSider()
// Optionally, add event listeners to handle window resize
window.addEventListener('resize', updateIsMobile);
// Cleanup event listener on component unmount
return () => {
window.removeEventListener('resize', updateIsMobile);
};
}, []);
return (
<StyleContext.Provider value={[state, dispatch]}>
{children}
</StyleContext.Provider>
);
};

View File

@@ -1,3 +1,4 @@
import i18next from 'i18next';
import { Tag } from '@douyinfe/semi-ui';
export function renderText(text, limit) {
@@ -16,7 +17,7 @@ export function renderGroup(group) {
if (group === '') {
return (
<Tag size='large' key='default' color='orange'>
用户分组
{i18next.t('用户分组')}
</Tag>
);
}
@@ -144,14 +145,16 @@ export function renderModelPrice(
completionRatio,
groupRatio,
) {
// 1 ratio = $0.002 / 1K tokens
if (modelPrice !== -1) {
return '模型价格:$' + modelPrice + ' * 分组倍率:' + groupRatio + ' = $' + modelPrice * groupRatio;
return i18next.t('模型价格:${{price}} * 分组倍率:{{ratio}} = ${{total}}', {
price: modelPrice,
ratio: groupRatio,
total: modelPrice * groupRatio
});
} else {
if (completionRatio === undefined) {
completionRatio = 0;
}
// 这里的 *2 是因为 1倍率=0.002刀,请勿删除
let inputRatioPrice = modelRatio * 2.0;
let completionRatioPrice = modelRatio * 2.0 * completionRatio;
let price =
@@ -160,21 +163,52 @@ export function renderModelPrice(
return (
<>
<article>
<p>提示${inputRatioPrice} * {groupRatio} = ${inputRatioPrice * groupRatio} / 1M tokens</p>
<p>补全${completionRatioPrice} * {groupRatio} = ${completionRatioPrice * groupRatio} / 1M tokens</p>
<p>{i18next.t('提示:${{price}} * {{ratio}} = ${{total}} / 1M tokens', {
price: inputRatioPrice,
ratio: groupRatio,
total: inputRatioPrice * groupRatio
})}</p>
<p>{i18next.t('补全:${{price}} * {{ratio}} = ${{total}} / 1M tokens', {
price: completionRatioPrice,
ratio: groupRatio,
total: completionRatioPrice * groupRatio
})}</p>
<p></p>
<p>
提示 {inputTokens} tokens / 1M tokens * ${inputRatioPrice} + 补全{' '}
{completionTokens} tokens / 1M tokens * ${completionRatioPrice} * 分组 {groupRatio} =
${price.toFixed(6)}
{i18next.t('提示 {{input}} tokens / 1M tokens * ${{price}} + 补全 {{completion}} tokens / 1M tokens * ${{compPrice}} * 分组 {{ratio}} = ${{total}}', {
input: inputTokens,
price: inputRatioPrice,
completion: completionTokens,
compPrice: completionRatioPrice,
ratio: groupRatio,
total: price.toFixed(6)
})}
</p>
<p>仅供参考以实际扣费为准</p>
<p>{i18next.t('仅供参考,以实际扣费为准')}</p>
</article>
</>
);
}
}
export function renderModelPriceSimple(
modelRatio,
modelPrice = -1,
groupRatio,
) {
if (modelPrice !== -1) {
return i18next.t('价格:${{price}} * 分组:{{ratio}}', {
price: modelPrice,
ratio: groupRatio
});
} else {
return i18next.t('模型: {{ratio}} * 分组: {{groupRatio}}', {
ratio: modelRatio,
groupRatio: groupRatio
});
}
}
export function renderAudioModelPrice(
inputTokens,
completionTokens,
@@ -211,8 +245,12 @@ export function renderAudioModelPrice(
<p>音频补全${inputRatioPrice} * {groupRatio} * {audioRatio} * {audioCompletionRatio} = ${inputRatioPrice * audioRatio * audioCompletionRatio * groupRatio} / 1M tokens</p>
<p></p>
<p>
提示 {inputTokens} tokens / 1M tokens * ${inputRatioPrice} + 补全{' '}
{completionTokens} tokens / 1M tokens * ${completionRatioPrice} +
{i18next.t('提示 {{input}} tokens / 1M tokens * ${{price}} + 补全 {{completion}} tokens / 1M tokens * ${{compPrice}} +', {
input: inputTokens,
price: inputRatioPrice,
completion: completionTokens,
compPrice: completionRatioPrice
})}
</p>
<p>
音频提示 {audioInputTokens} tokens / 1M tokens * ${inputRatioPrice} * {audioRatio} + 音频补全 {audioCompletionTokens} tokens / 1M tokens * ${inputRatioPrice} * {audioRatio} * {audioCompletionRatio}
@@ -232,7 +270,7 @@ export function renderQuotaWithPrompt(quota, digits) {
let displayInCurrency = localStorage.getItem('display_in_currency');
displayInCurrency = displayInCurrency === 'true';
if (displayInCurrency) {
return `(等价金额:${renderQuota(quota, digits)}`;
return '|' + i18next.t('等价金额') + ': ' + renderQuota(quota, digits) + '';
}
return '';
}
@@ -255,6 +293,44 @@ const colors = [
'yellow',
];
// 基础10色色板 (N ≤ 10)
const baseColors = [
'#1664FF', // 主色
'#1AC6FF',
'#FF8A00',
'#3CC780',
'#7442D4',
'#FFC400',
'#304D77',
'#B48DEB',
'#009488',
'#FF7DDA'
];
// 扩展20色色板 (10 < N ≤ 20)
const extendedColors = [
'#1664FF',
'#B2CFFF',
'#1AC6FF',
'#94EFFF',
'#FF8A00',
'#FFCE7A',
'#3CC780',
'#B9EDCD',
'#7442D4',
'#DDC5FA',
'#FFC400',
'#FAE878',
'#304D77',
'#8B959E',
'#B48DEB',
'#EFE3FF',
'#009488',
'#59BAA8',
'#FF7DDA',
'#FFCFEE'
];
export const modelColorMap = {
'dall-e': 'rgb(147,112,219)', // 深紫色
// 'dall-e-2': 'rgb(147,112,219)', // 介于紫色和蓝色之间的色调
@@ -264,7 +340,7 @@ export const modelColorMap = {
'gpt-3.5-turbo-0613': 'rgb(60,179,113)', // 海洋绿
'gpt-3.5-turbo-1106': 'rgb(32,178,170)', // 浅海洋绿
'gpt-3.5-turbo-16k': 'rgb(149,252,206)', // 淡橙色
'gpt-3.5-turbo-16k-0613': 'rgb(119,255,214)', // 淡桃
'gpt-3.5-turbo-16k-0613': 'rgb(119,255,214)', // 淡桃<EFBFBD><EFBFBD><EFBFBD>
'gpt-3.5-turbo-instruct': 'rgb(175,238,238)', // 粉蓝色
'gpt-4': 'rgb(135,206,235)', // 天蓝色
// 'gpt-4-0314': 'rgb(70,130,180)', // 钢蓝色
@@ -287,7 +363,7 @@ export const modelColorMap = {
'text-embedding-ada-002': 'rgb(255,182,193)', // 浅粉红
'text-embedding-v1': 'rgb(255,174,185)', // 浅粉红色(略有区别)
'text-moderation-latest': 'rgb(255,130,171)', // 强粉色
'text-moderation-stable': 'rgb(255,160,122)', // 浅珊瑚色(Babbage相同表示同一类功能
'text-moderation-stable': 'rgb(255,160,122)', // 浅珊瑚色(<EFBFBD><EFBFBD><EFBFBD>Babbage相同表示同一类功能
'tts-1': 'rgb(255,140,0)', // 深橙色
'tts-1-1106': 'rgb(255,165,0)', // 橙色
'tts-1-hd': 'rgb(255,215,0)', // 金色
@@ -299,14 +375,33 @@ export const modelColorMap = {
'claude-2.1': 'rgb(255,209,190)', // 浅橙色(略有区别)
};
export function modelToColor(modelName) {
// 1. 如果模型在预定义的 modelColorMap 中,使用预定义颜色
if (modelColorMap[modelName]) {
return modelColorMap[modelName];
}
// 2. 生成一个稳定的数字作为索引
let hash = 0;
for (let i = 0; i < modelName.length; i++) {
hash = ((hash << 5) - hash) + modelName.charCodeAt(i);
hash = hash & hash; // Convert to 32-bit integer
}
hash = Math.abs(hash);
// 3. 根据模型名称长度选择不同的色板
const colorPalette = modelName.length > 10 ? extendedColors : baseColors;
// 4. 使用hash值选择颜色
const index = hash % colorPalette.length;
return colorPalette[index];
}
export function stringToColor(str) {
let sum = 0;
// 对字符串中的每个字符进行操作
for (let i = 0; i < str.length; i++) {
// 将字符的ASCII值加到sum中
sum += str.charCodeAt(i);
}
// 使用模运算得到个位数
let i = sum % colors.length;
return colors[i];
}

26
web/src/i18n/i18n.js Normal file
View File

@@ -0,0 +1,26 @@
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import LanguageDetector from 'i18next-browser-languagedetector';
import enTranslation from './locales/en.json';
import zhTranslation from './locales/zh.json';
i18n
.use(LanguageDetector)
.use(initReactI18next)
.init({
resources: {
en: {
translation: enTranslation
},
zh: {
translation: zhTranslation
}
},
fallbackLng: 'zh',
interpolation: {
escapeValue: false
}
});
export default i18n;

File diff suppressed because it is too large Load Diff

1238
web/src/i18n/locales/en.json Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,13 @@
{
"首页": "首页",
"控制台": "控制台",
"定价": "定价",
"关于": "关于",
"登录": "登录",
"注册": "注册",
"退出": "退出",
"语言": "语言",
"展开侧边栏": "展开侧边栏",
"关闭侧边栏": "关闭侧边栏",
"注销成功!": "注销成功!"
}

View File

@@ -17,7 +17,36 @@ body {
flex-direction: column;
}
#root > section > header > section > div > div > div > div.semi-navigation-header-list-outer > div.semi-navigation-list-wrapper > ul > div > a > li > span{
font-weight: 600 !important;
}
.semi-descriptions-double-small .semi-descriptions-item {
padding-right: 30px;
}
@media only screen and (max-width: 767px) {
/*.semi-navigation-sub-wrap .semi-navigation-sub-title, .semi-navigation-item {*/
/* padding: 0 0;*/
/*}*/
.topnav .semi-navigation-list-wrapper {
max-width: calc(55vw - 20px);
overflow-x: auto;
scrollbar-width: none;
}
#root > section > header > section > div > div > div > div.semi-navigation-footer > div > a > li {
padding: 0 0;
}
#root > section > header > section > div > div > div > div.semi-navigation-header-list-outer > div.semi-navigation-list-wrapper > ul > div > a > li {
padding: 0 5px;
}
#root > section > header > section > div > div > div > div.semi-navigation-footer > div:nth-child(1) > a > li {
padding: 0 5px;
}
.semi-navigation-footer {
padding-left: 0;
padding-right: 0;
}
.semi-table-tbody,
.semi-table-row,
.semi-table-row-cell {
@@ -39,6 +68,10 @@ body {
row-gap: 3px;
column-gap: 10px;
}
.semi-navigation-horizontal .semi-navigation-header {
margin-right: 0;
}
}
.semi-table-tbody > .semi-table-row > .semi-table-row-cell {
@@ -83,11 +116,6 @@ code {
monospace;
}
.semi-navigation-vertical {
/*display: flex;*/
/*flex-direction: column;*/
}
.semi-navigation-item {
margin-bottom: 0;
}

View File

@@ -13,6 +13,9 @@ import { Layout } from '@douyinfe/semi-ui';
import SiderBar from './components/SiderBar';
import { ThemeProvider } from './context/Theme';
import FooterBar from './components/Footer';
import { StyleProvider } from './context/Style/index.js';
import PageLayout from './components/PageLayout.js';
import './i18n/i18n.js';
// initialization
@@ -24,27 +27,9 @@ root.render(
<UserProvider>
<BrowserRouter>
<ThemeProvider>
<Layout style={{ height: '100vh', display: 'flex', flexDirection: 'column' }}>
<Header>
<HeaderBar />
</Header>
<Layout style={{ flex: 1, overflow: 'hidden' }}>
<Sider>
<SiderBar />
</Sider>
<Layout>
<Content
style={{ overflowY: 'auto', padding: '24px' }}
>
<App />
</Content>
<Layout.Footer>
<FooterBar></FooterBar>
</Layout.Footer>
</Layout>
</Layout>
<ToastContainer />
</Layout>
<StyleProvider>
<PageLayout/>
</StyleProvider>
</ThemeProvider>
</BrowserRouter>
</UserProvider>

View File

@@ -1,5 +1,6 @@
import React, { useEffect, useRef, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import {
API,
isMobile,
@@ -61,6 +62,7 @@ function type2secretPrompt(type) {
}
const EditChannel = (props) => {
const { t } = useTranslation();
const navigate = useNavigate();
const channelId = props.editingChannel.id;
const isEdit = channelId !== undefined;
@@ -192,7 +194,7 @@ const EditChannel = (props) => {
const fetchUpstreamModelList = async (name) => {
if (inputs['type'] !== 1) {
showError('仅支持 OpenAI 接口格式');
showError(t('仅支持 OpenAI 接口格式'));
return;
}
setLoading(true);
@@ -207,7 +209,7 @@ const EditChannel = (props) => {
}
} else {
if (!inputs?.['key']) {
showError('请填写密钥');
showError(t('请填写密钥'));
err = true;
} else {
try {
@@ -220,7 +222,7 @@ const EditChannel = (props) => {
'Authorization': `Bearer ${key}`
}
});
if (res.data && res.data?.success) {
if (res.data) {
models.push(...res.data.data.map((model) => model.id));
} else {
err = true;
@@ -232,9 +234,9 @@ const EditChannel = (props) => {
}
if (!err) {
handleInputChange(name, Array.from(new Set(models)));
showSuccess('获取模型列表成功');
showSuccess(t('获取模型列表成功'));
} else {
showError('获取模型列表失败');
showError(t('获取模型列表失败'));
}
setLoading(false);
};
@@ -305,15 +307,15 @@ const EditChannel = (props) => {
const submit = async () => {
if (!isEdit && (inputs.name === '' || inputs.key === '')) {
showInfo('请填写渠道名称和渠道密钥!');
showInfo(t('请填写渠道名称和渠道密钥!'));
return;
}
if (inputs.models.length === 0) {
showInfo('请至少选择一个模型!');
showInfo(t('请至少选择一个模型!'));
return;
}
if (inputs.model_mapping !== '' && !verifyJSON(inputs.model_mapping)) {
showInfo('模型映射必须是合法的 JSON 格式!');
showInfo(t('模型映射必须是合法的 JSON 格式!'));
return;
}
let localInputs = { ...inputs };
@@ -331,7 +333,7 @@ const EditChannel = (props) => {
}
let res;
if (!Array.isArray(localInputs.models)) {
showError('提交失败,请勿重复提交!');
showError(t('提交失败,请勿重复提交!'));
handleCancel();
return;
}
@@ -349,9 +351,9 @@ const EditChannel = (props) => {
const { success, message } = res.data;
if (success) {
if (isEdit) {
showSuccess('渠道更新成功!');
showSuccess(t('渠道更新成功!'));
} else {
showSuccess('渠道创建成功!');
showSuccess(t('渠道创建成功!'));
setInputs(originInputs);
}
props.refresh();
@@ -363,7 +365,6 @@ const EditChannel = (props) => {
const addCustomModels = () => {
if (customModel.trim() === '') return;
// 使用逗号分隔字符串,然后去除每个模型名称前后的空格
const modelArray = customModel.split(',').map((model) => model.trim());
let localModels = [...inputs.models];
@@ -371,24 +372,21 @@ const EditChannel = (props) => {
let hasError = false;
modelArray.forEach((model) => {
// 检查模型是否已存在,且模型名称非空
if (model && !localModels.includes(model)) {
localModels.push(model); // 添加到模型列表
localModels.push(model);
localModelOptions.push({
// 添加到下拉选项
key: model,
text: model,
value: model
});
} else if (model) {
showError('某些模型已存在!');
showError(t('某些模型已存在!'));
hasError = true;
}
});
if (hasError) return; // 如果有错误则终止操作
if (hasError) return;
// 更新状态值
setModelOptions(localModelOptions);
setCustomModel('');
handleInputChange('models', localModels);
@@ -401,7 +399,7 @@ const EditChannel = (props) => {
maskClosable={false}
placement={isEdit ? 'right' : 'left'}
title={
<Title level={3}>{isEdit ? '更新渠道信息' : '创建新的渠道'}</Title>
<Title level={3}>{isEdit ? t('更新渠道信息') : t('创建新的渠道')}</Title>
}
headerStyle={{ borderBottom: '1px solid var(--semi-color-border)' }}
bodyStyle={{ borderBottom: '1px solid var(--semi-color-border)' }}
@@ -410,7 +408,7 @@ const EditChannel = (props) => {
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<Space>
<Button theme="solid" size={'large'} onClick={submit}>
提交
{t('提交')}
</Button>
<Button
theme="solid"
@@ -418,7 +416,7 @@ const EditChannel = (props) => {
type={'tertiary'}
onClick={handleCancel}
>
取消
{t('取消')}
</Button>
</Space>
</div>
@@ -429,7 +427,7 @@ const EditChannel = (props) => {
>
<Spin spinning={loading}>
<div style={{ marginTop: 10 }}>
<Typography.Text strong>类型</Typography.Text>
<Typography.Text strong>{t('类型')}</Typography.Text>
</div>
<Select
name="type"
@@ -444,20 +442,7 @@ const EditChannel = (props) => {
<div style={{ marginTop: 10 }}>
<Banner
type={'warning'}
description={
<>
注意<strong>模型部署名称必须和模型名称保持一致</strong>
因为 One API 会把请求体中的 model
参数替换为你的部署名称模型名称中的点会被剔除
<a
target="_blank"
href="https://github.com/songquanpeng/one-api/issues/133?notification_referrer_id=NT_kwDOAmJSYrM2NjIwMzI3NDgyOjM5OTk4MDUw#issuecomment-1571602271"
>
图片演示
</a>
</>
}
description={t('注意,模型部署名称必须和模型名称保持一致,因为 One API 会把请求体中的 model 参数替换为你的部署名称(模型名称中的点会被剔除)')}
></Banner>
</div>
<div style={{ marginTop: 10 }}>
@@ -468,9 +453,7 @@ const EditChannel = (props) => {
<Input
label="AZURE_OPENAI_ENDPOINT"
name="azure_base_url"
placeholder={
'请输入 AZURE_OPENAI_ENDPOINT例如https://docs-test-001.openai.azure.com'
}
placeholder={t('请输入 AZURE_OPENAI_ENDPOINT例如https://docs-test-001.openai.azure.com')}
onChange={(value) => {
handleInputChange('base_url', value);
}}
@@ -478,14 +461,12 @@ const EditChannel = (props) => {
autoComplete="new-password"
/>
<div style={{ marginTop: 10 }}>
<Typography.Text strong>默认 API 版本</Typography.Text>
<Typography.Text strong>{t('默认 API 版本')}</Typography.Text>
</div>
<Input
label="默认 API 版本"
label={t('默认 API 版本')}
name="azure_other"
placeholder={
'请输入默认 API 版本例如2023-06-01-preview该配置可以被实际的请求查询参数所覆盖'
}
placeholder={t('请输入默认 API 版本例如2023-06-01-preview该配置可以被实际的请求查询参数所覆盖')}
onChange={(value) => {
handleInputChange('other', value);
}}
@@ -499,23 +480,17 @@ const EditChannel = (props) => {
<div style={{ marginTop: 10 }}>
<Banner
type={'warning'}
description={
<>
如果你对接的是上游One API或者New API等转发项目请使用OpenAI类型不要使用此类型除非你知道你在做什么
</>
}
description={t('如果你对接的是上游One API或者New API等转发项目请使用OpenAI类型不要使用此类型除非你知道你在做什么。')}
></Banner>
</div>
<div style={{ marginTop: 10 }}>
<Typography.Text strong>
完整的 Base URL支持变量{'{model}'}
{t('完整的 Base URL支持变量{model}')}
</Typography.Text>
</div>
<Input
name="base_url"
placeholder={
'请输入完整的URL例如https://api.openai.com/v1/chat/completions'
}
placeholder={t('请输入完整的URL例如https://api.openai.com/v1/chat/completions')}
onChange={(value) => {
handleInputChange('base_url', value);
}}
@@ -527,12 +502,12 @@ const EditChannel = (props) => {
{inputs.type !== 3 && inputs.type !== 8 && inputs.type !== 22 && inputs.type !== 36 && (
<>
<div style={{ marginTop: 10 }}>
<Typography.Text strong>代理</Typography.Text>
<Typography.Text strong>{t('代理')}</Typography.Text>
</div>
<Input
label="代理"
label={t('代理')}
name="base_url"
placeholder={'此项可选,用于通过代理站来进行 API 调用'}
placeholder={t('此项可选,用于通过代理站来进行 API 调用')}
onChange={(value) => {
handleInputChange('base_url', value);
}}
@@ -544,13 +519,11 @@ const EditChannel = (props) => {
{inputs.type === 22 && (
<>
<div style={{ marginTop: 10 }}>
<Typography.Text strong>私有部署地址</Typography.Text>
<Typography.Text strong>{t('私有部署地址')}</Typography.Text>
</div>
<Input
name="base_url"
placeholder={
'请输入私有部署地址格式为https://fastgpt.run/api/openapi'
}
placeholder={t('请输入私有部署地址格式为https://fastgpt.run/api/openapi')}
onChange={(value) => {
handleInputChange('base_url', value);
}}
@@ -563,14 +536,12 @@ const EditChannel = (props) => {
<>
<div style={{ marginTop: 10 }}>
<Typography.Text strong>
注意非Chat API请务必填写正确的API地址否则可能导致无法使用
{t('注意非Chat API请务必填写正确的API地址否则可能导致无法使用')}
</Typography.Text>
</div>
<Input
name="base_url"
placeholder={
'请输入到 /suno 前的路径通常就是域名例如https://api.example.com '
}
placeholder={t('请输入到 /suno 前的路径通常就是域名例如https://api.example.com')}
onChange={(value) => {
handleInputChange('base_url', value);
}}
@@ -580,12 +551,12 @@ const EditChannel = (props) => {
</>
)}
<div style={{ marginTop: 10 }}>
<Typography.Text strong>名称</Typography.Text>
<Typography.Text strong>{t('名称')}</Typography.Text>
</div>
<Input
required
name="name"
placeholder={'请为渠道命名'}
placeholder={t('请为渠道命名')}
onChange={(value) => {
handleInputChange('name', value);
}}
@@ -593,16 +564,16 @@ const EditChannel = (props) => {
autoComplete="new-password"
/>
<div style={{ marginTop: 10 }}>
<Typography.Text strong>分组</Typography.Text>
<Typography.Text strong>{t('分组')}</Typography.Text>
</div>
<Select
placeholder={'请选择可以使用该渠道的分组'}
placeholder={t('请选择可以使用该渠道的分组')}
name="groups"
required
multiple
selection
allowAdditions
additionLabel={'请在系统设置页面编辑分组倍率以添加新的分组:'}
additionLabel={t('请在系统设置页面编辑分组倍率以添加新的分组:')}
onChange={(value) => {
handleInputChange('groups', value);
}}
@@ -631,17 +602,15 @@ const EditChannel = (props) => {
{inputs.type === 41 && (
<>
<div style={{ marginTop: 10 }}>
<Typography.Text strong>部署地区</Typography.Text>
<Typography.Text strong>{t('部署地区')}</Typography.Text>
</div>
<TextArea
name="other"
placeholder={
'请输入部署地区例如us-central1\n支持使用模型映射格式\n' +
placeholder={t('请输入部署地区例如us-central1\n支持使用模型映射格式\n' +
'{\n' +
' "default": "us-central1",\n' +
' "claude-3-5-sonnet-20240620": "europe-west1"\n' +
'}'
}
'}')}
autosize={{ minRows: 2 }}
onChange={(value) => {
handleInputChange('other', value);
@@ -662,7 +631,7 @@ const EditChannel = (props) => {
);
}}
>
填入模板
{t('填入模板')}
</Typography.Text>
</>
)}
@@ -702,7 +671,7 @@ const EditChannel = (props) => {
</>
)}
<div style={{ marginTop: 10 }}>
<Typography.Text strong>模型</Typography.Text>
<Typography.Text strong>{t('模型')}</Typography.Text>
</div>
<Select
placeholder={'请选择该渠道所支持的模型'}
@@ -710,6 +679,8 @@ const EditChannel = (props) => {
required
multiple
selection
filter
searchPosition='dropdown'
onChange={(value) => {
handleInputChange('models', value);
}}
@@ -725,7 +696,7 @@ const EditChannel = (props) => {
handleInputChange('models', basicModels);
}}
>
填入相关模型
{t('填入相关模型')}
</Button>
<Button
type="secondary"
@@ -733,16 +704,16 @@ const EditChannel = (props) => {
handleInputChange('models', fullModels);
}}
>
填入所有模型
{t('填入所有模型')}
</Button>
<Tooltip content={fetchButtonTips}>
<Tooltip content={t('新建渠道时,请求通过当前浏览器发出;编辑已有渠道,请求通过后端服务器发出')}>
<Button
type="tertiary"
onClick={() => {
fetchUpstreamModelList('models');
}}
>
获取模型列表
{t('获取模型列表')}
</Button>
</Tooltip>
<Button
@@ -751,16 +722,16 @@ const EditChannel = (props) => {
handleInputChange('models', []);
}}
>
清除所有模型
{t('清除所有模型')}
</Button>
</Space>
<Input
addonAfter={
<Button type="primary" onClick={addCustomModels}>
填入
{t('填入')}
</Button>
}
placeholder="输入自定义模型名称"
placeholder={t('输入自定义模型名称')}
value={customModel}
onChange={(value) => {
setCustomModel(value.trim());
@@ -768,10 +739,10 @@ const EditChannel = (props) => {
/>
</div>
<div style={{ marginTop: 10 }}>
<Typography.Text strong>模型重定向</Typography.Text>
<Typography.Text strong>{t('模型重定向')}</Typography.Text>
</div>
<TextArea
placeholder={`此项可选,用于修改请求体中的模型名称,为一个 JSON 字符串,键为请求中模型名称,值为要替换的模型名称,例如:\n${JSON.stringify(MODEL_MAPPING_EXAMPLE, null, 2)}`}
placeholder={t('此项可选,用于修改请求体中的模型名称,为一个 JSON 字符串,键为请求中模型名称,值为要替换的模型名称,例如:') + `\n${JSON.stringify(MODEL_MAPPING_EXAMPLE, null, 2)}`}
name="model_mapping"
onChange={(value) => {
handleInputChange('model_mapping', value);
@@ -793,17 +764,17 @@ const EditChannel = (props) => {
);
}}
>
填入模板
{t('填入模板')}
</Typography.Text>
<div style={{ marginTop: 10 }}>
<Typography.Text strong>密钥</Typography.Text>
<Typography.Text strong>{t('密钥')}</Typography.Text>
</div>
{batch ? (
<TextArea
label="密钥"
label={t('密钥')}
name="key"
required
placeholder={'请输入密钥,一行一个'}
placeholder={t('请输入密钥,一行一个')}
onChange={(value) => {
handleInputChange('key', value);
}}
@@ -815,7 +786,7 @@ const EditChannel = (props) => {
<>
{inputs.type === 41 ? (
<TextArea
label="鉴权json"
label={t('鉴权json')}
name="key"
required
placeholder={'{\n' +
@@ -840,18 +811,17 @@ const EditChannel = (props) => {
/>
) : (
<Input
label="密钥"
label={t('密钥')}
name="key"
required
placeholder={type2secretPrompt(inputs.type)}
placeholder={t(type2secretPrompt(inputs.type))}
onChange={(value) => {
handleInputChange('key', value);
}}
value={inputs.key}
autoComplete="new-password"
/>
)
}
)}
</>
)}
{!isEdit && (
@@ -859,23 +829,23 @@ const EditChannel = (props) => {
<Space>
<Checkbox
checked={batch}
label="批量创建"
label={t('批量创建')}
name="batch"
onChange={() => setBatch(!batch)}
/>
<Typography.Text strong>批量创建</Typography.Text>
<Typography.Text strong>{t('批量创建')}</Typography.Text>
</Space>
</div>
)}
{inputs.type === 1 && (
<>
<div style={{ marginTop: 10 }}>
<Typography.Text strong>组织</Typography.Text>
<Typography.Text strong>{t('组织')}</Typography.Text>
</div>
<Input
label="组织,可选,不填则为默认组织"
label={t('组织,可选,不填则为默认组织')}
name="openai_organization"
placeholder="请输入组织org-xxx"
placeholder={t('请输入组织org-xxx')}
onChange={(value) => {
handleInputChange('openai_organization', value);
}}
@@ -884,11 +854,11 @@ const EditChannel = (props) => {
</>
)}
<div style={{ marginTop: 10 }}>
<Typography.Text strong>默认测试模型</Typography.Text>
<Typography.Text strong>{t('默认测试模型')}</Typography.Text>
</div>
<Input
name="test_model"
placeholder="不填则为模型列表第一个"
placeholder={t('不填则为模型列表第一个')}
onChange={(value) => {
handleInputChange('test_model', value);
}}
@@ -902,20 +872,20 @@ const EditChannel = (props) => {
onChange={() => {
setAutoBan(!autoBan);
}}
// onChange={handleInputChange}
/>
<Typography.Text strong>
是否自动禁用仅当自动禁用开启时有效关闭后不会自动禁用该渠道
{t('是否自动禁用仅当自动禁用开启时有效关闭后不会自动禁用该渠道:')}
</Typography.Text>
</Space>
</div>
<div style={{ marginTop: 10 }}>
<Typography.Text strong>
状态码复写仅影响本地判断不修改返回到上游的状态码
{t('状态码复写(仅影响本地判断不修改返回到上游的状态码)')}
</Typography.Text>
</div>
<TextArea
placeholder={`此项可选用于复写返回的状态码比如将claude渠道的400错误复写为500用于重试请勿滥用该功能例如\n${JSON.stringify(STATUS_CODE_MAPPING_EXAMPLE, null, 2)}`}
placeholder={t('此项可选用于复写返回的状态码比如将claude渠道的400错误复写为500用于重试请勿滥用该功能例如') +
'\n' + JSON.stringify(STATUS_CODE_MAPPING_EXAMPLE, null, 2)}
name="status_code_mapping"
onChange={(value) => {
handleInputChange('status_code_mapping', value);
@@ -937,17 +907,17 @@ const EditChannel = (props) => {
);
}}
>
填入模板
{t('填入模板')}
</Typography.Text>
<div style={{ marginTop: 10 }}>
<Typography.Text strong>
渠道标签
{t('渠道标签')}
</Typography.Text>
</div>
<Input
label="渠道标签"
label={t('渠道标签')}
name="tag"
placeholder={'渠道标签'}
placeholder={t('渠道标签')}
onChange={(value) => {
handleInputChange('tag', value);
}}
@@ -956,34 +926,80 @@ const EditChannel = (props) => {
/>
<div style={{ marginTop: 10 }}>
<Typography.Text strong>
渠道优先级
{t('渠道优先级')}
</Typography.Text>
</div>
<Input
label="渠道优先级"
label={t('渠道优先级')}
name="priority"
placeholder={'渠道优先级'}
placeholder={t('渠道优先级')}
onChange={(value) => {
handleInputChange('priority', parseInt(value));
const number = parseInt(value);
if (isNaN(number)) {
handleInputChange('priority', value);
} else {
handleInputChange('priority', number);
}
}}
value={inputs.priority}
autoComplete="new-password"
/>
<div style={{ marginTop: 10 }}>
<Typography.Text strong>
渠道权重
{t('渠道权重')}
</Typography.Text>
</div>
<Input
label="渠道权重"
label={t('渠道权重')}
name="weight"
placeholder={'渠道权重'}
placeholder={t('渠道权重')}
onChange={(value) => {
handleInputChange('weight', parseInt(value));
const number = parseInt(value);
if (isNaN(number)) {
handleInputChange('weight', value);
} else {
handleInputChange('weight', number);
}
}}
value={inputs.weight}
autoComplete="new-password"
/>
{inputs.type === 8 && (
<>
<div style={{ marginTop: 10 }}>
<Typography.Text strong>
{t('渠道额外设置')}
</Typography.Text>
</div>
<TextArea
placeholder={t('此项可选,用于配置渠道特定设置,为一个 JSON 字符串,例如:') + '\n{\n "force_format": true\n}'}
name="setting"
onChange={(value) => {
handleInputChange('setting', value);
}}
autosize
value={inputs.setting}
autoComplete="new-password"
/>
<Typography.Text
style={{
color: 'rgba(var(--semi-blue-5), 1)',
userSelect: 'none',
cursor: 'pointer'
}}
onClick={() => {
handleInputChange(
'setting',
JSON.stringify({
force_format: true
}, null, 2)
);
}}
>
{t('填入模板')}
</Typography.Text>
</>
)}
</Spin>
</SideSheet>
</>

View File

@@ -16,6 +16,7 @@ const EditTagModal = (props) => {
const [groupOptions, setGroupOptions] = useState([]);
const [basicModels, setBasicModels] = useState([]);
const [fullModels, setFullModels] = useState([]);
const [customModel, setCustomModel] = useState('');
const originInputs = {
tag: '',
new_tag: null,
@@ -183,6 +184,40 @@ const EditTagModal = (props) => {
fetchGroups().then();
}, [visible]);
const addCustomModels = () => {
if (customModel.trim() === '') return;
// 使用逗号分隔字符串,然后去除每个模型名称前后的空格
const modelArray = customModel.split(',').map((model) => model.trim());
let localModels = [...inputs.models];
let localModelOptions = [...modelOptions];
let hasError = false;
modelArray.forEach((model) => {
// 检查模型是否已存在,且模型名称非空
if (model && !localModels.includes(model)) {
localModels.push(model); // 添加到模型列表
localModelOptions.push({
// 添加到下拉选项
key: model,
text: model,
value: model
});
} else if (model) {
showError('某些模型已存在!');
hasError = true;
}
});
if (hasError) return; // 如果有错误则终止操作
// 更新状态值
setModelOptions(localModelOptions);
setCustomModel('');
handleInputChange('models', localModels);
};
return (
<SideSheet
title="编辑标签"
@@ -209,7 +244,7 @@ const EditTagModal = (props) => {
</div>
<Spin spinning={loading}>
<TextInput
label="标签,留空则不更改"
label="标签,留空则解散标签"
name="newTag"
value={inputs.new_tag}
onChange={(value) => setInputs({ ...inputs, new_tag: value })}
@@ -224,6 +259,8 @@ const EditTagModal = (props) => {
required
multiple
selection
filter
searchPosition='dropdown'
onChange={(value) => {
handleInputChange('models', value);
}}
@@ -231,6 +268,18 @@ const EditTagModal = (props) => {
autoComplete="new-password"
optionList={modelOptions}
/>
<Input
addonAfter={
<Button type="primary" onClick={addCustomModels}>
填入
</Button>
}
placeholder="输入自定义模型名称"
value={customModel}
onChange={(value) => {
setCustomModel(value.trim());
}}
/>
<div style={{ marginTop: 10 }}>
<Typography.Text strong>分组留空则不更改</Typography.Text>
</div>

View File

@@ -1,18 +1,22 @@
import React from 'react';
import ChannelsTable from '../../components/ChannelsTable';
import { Layout } from '@douyinfe/semi-ui';
import { useTranslation } from 'react-i18next';
const File = () => (
<>
<Layout>
<Layout.Header>
<h3>管理渠道</h3>
const File = () => {
const { t } = useTranslation();
return (
<>
<Layout>
<Layout.Header>
<h3>{t('管理渠道')}</h3>
</Layout.Header>
<Layout.Content>
<ChannelsTable />
</Layout.Content>
</Layout>
</>
);
</Layout.Content>
</Layout>
</>
);
};
export default File;

View File

@@ -1,8 +1,8 @@
import React, { useEffect, useRef, useState } from 'react';
import React, { useContext, useEffect, useRef, useState } from 'react';
import { initVChartSemiTheme } from '@visactor/vchart-semi-theme';
import { Button, Col, Form, Layout, Row, Spin } from '@douyinfe/semi-ui';
import VChart from '@visactor/vchart';
import { Button, Card, Col, Descriptions, Form, Layout, Row, Spin, Tabs } from '@douyinfe/semi-ui';
import { VChart } from "@visactor/react-vchart";
import {
API,
isAdmin,
@@ -17,11 +17,18 @@ import {
renderQuota,
renderQuotaNumberWithDigit,
stringToColor,
modelToColor,
} from '../../helpers/render';
import { UserContext } from '../../context/User/index.js';
import { StyleContext } from '../../context/Style/index.js';
import { useTranslation } from 'react-i18next';
const Detail = (props) => {
const { t } = useTranslation();
const formRef = useRef();
let now = new Date();
const [userState, userDispatch] = useContext(UserContext);
const [styleState, styleDispatch] = useContext(StyleContext);
const [inputs, setInputs] = useState({
username: '',
token_name: '',
@@ -40,32 +47,76 @@ const Detail = (props) => {
inputs;
const isAdminUser = isAdmin();
const initialized = useRef(false);
const [modelDataChart, setModelDataChart] = useState(null);
const [modelDataPieChart, setModelDataPieChart] = useState(null);
const [loading, setLoading] = useState(false);
const [quotaData, setQuotaData] = useState([]);
const [consumeQuota, setConsumeQuota] = useState(0);
const [consumeTokens, setConsumeTokens] = useState(0);
const [times, setTimes] = useState(0);
const [dataExportDefaultTime, setDataExportDefaultTime] = useState(
localStorage.getItem('data_export_default_time') || 'hour',
);
const handleInputChange = (value, name) => {
if (name === 'data_export_default_time') {
setDataExportDefaultTime(value);
return;
}
setInputs((inputs) => ({ ...inputs, [name]: value }));
};
const spec_line = {
type: 'bar',
data: [
{
id: 'barData',
values: [],
const [pieData, setPieData] = useState([{ type: 'null', value: '0' }]);
const [lineData, setLineData] = useState([]);
const [spec_pie, setSpecPie] = useState({
type: 'pie',
data: [{
id: 'id0',
values: pieData
}],
outerRadius: 0.8,
innerRadius: 0.5,
padAngle: 0.6,
valueField: 'value',
categoryField: 'type',
pie: {
style: {
cornerRadius: 10,
},
],
state: {
hover: {
outerRadius: 0.85,
stroke: '#000',
lineWidth: 1,
},
selected: {
outerRadius: 0.85,
stroke: '#000',
lineWidth: 1,
},
},
},
title: {
visible: true,
text: t('模型调用次数占比'),
subtext: `${t('总计')}${renderNumber(times)}`,
},
legends: {
visible: true,
orient: 'left',
},
label: {
visible: true,
},
tooltip: {
mark: {
content: [
{
key: (datum) => datum['type'],
value: (datum) => renderNumber(datum['value']),
},
],
},
},
color: {
specified: modelColorMap,
},
});
const [spec_line, setSpecLine] = useState({
type: 'bar',
data: [{
id: 'barData',
values: lineData
}],
xField: 'Time',
yField: 'Usage',
seriesField: 'Model',
@@ -76,11 +127,10 @@ const Detail = (props) => {
},
title: {
visible: true,
text: '模型消耗分布',
subtext: '0',
text: t('模型消耗分布'),
subtext: `${t('总计')}${renderQuota(consumeQuota, 2)}`,
},
bar: {
// The state style of bar
state: {
hover: {
stroke: '#000',
@@ -106,9 +156,7 @@ const Detail = (props) => {
},
],
updateContent: (array) => {
// sort by value
array.sort((a, b) => b.value - a.value);
// add $
let sum = 0;
for (let i = 0; i < array.length; i++) {
sum += parseFloat(array[i].value);
@@ -117,9 +165,8 @@ const Detail = (props) => {
4,
);
}
// add to first
array.unshift({
key: '总计',
key: t('总计'),
value: renderQuotaNumberWithDigit(sum, 4),
});
return array;
@@ -129,196 +176,197 @@ const Detail = (props) => {
color: {
specified: modelColorMap,
},
});
// 添加一个新的状态来存储模型-颜色映射
const [modelColors, setModelColors] = useState({});
const handleInputChange = (value, name) => {
if (name === 'data_export_default_time') {
setDataExportDefaultTime(value);
return;
}
setInputs((inputs) => ({ ...inputs, [name]: value }));
};
const spec_pie = {
type: 'pie',
data: [
{
id: 'id0',
values: [{ type: 'null', value: '0' }],
},
],
outerRadius: 0.8,
innerRadius: 0.5,
padAngle: 0.6,
valueField: 'value',
categoryField: 'type',
pie: {
style: {
cornerRadius: 10,
},
state: {
hover: {
outerRadius: 0.85,
stroke: '#000',
lineWidth: 1,
},
selected: {
outerRadius: 0.85,
stroke: '#000',
lineWidth: 1,
},
},
},
title: {
visible: true,
text: '模型调用次数占比',
},
legends: {
visible: true,
orient: 'left',
},
label: {
visible: true,
},
tooltip: {
mark: {
content: [
{
key: (datum) => datum['type'],
value: (datum) => renderNumber(datum['value']),
},
],
},
},
color: {
specified: modelColorMap,
},
};
const loadQuotaData = async (lineChart, pieChart) => {
const loadQuotaData = async () => {
setLoading(true);
let url = '';
let localStartTimestamp = Date.parse(start_timestamp) / 1000;
let localEndTimestamp = Date.parse(end_timestamp) / 1000;
if (isAdminUser) {
url = `/api/data/?username=${username}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}&default_time=${dataExportDefaultTime}`;
} else {
url = `/api/data/self/?start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}&default_time=${dataExportDefaultTime}`;
}
const res = await API.get(url);
const { success, message, data } = res.data;
if (success) {
setQuotaData(data);
if (data.length === 0) {
data.push({
count: 0,
model_name: '无数据',
quota: 0,
created_at: now.getTime() / 1000,
try {
let url = '';
let localStartTimestamp = Date.parse(start_timestamp) / 1000;
let localEndTimestamp = Date.parse(end_timestamp) / 1000;
if (isAdminUser) {
url = `/api/data/?username=${username}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}&default_time=${dataExportDefaultTime}`;
} else {
url = `/api/data/self/?start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}&default_time=${dataExportDefaultTime}`;
}
const res = await API.get(url);
const { success, message, data } = res.data;
if (success) {
setQuotaData(data);
if (data.length === 0) {
data.push({
count: 0,
model_name: '无数据',
quota: 0,
created_at: now.getTime() / 1000,
});
}
// 根据dataExportDefaultTime重制时间粒度
let timeGranularity = 3600;
if (dataExportDefaultTime === 'day') {
timeGranularity = 86400;
} else if (dataExportDefaultTime === 'week') {
timeGranularity = 604800;
}
// sort created_at
data.sort((a, b) => a.created_at - b.created_at);
data.forEach((item) => {
item['created_at'] =
Math.floor(item['created_at'] / timeGranularity) * timeGranularity;
});
updateChartData(data);
} else {
showError(message);
}
// 根据dataExportDefaultTime重制时间粒度
let timeGranularity = 3600;
if (dataExportDefaultTime === 'day') {
timeGranularity = 86400;
} else if (dataExportDefaultTime === 'week') {
timeGranularity = 604800;
}
// sort created_at
data.sort((a, b) => a.created_at - b.created_at);
data.forEach((item) => {
item['created_at'] =
Math.floor(item['created_at'] / timeGranularity) * timeGranularity;
});
updateChart(lineChart, pieChart, data);
} else {
showError(message);
} finally {
setLoading(false);
}
setLoading(false);
};
const refresh = async () => {
await loadQuotaData(modelDataChart, modelDataPieChart);
await loadQuotaData();
};
const initChart = async () => {
let lineChart = modelDataChart;
if (!modelDataChart) {
lineChart = new VChart(spec_line, { dom: 'model_data' });
setModelDataChart(lineChart);
lineChart.renderAsync();
}
let pieChart = modelDataPieChart;
if (!modelDataPieChart) {
pieChart = new VChart(spec_pie, { dom: 'model_pie' });
setModelDataPieChart(pieChart);
pieChart.renderAsync();
}
console.log('init vchart');
await loadQuotaData(lineChart, pieChart);
await loadQuotaData();
};
const updateChart = (lineChart, pieChart, data) => {
if (isAdminUser) {
// 将所有用户合并
}
let pieData = [];
let lineData = [];
let consumeQuota = 0;
let times = 0;
for (let i = 0; i < data.length; i++) {
const item = data[i];
consumeQuota += item.quota;
times += item.count;
// 合并model_name
let pieItem = pieData.find((it) => it.type === item.model_name);
const updateChartData = (data) => {
let newPieData = [];
let newLineData = [];
let totalQuota = 0;
let totalTimes = 0;
let uniqueModels = new Set();
let totalTokens = 0;
// 收集所有唯一的模型名称和时间点
let uniqueTimes = new Set();
data.forEach(item => {
uniqueModels.add(item.model_name);
uniqueTimes.add(timestamp2string1(item.created_at, dataExportDefaultTime));
totalTokens += item.token_used;
});
// 处理颜色映射
const newModelColors = {};
Array.from(uniqueModels).forEach((modelName) => {
newModelColors[modelName] = modelColorMap[modelName] ||
modelColors[modelName] ||
modelToColor(modelName);
});
setModelColors(newModelColors);
// 处理饼图数据
for (let item of data) {
totalQuota += item.quota;
totalTimes += item.count;
let pieItem = newPieData.find((it) => it.type === item.model_name);
if (pieItem) {
pieItem.value += item.count;
} else {
pieData.push({
newPieData.push({
type: item.model_name,
value: item.count,
});
}
// 合并created_at和model_name 为 lineData, created_at 数据类型是小时的时间戳
// 转换日期格式
let createTime = timestamp2string1(
item.created_at,
dataExportDefaultTime,
);
let lineItem = lineData.find(
(it) => it.Time === createTime && it.Model === item.model_name,
);
if (lineItem) {
lineItem.Usage += parseFloat(getQuotaWithUnit(item.quota));
} else {
lineData.push({
Time: createTime,
Model: item.model_name,
Usage: parseFloat(getQuotaWithUnit(item.quota)),
});
}
}
setConsumeQuota(consumeQuota);
setTimes(times);
// sort by count
pieData.sort((a, b) => b.value - a.value);
spec_pie.title.subtext = `总计:${renderNumber(times)}`;
spec_pie.data[0].values = pieData;
// 处理柱状图数据
let timePoints = Array.from(uniqueTimes);
if (timePoints.length < 7) {
// 根据时间粒度生成合适的时间点
const generateTimePoints = () => {
let lastTime = Math.max(...data.map(item => item.created_at));
let points = [];
let interval = dataExportDefaultTime === 'hour' ? 3600
: dataExportDefaultTime === 'day' ? 86400
: 604800;
spec_line.title.subtext = `总计:${renderQuota(consumeQuota, 2)}`;
spec_line.data[0].values = lineData;
pieChart.updateSpec(spec_pie);
lineChart.updateSpec(spec_line);
for (let i = 0; i < 7; i++) {
points.push(timestamp2string1(lastTime - (i * interval), dataExportDefaultTime));
}
return points.reverse();
};
// pieChart.updateData('id0', pieData);
// lineChart.updateData('barData', lineData);
pieChart.reLayout();
lineChart.reLayout();
timePoints = generateTimePoints();
}
// 为每个时间点和模型生成数据
timePoints.forEach(time => {
Array.from(uniqueModels).forEach(model => {
let existingData = data.find(item =>
timestamp2string1(item.created_at, dataExportDefaultTime) === time &&
item.model_name === model
);
newLineData.push({
Time: time,
Model: model,
Usage: existingData ? parseFloat(getQuotaWithUnit(existingData.quota)) : 0
});
});
});
// 排序
newPieData.sort((a, b) => b.value - a.value);
newLineData.sort((a, b) => a.Time.localeCompare(b.Time));
// 更新图表配置和数据
setSpecPie(prev => ({
...prev,
data: [{ id: 'id0', values: newPieData }],
title: {
...prev.title,
subtext: `${t('总计')}${renderNumber(totalTimes)}`
},
color: {
specified: newModelColors
}
}));
setSpecLine(prev => ({
...prev,
data: [{ id: 'barData', values: newLineData }],
title: {
...prev.title,
subtext: `${t('总计')}${renderQuota(totalQuota, 2)}`
},
color: {
specified: newModelColors
}
}));
setPieData(newPieData);
setLineData(newLineData);
setConsumeQuota(totalQuota);
setTimes(totalTimes);
setConsumeTokens(totalTokens);
};
const getUserData = async () => {
let res = await API.get(`/api/user/self`);
const {success, message, data} = res.data;
if (success) {
userDispatch({type: 'login', payload: data});
} else {
showError(message);
}
};
useEffect(() => {
// setDataExportDefaultTime(localStorage.getItem('data_export_default_time'));
// if (dataExportDefaultTime === 'day') {
// // 设置开始时间为7天前
// let st = timestamp2string(now.getTime() / 1000 - 86400 * 7)
// inputs.start_timestamp = st;
// formRef.current.formApi.setValue('start_timestamp', st);
// }
getUserData()
if (!initialized.current) {
initVChartSemiTheme({
isWatchingThemeSwitch: true,
@@ -332,14 +380,14 @@ const Detail = (props) => {
<>
<Layout>
<Layout.Header>
<h3>数据看板</h3>
<h3>{t('数据看板')}</h3>
</Layout.Header>
<Layout.Content>
<Form ref={formRef} layout='horizontal' style={{ marginTop: 10 }}>
<>
<Form.DatePicker
field='start_timestamp'
label='起始时间'
label={t('起始时间')}
style={{ width: 272 }}
initValue={start_timestamp}
value={start_timestamp}
@@ -352,7 +400,7 @@ const Detail = (props) => {
<Form.DatePicker
field='end_timestamp'
fluid
label='结束时间'
label={t('结束时间')}
style={{ width: 272 }}
initValue={end_timestamp}
value={end_timestamp}
@@ -362,15 +410,15 @@ const Detail = (props) => {
/>
<Form.Select
field='data_export_default_time'
label='时间粒度'
label={t('时间粒度')}
style={{ width: 176 }}
initValue={dataExportDefaultTime}
placeholder={'时间粒度'}
placeholder={t('时间粒度')}
name='data_export_default_time'
optionList={[
{ label: '小时', value: 'hour' },
{ label: '天', value: 'day' },
{ label: '周', value: 'week' },
{ label: t('小时'), value: 'hour' },
{ label: t('天'), value: 'day' },
{ label: t('周'), value: 'week' },
]}
onChange={(value) =>
handleInputChange(value, 'data_export_default_time')
@@ -380,42 +428,102 @@ const Detail = (props) => {
<>
<Form.Input
field='username'
label='用户名称'
label={t('用户名称')}
style={{ width: 176 }}
value={username}
placeholder={'可选值'}
placeholder={t('可选值')}
name='username'
onChange={(value) => handleInputChange(value, 'username')}
/>
</>
)}
<Button
label={t('查询')}
type='primary'
htmlType='submit'
className='btn-margin-right'
onClick={refresh}
loading={loading}
style={{ marginTop: 24 }}
>
{t('查询')}
</Button>
<Form.Section>
<Button
label='查询'
type='primary'
htmlType='submit'
className='btn-margin-right'
onClick={refresh}
loading={loading}
>
查询
</Button>
</Form.Section>
</>
</Form>
<Spin spinning={loading}>
<div style={{ height: 500 }}>
<div
id='model_pie'
style={{ width: '100%', minWidth: 100 }}
></div>
</div>
<div style={{ height: 500 }}>
<div
id='model_data'
style={{ width: '100%', minWidth: 100 }}
></div>
</div>
<Row gutter={{ xs: 16, sm: 16, md: 16, lg: 24, xl: 24, xxl: 24 }} style={{marginTop: 20}} type="flex" justify="space-between">
<Col span={styleState.isMobile?24:8}>
<Card className='panel-desc-card'>
<Descriptions row size="small">
<Descriptions.Item itemKey={t('当前余额')}>
{renderQuota(userState?.user?.quota)}
</Descriptions.Item>
<Descriptions.Item itemKey={t('历史消耗')}>
{renderQuota(userState?.user?.used_quota)}
</Descriptions.Item>
<Descriptions.Item itemKey={t('请求次数')}>
{userState.user?.request_count}
</Descriptions.Item>
</Descriptions>
</Card>
</Col>
<Col span={styleState.isMobile?24:8}>
<Card>
<Descriptions row size="small">
<Descriptions.Item itemKey={t('统计额度')}>
{renderQuota(consumeQuota)}
</Descriptions.Item>
<Descriptions.Item itemKey={t('统计Tokens')}>
{consumeTokens}
</Descriptions.Item>
<Descriptions.Item itemKey={t('统计次数')}>
{times}
</Descriptions.Item>
</Descriptions>
</Card>
</Col>
<Col span={styleState.isMobile ? 24 : 8}>
<Card>
<Descriptions row size='small'>
<Descriptions.Item itemKey={t('平均RPM')}>
{(times /
((Date.parse(end_timestamp) -
Date.parse(start_timestamp)) /
60000)).toFixed(3)}
</Descriptions.Item>
<Descriptions.Item itemKey={t('平均TPM')}>
{(consumeTokens /
((Date.parse(end_timestamp) -
Date.parse(start_timestamp)) /
60000)).toFixed(3)}
</Descriptions.Item>
</Descriptions>
</Card>
</Col>
</Row>
<Card style={{marginTop: 20}}>
<Tabs type="line" defaultActiveKey="1">
<Tabs.TabPane tab={t('消耗分布')} itemKey="1">
<div style={{ height: 500 }}>
<VChart
spec={spec_line}
option={{ mode: "desktop-browser" }}
/>
</div>
</Tabs.TabPane>
<Tabs.TabPane tab={t('调用次数分布')} itemKey="2">
<div style={{ height: 500 }}>
<VChart
spec={spec_pie}
option={{ mode: "desktop-browser" }}
/>
</div>
</Tabs.TabPane>
</Tabs>
</Card>
</Spin>
</Layout.Content>
</Layout>

View File

@@ -3,11 +3,15 @@ import { Card, Col, Row } from '@douyinfe/semi-ui';
import { API, showError, showNotice, timestamp2string } from '../../helpers';
import { StatusContext } from '../../context/Status';
import { marked } from 'marked';
import { StyleContext } from '../../context/Style/index.js';
import { useTranslation } from 'react-i18next';
const Home = () => {
const { t, i18n } = useTranslation();
const [statusState] = useContext(StatusContext);
const [homePageContentLoaded, setHomePageContentLoaded] = useState(false);
const [homePageContent, setHomePageContent] = useState('');
const [styleState, styleDispatch] = useContext(StyleContext);
const displayNotice = async () => {
const res = await API.get('/api/notice');
@@ -35,6 +39,20 @@ const Home = () => {
}
setHomePageContent(content);
localStorage.setItem('home_page_content', content);
// 如果内容是 URL则发送主题模式
if (data.startsWith('https://')) {
const iframe = document.querySelector('iframe');
if (iframe) {
const theme = localStorage.getItem('theme-mode') || 'light';
// 测试是否正确传递theme-mode给iframe
// console.log('Sending theme-mode to iframe:', theme);
iframe.onload = () => {
iframe.contentWindow.postMessage({ themeMode: theme }, '*');
iframe.contentWindow.postMessage({ lang: i18n.language }, '*');
};
}
}
} else {
showError(message);
setHomePageContent('加载首页内容失败...');
@@ -50,7 +68,8 @@ const Home = () => {
useEffect(() => {
displayNotice().then();
displayHomePageContent().then();
}, []);
});
return (
<>
{homePageContentLoaded && homePageContent === '' ? (
@@ -58,13 +77,13 @@ const Home = () => {
<Card
bordered={false}
headerLine={false}
title='系统状况'
title={t('系统状况')}
bodyStyle={{ padding: '10px 20px' }}
>
<Row gutter={16}>
<Col span={12}>
<Card
title='系统信息'
title={t('系统信息')}
headerExtraContent={
<span
style={{
@@ -72,19 +91,19 @@ const Home = () => {
color: 'var(--semi-color-text-1)',
}}
>
系统信息总览
{t('系统信息总览')}
</span>
}
>
<p>名称{statusState?.status?.system_name}</p>
<p>{t('名称')}{statusState?.status?.system_name}</p>
<p>
版本
{t('版本')}
{statusState?.status?.version
? statusState?.status?.version
: 'unknown'}
</p>
<p>
源码
{t('源码')}
<a
href='https://github.com/Calcium-Ion/new-api'
target='_blank'
@@ -94,7 +113,7 @@ const Home = () => {
</a>
</p>
<p>
协议
{t('协议')}
<a
href='https://www.apache.org/licenses/LICENSE-2.0'
target='_blank'
@@ -103,12 +122,12 @@ const Home = () => {
Apache-2.0 License
</a>
</p>
<p>启动时间{getStartTimeString()}</p>
<p>{t('启动时间')}{getStartTimeString()}</p>
</Card>
</Col>
<Col span={12}>
<Card
title='系统配置'
title={t('系统配置')}
headerExtraContent={
<span
style={{
@@ -116,45 +135,45 @@ const Home = () => {
color: 'var(--semi-color-text-1)',
}}
>
系统配置总览
{t('系统配置总览')}
</span>
}
>
<p>
邮箱验证
{t('邮箱验证')}
{statusState?.status?.email_verification === true
? '已启用'
: '未启用'}
? t('已启用')
: t('未启用')}
</p>
<p>
GitHub 身份验证
{t('GitHub 身份验证')}
{statusState?.status?.github_oauth === true
? '已启用'
: '未启用'}
? t('已启用')
: t('未启用')}
</p>
<p>
微信身份验证
{t('微信身份验证')}
{statusState?.status?.wechat_login === true
? '已启用'
: '未启用'}
? t('已启用')
: t('未启用')}
</p>
<p>
Turnstile 用户校验
{t('Turnstile 用户校验')}
{statusState?.status?.turnstile_check === true
? '已启用'
: '未启用'}
? t('已启用')
: t('未启用')}
</p>
<p>
Telegram 身份验证
{t('Telegram 身份验证')}
{statusState?.status?.telegram_oauth === true
? '已启用'
: '未启用'}
? t('已启用')
: t('未启用')}
</p>
<p>
Linux DO 身份验证
{t('Linux DO 身份验证')}
{statusState?.status?.linuxdo_oauth === true
? '已启用'
: '未启用'}
? t('已启用')
: t('未启用')}
</p>
</Card>
</Col>

View File

@@ -1,24 +1,27 @@
import React, { useCallback, useContext, useEffect, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { UserContext } from '../context/User';
import { API, getUserIdFromLocalStorage, showError } from '../helpers';
import { Card, Chat, Input, Layout, Select, Slider, TextArea, Typography } from '@douyinfe/semi-ui';
import { UserContext } from '../../context/User/index.js';
import { API, getUserIdFromLocalStorage, showError } from '../../helpers/index.js';
import { Card, Chat, Input, Layout, Select, Slider, TextArea, Typography, Button } from '@douyinfe/semi-ui';
import { SSE } from 'sse';
import { IconSetting } from '@douyinfe/semi-icons';
import { StyleContext } from '../../context/Style/index.js';
import { useTranslation } from 'react-i18next';
const defaultMessage = [
{
role: 'user',
id: '2',
createAt: 1715676751919,
content: "你好",
const roleInfo = {
user: {
name: 'User',
avatar: 'https://lf3-static.bytednsdoc.com/obj/eden-cn/ptlz_zlp/ljhwZthlaukjlkulzlp/docs-icon.png'
},
{
role: 'assistant',
id: '3',
createAt: 1715676751919,
content: "你好,请问有什么可以帮助您的吗?",
assistant: {
name: 'Assistant',
avatar: 'logo.png'
},
system: {
name: 'System',
avatar: 'https://lf3-static.bytednsdoc.com/obj/eden-cn/ptlz_zlp/ljhwZthlaukjlkulzlp/other/logo.png'
}
];
}
let id = 4;
function getId() {
@@ -26,6 +29,23 @@ function getId() {
}
const Playground = () => {
const { t } = useTranslation();
const defaultMessage = [
{
role: 'user',
id: '2',
createAt: 1715676751919,
content: t('你好'),
},
{
role: 'assistant',
id: '3',
createAt: 1715676751919,
content: t('你好,请问有什么可以帮助您的吗?'),
}
];
const [inputs, setInputs] = useState({
model: 'gpt-4o-mini',
group: '',
@@ -39,6 +59,8 @@ const Playground = () => {
const [message, setMessage] = useState(defaultMessage);
const [models, setModels] = useState([]);
const [groups, setGroups] = useState([]);
const [showSettings, setShowSettings] = useState(true);
const [styleState, styleDispatch] = useContext(StyleContext);
const handleInputChange = (name, value) => {
setInputs((inputs) => ({ ...inputs, [name]: value }));
@@ -46,7 +68,7 @@ const Playground = () => {
useEffect(() => {
if (searchParams.get('expired')) {
showError('未登录或登录已过期,请重新登录!');
showError(t('未登录或登录已过期,请重新登录!'));
}
let status = localStorage.getItem('status');
if (status) {
@@ -67,7 +89,7 @@ const Playground = () => {
}));
setModels(localModelOptions);
} else {
showError(message);
showError(t(message));
}
};
@@ -75,31 +97,33 @@ const Playground = () => {
let res = await API.get(`/api/user/self/groups`);
const { success, message, data } = res.data;
if (success) {
// return data is a map, key is group name, value is group description
// label is group description, value is group name
let localGroupOptions = Object.keys(data).map((group) => ({
label: data[group],
value: group,
}));
// handleInputChange('group', localGroupOptions[0].value);
if (localGroupOptions.length > 0) {
// set default group at first
localGroupOptions.unshift({
label: '用户分组',
value: '',
});
} else {
if (localGroupOptions.length === 0) {
localGroupOptions = [{
label: '用户分组',
label: t('用户分组'),
value: '',
}];
setGroups(localGroupOptions);
} else {
const localUser = JSON.parse(localStorage.getItem('user'));
const userGroup = (userState.user && userState.user.group) || (localUser && localUser.group);
if (userGroup) {
const userGroupIndex = localGroupOptions.findIndex(g => g.value === userGroup);
if (userGroupIndex > -1) {
const userGroupOption = localGroupOptions.splice(userGroupIndex, 1)[0];
localGroupOptions.unshift(userGroupOption);
}
}
}
setGroups(localGroupOptions);
handleInputChange('group', localGroupOptions[0].value);
} else {
showError(message);
showError(t(message));
}
};
@@ -242,94 +266,147 @@ const Playground = () => {
})
}, []);
const SettingsToggle = () => {
if (!styleState.isMobile) return null;
return (
<Button
icon={<IconSetting />}
style={{
position: 'absolute',
left: showSettings ? -10 : -20,
top: '50%',
transform: 'translateY(-50%)',
zIndex: 1000,
width: 40,
height: 40,
borderRadius: '0 20px 20px 0',
padding: 0,
boxShadow: '2px 0 8px rgba(0, 0, 0, 0.15)',
}}
onClick={() => setShowSettings(!showSettings)}
theme="solid"
type="primary"
/>
);
};
function CustomInputRender(props) {
const { detailProps } = props;
const { clearContextNode, uploadNode, inputNode, sendNode, onClick } = detailProps;
return <div style={{margin: '8px 16px', display: 'flex', flexDirection:'row',
alignItems: 'flex-end', borderRadius: 16,padding: 10, border: '1px solid var(--semi-color-border)'}}
onClick={onClick}
>
{/*{uploadNode}*/}
{inputNode}
{sendNode}
</div>
}
const renderInputArea = useCallback((props) => {
return (<CustomInputRender {...props} />)
}, []);
return (
<Layout style={{height: '100%'}}>
<Layout.Sider>
<Card style={commonOuterStyle}>
<div style={{ marginTop: 10 }}>
<Typography.Text strong>分组</Typography.Text>
</div>
<Select
placeholder={'请选择分组'}
name='group'
required
selection
onChange={(value) => {
handleInputChange('group', value);
}}
value={inputs.group}
autoComplete='new-password'
optionList={groups}
/>
<div style={{ marginTop: 10 }}>
<Typography.Text strong>模型</Typography.Text>
</div>
<Select
placeholder={'请选择模型'}
name='model'
required
selection
filter
onChange={(value) => {
handleInputChange('model', value);
}}
value={inputs.model}
autoComplete='new-password'
optionList={models}
/>
<div style={{ marginTop: 10 }}>
<Typography.Text strong>Temperature</Typography.Text>
</div>
<Slider
step={0.1}
min={0.1}
max={1}
value={inputs.temperature}
onChange={(value) => {
handleInputChange('temperature', value);
}}
/>
<div style={{ marginTop: 10 }}>
<Typography.Text strong>MaxTokens</Typography.Text>
</div>
<Input
placeholder='MaxTokens'
name='max_tokens'
required
autoComplete='new-password'
defaultValue={0}
value={inputs.max_tokens}
onChange={(value) => {
handleInputChange('max_tokens', value);
}}
/>
{(showSettings || !styleState.isMobile) && (
<Layout.Sider style={{ display: styleState.isMobile ? 'block' : 'initial' }}>
<Card style={commonOuterStyle}>
<div style={{ marginTop: 10 }}>
<Typography.Text strong>{t('分组')}</Typography.Text>
</div>
<Select
placeholder={t('请选择分组')}
name='group'
required
selection
onChange={(value) => {
handleInputChange('group', value);
}}
value={inputs.group}
autoComplete='new-password'
optionList={groups.map((group) => ({
...group,
label: styleState.isMobile && group.label.length > 16
? group.label.substring(0, 16) + '...'
: group.label,
}))}
/>
<div style={{ marginTop: 10 }}>
<Typography.Text strong>{t('模型')}</Typography.Text>
</div>
<Select
placeholder={t('请选择模型')}
name='model'
required
selection
searchPosition='dropdown'
filter
onChange={(value) => {
handleInputChange('model', value);
}}
value={inputs.model}
autoComplete='new-password'
optionList={models}
/>
<div style={{ marginTop: 10 }}>
<Typography.Text strong>Temperature</Typography.Text>
</div>
<Slider
step={0.1}
min={0.1}
max={1}
value={inputs.temperature}
onChange={(value) => {
handleInputChange('temperature', value);
}}
/>
<div style={{ marginTop: 10 }}>
<Typography.Text strong>MaxTokens</Typography.Text>
</div>
<Input
placeholder='MaxTokens'
name='max_tokens'
required
autoComplete='new-password'
defaultValue={0}
value={inputs.max_tokens}
onChange={(value) => {
handleInputChange('max_tokens', value);
}}
/>
<div style={{ marginTop: 10 }}>
<Typography.Text strong>System</Typography.Text>
</div>
<TextArea
placeholder='System Prompt'
name='system'
required
autoComplete='new-password'
autosize
defaultValue={systemPrompt}
// value={systemPrompt}
onChange={(value) => {
setSystemPrompt(value);
}}
/>
<div style={{ marginTop: 10 }}>
<Typography.Text strong>System</Typography.Text>
</div>
<TextArea
placeholder='System Prompt'
name='system'
required
autoComplete='new-password'
autosize
defaultValue={systemPrompt}
// value={systemPrompt}
onChange={(value) => {
setSystemPrompt(value);
}}
/>
</Card>
</Layout.Sider>
</Card>
</Layout.Sider>
)}
<Layout.Content>
<div style={{height: '100%'}}>
<div style={{height: '100%', position: 'relative'}}>
<SettingsToggle />
<Chat
chatBoxRenderConfig={{
renderChatBoxAction: () => {
return <div></div>
}
}}
renderInputArea={renderInputArea}
roleConfig={roleInfo}
style={commonOuterStyle}
chats={message}
onMessageSend={onMessageSend}

View File

@@ -1,5 +1,6 @@
import React, { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import {
API,
downloadTextAsFile,
@@ -7,7 +8,7 @@ import {
showError,
showSuccess,
} from '../../helpers';
import { renderQuotaWithPrompt } from '../../helpers/render';
import { getQuotaPerUnit, renderQuota, renderQuotaWithPrompt } from '../../helpers/render';
import {
AutoComplete,
Button,
@@ -22,6 +23,7 @@ import Title from '@douyinfe/semi-ui/lib/es/typography/title';
import { Divider } from 'semantic-ui-react';
const EditRedemption = (props) => {
const { t } = useTranslation();
const isEdit = props.editingRedemption.id !== undefined;
const [loading, setLoading] = useState(isEdit);
@@ -66,11 +68,16 @@ const EditRedemption = (props) => {
}, [props.editingRedemption.id]);
const submit = async () => {
if (!isEdit && inputs.name === '') return;
let name = inputs.name;
if (!isEdit && inputs.name === '') {
// set default name
name = renderQuota(quota);
}
setLoading(true);
let localInputs = inputs;
localInputs.count = parseInt(localInputs.count);
localInputs.quota = parseInt(localInputs.quota);
localInputs.name = name;
let res;
if (isEdit) {
res = await API.put(`/api/redemption/`, {
@@ -85,11 +92,11 @@ const EditRedemption = (props) => {
const { success, message, data } = res.data;
if (success) {
if (isEdit) {
showSuccess('兑换码更新成功!');
showSuccess(t('兑换码更新成功!'));
props.refresh();
props.handleClose();
} else {
showSuccess('兑换码创建成功!');
showSuccess(t('兑换码创建成功!'));
setInputs(originInputs);
props.refresh();
props.handleClose();
@@ -102,13 +109,12 @@ const EditRedemption = (props) => {
for (let i = 0; i < data.length; i++) {
text += data[i] + '\n';
}
// downloadTextAsFile(text, `${inputs.name}.txt`);
Modal.confirm({
title: '兑换码创建成功',
title: t('兑换码创建成功'),
content: (
<div>
<p>兑换码创建成功是否下载兑换码</p>
<p>兑换码将以文本文件的形式下载文件名为兑换码的名称</p>
<p>{t('兑换码创建成功是否下载兑换码?')}</p>
<p>{t('兑换码将以文本文件的形式下载文件名为兑换码的名称。')}</p>
</div>
),
onOk: () => {
@@ -125,7 +131,7 @@ const EditRedemption = (props) => {
placement={isEdit ? 'right' : 'left'}
title={
<Title level={3}>
{isEdit ? '更新兑换码信息' : '创建新的兑换码'}
{isEdit ? t('更新兑换码信息') : t('创建新的兑换码')}
</Title>
}
headerStyle={{ borderBottom: '1px solid var(--semi-color-border)' }}
@@ -135,7 +141,7 @@ const EditRedemption = (props) => {
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<Space>
<Button theme='solid' size={'large'} onClick={submit}>
提交
{t('提交')}
</Button>
<Button
theme='solid'
@@ -143,7 +149,7 @@ const EditRedemption = (props) => {
type={'tertiary'}
onClick={handleCancel}
>
取消
{t('取消')}
</Button>
</Space>
</div>
@@ -155,9 +161,9 @@ const EditRedemption = (props) => {
<Spin spinning={loading}>
<Input
style={{ marginTop: 20 }}
label='名称'
label={t('名称')}
name='name'
placeholder={'请输入名称'}
placeholder={t('请输入名称')}
onChange={(value) => handleInputChange('name', value)}
value={name}
autoComplete='new-password'
@@ -165,12 +171,12 @@ const EditRedemption = (props) => {
/>
<Divider />
<div style={{ marginTop: 20 }}>
<Typography.Text>{`额度${renderQuotaWithPrompt(quota)}`}</Typography.Text>
<Typography.Text>{t('额度') + renderQuotaWithPrompt(quota)}</Typography.Text>
</div>
<AutoComplete
style={{ marginTop: 8 }}
name='quota'
placeholder={'请输入额度'}
placeholder={t('请输入额度')}
onChange={(value) => handleInputChange('quota', value)}
value={quota}
autoComplete='new-password'
@@ -188,12 +194,12 @@ const EditRedemption = (props) => {
{!isEdit && (
<>
<Divider />
<Typography.Text>生成数量</Typography.Text>
<Typography.Text>{t('生成数量')}</Typography.Text>
<Input
style={{ marginTop: 8 }}
label='生成数量'
label={t('生成数量')}
name='count'
placeholder={'请输入生成数量'}
placeholder={t('请输入生成数量')}
onChange={(value) => handleInputChange('count', value)}
value={count}
autoComplete='new-password'

View File

@@ -1,12 +1,15 @@
import React from 'react';
import RedemptionsTable from '../../components/RedemptionsTable';
import { Layout } from '@douyinfe/semi-ui';
import { useTranslation } from 'react-i18next';
const Redemption = () => (
<>
<Layout>
<Layout.Header>
<h3>管理兑换码</h3>
const Redemption = () => {
const { t } = useTranslation();
return (
<>
<Layout>
<Layout.Header>
<h3>{t('管理兑换码')}</h3>
</Layout.Header>
<Layout.Content>
<RedemptionsTable />
@@ -14,5 +17,6 @@ const Redemption = () => (
</Layout>
</>
);
}
export default Redemption;

View File

@@ -0,0 +1,131 @@
import React, { useEffect, useState, useRef } from 'react';
import { Button, Col, Form, Row, Spin } from '@douyinfe/semi-ui';
import {
compareObjects,
API,
showError,
showSuccess,
showWarning,
verifyJSON,
} from '../../../helpers';
import { useTranslation } from 'react-i18next';
export default function GroupRatioSettings(props) {
const { t } = useTranslation();
const [loading, setLoading] = useState(false);
const [inputs, setInputs] = useState({
GroupRatio: '',
UserUsableGroups: ''
});
const refForm = useRef();
const [inputsRow, setInputsRow] = useState(inputs);
async function onSubmit() {
try {
await refForm.current.validate().then(() => {
const updateArray = compareObjects(inputs, inputsRow);
if (!updateArray.length) return showWarning(t('你似乎并没有修改什么'));
const requestQueue = updateArray.map((item) => {
const value = typeof inputs[item.key] === 'boolean'
? String(inputs[item.key])
: inputs[item.key];
return API.put('/api/option/', { key: item.key, value });
});
setLoading(true);
Promise.all(requestQueue)
.then((res) => {
if (res.includes(undefined)) {
return showError(requestQueue.length > 1 ? t('部分保存失败,请重试') : t('保存失败'));
}
for (let i = 0; i < res.length; i++) {
if (!res[i].data.success) {
return showError(res[i].data.message);
}
}
showSuccess(t('保存成功'));
props.refresh();
})
.catch(error => {
console.error('Unexpected error:', error);
showError(t('保存失败,请重试'));
})
.finally(() => {
setLoading(false);
});
}).catch(() => {
showError(t('请检查输入'));
});
} catch (error) {
showError(t('请检查输入'));
console.error(error);
}
}
useEffect(() => {
const currentInputs = {};
for (let key in props.options) {
if (Object.keys(inputs).includes(key)) {
currentInputs[key] = props.options[key];
}
}
setInputs(currentInputs);
setInputsRow(structuredClone(currentInputs));
refForm.current.setValues(currentInputs);
}, [props.options]);
return (
<Spin spinning={loading}>
<Form
values={inputs}
getFormApi={(formAPI) => (refForm.current = formAPI)}
style={{ marginBottom: 15 }}
>
<Form.Section text={t('分组设置')}>
<Row gutter={16}>
<Col span={16}>
<Form.TextArea
label={t('分组倍率')}
placeholder={t('为一个 JSON 文本,键为分组名称,值为倍率')}
field={'GroupRatio'}
autosize={{ minRows: 6, maxRows: 12 }}
trigger='blur'
stopValidateWithError
rules={[
{
validator: (rule, value) => verifyJSON(value),
message: t('不是合法的 JSON 字符串')
}
]}
onChange={(value) => setInputs({ ...inputs, GroupRatio: value })}
/>
</Col>
</Row>
<Row gutter={16}>
<Col span={16}>
<Form.TextArea
label={t('用户可选分组')}
placeholder={t('为一个 JSON 文本,键为分组名称,值为分组描述')}
field={'UserUsableGroups'}
autosize={{ minRows: 6, maxRows: 12 }}
trigger='blur'
stopValidateWithError
rules={[
{
validator: (rule, value) => verifyJSON(value),
message: t('不是合法的 JSON 字符串')
}
]}
onChange={(value) => setInputs({ ...inputs, UserUsableGroups: value })}
/>
</Col>
</Row>
</Form.Section>
</Form>
<Button onClick={onSubmit}>{t('保存分组倍率设置')}</Button>
</Spin>
);
}

View File

@@ -0,0 +1,178 @@
import React, { useEffect, useState, useRef } from 'react';
import { Button, Col, Form, Popconfirm, Row, Space, Spin } from '@douyinfe/semi-ui';
import {
compareObjects,
API,
showError,
showSuccess,
showWarning,
verifyJSON,
} from '../../../helpers';
import { useTranslation } from 'react-i18next';
export default function ModelRatioSettings(props) {
const [loading, setLoading] = useState(false);
const [inputs, setInputs] = useState({
ModelPrice: '',
ModelRatio: '',
CompletionRatio: '',
});
const refForm = useRef();
const [inputsRow, setInputsRow] = useState(inputs);
const { t } = useTranslation();
async function onSubmit() {
try {
await refForm.current.validate().then(() => {
const updateArray = compareObjects(inputs, inputsRow);
if (!updateArray.length) return showWarning(t('你似乎并没有修改什么'));
const requestQueue = updateArray.map((item) => {
const value = typeof inputs[item.key] === 'boolean'
? String(inputs[item.key])
: inputs[item.key];
return API.put('/api/option/', { key: item.key, value });
});
setLoading(true);
Promise.all(requestQueue)
.then((res) => {
if (res.includes(undefined)) {
return showError(requestQueue.length > 1 ? t('部分保存失败,请重试') : t('保存失败'));
}
for (let i = 0; i < res.length; i++) {
if (!res[i].data.success) {
return showError(res[i].data.message);
}
}
showSuccess(t('保存成功'));
props.refresh();
})
.catch(error => {
console.error('Unexpected error:', error);
showError(t('保存失败,请重试'));
})
.finally(() => {
setLoading(false);
});
}).catch(() => {
showError(t('请检查输入'));
});
} catch (error) {
showError(t('请检查输入'));
console.error(error);
}
}
async function resetModelRatio() {
try {
let res = await API.post(`/api/option/rest_model_ratio`);
if (res.data.success) {
showSuccess(res.data.message);
props.refresh();
} else {
showError(res.data.message);
}
} catch (error) {
showError(error);
}
}
useEffect(() => {
const currentInputs = {};
for (let key in props.options) {
if (Object.keys(inputs).includes(key)) {
currentInputs[key] = props.options[key];
}
}
setInputs(currentInputs);
setInputsRow(structuredClone(currentInputs));
refForm.current.setValues(currentInputs);
}, [props.options]);
return (
<Spin spinning={loading}>
<Form
values={inputs}
getFormApi={(formAPI) => (refForm.current = formAPI)}
style={{ marginBottom: 15 }}
>
<Form.Section>
<Row gutter={16}>
<Col span={16}>
<Form.TextArea
label={t('模型固定价格')}
extraText={t('一次调用消耗多少刀,优先级大于模型倍率')}
placeholder={t('为一个 JSON 文本,键为模型名称,值为一次调用消耗多少刀,比如 "gpt-4-gizmo-*": 0.1一次消耗0.1刀')}
field={'ModelPrice'}
autosize={{ minRows: 6, maxRows: 12 }}
trigger='blur'
stopValidateWithError
rules={[
{
validator: (rule, value) => verifyJSON(value),
message: '不是合法的 JSON 字符串'
}
]}
onChange={(value) => setInputs({ ...inputs, ModelPrice: value })}
/>
</Col>
</Row>
<Row gutter={16}>
<Col span={16}>
<Form.TextArea
label={t('模型倍率')}
placeholder={t('为一个 JSON 文本,键为模型名称,值为倍率')}
field={'ModelRatio'}
autosize={{ minRows: 6, maxRows: 12 }}
trigger='blur'
stopValidateWithError
rules={[
{
validator: (rule, value) => verifyJSON(value),
message: '不是合法的 JSON 字符串'
}
]}
onChange={(value) => setInputs({ ...inputs, ModelRatio: value })}
/>
</Col>
</Row>
<Row gutter={16}>
<Col span={16}>
<Form.TextArea
label={t('模型补全倍率(仅对自定义模型有效)')}
extraText={t('仅对自定义模型有效')}
placeholder={t('为一个 JSON 文本,键为模型名称,值为倍率')}
field={'CompletionRatio'}
autosize={{ minRows: 6, maxRows: 12 }}
trigger='blur'
stopValidateWithError
rules={[
{
validator: (rule, value) => verifyJSON(value),
message: '不是合法的 JSON 字符串'
}
]}
onChange={(value) => setInputs({ ...inputs, CompletionRatio: value })}
/>
</Col>
</Row>
</Form.Section>
</Form>
<Space>
<Button onClick={onSubmit}>{t('保存模型倍率设置')}</Button>
<Popconfirm
title={t('确定重置模型倍率吗?')}
content={t('此修改将不可逆')}
okType={'danger'}
position={'top'}
onConfirm={resetModelRatio}
>
<Button type={'danger'}>{t('重置模型倍率')}</Button>
</Popconfirm>
</Space>
</Spin>
);
}

View File

@@ -0,0 +1,319 @@
// ModelSettingsVisualEditor.js
import React, { useEffect, useState } from 'react';
import { Table, Button, Input, Modal, Form, Space } from '@douyinfe/semi-ui';
import { IconDelete, IconPlus, IconSearch, IconSave } from '@douyinfe/semi-icons';
import { showError, showSuccess } from '../../../helpers';
import { API } from '../../../helpers';
import { useTranslation } from 'react-i18next';
export default function ModelSettingsVisualEditor(props) {
const { t } = useTranslation();
const [models, setModels] = useState([]);
const [visible, setVisible] = useState(false);
const [currentModel, setCurrentModel] = useState(null);
const [searchText, setSearchText] = useState('');
const [currentPage, setCurrentPage] = useState(1);
const [loading, setLoading] = useState(false);
const pageSize = 10;
useEffect(() => {
try {
const modelPrice = JSON.parse(props.options.ModelPrice || '{}');
const modelRatio = JSON.parse(props.options.ModelRatio || '{}');
const completionRatio = JSON.parse(props.options.CompletionRatio || '{}');
// 合并所有模型名称
const modelNames = new Set([
...Object.keys(modelPrice),
...Object.keys(modelRatio),
...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]
}));
setModels(modelData);
} catch (error) {
console.error('JSON解析错误:', error);
}
}, [props.options]);
// 首先声明分页相关的工具函数
const getPagedData = (data, currentPage, pageSize) => {
const start = (currentPage - 1) * pageSize;
const end = start + pageSize;
return data.slice(start, end);
};
// 在 return 语句之前,先处理过滤和分页逻辑
const filteredModels = models.filter(model =>
searchText ? model.name.toLowerCase().includes(searchText.toLowerCase()) : true
);
// 然后基于过滤后的数据计算分页数据
const pagedData = getPagedData(filteredModels, currentPage, pageSize);
const SubmitData = async () => {
setLoading(true);
const output = {
ModelPrice: {},
ModelRatio: {},
CompletionRatio: {}
};
let currentConvertModelName = '';
try {
// 数据转换
models.forEach(model => {
currentConvertModelName = model.name;
if (model.price !== '') {
// 如果价格不为空,则转换为浮点数,忽略倍率参数
output.ModelPrice[model.name] = parseFloat(model.price)
} else {
if (model.ratio !== '') output.ModelRatio[model.name] = parseFloat(model.ratio);
if (model.completionRatio != '') output.CompletionRatio[model.name] = parseFloat(model.completionRatio);
}
});
// 准备API请求数组
const finalOutput = {
ModelPrice: JSON.stringify(output.ModelPrice, null, 2),
ModelRatio: JSON.stringify(output.ModelRatio, null, 2),
CompletionRatio: JSON.stringify(output.CompletionRatio, null, 2)
};
const requestQueue = Object.entries(finalOutput).map(([key, value]) => {
return API.put('/api/option/', {
key,
value
});
});
// 批量处理请求
const results = await Promise.all(requestQueue);
// 验证结果
if (requestQueue.length === 1) {
if (results.includes(undefined)) return;
} else if (requestQueue.length > 1) {
if (results.includes(undefined)) {
return showError('部分保存失败,请重试');
}
}
// 检查每个请求的结果
for (const res of results) {
if (!res.data.success) {
return showError(res.data.message);
}
}
showSuccess('保存成功');
props.refresh();
} catch (error) {
console.error('保存失败:', error);
showError('保存失败,请重试');
} finally {
setLoading(false);
}
};
const columns = [
{
title: t('模型名称'),
dataIndex: 'name',
key: 'name',
},
{
title: t('模型固定价格'),
dataIndex: 'price',
key: 'price',
render: (text, record) => (
<Input
value={text}
placeholder={t('按量计费')}
onChange={value => updateModel(record.name, 'price', value)}
/>
)
},
{
title: t('模型倍率'),
dataIndex: 'ratio',
key: 'ratio',
render: (text, record) => (
<Input
value={text}
placeholder={record.price !== '' ? t('模型倍率') : t('默认补全倍率')}
disabled={record.price !== ''}
onChange={value => updateModel(record.name, 'ratio', value)}
/>
)
},
{
title: t('补全倍率'),
dataIndex: 'completionRatio',
key: 'completionRatio',
render: (text, record) => (
<Input
value={text}
placeholder={record.price !== '' ? t('补全倍率') : t('默认补全倍率')}
disabled={record.price !== ''}
onChange={value => updateModel(record.name, 'completionRatio', value)}
/>
)
},
{
title: t('操作'),
key: 'action',
render: (_, record) => (
<Button
icon={<IconDelete />}
type="danger"
onClick={() => deleteModel(record.name)}
/>
)
}
];
const updateModel = (name, field, value) => {
if (isNaN(value)) {
showError('请输入数字');
return;
}
setModels(prev =>
prev.map(model =>
model.name === name
? { ...model, [field]: value }
: model
)
);
};
const deleteModel = (name) => {
setModels(prev => prev.filter(model => model.name !== name));
};
const addModel = (values) => {
// 检查模型名称是否存在, 如果存在则拒绝添加
if (models.some(model => model.name === values.name)) {
showError('模型名称已存在');
return;
}
// 不允许同时添加固定价格和倍率
if (values.price !== '' && (values.ratio !== '' || values.completionRatio !== '')) {
showError('固定价格和倍率不能同时存在');
return;
}
setModels(prev => [{
name: values.name,
price: values.price || '',
ratio: values.ratio || '',
completionRatio: values.completionRatio || ''
}, ...prev]);
setVisible(false);
showSuccess('添加成功');
};
return (
<>
<Space vertical align="start" style={{ width: '100%' }}>
<Space>
<Button icon={<IconPlus />} onClick={() => setVisible(true)}>
{t('添加模型')}
</Button>
<Button type="primary" icon={<IconSave />} onClick={SubmitData}>
{t('应用更改')}
</Button>
<Input
prefix={<IconSearch />}
placeholder={t('搜索模型名称')}
value={searchText}
onChange={value => {
setSearchText(value)
setCurrentPage(1);
}}
style={{ width: 200 }}
/>
</Space>
<Table
columns={columns}
dataSource={pagedData}
pagination={{
currentPage: currentPage,
pageSize: pageSize,
total: filteredModels.length,
onPageChange: page => setCurrentPage(page),
formatPageText: (page) =>
t('第 {{start}} - {{end}} 条,共 {{total}} 条', {
start: page.currentStart,
end: page.currentEnd,
total: filteredModels.length
}),
showTotal: true,
showSizeChanger: false
}}
/>
</Space>
<Modal
title={t('添加模型')}
visible={visible}
onCancel={() => setVisible(false)}
onOk={() => {
currentModel && addModel(currentModel);
}}
>
<Form>
<Form.Input
field="name"
label={t('模型名称')}
placeholder="strawberry"
required
onChange={value => setCurrentModel(prev => ({ ...prev, name: value }))}
/>
<Form.Switch
field="priceMode"
label={<>{t('定价模式')}{currentModel?.priceMode ? t("固定价格") : t("倍率模式")}</>}
onChange={checked => {
setCurrentModel(prev => ({
...prev,
price: '',
ratio: '',
completionRatio: '',
priceMode: checked
}));
}}
/>
{currentModel?.priceMode ? (
<Form.Input
field="price"
label={t('固定价格(每次)')}
placeholder={t('输入每次价格')}
onChange={value => setCurrentModel(prev => ({ ...prev, price: value }))}
/>
) : (
<>
<Form.Input
field="ratio"
label={t('模型倍率')}
placeholder={t('输入模型倍率')}
onChange={value => setCurrentModel(prev => ({ ...prev, ratio: value }))}
/>
<Form.Input
field="completionRatio"
label={t('补全倍率')}
placeholder={t('输入补全价格')}
onChange={value => setCurrentModel(prev => ({ ...prev, completionRatio: value }))}
/>
</>
)}
</Form>
</Modal>
</>
);
}

View File

@@ -9,8 +9,10 @@ import {
verifyJSON,
verifyJSONPromise
} from '../../../helpers';
import { useTranslation } from 'react-i18next';
export default function SettingsChats(props) {
const { t } = useTranslation();
const [loading, setLoading] = useState(false);
const [inputs, setInputs] = useState({
Chats: "[]",
@@ -24,7 +26,7 @@ export default function SettingsChats(props) {
await refForm.current.validate().then(() => {
console.log('Validation passed');
const updateArray = compareObjects(inputs, inputsRow);
if (!updateArray.length) return showWarning('你似乎并没有修改什么');
if (!updateArray.length) return showWarning(t('你似乎并没有修改什么'));
const requestQueue = updateArray.map((item) => {
let value = '';
if (typeof inputs[item.key] === 'boolean') {
@@ -44,23 +46,23 @@ export default function SettingsChats(props) {
if (res.includes(undefined)) return;
} else if (requestQueue.length > 1) {
if (res.includes(undefined))
return showError('部分保存失败,请重试');
return showError(t('部分保存失败,请重试'));
}
showSuccess('保存成功');
showSuccess(t('保存成功'));
props.refresh();
})
.catch(() => {
showError('保存失败,请重试');
showError(t('保存失败,请重试'));
})
.finally(() => {
setLoading(false);
});
}).catch((error) => {
console.error('Validation failed:', error);
showError('请检查输入');
showError(t('请检查输入'));
});
} catch (error) {
showError('请检查输入');
showError(t('请检查输入'));
console.error(error);
}
}
@@ -104,19 +106,19 @@ export default function SettingsChats(props) {
getFormApi={(formAPI) => (refForm.current = formAPI)}
style={{ marginBottom: 15 }}
>
<Form.Section text={'令牌聊天设置'}>
<Form.Section text={t('令牌聊天设置')}>
<Banner
type='warning'
description={'必须将上方聊天链接全部设置为空,才能使用下方聊天设置功能'}
description={t('必须将上方聊天链接全部设置为空,才能使用下方聊天设置功能')}
/>
<Banner
type='info'
description={'链接中的{key}将自动替换为sk-xxxx{address}将自动替换为系统设置的服务器地址,末尾不带/和/v1'}
description={t('链接中的{key}将自动替换为sk-xxxx{address}将自动替换为系统设置的服务器地址,末尾不带/和/v1')}
/>
<Form.TextArea
label={'聊天配置'}
label={t('聊天配置')}
extraText={''}
placeholder={'为一个 JSON 文本'}
placeholder={t('为一个 JSON 文本')}
field={'Chats'}
autosize={{ minRows: 6, maxRows: 12 }}
trigger='blur'
@@ -126,7 +128,7 @@ export default function SettingsChats(props) {
validator: (rule, value) => {
return verifyJSON(value);
},
message: '不是合法的 JSON 字符串'
message: t('不是合法的 JSON 字符串')
}
]}
onChange={(value) =>
@@ -140,7 +142,7 @@ export default function SettingsChats(props) {
</Form>
<Space>
<Button onClick={onSubmit}>
保存聊天设置
{t('保存聊天设置')}
</Button>
</Space>
</Spin>

View File

@@ -1,5 +1,6 @@
import React, { useEffect, useState, useRef } from 'react';
import { Button, Col, Form, Row, Spin } from '@douyinfe/semi-ui';
import { useTranslation } from 'react-i18next';
import {
compareObjects,
API,
@@ -9,6 +10,7 @@ import {
} from '../../../helpers';
export default function SettingsCreditLimit(props) {
const { t } = useTranslation();
const [loading, setLoading] = useState(false);
const [inputs, setInputs] = useState({
QuotaForNewUser: '',
@@ -21,7 +23,7 @@ export default function SettingsCreditLimit(props) {
function onSubmit() {
const updateArray = compareObjects(inputs, inputsRow);
if (!updateArray.length) return showWarning('你似乎并没有修改什么');
if (!updateArray.length) return showWarning(t('你似乎并没有修改什么'));
const requestQueue = updateArray.map((item) => {
let value = '';
if (typeof inputs[item.key] === 'boolean') {
@@ -40,13 +42,13 @@ export default function SettingsCreditLimit(props) {
if (requestQueue.length === 1) {
if (res.includes(undefined)) return;
} else if (requestQueue.length > 1) {
if (res.includes(undefined)) return showError('部分保存失败,请重试');
if (res.includes(undefined)) return showError(t('部分保存失败,请重试'));
}
showSuccess('保存成功');
showSuccess(t('保存成功'));
props.refresh();
})
.catch(() => {
showError('保存失败,请重试');
showError(t('保存失败,请重试'));
})
.finally(() => {
setLoading(false);
@@ -72,11 +74,11 @@ export default function SettingsCreditLimit(props) {
getFormApi={(formAPI) => (refForm.current = formAPI)}
style={{ marginBottom: 15 }}
>
<Form.Section text={'额度设置'}>
<Form.Section text={t('额度设置')}>
<Row gutter={16}>
<Col span={6}>
<Form.InputNumber
label={'新用户初始额度'}
label={t('新用户初始额度')}
field={'QuotaForNewUser'}
step={1}
min={0}
@@ -92,12 +94,12 @@ export default function SettingsCreditLimit(props) {
</Col>
<Col span={6}>
<Form.InputNumber
label={'请求预扣费额度'}
label={t('请求预扣费额度')}
field={'PreConsumedQuota'}
step={1}
min={0}
suffix={'Token'}
extraText={'请求结束后多退少补'}
extraText={t('请求结束后多退少补')}
placeholder={''}
onChange={(value) =>
setInputs({
@@ -109,13 +111,13 @@ export default function SettingsCreditLimit(props) {
</Col>
<Col span={6}>
<Form.InputNumber
label={'邀请新用户奖励额度'}
label={t('邀请新用户奖励额度')}
field={'QuotaForInviter'}
step={1}
min={0}
suffix={'Token'}
extraText={''}
placeholder={'例如2000'}
placeholder={t('例如2000')}
onChange={(value) =>
setInputs({
...inputs,
@@ -126,13 +128,13 @@ export default function SettingsCreditLimit(props) {
</Col>
<Col span={6}>
<Form.InputNumber
label={'新用户使用邀请码奖励额度'}
label={t('新用户使用邀请码奖励额度')}
field={'QuotaForInvitee'}
step={1}
min={0}
suffix={'Token'}
extraText={''}
placeholder={'例如1000'}
placeholder={t('例如1000')}
onChange={(value) =>
setInputs({
...inputs,
@@ -145,7 +147,7 @@ export default function SettingsCreditLimit(props) {
<Row>
<Button size='default' onClick={onSubmit}>
保存额度设置
{t('保存额度设置')}
</Button>
</Row>
</Form.Section>

View File

@@ -1,5 +1,5 @@
import React, { useEffect, useState, useRef } from 'react';
import { Button, Col, Form, Row, Spin, Tag } from '@douyinfe/semi-ui';
import { Button, Col, Form, Row, Spin } from '@douyinfe/semi-ui';
import {
compareObjects,
API,
@@ -7,12 +7,15 @@ import {
showSuccess,
showWarning,
} from '../../../helpers';
import { useTranslation } from 'react-i18next';
export default function DataDashboard(props) {
const { t } = useTranslation();
const optionsDataExportDefaultTime = [
{ key: 'hour', label: '小时', value: 'hour' },
{ key: 'day', label: '天', value: 'day' },
{ key: 'week', label: '周', value: 'week' },
{ key: 'hour', label: t('小时'), value: 'hour' },
{ key: 'day', label: t('天'), value: 'day' },
{ key: 'week', label: t('周'), value: 'week' },
];
const [loading, setLoading] = useState(false);
const [inputs, setInputs] = useState({
@@ -25,7 +28,7 @@ export default function DataDashboard(props) {
function onSubmit() {
const updateArray = compareObjects(inputs, inputsRow);
if (!updateArray.length) return showWarning('你似乎并没有修改什么');
if (!updateArray.length) return showWarning(t('你似乎并没有修改什么'));
const requestQueue = updateArray.map((item) => {
let value = '';
if (typeof inputs[item.key] === 'boolean') {
@@ -44,13 +47,13 @@ export default function DataDashboard(props) {
if (requestQueue.length === 1) {
if (res.includes(undefined)) return;
} else if (requestQueue.length > 1) {
if (res.includes(undefined)) return showError('部分保存失败,请重试');
if (res.includes(undefined)) return showError(t('部分保存失败,请重试'));
}
showSuccess('保存成功');
showSuccess(t('保存成功'));
props.refresh();
})
.catch(() => {
showError('保存失败,请重试');
showError(t('保存失败,请重试'));
})
.finally(() => {
setLoading(false);
@@ -81,12 +84,12 @@ export default function DataDashboard(props) {
getFormApi={(formAPI) => (refForm.current = formAPI)}
style={{ marginBottom: 15 }}
>
<Form.Section text={'数据看板设置'}>
<Form.Section text={t('数据看板设置')}>
<Row gutter={16}>
<Col span={8}>
<Form.Switch
field={'DataExportEnabled'}
label={'启用数据看板(实验性)'}
label={t('启用数据看板(实验性)')}
size='default'
checkedText=''
uncheckedText=''
@@ -102,12 +105,12 @@ export default function DataDashboard(props) {
<Row>
<Col span={8}>
<Form.InputNumber
label={'数据看板更新间隔 '}
label={t('数据看板更新间隔')}
step={1}
min={1}
suffix={'分钟'}
extraText={'设置过短会影响数据库性能'}
placeholder={'数据看板更新间隔'}
suffix={t('分钟')}
extraText={t('设置过短会影响数据库性能')}
placeholder={t('数据看板更新间隔')}
field={'DataExportInterval'}
onChange={(value) =>
setInputs({
@@ -119,11 +122,11 @@ export default function DataDashboard(props) {
</Col>
<Col span={8}>
<Form.Select
label='数据看板默认时间粒度'
label={t('数据看板默认时间粒度')}
optionList={optionsDataExportDefaultTime}
field={'DataExportDefaultTime'}
extraText={'仅修改展示粒度,统计精确到小时'}
placeholder={'数据看板默认时间粒度'}
extraText={t('仅修改展示粒度,统计精确到小时')}
placeholder={t('数据看板默认时间粒度')}
style={{ width: 180 }}
onChange={(value) =>
setInputs({
@@ -136,7 +139,7 @@ export default function DataDashboard(props) {
</Row>
<Row>
<Button size='default' onClick={onSubmit}>
保存数据看板设置
{t('保存数据看板设置')}
</Button>
</Row>
</Form.Section>

View File

@@ -7,8 +7,10 @@ import {
showSuccess,
showWarning,
} from '../../../helpers';
import { useTranslation } from 'react-i18next';
export default function SettingsDrawing(props) {
const { t } = useTranslation();
const [loading, setLoading] = useState(false);
const [inputs, setInputs] = useState({
DrawingEnabled: false,
@@ -23,7 +25,7 @@ export default function SettingsDrawing(props) {
function onSubmit() {
const updateArray = compareObjects(inputs, inputsRow);
if (!updateArray.length) return showWarning('你似乎并没有修改什么');
if (!updateArray.length) return showWarning(t('你似乎并没有修改什么'));
const requestQueue = updateArray.map((item) => {
let value = '';
if (typeof inputs[item.key] === 'boolean') {
@@ -42,13 +44,13 @@ export default function SettingsDrawing(props) {
if (requestQueue.length === 1) {
if (res.includes(undefined)) return;
} else if (requestQueue.length > 1) {
if (res.includes(undefined)) return showError('部分保存失败,请重试');
if (res.includes(undefined)) return showError(t('部分保存失败,请重试'));
}
showSuccess('保存成功');
showSuccess(t('保存成功'));
props.refresh();
})
.catch(() => {
showError('保存失败,请重试');
showError(t('保存失败,请重试'));
})
.finally(() => {
setLoading(false);
@@ -67,6 +69,7 @@ export default function SettingsDrawing(props) {
refForm.current.setValues(currentInputs);
localStorage.setItem('mj_notify_enabled', String(inputs.MjNotifyEnabled));
}, [props.options]);
return (
<>
<Spin spinning={loading}>
@@ -75,12 +78,12 @@ export default function SettingsDrawing(props) {
getFormApi={(formAPI) => (refForm.current = formAPI)}
style={{ marginBottom: 15 }}
>
<Form.Section text={'绘图设置'}>
<Form.Section text={t('绘图设置')}>
<Row gutter={16}>
<Col span={8}>
<Form.Switch
field={'DrawingEnabled'}
label={'启用绘图功能'}
label={t('启用绘图功能')}
size='default'
checkedText=''
uncheckedText=''
@@ -95,7 +98,7 @@ export default function SettingsDrawing(props) {
<Col span={8}>
<Form.Switch
field={'MjNotifyEnabled'}
label={'允许回调(会泄露服务器 IP 地址)'}
label={t('允许回调(会泄露服务器 IP 地址)')}
size='default'
checkedText=''
uncheckedText=''
@@ -110,7 +113,7 @@ export default function SettingsDrawing(props) {
<Col span={8}>
<Form.Switch
field={'MjAccountFilterEnabled'}
label={'允许 AccountFilter 参数'}
label={t('允许 AccountFilter 参数')}
size='default'
checkedText=''
uncheckedText=''
@@ -125,7 +128,7 @@ export default function SettingsDrawing(props) {
<Col span={8}>
<Form.Switch
field={'MjForwardUrlEnabled'}
label={'开启之后将上游地址替换为服务器地址'}
label={t('开启之后将上游地址替换为服务器地址')}
size='default'
checkedText=''
uncheckedText=''
@@ -142,8 +145,8 @@ export default function SettingsDrawing(props) {
field={'MjModeClearEnabled'}
label={
<>
开启之后会清除用户提示词中的 <Tag>--fast</Tag>
<Tag>--relax</Tag> <Tag>--turbo</Tag>
{t('开启之后会清除用户提示词中的')} <Tag>--fast</Tag>
<Tag>--relax</Tag> {t('')} <Tag>--turbo</Tag> {t('')}
</>
}
size='default'
@@ -160,11 +163,7 @@ export default function SettingsDrawing(props) {
<Col span={8}>
<Form.Switch
field={'MjActionCheckSuccessEnabled'}
label={
<>
检测必须等待绘图成功才能进行放大等操作
</>
}
label={t('检测必须等待绘图成功才能进行放大等操作')}
size='default'
checkedText=''
uncheckedText=''
@@ -179,7 +178,7 @@ export default function SettingsDrawing(props) {
</Row>
<Row>
<Button size='default' onClick={onSubmit}>
保存绘图设置
{t('保存绘图设置')}
</Button>
</Row>
</Form.Section>

View File

@@ -7,8 +7,10 @@ import {
showSuccess,
showWarning,
} from '../../../helpers';
import { useTranslation } from 'react-i18next';
export default function GeneralSettings(props) {
const { t } = useTranslation();
const [loading, setLoading] = useState(false);
const [inputs, setInputs] = useState({
TopUpLink: '',
@@ -22,13 +24,15 @@ export default function GeneralSettings(props) {
});
const refForm = useRef();
const [inputsRow, setInputsRow] = useState(inputs);
function onChange(value, e) {
const name = e.target.id;
setInputs((inputs) => ({ ...inputs, [name]: value }));
}
function onSubmit() {
const updateArray = compareObjects(inputs, inputsRow);
if (!updateArray.length) return showWarning('你似乎并没有修改什么');
if (!updateArray.length) return showWarning(t('你似乎并没有修改什么'));
const requestQueue = updateArray.map((item) => {
let value = '';
if (typeof inputs[item.key] === 'boolean') {
@@ -47,13 +51,13 @@ export default function GeneralSettings(props) {
if (requestQueue.length === 1) {
if (res.includes(undefined)) return;
} else if (requestQueue.length > 1) {
if (res.includes(undefined)) return showError('部分保存失败,请重试');
if (res.includes(undefined)) return showError(t('部分保存失败,请重试'));
}
showSuccess('保存成功');
showSuccess(t('保存成功'));
props.refresh();
})
.catch(() => {
showError('保存失败,请重试');
showError(t('保存失败,请重试'));
})
.finally(() => {
setLoading(false);
@@ -71,26 +75,27 @@ export default function GeneralSettings(props) {
setInputsRow(structuredClone(currentInputs));
refForm.current.setValues(currentInputs);
}, [props.options]);
return (
<>
<Spin spinning={loading}>
<Banner
type='warning'
description={'聊天链接功能已经弃用,请使用下方聊天设置功能'}
description={t('聊天链接功能已经弃用,请使用下方聊天设置功能')}
/>
<Form
values={inputs}
getFormApi={(formAPI) => (refForm.current = formAPI)}
style={{ marginBottom: 15 }}
>
<Form.Section text={'通用设置'}>
<Form.Section text={t('通用设置')}>
<Row gutter={16}>
<Col span={8}>
<Form.Input
field={'TopUpLink'}
label={'充值链接'}
label={t('充值链接')}
initValue={''}
placeholder={'例如发卡网站的购买链接'}
placeholder={t('例如发卡网站的购买链接')}
onChange={onChange}
showClear
/>
@@ -98,9 +103,9 @@ export default function GeneralSettings(props) {
<Col span={8}>
<Form.Input
field={'ChatLink'}
label={'默认聊天页面链接'}
label={t('默认聊天页面链接')}
initValue={''}
placeholder='例如 ChatGPT Next Web 的部署地址'
placeholder={t('例如 ChatGPT Next Web 的部署地址')}
onChange={onChange}
showClear
/>
@@ -108,9 +113,9 @@ export default function GeneralSettings(props) {
<Col span={8}>
<Form.Input
field={'ChatLink2'}
label={'聊天页面 2 链接'}
label={t('聊天页面 2 链接')}
initValue={''}
placeholder='例如 ChatGPT Next Web 的部署地址'
placeholder={t('例如 ChatGPT Next Web 的部署地址')}
onChange={onChange}
showClear
/>
@@ -118,9 +123,9 @@ export default function GeneralSettings(props) {
<Col span={8}>
<Form.Input
field={'QuotaPerUnit'}
label={'单位美元额度'}
label={t('单位美元额度')}
initValue={''}
placeholder='一单位货币能兑换的额度'
placeholder={t('一单位货币能兑换的额度')}
onChange={onChange}
showClear
/>
@@ -128,9 +133,9 @@ export default function GeneralSettings(props) {
<Col span={8}>
<Form.Input
field={'RetryTimes'}
label={'失败重试次数'}
label={t('失败重试次数')}
initValue={''}
placeholder='失败重试次数'
placeholder={t('失败重试次数')}
onChange={onChange}
showClear
/>
@@ -140,7 +145,7 @@ export default function GeneralSettings(props) {
<Col span={8}>
<Form.Switch
field={'DisplayInCurrencyEnabled'}
label={'以货币形式显示额度'}
label={t('以货币形式显示额度')}
size='default'
checkedText=''
uncheckedText=''
@@ -155,7 +160,7 @@ export default function GeneralSettings(props) {
<Col span={8}>
<Form.Switch
field={'DisplayTokenStatEnabled'}
label={'Billing 相关 API 显示令牌额度而非用户额度'}
label={t('额度查询接口返回令牌额度而非用户额度')}
size='default'
checkedText=''
uncheckedText=''
@@ -170,7 +175,7 @@ export default function GeneralSettings(props) {
<Col span={8}>
<Form.Switch
field={'DefaultCollapseSidebar'}
label={'默认折叠侧边栏'}
label={t('默认折叠侧边栏')}
size='default'
checkedText=''
uncheckedText=''
@@ -185,7 +190,7 @@ export default function GeneralSettings(props) {
</Row>
<Row>
<Button size='default' onClick={onSubmit}>
保存通用设置
{t('保存通用设置')}
</Button>
</Row>
</Form.Section>

View File

@@ -1,6 +1,7 @@
import React, { useEffect, useState, useRef } from 'react';
import { Button, Col, Form, Row, Spin, DatePicker } from '@douyinfe/semi-ui';
import dayjs from 'dayjs';
import { useTranslation } from 'react-i18next';
import {
compareObjects,
API,
@@ -10,6 +11,7 @@ import {
} from '../../../helpers';
export default function SettingsLog(props) {
const { t } = useTranslation();
const [loading, setLoading] = useState(false);
const [loadingCleanHistoryLog, setLoadingCleanHistoryLog] = useState(false);
const [inputs, setInputs] = useState({
@@ -24,7 +26,7 @@ export default function SettingsLog(props) {
(item) => item.key !== 'historyTimestamp',
);
if (!updateArray.length) return showWarning('你似乎并没有修改什么');
if (!updateArray.length) return showWarning(t('你似乎并没有修改什么'));
const requestQueue = updateArray.map((item) => {
let value = '';
if (typeof inputs[item.key] === 'boolean') {
@@ -43,13 +45,13 @@ export default function SettingsLog(props) {
if (requestQueue.length === 1) {
if (res.includes(undefined)) return;
} else if (requestQueue.length > 1) {
if (res.includes(undefined)) return showError('部分保存失败,请重试');
if (res.includes(undefined)) return showError(t('部分保存失败,请重试'));
}
showSuccess('保存成功');
showSuccess(t('保存成功'));
props.refresh();
})
.catch(() => {
showError('保存失败,请重试');
showError(t('保存失败,请重试'));
})
.finally(() => {
setLoading(false);
@@ -58,16 +60,16 @@ export default function SettingsLog(props) {
async function onCleanHistoryLog() {
try {
setLoadingCleanHistoryLog(true);
if (!inputs.historyTimestamp) throw new Error('请选择日志记录时间');
if (!inputs.historyTimestamp) throw new Error(t('请选择日志记录时间'));
const res = await API.delete(
`/api/log/?target_timestamp=${Date.parse(inputs.historyTimestamp) / 1000}`,
);
const { success, message, data } = res.data;
if (success) {
showSuccess(`${data} 条日志已清理!`);
showSuccess(`${data} ${t('条日志已清理!')}`);
return;
} else {
throw new Error('日志清理失败:' + message);
throw new Error(t('日志清理失败:') + message);
}
} catch (error) {
showError(error.message);
@@ -96,12 +98,12 @@ export default function SettingsLog(props) {
getFormApi={(formAPI) => (refForm.current = formAPI)}
style={{ marginBottom: 15 }}
>
<Form.Section text={'日志设置'}>
<Form.Section text={t('日志设置')}>
<Row gutter={16}>
<Col span={8}>
<Form.Switch
field={'LogConsumeEnabled'}
label={'启用额度消费日志记录'}
label={t('启用额度消费日志记录')}
size='default'
checkedText=''
uncheckedText=''
@@ -116,7 +118,7 @@ export default function SettingsLog(props) {
<Col span={8}>
<Spin spinning={loadingCleanHistoryLog}>
<Form.DatePicker
label='日志记录时间'
label={t('日志记录时间')}
field={'historyTimestamp'}
type='dateTime'
inputReadOnly={true}
@@ -128,7 +130,7 @@ export default function SettingsLog(props) {
}}
/>
<Button size='default' onClick={onCleanHistoryLog}>
清除历史日志
{t('清除历史日志')}
</Button>
</Spin>
</Col>
@@ -136,7 +138,7 @@ export default function SettingsLog(props) {
<Row>
<Button size='default' onClick={onSubmit}>
保存日志设置
{t('保存日志设置')}
</Button>
</Row>
</Form.Section>

View File

@@ -7,8 +7,10 @@ import {
showSuccess,
showWarning,
} from '../../../helpers';
import { useTranslation } from 'react-i18next';
export default function SettingsMonitoring(props) {
const { t } = useTranslation();
const [loading, setLoading] = useState(false);
const [inputs, setInputs] = useState({
ChannelDisableThreshold: '',
@@ -21,7 +23,7 @@ export default function SettingsMonitoring(props) {
function onSubmit() {
const updateArray = compareObjects(inputs, inputsRow);
if (!updateArray.length) return showWarning('你似乎并没有修改什么');
if (!updateArray.length) return showWarning(t('你似乎并没有修改什么'));
const requestQueue = updateArray.map((item) => {
let value = '';
if (typeof inputs[item.key] === 'boolean') {
@@ -40,13 +42,13 @@ export default function SettingsMonitoring(props) {
if (requestQueue.length === 1) {
if (res.includes(undefined)) return;
} else if (requestQueue.length > 1) {
if (res.includes(undefined)) return showError('部分保存失败,请重试');
if (res.includes(undefined)) return showError(t('部分保存失败,请重试'));
}
showSuccess('保存成功');
showSuccess(t('保存成功'));
props.refresh();
})
.catch(() => {
showError('保存失败,请重试');
showError(t('保存失败,请重试'));
})
.finally(() => {
setLoading(false);
@@ -64,6 +66,7 @@ export default function SettingsMonitoring(props) {
setInputsRow(structuredClone(currentInputs));
refForm.current.setValues(currentInputs);
}, [props.options]);
return (
<>
<Spin spinning={loading}>
@@ -72,15 +75,15 @@ export default function SettingsMonitoring(props) {
getFormApi={(formAPI) => (refForm.current = formAPI)}
style={{ marginBottom: 15 }}
>
<Form.Section text={'监控设置'}>
<Form.Section text={t('监控设置')}>
<Row gutter={16}>
<Col span={8}>
<Form.InputNumber
label={'最长响应时间'}
label={t('最长响应时间')}
step={1}
min={0}
suffix={'秒'}
extraText={'当运行通道全部测试时,超过此时间将自动禁用通道'}
suffix={t('秒')}
extraText={t('当运行通道全部测试时,超过此时间将自动禁用通道')}
placeholder={''}
field={'ChannelDisableThreshold'}
onChange={(value) =>
@@ -93,11 +96,11 @@ export default function SettingsMonitoring(props) {
</Col>
<Col span={8}>
<Form.InputNumber
label={'额度提醒阈值'}
label={t('额度提醒阈值')}
step={1}
min={0}
suffix={'Token'}
extraText={'低于此额度时将发送邮件提醒用户'}
extraText={t('低于此额度时将发送邮件提醒用户')}
placeholder={''}
field={'QuotaRemindThreshold'}
onChange={(value) =>
@@ -113,7 +116,7 @@ export default function SettingsMonitoring(props) {
<Col span={8}>
<Form.Switch
field={'AutomaticDisableChannelEnabled'}
label={'失败时自动禁用通道'}
label={t('失败时自动禁用通道')}
size='default'
checkedText=''
uncheckedText=''
@@ -128,7 +131,7 @@ export default function SettingsMonitoring(props) {
<Col span={8}>
<Form.Switch
field={'AutomaticEnableChannelEnabled'}
label={'成功时自动启用通道'}
label={t('成功时自动启用通道')}
size='default'
checkedText=''
uncheckedText=''
@@ -143,7 +146,7 @@ export default function SettingsMonitoring(props) {
</Row>
<Row>
<Button size='default' onClick={onSubmit}>
保存监控设置
{t('保存监控设置')}
</Button>
</Row>
</Form.Section>

View File

@@ -7,8 +7,10 @@ import {
showSuccess,
showWarning,
} from '../../../helpers';
import { useTranslation } from 'react-i18next';
export default function SettingsSensitiveWords(props) {
const { t } = useTranslation();
const [loading, setLoading] = useState(false);
const [inputs, setInputs] = useState({
CheckSensitiveEnabled: false,
@@ -20,7 +22,7 @@ export default function SettingsSensitiveWords(props) {
function onSubmit() {
const updateArray = compareObjects(inputs, inputsRow);
if (!updateArray.length) return showWarning('你似乎并没有修改什么');
if (!updateArray.length) return showWarning(t('你似乎并没有修改什么'));
const requestQueue = updateArray.map((item) => {
let value = '';
if (typeof inputs[item.key] === 'boolean') {
@@ -39,13 +41,13 @@ export default function SettingsSensitiveWords(props) {
if (requestQueue.length === 1) {
if (res.includes(undefined)) return;
} else if (requestQueue.length > 1) {
if (res.includes(undefined)) return showError('部分保存失败,请重试');
if (res.includes(undefined)) return showError(t('部分保存失败,请重试'));
}
showSuccess('保存成功');
showSuccess(t('保存成功'));
props.refresh();
})
.catch(() => {
showError('保存失败,请重试');
showError(t('保存失败,请重试'));
})
.finally(() => {
setLoading(false);
@@ -71,12 +73,12 @@ export default function SettingsSensitiveWords(props) {
getFormApi={(formAPI) => (refForm.current = formAPI)}
style={{ marginBottom: 15 }}
>
<Form.Section text={'屏蔽词过滤设置'}>
<Form.Section text={t('屏蔽词过滤设置')}>
<Row gutter={16}>
<Col span={8}>
<Form.Switch
field={'CheckSensitiveEnabled'}
label={'启用屏蔽词过滤功能'}
label={t('启用屏蔽词过滤功能')}
size='default'
checkedText=''
uncheckedText=''
@@ -91,7 +93,7 @@ export default function SettingsSensitiveWords(props) {
<Col span={8}>
<Form.Switch
field={'CheckSensitiveOnPromptEnabled'}
label={'启用 Prompt 检查'}
label={t('启用 Prompt 检查')}
size='default'
checkedText=''
uncheckedText=''
@@ -107,9 +109,9 @@ export default function SettingsSensitiveWords(props) {
<Row>
<Col span={16}>
<Form.TextArea
label={'屏蔽词列表'}
extraText={'一行一个屏蔽词,不需要符号分割'}
placeholder={'一行一个屏蔽词,不需要符号分割'}
label={t('屏蔽词列表')}
extraText={t('一行一个屏蔽词,不需要符号分割')}
placeholder={t('一行一个屏蔽词,不需要符号分割')}
field={'SensitiveWords'}
onChange={(value) =>
setInputs({
@@ -124,7 +126,7 @@ export default function SettingsSensitiveWords(props) {
</Row>
<Row>
<Button size='default' onClick={onSubmit}>
保存屏蔽词过滤设置
{t('保存屏蔽词过滤设置')}
</Button>
</Row>
</Form.Section>

View File

@@ -1,19 +1,22 @@
import React, { useEffect, useState } from 'react';
import { Layout, TabPane, Tabs } from '@douyinfe/semi-ui';
import { useNavigate, useLocation } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import SystemSetting from '../../components/SystemSetting';
import { isRoot } from '../../helpers';
import OtherSetting from '../../components/OtherSetting';
import PersonalSetting from '../../components/PersonalSetting';
import OperationSetting from '../../components/OperationSetting';
const Setting = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const location = useLocation();
const [tabActiveKey, setTabActiveKey] = useState('1');
let panes = [
{
tab: '个人设置',
tab: t('个人设置'),
content: <PersonalSetting />,
itemKey: 'personal',
},
@@ -21,17 +24,17 @@ const Setting = () => {
if (isRoot()) {
panes.push({
tab: '运营设置',
tab: t('运营设置'),
content: <OperationSetting />,
itemKey: 'operation',
});
panes.push({
tab: '系统设置',
tab: t('系统设置'),
content: <SystemSetting />,
itemKey: 'system',
});
panes.push({
tab: '其他设置',
tab: t('其他设置'),
content: <OtherSetting />,
itemKey: 'other',
});

View File

@@ -1,20 +1,24 @@
import React from 'react';
import TokensTable from '../../components/TokensTable';
import { Banner, Layout } from '@douyinfe/semi-ui';
const Token = () => (
<>
<Layout>
<Layout.Header>
import { useTranslation } from 'react-i18next';
const Token = () => {
const { t } = useTranslation();
return (
<>
<Layout>
<Layout.Header>
<Banner
type='warning'
description='令牌无法精确控制使用额度,请勿直接将令牌分发给用户。'
description={t('令牌无法精确控制使用额度,只允许自用,请勿直接将令牌分发给他人。')}
/>
</Layout.Header>
<Layout.Content>
<TokensTable />
</Layout.Content>
</Layout>
</>
);
</Layout.Content>
</Layout>
</>
);
};
export default Token;

View File

@@ -21,8 +21,10 @@ import {
import Title from '@douyinfe/semi-ui/lib/es/typography/title';
import Text from '@douyinfe/semi-ui/lib/es/typography/text';
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
const TopUp = () => {
const { t } = useTranslation();
const [redemptionCode, setRedemptionCode] = useState('');
const [topUpCode, setTopUpCode] = useState('');
const [topUpCount, setTopUpCount] = useState(0);
@@ -38,7 +40,7 @@ const TopUp = () => {
const topUp = async () => {
if (redemptionCode === '') {
showInfo('请输入兑换码!');
showInfo(t('请输入兑换码!'));
return;
}
setIsSubmitting(true);
@@ -48,10 +50,10 @@ const TopUp = () => {
});
const { success, message, data } = res.data;
if (success) {
showSuccess('兑换成功!');
showSuccess(t('兑换成功!'));
Modal.success({
title: '兑换成功!',
content: '成功兑换额度:' + renderQuota(data),
title: t('兑换成功!'),
content: t('成功兑换额度:') + renderQuota(data),
centered: true,
});
setUserQuota((quota) => {
@@ -62,7 +64,7 @@ const TopUp = () => {
showError(message);
}
} catch (err) {
showError('请求失败');
showError(t('请求失败'));
} finally {
setIsSubmitting(false);
}
@@ -70,7 +72,7 @@ const TopUp = () => {
const openTopUpLink = () => {
if (!topUpLink) {
showError('超级管理员未设置充值链接!');
showError(t('超级管理员未设置充值链接!'));
return;
}
window.open(topUpLink, '_blank');
@@ -78,12 +80,12 @@ const TopUp = () => {
const preTopUp = async (payment) => {
if (!enableOnlineTopUp) {
showError('管理员未开启在线充值!');
showError(t('管理员未开启在线充值!'));
return;
}
await getAmount();
if (topUpCount < minTopUp) {
showError('充值数量不能小于' + minTopUp);
showError(t('充值数量不能小于') + minTopUp);
return;
}
setPayWay(payment);
@@ -174,7 +176,7 @@ const TopUp = () => {
const renderAmount = () => {
// console.log(amount);
return amount + '元';
return amount + ' ' + t('元');
};
const getAmount = async (value) => {
@@ -214,11 +216,11 @@ const TopUp = () => {
<div>
<Layout>
<Layout.Header>
<h3>我的钱包</h3>
<h3>{t('我的钱包')}</h3>
</Layout.Header>
<Layout.Content>
<Modal
title='确定要充值吗'
title={t('确定要充值吗')}
visible={open}
onOk={onlineTopUp}
onCancel={handleCancel}
@@ -226,24 +228,24 @@ const TopUp = () => {
size={'small'}
centered={true}
>
<p>充值数量{topUpCount}</p>
<p>实付金额{renderAmount()}</p>
<p>是否确认充值</p>
<p>{t('充值数量')}{topUpCount}</p>
<p>{t('实付金额')}{renderAmount()}</p>
<p>{t('是否确认充值?')}</p>
</Modal>
<div
style={{ marginTop: 20, display: 'flex', justifyContent: 'center' }}
>
<Card style={{ width: '500px', padding: '20px' }}>
<Title level={3} style={{ textAlign: 'center' }}>
余额 {renderQuota(userQuota)}
{t('余额')} {renderQuota(userQuota)}
</Title>
<div style={{ marginTop: 20 }}>
<Divider>兑换余额</Divider>
<Divider>{t('兑换余额')}</Divider>
<Form>
<Form.Input
field={'redemptionCode'}
label={'兑换码'}
placeholder='兑换码'
label={t('兑换码')}
placeholder={t('兑换码')}
name='redemptionCode'
value={redemptionCode}
onChange={(value) => {
@@ -257,7 +259,7 @@ const TopUp = () => {
theme={'solid'}
onClick={openTopUpLink}
>
获取兑换码
{t('获取兑换码')}
</Button>
) : null}
<Button
@@ -266,21 +268,19 @@ const TopUp = () => {
onClick={topUp}
disabled={isSubmitting}
>
{isSubmitting ? '兑换中...' : '兑换'}
{isSubmitting ? t('兑换中...') : t('兑换')}
</Button>
</Space>
</Form>
</div>
<div style={{ marginTop: 20 }}>
<Divider>在线充值</Divider>
<Divider>{t('在线充值')}</Divider>
<Form>
<Form.Input
disabled={!enableOnlineTopUp}
field={'redemptionCount'}
label={'实付金额:' + renderAmount()}
placeholder={
'充值数量,最低 ' + renderQuotaWithAmount(minTopUp)
}
label={t('实付金额:') + ' ' + renderAmount()}
placeholder={t('充值数量,最低 ') + renderQuotaWithAmount(minTopUp)}
name='redemptionCount'
type={'number'}
value={topUpCount}
@@ -300,7 +300,7 @@ const TopUp = () => {
preTopUp('zfb');
}}
>
支付宝
{t('支付宝')}
</Button>
<Button
style={{
@@ -312,7 +312,7 @@ const TopUp = () => {
preTopUp('wx');
}}
>
微信
{t('微信')}
</Button>
</Space>
</Form>

View File

@@ -14,6 +14,7 @@ import {
Spin,
Typography,
} from '@douyinfe/semi-ui';
import { useTranslation } from 'react-i18next';
const EditUser = (props) => {
const userId = props.editingUser.id;
@@ -120,11 +121,13 @@ const EditUser = (props) => {
setIsModalOpen(true);
};
const { t } = useTranslation();
return (
<>
<SideSheet
placement={'right'}
title={<Title level={3}>{'编辑用户'}</Title>}
title={<Title level={3}>{t('编辑用户')}</Title>}
headerStyle={{ borderBottom: '1px solid var(--semi-color-border)' }}
bodyStyle={{ borderBottom: '1px solid var(--semi-color-border)' }}
visible={props.visible}
@@ -132,7 +135,7 @@ const EditUser = (props) => {
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<Space>
<Button theme='solid' size={'large'} onClick={submit}>
提交
{t('提交')}
</Button>
<Button
theme='solid'
@@ -140,7 +143,7 @@ const EditUser = (props) => {
type={'tertiary'}
onClick={handleCancel}
>
取消
{t('取消')}
</Button>
</Space>
</div>
@@ -151,35 +154,35 @@ const EditUser = (props) => {
>
<Spin spinning={loading}>
<div style={{ marginTop: 20 }}>
<Typography.Text>用户名</Typography.Text>
<Typography.Text>{t('用户名')}</Typography.Text>
</div>
<Input
label='用户名'
label={t('用户名')}
name='username'
placeholder={'请输入新的用户名'}
placeholder={t('请输入新的用户名')}
onChange={(value) => handleInputChange('username', value)}
value={username}
autoComplete='new-password'
/>
<div style={{ marginTop: 20 }}>
<Typography.Text>密码</Typography.Text>
<Typography.Text>{t('密码')}</Typography.Text>
</div>
<Input
label='密码'
label={t('密码')}
name='password'
type={'password'}
placeholder={'请输入新的密码,最短 8 位'}
placeholder={t('请输入新的密码,最短 8 位')}
onChange={(value) => handleInputChange('password', value)}
value={password}
autoComplete='new-password'
/>
<div style={{ marginTop: 20 }}>
<Typography.Text>显示名称</Typography.Text>
<Typography.Text>{t('显示名称')}</Typography.Text>
</div>
<Input
label='显示名称'
label={t('显示名称')}
name='display_name'
placeholder={'请输入新的显示名称'}
placeholder={t('请输入新的显示名称')}
onChange={(value) => handleInputChange('display_name', value)}
value={display_name}
autoComplete='new-password'
@@ -187,76 +190,76 @@ const EditUser = (props) => {
{userId && (
<>
<div style={{ marginTop: 20 }}>
<Typography.Text>分组</Typography.Text>
<Typography.Text>{t('分组')}</Typography.Text>
</div>
<Select
placeholder={'请选择分组'}
placeholder={t('请选择分组')}
name='group'
fluid
search
selection
allowAdditions
additionLabel={'请在系统设置页面编辑分组倍率以添加新的分组:'}
additionLabel={t('请在系统设置页面编辑分组倍率以添加新的分组:')}
onChange={(value) => handleInputChange('group', value)}
value={inputs.group}
autoComplete='new-password'
optionList={groupOptions}
/>
<div style={{ marginTop: 20 }}>
<Typography.Text>{`剩余额度${renderQuotaWithPrompt(quota)}`}</Typography.Text>
<Typography.Text>{`${t('剩余额度')}${renderQuotaWithPrompt(quota)}`}</Typography.Text>
</div>
<Space>
<Input
name='quota'
placeholder={'请输入新的剩余额度'}
placeholder={t('请输入新的剩余额度')}
onChange={(value) => handleInputChange('quota', value)}
value={quota}
type={'number'}
autoComplete='new-password'
/>
<Button onClick={openAddQuotaModal}>添加额度</Button>
<Button onClick={openAddQuotaModal}>{t('添加额度')}</Button>
</Space>
</>
)}
<Divider style={{ marginTop: 20 }}>以下信息不可修改</Divider>
<Divider style={{ marginTop: 20 }}>{t('以下信息不可修改')}</Divider>
<div style={{ marginTop: 20 }}>
<Typography.Text>已绑定的 GitHub 账户</Typography.Text>
<Typography.Text>{t('已绑定的 GitHub 账户')}</Typography.Text>
</div>
<Input
name='github_id'
value={github_id}
autoComplete='new-password'
placeholder='此项只读,需要用户通过个人设置页面的相关绑定按钮进行绑定,不可直接修改'
placeholder={t('此项只读,需要用户通过个人设置页面的相关绑定按钮进行绑定,不可直接修改')}
readonly
/>
<div style={{ marginTop: 20 }}>
<Typography.Text>已绑定的微信账户</Typography.Text>
<Typography.Text>{t('已绑定的微信账户')}</Typography.Text>
</div>
<Input
name='wechat_id'
value={wechat_id}
autoComplete='new-password'
placeholder='此项只读,需要用户通过个人设置页面的相关绑定按钮进行绑定,不可直接修改'
placeholder={t('此项只读,需要用户通过个人设置页面的相关绑定按钮进行绑定,不可直接修改')}
readonly
/>
<div style={{ marginTop: 20 }}>
<Typography.Text>已绑定的邮箱账户</Typography.Text>
<Typography.Text>{t('已绑定的邮箱账户')}</Typography.Text>
</div>
<Input
name='email'
value={email}
autoComplete='new-password'
placeholder='此项只读,需要用户通过个人设置页面的相关绑定按钮进行绑定,不可直接修改'
placeholder={t('此项只读,需要用户通过个人设置页面的相关绑定按钮进行绑定,不可直接修改')}
readonly
/>
<div style={{ marginTop: 20 }}>
<Typography.Text>已绑定的Telegram账户</Typography.Text>
<Typography.Text>{t('已绑定的Telegram账户')}</Typography.Text>
</div>
<Input
name='telegram_id'
value={telegram_id}
autoComplete='new-password'
placeholder='此项只读,需要用户通过个人设置页面的相关绑定按钮进行绑定,不可直接修改'
placeholder={t('此项只读,需要用户通过个人设置页面的相关绑定按钮进行绑定,不可直接修改')}
readonly
/>
</Spin>
@@ -272,11 +275,11 @@ const EditUser = (props) => {
closable={null}
>
<div style={{ marginTop: 20 }}>
<Typography.Text>{`新额度${renderQuota(quota)} + ${renderQuota(addQuotaLocal)} = ${renderQuota(quota + parseInt(addQuotaLocal))}`}</Typography.Text>
<Typography.Text>{`${t('新额度')}${renderQuota(quota)} + ${renderQuota(addQuotaLocal)} = ${renderQuota(quota + parseInt(addQuotaLocal))}`}</Typography.Text>
</div>
<Input
name='addQuotaLocal'
placeholder={'需要添加的额度(支持负数)'}
placeholder={t('需要添加的额度(支持负数)')}
onChange={(value) => {
setAddQuotaLocal(value);
}}

View File

@@ -1,18 +1,22 @@
import React from 'react';
import UsersTable from '../../components/UsersTable';
import { Layout } from '@douyinfe/semi-ui';
import { useTranslation } from 'react-i18next';
const User = () => (
<>
<Layout>
<Layout.Header>
<h3>管理用户</h3>
const User = () => {
const { t } = useTranslation();
return (
<>
<Layout>
<Layout.Header>
<h3>{t('管理用户')}</h3>
</Layout.Header>
<Layout.Content>
<UsersTable />
</Layout.Content>
</Layout>
</>
);
</Layout.Content>
</Layout>
</>
);
};
export default User;

View File

@@ -26,6 +26,7 @@ export default defineConfig({
esbuildOptions: {
loader: {
'.js': 'jsx',
'.json': 'json',
},
},
},
@@ -45,6 +46,7 @@ export default defineConfig({
'react-toastify',
'react-turnstile',
],
'i18n': ['i18next', 'react-i18next', 'i18next-browser-languagedetector'],
},
},
},