Compare commits

...

20 Commits

Author SHA1 Message Date
CaIon 51086c3ba6 🔧 fix(model_ratio): adjust return values for gemini-2.5-pro and gemini-2.5-flash models 2025-06-24 18:08:42 +08:00
t0ng7u 7a4c213b65 🎨 style(channels-table): standardize operation component size to small
All operation-related UI controls in `ChannelsTable` (buttons, dropdowns,
switches, inputs, tags, etc.) now explicitly use `size="small"`.

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

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

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

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

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

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

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

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

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

Key points:

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

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

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

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

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

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

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

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

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

Additional changes:
- Import `useEffect` from React.

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

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

This enhancement helps administrators swiftly identify and resolve incompatible pricing rules, addressing the need discussed in issue #1286.
2025-06-23 15:55:10 +08:00
CaIon 8a1e437ce9 🔧 chore: update STREAMING_TIMEOUT default value to 120 seconds in configuration 2025-06-22 18:47:40 +08:00
JoeyLearnsToCode 607d5fc25e feat: 渠道编辑页增加复制所有模型功能 2025-05-19 19:33:29 +08:00
13 changed files with 733 additions and 196 deletions
+1 -1
View File
@@ -59,7 +59,7 @@
# 设置 Dify 渠道是否输出工作流和节点信息到客户端
# DIFY_DEBUG=true
# 设置流式一次回复的超时时间
# STREAMING_TIMEOUT=90
# STREAMING_TIMEOUT=120
# 节点类型
+1 -1
View File
@@ -100,7 +100,7 @@ This version supports multiple models, please refer to [API Documentation-Relay
For detailed configuration instructions, please refer to [Installation Guide-Environment Variables Configuration](https://docs.newapi.pro/installation/environment-variables):
- `GENERATE_DEFAULT_TOKEN`: Whether to generate initial tokens for newly registered users, default is `false`
- `STREAMING_TIMEOUT`: Streaming response timeout, default is 60 seconds
- `STREAMING_TIMEOUT`: Streaming response timeout, default is 120 seconds
- `DIFY_DEBUG`: Whether to output workflow and node information for Dify channels, default is `true`
- `FORCE_STREAM_OPTION`: Whether to override client stream_options parameter, default is `true`
- `GET_MEDIA_TOKEN`: Whether to count image tokens, default is `true`
+1 -1
View File
@@ -103,7 +103,7 @@ New API提供了丰富的功能,详细特性请参考[特性说明](https://do
详细配置说明请参考[安装指南-环境变量配置](https://docs.newapi.pro/installation/environment-variables)
- `GENERATE_DEFAULT_TOKEN`:是否为新注册用户生成初始令牌,默认为 `false`
- `STREAMING_TIMEOUT`:流式回复超时时间,默认60秒
- `STREAMING_TIMEOUT`:流式回复超时时间,默认120秒
- `DIFY_DEBUG`:Dify渠道是否输出工作流和节点信息,默认 `true`
- `FORCE_STREAM_OPTION`:是否覆盖客户端stream_options参数,默认 `true`
- `GET_MEDIA_TOKEN`:是否统计图片token,默认 `true`
+1 -1
View File
@@ -23,7 +23,7 @@ var ErrorLogEnabled bool
//}
func InitEnv() {
StreamingTimeout = common.GetEnvOrDefault("STREAMING_TIMEOUT", 60)
StreamingTimeout = common.GetEnvOrDefault("STREAMING_TIMEOUT", 120)
DifyDebug = common.GetEnvOrDefaultBool("DIFY_DEBUG", true)
MaxFileDownloadMB = common.GetEnvOrDefault("MAX_FILE_DOWNLOAD_MB", 20)
// ForceStreamOption 覆盖请求参数,强制返回usage信息
+124 -21
View File
@@ -40,6 +40,17 @@ type OpenAIModelsResponse struct {
Success bool `json:"success"`
}
func parseStatusFilter(statusParam string) int {
switch strings.ToLower(statusParam) {
case "enabled", "1":
return common.ChannelStatusEnabled
case "disabled", "0":
return 0
default:
return -1
}
}
func GetAllChannels(c *gin.Context) {
p, _ := strconv.Atoi(c.Query("p"))
pageSize, _ := strconv.Atoi(c.Query("page_size"))
@@ -52,6 +63,9 @@ func GetAllChannels(c *gin.Context) {
channelData := make([]*model.Channel, 0)
idSort, _ := strconv.ParseBool(c.Query("id_sort"))
enableTagMode, _ := strconv.ParseBool(c.Query("tag_mode"))
statusParam := c.Query("status")
// statusFilter: -1 all, 1 enabled, 0 disabled (include auto & manual)
statusFilter := parseStatusFilter(statusParam)
// type filter
typeStr := c.Query("type")
typeFilter := -1
@@ -64,42 +78,75 @@ func GetAllChannels(c *gin.Context) {
var total int64
if enableTagMode {
// tag 分页:先分页 tag,再取各 tag 下 channels
tags, err := model.GetPaginatedTags((p-1)*pageSize, pageSize)
if err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()})
return
}
for _, tag := range tags {
if tag != nil && *tag != "" {
tagChannel, err := model.GetChannelsByTag(*tag, idSort)
if err == nil {
channelData = append(channelData, tagChannel...)
}
if tag == nil || *tag == "" {
continue
}
tagChannels, err := model.GetChannelsByTag(*tag, idSort)
if err != nil {
continue
}
filtered := make([]*model.Channel, 0)
for _, ch := range tagChannels {
if statusFilter == common.ChannelStatusEnabled && ch.Status != common.ChannelStatusEnabled {
continue
}
if statusFilter == 0 && ch.Status == common.ChannelStatusEnabled {
continue
}
if typeFilter >= 0 && ch.Type != typeFilter {
continue
}
filtered = append(filtered, ch)
}
channelData = append(channelData, filtered...)
}
// 计算 tag 总数用于分页
total, _ = model.CountAllTags()
} else if typeFilter >= 0 {
channels, err := model.GetChannelsByType((p-1)*pageSize, pageSize, idSort, typeFilter)
if err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()})
return
}
channelData = channels
total, _ = model.CountChannelsByType(typeFilter)
} else {
channels, err := model.GetAllChannels((p-1)*pageSize, pageSize, false, idSort)
baseQuery := model.DB.Model(&model.Channel{})
if typeFilter >= 0 {
baseQuery = baseQuery.Where("type = ?", typeFilter)
}
if statusFilter == common.ChannelStatusEnabled {
baseQuery = baseQuery.Where("status = ?", common.ChannelStatusEnabled)
} else if statusFilter == 0 {
baseQuery = baseQuery.Where("status != ?", common.ChannelStatusEnabled)
}
baseQuery.Count(&total)
order := "priority desc"
if idSort {
order = "id desc"
}
err := baseQuery.Order(order).Limit(pageSize).Offset((p-1)*pageSize).Omit("key").Find(&channelData).Error
if err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()})
return
}
channelData = channels
total, _ = model.CountAllChannels()
}
// calculate type counts
typeCounts, _ := model.CountChannelsGroupByType()
countQuery := model.DB.Model(&model.Channel{})
if statusFilter == common.ChannelStatusEnabled {
countQuery = countQuery.Where("status = ?", common.ChannelStatusEnabled)
} else if statusFilter == 0 {
countQuery = countQuery.Where("status != ?", common.ChannelStatusEnabled)
}
var results []struct {
Type int64
Count int64
}
_ = countQuery.Select("type, count(*) as count").Group("type").Find(&results).Error
typeCounts := make(map[int64]int64)
for _, r := range results {
typeCounts[r.Type] = r.Count
}
c.JSON(http.StatusOK, gin.H{
"success": true,
@@ -199,6 +246,8 @@ func SearchChannels(c *gin.Context) {
keyword := c.Query("keyword")
group := c.Query("group")
modelKeyword := c.Query("model")
statusParam := c.Query("status")
statusFilter := parseStatusFilter(statusParam)
idSort, _ := strconv.ParseBool(c.Query("id_sort"))
enableTagMode, _ := strconv.ParseBool(c.Query("tag_mode"))
channelData := make([]*model.Channel, 0)
@@ -231,17 +280,71 @@ func SearchChannels(c *gin.Context) {
channelData = channels
}
if statusFilter == common.ChannelStatusEnabled || statusFilter == 0 {
filtered := make([]*model.Channel, 0, len(channelData))
for _, ch := range channelData {
if statusFilter == common.ChannelStatusEnabled && ch.Status != common.ChannelStatusEnabled {
continue
}
if statusFilter == 0 && ch.Status == common.ChannelStatusEnabled {
continue
}
filtered = append(filtered, ch)
}
channelData = filtered
}
// calculate type counts for search results
typeCounts := make(map[int64]int64)
for _, channel := range channelData {
typeCounts[int64(channel.Type)]++
}
typeParam := c.Query("type")
typeFilter := -1
if typeParam != "" {
if tp, err := strconv.Atoi(typeParam); err == nil {
typeFilter = tp
}
}
if typeFilter >= 0 {
filtered := make([]*model.Channel, 0, len(channelData))
for _, ch := range channelData {
if ch.Type == typeFilter {
filtered = append(filtered, ch)
}
}
channelData = filtered
}
page, _ := strconv.Atoi(c.DefaultQuery("p", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
if page < 1 {
page = 1
}
if pageSize <= 0 {
pageSize = 20
}
total := len(channelData)
startIdx := (page - 1) * pageSize
if startIdx > total {
startIdx = total
}
endIdx := startIdx + pageSize
if endIdx > total {
endIdx = total
}
pagedData := channelData[startIdx:endIdx]
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": gin.H{
"items": channelData,
"items": pagedData,
"total": total,
"type_counts": typeCounts,
},
})
+3 -3
View File
@@ -501,13 +501,13 @@ func getHardcodedCompletionModelRatio(name string) (float64, bool) {
} else if strings.HasPrefix(name, "gemini-2.0") {
return 4, true
} else if strings.HasPrefix(name, "gemini-2.5-pro") { // 移除preview来增加兼容性,这里假设正式版的倍率和preview一致
return 8, true
return 8, false
} else if strings.HasPrefix(name, "gemini-2.5-flash") { // 处理不同的flash模型倍率
if strings.HasPrefix(name, "gemini-2.5-flash-preview") {
if strings.HasSuffix(name, "-nothinking") {
return 4, true
return 4, false
}
return 3.5 / 0.15, true
return 3.5 / 0.15, false
}
if strings.HasPrefix(name, "gemini-2.5-flash-lite-preview") {
return 4, true
+255 -76
View File
@@ -20,7 +20,7 @@ import {
Tags,
} from 'lucide-react';
import { CHANNEL_OPTIONS, ITEMS_PER_PAGE } from '../../constants/index.js';
import { CHANNEL_OPTIONS, ITEMS_PER_PAGE, MODEL_TABLE_PAGE_SIZE } from '../../constants/index.js';
import {
Button,
Divider,
@@ -40,7 +40,8 @@ import {
Card,
Form,
Tabs,
TabPane
TabPane,
Select,
} from '@douyinfe/semi-ui';
import {
IllustrationNoResult,
@@ -62,7 +63,7 @@ import {
IconCopy,
IconSmallTriangleRight
} from '@douyinfe/semi-icons';
import { loadChannelModels } from '../../helpers/index.js';
import { loadChannelModels, isMobile, copy } from '../../helpers';
import EditTagModal from '../../pages/Channel/EditTagModal.js';
import { useTranslation } from 'react-i18next';
import { useTableCompactMode } from '../../hooks/useTableCompactMode';
@@ -189,6 +190,11 @@ const ChannelsTable = () => {
const [visibleColumns, setVisibleColumns] = useState({});
const [showColumnSelector, setShowColumnSelector] = useState(false);
// 状态筛选 all / enabled / disabled
const [statusFilter, setStatusFilter] = useState(
localStorage.getItem('channel-status-filter') || 'all'
);
// Load saved column preferences from localStorage
useEffect(() => {
const savedColumns = localStorage.getItem('channels-table-columns');
@@ -678,9 +684,11 @@ const ChannelsTable = () => {
const [modelSearchKeyword, setModelSearchKeyword] = useState('');
const [modelTestResults, setModelTestResults] = useState({});
const [testingModels, setTestingModels] = useState(new Set());
const [selectedModelKeys, setSelectedModelKeys] = useState([]);
const [isBatchTesting, setIsBatchTesting] = useState(false);
const [testQueue, setTestQueue] = useState([]);
const [isProcessingQueue, setIsProcessingQueue] = useState(false);
const [modelTablePage, setModelTablePage] = useState(1);
const [activeTypeKey, setActiveTypeKey] = useState('all');
const [typeCounts, setTypeCounts] = useState({});
const requestCounter = useRef(0);
@@ -691,6 +699,7 @@ const ChannelsTable = () => {
searchGroup: '',
searchModel: '',
};
const allSelectingRef = useRef(false);
// Filter columns based on visibility settings
const getVisibleColumns = () => {
@@ -867,12 +876,30 @@ const ChannelsTable = () => {
setChannels(channelDates);
};
const loadChannels = async (page, pageSize, idSort, enableTagMode, typeKey = activeTypeKey) => {
const loadChannels = async (
page,
pageSize,
idSort,
enableTagMode,
typeKey = activeTypeKey,
statusF,
) => {
if (statusF === undefined) statusF = statusFilter;
const { searchKeyword, searchGroup, searchModel } = getFormValues();
if (searchKeyword !== '' || searchGroup !== '' || searchModel !== '') {
setLoading(true);
await searchChannels(enableTagMode, typeKey, statusF, page, pageSize, idSort);
setLoading(false);
return;
}
const reqId = ++requestCounter.current; // 记录当前请求序号
setLoading(true);
const typeParam = (!enableTagMode && typeKey !== 'all') ? `&type=${typeKey}` : '';
const typeParam = (typeKey !== 'all') ? `&type=${typeKey}` : '';
const statusParam = statusF !== 'all' ? `&status=${statusF}` : '';
const res = await API.get(
`/api/channel/?p=${page}&page_size=${pageSize}&id_sort=${idSort}&tag_mode=${enableTagMode}${typeParam}`,
`/api/channel/?p=${page}&page_size=${pageSize}&id_sort=${idSort}&tag_mode=${enableTagMode}${typeParam}${statusParam}`,
);
if (res === undefined || reqId !== requestCounter.current) {
return;
@@ -923,7 +950,7 @@ const ChannelsTable = () => {
if (searchKeyword === '' && searchGroup === '' && searchModel === '') {
await loadChannels(activePage, pageSize, idSort, enableTagMode);
} else {
await searchChannels(enableTagMode);
await searchChannels(enableTagMode, activeTypeKey, statusFilter, activePage, pageSize, idSort);
}
};
@@ -1029,7 +1056,7 @@ const ChannelsTable = () => {
}
};
// 获取表单值的辅助函数,确保所有值都是字符串
// 获取表单值的辅助函数
const getFormValues = () => {
const formValues = formApi ? formApi.getValues() : {};
return {
@@ -1039,27 +1066,35 @@ const ChannelsTable = () => {
};
};
const searchChannels = async (enableTagMode) => {
const searchChannels = async (
enableTagMode,
typeKey = activeTypeKey,
statusF = statusFilter,
page = 1,
pageSz = pageSize,
sortFlag = idSort,
) => {
const { searchKeyword, searchGroup, searchModel } = getFormValues();
setSearching(true);
try {
if (searchKeyword === '' && searchGroup === '' && searchModel === '') {
await loadChannels(activePage - 1, pageSize, idSort, enableTagMode);
await loadChannels(page, pageSz, sortFlag, enableTagMode, typeKey, statusF);
return;
}
const typeParam = (!enableTagMode && activeTypeKey !== 'all') ? `&type=${activeTypeKey}` : '';
const typeParam = (typeKey !== 'all') ? `&type=${typeKey}` : '';
const statusParam = statusF !== 'all' ? `&status=${statusF}` : '';
const res = await API.get(
`/api/channel/search?keyword=${searchKeyword}&group=${searchGroup}&model=${searchModel}&id_sort=${idSort}&tag_mode=${enableTagMode}${typeParam}`,
`/api/channel/search?keyword=${searchKeyword}&group=${searchGroup}&model=${searchModel}&id_sort=${sortFlag}&tag_mode=${enableTagMode}&p=${page}&page_size=${pageSz}${typeParam}${statusParam}`,
);
const { success, message, data } = res.data;
if (success) {
const { items = [], type_counts = {} } = data;
const { items = [], total = 0, type_counts = {} } = data;
const sumAll = Object.values(type_counts).reduce((acc, v) => acc + v, 0);
setTypeCounts({ ...type_counts, all: sumAll });
setChannelFormat(items, enableTagMode);
setActivePage(1);
setChannelCount(total);
setActivePage(page);
} else {
showError(message);
}
@@ -1099,7 +1134,22 @@ const ChannelsTable = () => {
const processTestQueue = async () => {
if (!isProcessingQueue || testQueue.length === 0) return;
const { channel, model } = testQueue[0];
const { channel, model, indexInFiltered } = testQueue[0];
// 自动翻页到正在测试的模型所在页
if (currentTestChannel && currentTestChannel.id === channel.id) {
let pageNo;
if (indexInFiltered !== undefined) {
pageNo = Math.floor(indexInFiltered / MODEL_TABLE_PAGE_SIZE) + 1;
} else {
const filteredModelsList = currentTestChannel.models
.split(',')
.filter((m) => m.toLowerCase().includes(modelSearchKeyword.toLowerCase()));
const modelIdx = filteredModelsList.indexOf(model);
pageNo = modelIdx !== -1 ? Math.floor(modelIdx / MODEL_TABLE_PAGE_SIZE) + 1 : 1;
}
setModelTablePage(pageNo);
}
try {
setTestingModels(prev => new Set([...prev, model]));
@@ -1162,16 +1212,22 @@ const ChannelsTable = () => {
setIsBatchTesting(true);
const models = currentTestChannel.models
// 重置分页到第一页
setModelTablePage(1);
const filteredModels = currentTestChannel.models
.split(',')
.filter((model) =>
model.toLowerCase().includes(modelSearchKeyword.toLowerCase())
model.toLowerCase().includes(modelSearchKeyword.toLowerCase()),
);
setTestQueue(models.map(model => ({
channel: currentTestChannel,
model
})));
setTestQueue(
filteredModels.map((model, idx) => ({
channel: currentTestChannel,
model,
indexInFiltered: idx, // 记录在过滤列表中的顺序
})),
);
setIsProcessingQueue(true);
};
@@ -1185,6 +1241,8 @@ const ChannelsTable = () => {
} else {
setShowModelTestModal(false);
setModelSearchKeyword('');
setSelectedModelKeys([]);
setModelTablePage(1);
}
};
@@ -1265,32 +1323,31 @@ const ChannelsTable = () => {
};
let pageData = channels;
if (activeTypeKey !== 'all') {
const typeVal = parseInt(activeTypeKey);
if (!isNaN(typeVal)) {
pageData = pageData.filter((ch) => {
if (ch.children !== undefined) {
return ch.children.some((c) => c.type === typeVal);
}
return ch.type === typeVal;
});
}
}
const handlePageChange = (page) => {
const { searchKeyword, searchGroup, searchModel } = getFormValues();
setActivePage(page);
loadChannels(page, pageSize, idSort, enableTagMode).then(() => { });
if (searchKeyword === '' && searchGroup === '' && searchModel === '') {
loadChannels(page, pageSize, idSort, enableTagMode).then(() => { });
} else {
searchChannels(enableTagMode, activeTypeKey, statusFilter, page, pageSize, idSort);
}
};
const handlePageSizeChange = async (size) => {
localStorage.setItem('page-size', size + '');
setPageSize(size);
setActivePage(1);
loadChannels(1, size, idSort, enableTagMode)
.then()
.catch((reason) => {
showError(reason);
});
const { searchKeyword, searchGroup, searchModel } = getFormValues();
if (searchKeyword === '' && searchGroup === '' && searchModel === '') {
loadChannels(1, size, idSort, enableTagMode)
.then()
.catch((reason) => {
showError(reason);
});
} else {
searchChannels(enableTagMode, activeTypeKey, statusFilter, 1, size, idSort);
}
};
const fetchGroups = async () => {
@@ -1471,6 +1528,7 @@ const ChannelsTable = () => {
<div className="flex flex-col md:flex-row justify-between gap-4">
<div className="flex flex-wrap md:flex-nowrap items-center gap-2 w-full md:w-auto order-2 md:order-1">
<Button
size='small'
disabled={!enableBatchDelete}
theme='light'
type='danger'
@@ -1487,6 +1545,7 @@ const ChannelsTable = () => {
</Button>
<Button
size='small'
disabled={!enableBatchDelete}
theme='light'
type='primary'
@@ -1497,11 +1556,13 @@ const ChannelsTable = () => {
</Button>
<Dropdown
size='small'
trigger='click'
render={
<Dropdown.Menu>
<Dropdown.Item>
<Button
size='small'
theme='light'
type='warning'
className="!rounded-full w-full"
@@ -1520,6 +1581,7 @@ const ChannelsTable = () => {
</Dropdown.Item>
<Dropdown.Item>
<Button
size='small'
theme='light'
type='secondary'
className="!rounded-full w-full"
@@ -1538,6 +1600,7 @@ const ChannelsTable = () => {
</Dropdown.Item>
<Dropdown.Item>
<Button
size='small'
theme='light'
type='danger'
className="!rounded-full w-full"
@@ -1556,6 +1619,7 @@ const ChannelsTable = () => {
</Dropdown.Item>
<Dropdown.Item>
<Button
size='small'
theme='light'
type='tertiary'
className="!rounded-full w-full"
@@ -1575,15 +1639,15 @@ const ChannelsTable = () => {
</Dropdown.Menu>
}
>
<Button theme='light' type='tertiary' icon={<IconSetting />} className="!rounded-full w-full md:w-auto">
<Button size='small' theme='light' type='tertiary' className="!rounded-full w-full md:w-auto">
{t('批量操作')}
</Button>
</Dropdown>
<Button
size='small'
theme='light'
type='secondary'
icon={<IconDescend />}
className="!rounded-full w-full md:w-auto"
onClick={() => setCompactMode(!compactMode)}
>
@@ -1597,11 +1661,17 @@ const ChannelsTable = () => {
{t('使用ID排序')}
</Typography.Text>
<Switch
size='small'
checked={idSort}
onChange={(v) => {
localStorage.setItem('id-sort', v + '');
setIdSort(v);
loadChannels(activePage, pageSize, v, enableTagMode);
const { searchKeyword, searchGroup, searchModel } = getFormValues();
if (searchKeyword === '' && searchGroup === '' && searchModel === '') {
loadChannels(activePage, pageSize, v, enableTagMode);
} else {
searchChannels(enableTagMode, activeTypeKey, statusFilter, activePage, pageSize, v);
}
}}
/>
</div>
@@ -1611,6 +1681,7 @@ const ChannelsTable = () => {
{t('开启批量操作')}
</Typography.Text>
<Switch
size='small'
checked={enableBatchDelete}
onChange={(v) => {
localStorage.setItem('enable-batch-delete', v + '');
@@ -1624,6 +1695,7 @@ const ChannelsTable = () => {
{t('标签聚合模式')}
</Typography.Text>
<Switch
size='small'
checked={enableTagMode}
onChange={(v) => {
localStorage.setItem('enable-tag-mode', v + '');
@@ -1633,6 +1705,27 @@ const ChannelsTable = () => {
}}
/>
</div>
{/* 状态筛选器 */}
<div className="flex items-center justify-between w-full md:w-auto">
<Typography.Text strong className="mr-2">
{t('状态筛选')}
</Typography.Text>
<Select
size='small'
value={statusFilter}
onChange={(v) => {
localStorage.setItem('channel-status-filter', v);
setStatusFilter(v);
setActivePage(1);
loadChannels(1, pageSize, idSort, enableTagMode, activeTypeKey, v);
}}
>
<Select.Option value="all">{t('全部')}</Select.Option>
<Select.Option value="enabled">{t('已启用')}</Select.Option>
<Select.Option value="disabled">{t('已禁用')}</Select.Option>
</Select>
</div>
</div>
</div>
@@ -1641,6 +1734,7 @@ const ChannelsTable = () => {
<div className="flex flex-col md:flex-row justify-between items-center gap-4 w-full">
<div className="flex gap-2 w-full md:w-auto order-2 md:order-1">
<Button
size='small'
theme='light'
type='primary'
icon={<IconPlus />}
@@ -1656,9 +1750,9 @@ const ChannelsTable = () => {
</Button>
<Button
size='small'
theme='light'
type='primary'
icon={<IconRefresh />}
className="!rounded-full w-full md:w-auto"
onClick={refresh}
>
@@ -1666,9 +1760,9 @@ const ChannelsTable = () => {
</Button>
<Button
size='small'
theme='light'
type='tertiary'
icon={<IconSetting />}
onClick={() => setShowColumnSelector(true)}
className="!rounded-full w-full md:w-auto"
>
@@ -1690,9 +1784,10 @@ const ChannelsTable = () => {
>
<div className="relative w-full md:w-64">
<Form.Input
size='small'
field="searchKeyword"
prefix={<IconSearch />}
placeholder={t('搜索渠道的 ID,名称,密钥API地址 ...')}
placeholder={t('渠道ID,名称,密钥API地址')}
className="!rounded-full"
showClear
pure
@@ -1700,6 +1795,7 @@ const ChannelsTable = () => {
</div>
<div className="w-full md:w-48">
<Form.Input
size='small'
field="searchModel"
prefix={<IconSearch />}
placeholder={t('模型关键字')}
@@ -1708,8 +1804,9 @@ const ChannelsTable = () => {
pure
/>
</div>
<div className="w-full md:w-48">
<div className="w-full md:w-32">
<Form.Select
size='small'
field="searchGroup"
placeholder={t('选择分组')}
optionList={[
@@ -1728,6 +1825,7 @@ const ChannelsTable = () => {
/>
</div>
<Button
size='small'
type="primary"
htmlType="submit"
loading={loading || searching}
@@ -1736,6 +1834,7 @@ const ChannelsTable = () => {
{t('查询')}
</Button>
<Button
size='small'
theme='light'
onClick={() => {
if (formApi) {
@@ -1819,7 +1918,7 @@ const ChannelsTable = () => {
}
className="rounded-xl overflow-hidden"
size="middle"
loading={loading}
loading={loading || searching}
/>
</Card>
@@ -1855,13 +1954,73 @@ const ChannelsTable = () => {
<Modal
title={
currentTestChannel && (
<div className="flex items-center gap-2">
<Typography.Text strong className="!text-[var(--semi-color-text-0)] !text-base">
{currentTestChannel.name} {t('渠道的模型测试')}
</Typography.Text>
<Typography.Text type="tertiary" className="!text-xs flex items-center">
{t('共')} {currentTestChannel.models.split(',').length} {t('个模型')}
</Typography.Text>
<div className="flex flex-col gap-2 w-full">
<div className="flex items-center gap-2">
<Typography.Text strong className="!text-[var(--semi-color-text-0)] !text-base">
{currentTestChannel.name} {t('渠道的模型测试')}
</Typography.Text>
<Typography.Text type="tertiary" className="!text-xs flex items-center">
{t('共')} {currentTestChannel.models.split(',').length} {t('个模型')}
</Typography.Text>
</div>
{/* 搜索与操作按钮 */}
<div className="flex items-center justify-end gap-2 w-full">
<Input
placeholder={t('搜索模型...')}
value={modelSearchKeyword}
onChange={(v) => {
setModelSearchKeyword(v);
setModelTablePage(1);
}}
className="!w-full !rounded-full"
prefix={<IconSearch />}
showClear
/>
<Button
theme='light'
icon={<IconCopy />}
className="!rounded-full"
onClick={() => {
if (selectedModelKeys.length === 0) {
showError(t('请先选择模型!'));
return;
}
copy(selectedModelKeys.join(',')).then((ok) => {
if (ok) {
showSuccess(t('已复制 ${count} 个模型').replace('${count}', selectedModelKeys.length));
} else {
showError(t('复制失败,请手动复制'));
}
});
}}
>
{t('复制已选')}
</Button>
<Button
theme='light'
type='primary'
className="!rounded-full"
onClick={() => {
if (!currentTestChannel) return;
const successKeys = currentTestChannel.models
.split(',')
.filter((m) => m.toLowerCase().includes(modelSearchKeyword.toLowerCase()))
.filter((m) => {
const result = modelTestResults[`${currentTestChannel.id}-${m}`];
return result && result.success;
});
if (successKeys.length === 0) {
showInfo(t('暂无成功模型'));
}
setSelectedModelKeys(successKeys);
}}
>
{t('选择成功')}
</Button>
</div>
</div>
)
}
@@ -1911,22 +2070,11 @@ const ChannelsTable = () => {
}
maskClosable={!isBatchTesting}
className="!rounded-lg"
size="large"
size={isMobile() ? 'full-width' : 'large'}
>
<div className="max-h-[600px] overflow-y-auto">
<div className="model-test-scroll">
{currentTestChannel && (
<div>
<div className="flex items-center justify-end mb-2">
<Input
placeholder={t('搜索模型...')}
value={modelSearchKeyword}
onChange={(v) => setModelSearchKeyword(v)}
className="w-64 !rounded-full"
prefix={<IconSearch />}
showClear
/>
</div>
<Table
columns={[
{
@@ -2000,16 +2148,47 @@ const ChannelsTable = () => {
}
}
]}
dataSource={currentTestChannel.models
.split(',')
.filter((model) =>
model.toLowerCase().includes(modelSearchKeyword.toLowerCase())
)
.map((model) => ({
dataSource={(() => {
const filtered = currentTestChannel.models
.split(',')
.filter((model) =>
model.toLowerCase().includes(modelSearchKeyword.toLowerCase()),
);
const start = (modelTablePage - 1) * MODEL_TABLE_PAGE_SIZE;
const end = start + MODEL_TABLE_PAGE_SIZE;
return filtered.slice(start, end).map((model) => ({
model,
key: model
}))}
pagination={false}
key: model,
}));
})()}
rowSelection={{
selectedRowKeys: selectedModelKeys,
onChange: (keys) => {
if (allSelectingRef.current) {
allSelectingRef.current = false;
return;
}
setSelectedModelKeys(keys);
},
onSelectAll: (checked) => {
const filtered = currentTestChannel.models
.split(',')
.filter((m) => m.toLowerCase().includes(modelSearchKeyword.toLowerCase()));
allSelectingRef.current = true;
setSelectedModelKeys(checked ? filtered : []);
},
}}
pagination={{
currentPage: modelTablePage,
pageSize: MODEL_TABLE_PAGE_SIZE,
total: currentTestChannel.models
.split(',')
.filter((model) =>
model.toLowerCase().includes(modelSearchKeyword.toLowerCase()),
).length,
showSizeChanger: false,
onPageChange: (page) => setModelTablePage(page),
}}
/>
</div>
)}
+2
View File
@@ -131,3 +131,5 @@ export const CHANNEL_OPTIONS = [
label: '可灵',
},
];
export const MODEL_TABLE_PAGE_SIZE = 10;
+19 -2
View File
@@ -139,7 +139,7 @@
"已成功开始测试所有已启用通道,请刷新页面查看结果。": "Successfully started testing all enabled channels. Please refresh page to view results.",
"通道 ${name} 余额更新成功!": "Channel ${name} quota updated successfully!",
"已更新完毕所有已启用通道余额!": "Updated quota for all enabled channels!",
"搜索渠道的 ID,名称,密钥API地址 ...": "Search channel ID, name, key and Base URL...",
"渠道ID,名称,密钥API地址": "Channel ID, name, key, Base URL",
"名称": "Name",
"分组": "Group",
"类型": "Type",
@@ -428,6 +428,7 @@
"填入基础模型": "Fill in the basic model",
"填入所有模型": "Fill in all models",
"清除所有模型": "Clear all models",
"复制所有模型": "Copy all models",
"密钥": "Key",
"请输入密钥": "Please enter the key",
"批量创建": "Batch Create",
@@ -1726,5 +1727,21 @@
"放大编辑": "Expand editor",
"编辑公告内容": "Edit announcement content",
"自适应列表": "Adaptive list",
"紧凑列表": "Compact list"
"紧凑列表": "Compact list",
"仅显示矛盾倍率": "Only show conflicting ratios",
"矛盾": "Conflict",
"确认冲突项修改": "Confirm conflict item modification",
"该模型存在固定价格与倍率计费方式冲突,请确认选择": "The model has a fixed price and ratio billing method conflict, please confirm the selection",
"当前计费": "Current billing",
"修改为": "Modify to",
"状态筛选": "Status filter",
"没有模型可以复制": "No models to copy",
"模型列表已复制到剪贴板": "Model list copied to clipboard",
"复制失败": "Copy failed",
"复制已选": "Copy selected",
"选择成功": "Selection successful",
"暂无成功模型": "No successful models",
"请先选择模型!": "Please select a model first!",
"已复制 ${count} 个模型": "Copied ${count} models",
"复制失败,请手动复制": "Copy failed, please copy manually"
}
+2
View File
@@ -375,6 +375,7 @@ code {
}
/* 隐藏卡片内容区域的滚动条 */
.model-test-scroll,
.card-content-scroll,
.model-settings-scroll,
.thinking-content-scroll,
@@ -385,6 +386,7 @@ code {
scrollbar-width: none;
}
.model-test-scroll::-webkit-scrollbar,
.card-content-scroll::-webkit-scrollbar,
.model-settings-scroll::-webkit-scrollbar,
.thinking-content-scroll::-webkit-scrollbar,
+56 -27
View File
@@ -26,7 +26,7 @@ import {
Card,
Tag,
} from '@douyinfe/semi-ui';
import { getChannelModels } from '../../helpers';
import { getChannelModels, copy } from '../../helpers';
import {
IconSave,
IconClose,
@@ -111,6 +111,10 @@ const EditChannel = (props) => {
const [modalImageUrl, setModalImageUrl] = useState('');
const [isModalOpenurl, setIsModalOpenurl] = useState(false);
const handleInputChange = (name, value) => {
if (name === 'models' && Array.isArray(value)) {
value = Array.from(new Set(value.map((m) => (m || '').trim())));
}
if (name === 'base_url' && value.endsWith('/v1')) {
Modal.confirm({
title: '警告',
@@ -265,10 +269,14 @@ const EditChannel = (props) => {
const fetchModels = async () => {
try {
let res = await API.get(`/api/channel/models`);
let localModelOptions = res.data.data.map((model) => ({
label: model.id,
value: model.id,
}));
const localModelOptions = res.data.data.map((model) => {
const id = (model.id || '').trim();
return {
key: id,
label: id,
value: id,
};
});
setOriginModelOptions(localModelOptions);
setFullModels(res.data.data.map((model) => model.id));
setBasicModels(
@@ -300,27 +308,29 @@ const EditChannel = (props) => {
}
};
useEffect(() => {
// 使用 Map 来避免重复,以 value 为键
const modelMap = new Map();
// 先添加原始模型选项
originModelOptions.forEach(option => {
modelMap.set(option.value, option);
});
// 再添加当前选中的模型(如果不存在)
inputs.models.forEach(model => {
if (!modelMap.has(model)) {
modelMap.set(model, {
label: model,
value: model,
});
}
});
setModelOptions(Array.from(modelMap.values()));
}, [originModelOptions, inputs.models]);
useEffect(() => {
const modelMap = new Map();
originModelOptions.forEach(option => {
const v = (option.value || '').trim();
if (!modelMap.has(v)) {
modelMap.set(v, option);
}
});
inputs.models.forEach(model => {
const v = (model || '').trim();
if (!modelMap.has(v)) {
modelMap.set(v, {
key: v,
label: v,
value: v,
});
}
});
setModelOptions(Array.from(modelMap.values()));
}, [originModelOptions, inputs.models]);
useEffect(() => {
fetchModels().then();
@@ -403,7 +413,7 @@ useEffect(() => {
localModels.push(model);
localModelOptions.push({
key: model,
text: model,
label: model,
value: model,
});
addedModels.push(model);
@@ -832,6 +842,25 @@ useEffect(() => {
>
{t('清除所有模型')}
</Button>
<Button
type='tertiary'
onClick={() => {
if (inputs.models.length === 0) {
showInfo(t('没有模型可以复制'));
return;
}
try {
copy(inputs.models.join(','));
showSuccess(t('模型列表已复制到剪贴板'));
} catch (error) {
showError(t('复制失败'));
}
}}
size="large"
className="!rounded-lg"
>
{t('复制所有模型')}
</Button>
</div>
<div>
@@ -8,7 +8,9 @@ import {
Form,
Space,
RadioGroup,
Radio
Radio,
Checkbox,
Tag
} from '@douyinfe/semi-ui';
import {
IconDelete,
@@ -30,6 +32,7 @@ export default function ModelSettingsVisualEditor(props) {
const [loading, setLoading] = useState(false);
const [pricingMode, setPricingMode] = useState('per-token'); // 'per-token' or 'per-request'
const [pricingSubMode, setPricingSubMode] = useState('ratio'); // 'ratio' or 'token-price'
const [conflictOnly, setConflictOnly] = useState(false);
const formRef = useRef(null);
const pageSize = 10;
const quotaPerUnit = getQuotaPerUnit();
@@ -47,13 +50,19 @@ export default function ModelSettingsVisualEditor(props) {
...Object.keys(completionRatio),
]);
const modelData = Array.from(modelNames).map((name) => ({
name,
price: modelPrice[name] === undefined ? '' : modelPrice[name],
ratio: modelRatio[name] === undefined ? '' : modelRatio[name],
completionRatio:
completionRatio[name] === undefined ? '' : completionRatio[name],
}));
const modelData = Array.from(modelNames).map((name) => {
const price = modelPrice[name] === undefined ? '' : modelPrice[name];
const ratio = modelRatio[name] === undefined ? '' : modelRatio[name];
const comp = completionRatio[name] === undefined ? '' : completionRatio[name];
return {
name,
price,
ratio,
completionRatio: comp,
hasConflict: price !== '' && (ratio !== '' || comp !== ''),
};
});
setModels(modelData);
} catch (error) {
@@ -69,11 +78,13 @@ export default function ModelSettingsVisualEditor(props) {
};
// 在 return 语句之前,先处理过滤和分页逻辑
const filteredModels = models.filter((model) =>
searchText
const filteredModels = models.filter((model) => {
const keywordMatch = searchText
? model.name.toLowerCase().includes(searchText.toLowerCase())
: true,
);
: true;
const conflictMatch = conflictOnly ? model.hasConflict : true;
return keywordMatch && conflictMatch;
});
// 然后基于过滤后的数据计算分页数据
const pagedData = getPagedData(filteredModels, currentPage, pageSize);
@@ -152,6 +163,16 @@ export default function ModelSettingsVisualEditor(props) {
title: t('模型名称'),
dataIndex: 'name',
key: 'name',
render: (text, record) => (
<span>
{text}
{record.hasConflict && (
<Tag color='red' shape='circle' className='ml-2'>
{t('矛盾')}
</Tag>
)}
</span>
),
},
{
title: t('模型固定价格'),
@@ -219,9 +240,13 @@ export default function ModelSettingsVisualEditor(props) {
return;
}
setModels((prev) =>
prev.map((model) =>
model.name === name ? { ...model, [field]: value } : model,
),
prev.map((model) => {
if (model.name !== name) return model;
const updated = { ...model, [field]: value };
updated.hasConflict =
updated.price !== '' && (updated.ratio !== '' || updated.completionRatio !== '');
return updated;
}),
);
};
@@ -296,16 +321,18 @@ export default function ModelSettingsVisualEditor(props) {
if (existingModelIndex >= 0) {
// Update existing model
setModels((prev) =>
prev.map((model, index) =>
index === existingModelIndex
? {
name: values.name,
price: values.price || '',
ratio: values.ratio || '',
completionRatio: values.completionRatio || '',
}
: model,
),
prev.map((model, index) => {
if (index !== existingModelIndex) return model;
const updated = {
name: values.name,
price: values.price || '',
ratio: values.ratio || '',
completionRatio: values.completionRatio || '',
};
updated.hasConflict =
updated.price !== '' && (updated.ratio !== '' || updated.completionRatio !== '');
return updated;
}),
);
setVisible(false);
showSuccess(t('更新成功'));
@@ -317,15 +344,17 @@ export default function ModelSettingsVisualEditor(props) {
return;
}
setModels((prev) => [
{
setModels((prev) => {
const newModel = {
name: values.name,
price: values.price || '',
ratio: values.ratio || '',
completionRatio: values.completionRatio || '',
},
...prev,
]);
};
newModel.hasConflict =
newModel.price !== '' && (newModel.ratio !== '' || newModel.completionRatio !== '');
return [newModel, ...prev];
});
setVisible(false);
showSuccess(t('添加成功'));
}
@@ -426,7 +455,17 @@ export default function ModelSettingsVisualEditor(props) {
setCurrentPage(1);
}}
style={{ width: 200 }}
showClear
/>
<Checkbox
checked={conflictOnly}
onChange={(e) => {
setConflictOnly(e.target.checked);
setCurrentPage(1);
}}
>
{t('仅显示矛盾倍率')}
</Checkbox>
</Space>
<Table
columns={columns}
+199 -33
View File
@@ -1,4 +1,4 @@
import React, { useState, useCallback, useMemo } from 'react';
import React, { useState, useCallback, useMemo, useEffect } from 'react';
import {
Button,
Table,
@@ -9,6 +9,7 @@ import {
Input,
Tooltip,
Select,
Modal,
} from '@douyinfe/semi-ui';
import { IconSearch } from '@douyinfe/semi-icons';
import {
@@ -17,7 +18,7 @@ import {
AlertTriangle,
CheckCircle,
} from 'lucide-react';
import { API, showError, showSuccess, showWarning, stringToColor } from '../../../helpers';
import { API, showError, showSuccess, showWarning, stringToColor, isMobile } from '../../../helpers';
import { DEFAULT_ENDPOINT } from '../../../constants';
import { useTranslation } from 'react-i18next';
import {
@@ -26,6 +27,35 @@ import {
} from '@douyinfe/semi-illustrations';
import ChannelSelectorModal from '../../../components/settings/ChannelSelectorModal';
function ConflictConfirmModal({ t, visible, items, onOk, onCancel }) {
const columns = [
{ title: t('渠道'), dataIndex: 'channel' },
{ title: t('模型'), dataIndex: 'model' },
{
title: t('当前计费'),
dataIndex: 'current',
render: (text) => <div style={{ whiteSpace: 'pre-wrap' }}>{text}</div>,
},
{
title: t('修改为'),
dataIndex: 'newVal',
render: (text) => <div style={{ whiteSpace: 'pre-wrap' }}>{text}</div>,
},
];
return (
<Modal
title={t('确认冲突项修改')}
visible={visible}
onCancel={onCancel}
onOk={onOk}
size={isMobile() ? 'full-width' : 'large'}
>
<Table columns={columns} dataSource={items} pagination={false} size="small" />
</Modal>
);
}
export default function UpstreamRatioSync(props) {
const { t } = useTranslation();
const [modalVisible, setModalVisible] = useState(false);
@@ -56,8 +86,16 @@ export default function UpstreamRatioSync(props) {
// 倍率类型过滤
const [ratioTypeFilter, setRatioTypeFilter] = useState('');
// 冲突确认弹窗相关
const [confirmVisible, setConfirmVisible] = useState(false);
const [conflictItems, setConflictItems] = useState([]); // {channel, model, current, newVal, ratioType}
const channelSelectorRef = React.useRef(null);
useEffect(() => {
setCurrentPage(1);
}, [ratioTypeFilter, searchKeyword]);
const fetchAllChannels = async () => {
setLoading(true);
try {
@@ -155,15 +193,30 @@ export default function UpstreamRatioSync(props) {
}
};
const selectValue = (model, ratioType, value) => {
setResolutions(prev => ({
...prev,
[model]: {
...prev[model],
[ratioType]: value,
},
}));
};
function getBillingCategory(ratioType) {
return ratioType === 'model_price' ? 'price' : 'ratio';
}
const selectValue = useCallback((model, ratioType, value) => {
const category = getBillingCategory(ratioType);
setResolutions(prev => {
const newModelRes = { ...(prev[model] || {}) };
Object.keys(newModelRes).forEach((rt) => {
if (getBillingCategory(rt) !== category) {
delete newModelRes[rt];
}
});
newModelRes[ratioType] = value;
return {
...prev,
[model]: newModelRes,
};
});
}, [setResolutions]);
const applySync = async () => {
const currentRatios = {
@@ -173,19 +226,100 @@ export default function UpstreamRatioSync(props) {
ModelPrice: JSON.parse(props.options.ModelPrice || '{}'),
};
const conflicts = [];
const getLocalBillingCategory = (model) => {
if (currentRatios.ModelPrice[model] !== undefined) return 'price';
if (currentRatios.ModelRatio[model] !== undefined ||
currentRatios.CompletionRatio[model] !== undefined ||
currentRatios.CacheRatio[model] !== undefined) return 'ratio';
return null;
};
const findSourceChannel = (model, ratioType, value) => {
if (differences[model] && differences[model][ratioType]) {
const upMap = differences[model][ratioType].upstreams || {};
const entry = Object.entries(upMap).find(([_, v]) => v === value);
if (entry) return entry[0];
}
return t('未知');
};
Object.entries(resolutions).forEach(([model, ratios]) => {
const localCat = getLocalBillingCategory(model);
const newCat = 'model_price' in ratios ? 'price' : 'ratio';
if (localCat && localCat !== newCat) {
const currentDesc = localCat === 'price'
? `${t('固定价格')} : ${currentRatios.ModelPrice[model]}`
: `${t('模型倍率')} : ${currentRatios.ModelRatio[model] ?? '-'}\n${t('补全倍率')} : ${currentRatios.CompletionRatio[model] ?? '-'}`;
let newDesc = '';
if (newCat === 'price') {
newDesc = `${t('固定价格')} : ${ratios['model_price']}`;
} else {
const newModelRatio = ratios['model_ratio'] ?? '-';
const newCompRatio = ratios['completion_ratio'] ?? '-';
newDesc = `${t('模型倍率')} : ${newModelRatio}\n${t('补全倍率')} : ${newCompRatio}`;
}
const channels = Object.entries(ratios)
.map(([rt, val]) => findSourceChannel(model, rt, val))
.filter((v, idx, arr) => arr.indexOf(v) === idx)
.join(', ');
conflicts.push({
channel: channels,
model,
current: currentDesc,
newVal: newDesc,
});
}
});
if (conflicts.length > 0) {
setConflictItems(conflicts);
setConfirmVisible(true);
return;
}
await performSync(currentRatios);
};
const performSync = useCallback(async (currentRatios) => {
const finalRatios = {
ModelRatio: { ...currentRatios.ModelRatio },
CompletionRatio: { ...currentRatios.CompletionRatio },
CacheRatio: { ...currentRatios.CacheRatio },
ModelPrice: { ...currentRatios.ModelPrice },
};
Object.entries(resolutions).forEach(([model, ratios]) => {
const selectedTypes = Object.keys(ratios);
const hasPrice = selectedTypes.includes('model_price');
const hasRatio = selectedTypes.some(rt => rt !== 'model_price');
if (hasPrice) {
delete finalRatios.ModelRatio[model];
delete finalRatios.CompletionRatio[model];
delete finalRatios.CacheRatio[model];
}
if (hasRatio) {
delete finalRatios.ModelPrice[model];
}
Object.entries(ratios).forEach(([ratioType, value]) => {
const optionKey = ratioType
.split('_')
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join('');
currentRatios[optionKey][model] = parseFloat(value);
finalRatios[optionKey][model] = parseFloat(value);
});
});
setLoading(true);
try {
const updates = Object.entries(currentRatios).map(([key, value]) =>
const updates = Object.entries(finalRatios).map(([key, value]) =>
API.put('/api/option/', {
key,
value: JSON.stringify(value, null, 2),
@@ -225,7 +359,7 @@ export default function UpstreamRatioSync(props) {
} finally {
setLoading(false);
}
};
}, [resolutions, props.options, props.refresh]);
const getCurrentPageData = (dataSource) => {
const startIndex = (currentPage - 1) * pageSize;
@@ -300,6 +434,10 @@ export default function UpstreamRatioSync(props) {
const tmp = [];
Object.entries(differences).forEach(([model, ratioTypes]) => {
const hasPrice = 'model_price' in ratioTypes;
const hasOtherRatio = ['model_ratio', 'completion_ratio', 'cache_ratio'].some(rt => rt in ratioTypes);
const billingConflict = hasPrice && hasOtherRatio;
Object.entries(ratioTypes).forEach(([ratioType, diff]) => {
tmp.push({
key: `${model}_${ratioType}`,
@@ -308,6 +446,7 @@ export default function UpstreamRatioSync(props) {
current: diff.current,
upstreams: diff.upstreams,
confidence: diff.confidence || {},
billingConflict,
});
});
});
@@ -365,14 +504,25 @@ export default function UpstreamRatioSync(props) {
{
title: t('倍率类型'),
dataIndex: 'ratioType',
render: (text) => {
render: (text, record) => {
const typeMap = {
model_ratio: t('模型倍率'),
completion_ratio: t('补全倍率'),
cache_ratio: t('缓存倍率'),
model_price: t('固定价格'),
};
return <Tag color={stringToColor(text)} shape="circle">{typeMap[text] || text}</Tag>;
const baseTag = <Tag color={stringToColor(text)} shape="circle">{typeMap[text] || text}</Tag>;
if (record?.billingConflict) {
return (
<div className="flex items-center gap-1">
{baseTag}
<Tooltip position="top" content={t('该模型存在固定价格与倍率计费方式冲突,请确认选择')}>
<AlertTriangle size={14} className="text-yellow-500" />
</Tooltip>
</div>
);
}
return baseTag;
},
},
{
@@ -440,28 +590,27 @@ export default function UpstreamRatioSync(props) {
})();
const handleBulkSelect = (checked) => {
setResolutions((prev) => {
const newRes = { ...prev };
if (checked) {
filteredDataSource.forEach((row) => {
const upstreamVal = row.upstreams?.[upName];
if (upstreamVal !== null && upstreamVal !== undefined && upstreamVal !== 'same') {
if (checked) {
if (!newRes[row.model]) newRes[row.model] = {};
newRes[row.model][row.ratioType] = upstreamVal;
} else {
if (newRes[row.model]) {
delete newRes[row.model][row.ratioType];
if (Object.keys(newRes[row.model]).length === 0) {
delete newRes[row.model];
}
}
}
selectValue(row.model, row.ratioType, upstreamVal);
}
});
return newRes;
});
} else {
setResolutions((prev) => {
const newRes = { ...prev };
filteredDataSource.forEach((row) => {
if (newRes[row.model]) {
delete newRes[row.model][row.ratioType];
if (Object.keys(newRes[row.model]).length === 0) {
delete newRes[row.model];
}
}
});
return newRes;
});
}
};
return {
@@ -589,6 +738,23 @@ export default function UpstreamRatioSync(props) {
channelEndpoints={channelEndpoints}
updateChannelEndpoint={updateChannelEndpoint}
/>
<ConflictConfirmModal
t={t}
visible={confirmVisible}
items={conflictItems}
onOk={async () => {
setConfirmVisible(false);
const curRatios = {
ModelRatio: JSON.parse(props.options.ModelRatio || '{}'),
CompletionRatio: JSON.parse(props.options.CompletionRatio || '{}'),
CacheRatio: JSON.parse(props.options.CacheRatio || '{}'),
ModelPrice: JSON.parse(props.options.ModelPrice || '{}'),
};
await performSync(curRatios);
}}
onCancel={() => setConfirmVisible(false)}
/>
</>
);
}