From 69420f713f9aa6f2390ac6592d39905830df0246 Mon Sep 17 00:00:00 2001 From: JoeyLearnsToCode Date: Mon, 19 May 2025 19:33:29 +0800 Subject: [PATCH 01/17] =?UTF-8?q?feat:=20=E6=B8=A0=E9=81=93=E7=BC=96?= =?UTF-8?q?=E8=BE=91=E9=A1=B5=E5=A2=9E=E5=8A=A0=E5=A4=8D=E5=88=B6=E6=89=80?= =?UTF-8?q?=E6=9C=89=E6=A8=A1=E5=9E=8B=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/src/i18n/locales/en.json | 1 + web/src/pages/Channel/EditChannel.js | 11 ++++++++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 916329e72..0f77dbb9f 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -408,6 +408,7 @@ "填入基础模型": "Fill in the basic model", "填入所有模型": "Fill in all models", "清除所有模型": "Clear all models", + "复制所有模型": "Copy all models", "密钥": "Key", "请输入密钥": "Please enter the key", "批量创建": "Batch Create", diff --git a/web/src/pages/Channel/EditChannel.js b/web/src/pages/Channel/EditChannel.js index f7fab0572..e19f1cd2b 100644 --- a/web/src/pages/Channel/EditChannel.js +++ b/web/src/pages/Channel/EditChannel.js @@ -29,6 +29,7 @@ import { } from '@douyinfe/semi-ui'; import { getChannelModels, loadChannelModels } from '../../components/utils.js'; import { IconHelpCircle } from '@douyinfe/semi-icons'; +import { copy } from '../../helpers'; const MODEL_MAPPING_EXAMPLE = { 'gpt-3.5-turbo': 'gpt-3.5-turbo-0125', @@ -873,7 +874,7 @@ const EditChannel = (props) => { optionList={modelOptions} />
- + + Date: Sun, 22 Jun 2025 18:47:40 +0800 Subject: [PATCH 02/17] =?UTF-8?q?=F0=9F=94=A7=20chore:=20update=20STREAMIN?= =?UTF-8?q?G=5FTIMEOUT=20default=20value=20to=20120=20seconds=20in=20confi?= =?UTF-8?q?guration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 2 +- README.en.md | 2 +- README.md | 2 +- constant/env.go | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.env.example b/.env.example index bece06dbc..d317e1f3c 100644 --- a/.env.example +++ b/.env.example @@ -59,7 +59,7 @@ # 设置 Dify 渠道是否输出工作流和节点信息到客户端 # DIFY_DEBUG=true # 设置流式一次回复的超时时间 -# STREAMING_TIMEOUT=90 +# STREAMING_TIMEOUT=120 # 节点类型 diff --git a/README.en.md b/README.en.md index 10a3cdb05..b4ae921ae 100644 --- a/README.en.md +++ b/README.en.md @@ -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` diff --git a/README.md b/README.md index 6ba3574cd..498d7c6c8 100644 --- a/README.md +++ b/README.md @@ -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` diff --git a/constant/env.go b/constant/env.go index 612f3e8be..f33c67ff7 100644 --- a/constant/env.go +++ b/constant/env.go @@ -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信息 From 5367015a310a198e7fba638e7c46e16443e04af5 Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Mon, 23 Jun 2025 15:55:10 +0800 Subject: [PATCH 03/17] =?UTF-8?q?=F0=9F=8E=9B=EF=B8=8F=20feat(web):=20add?= =?UTF-8?q?=20=E2=80=9CConflict=20Rates=E2=80=9D=20filter=20&=20highlight?= =?UTF-8?q?=20in=20Model=20Settings=20Visual=20Editor=20(#1286)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- web/src/i18n/locales/en.json | 4 +- .../Ratio/ModelSettingsVisualEditor.js | 98 +++++++++++++------ 2 files changed, 71 insertions(+), 31 deletions(-) diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 295c464d6..8c808d01f 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -1726,5 +1726,7 @@ "放大编辑": "Expand editor", "编辑公告内容": "Edit announcement content", "自适应列表": "Adaptive list", - "紧凑列表": "Compact list" + "紧凑列表": "Compact list", + "仅显示矛盾倍率": "Only show conflicting ratios", + "矛盾": "Conflict" } \ No newline at end of file diff --git a/web/src/pages/Setting/Ratio/ModelSettingsVisualEditor.js b/web/src/pages/Setting/Ratio/ModelSettingsVisualEditor.js index 983f3afe1..ca3e396d6 100644 --- a/web/src/pages/Setting/Ratio/ModelSettingsVisualEditor.js +++ b/web/src/pages/Setting/Ratio/ModelSettingsVisualEditor.js @@ -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) => ( + + {text} + {record.hasConflict && ( + + {t('矛盾')} + + )} + + ), }, { 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('添加成功')); } @@ -427,6 +456,15 @@ export default function ModelSettingsVisualEditor(props) { }} style={{ width: 200 }} /> + { + setConflictOnly(e.target.checked); + setCurrentPage(1); + }} + > + {t('仅显示矛盾倍率')} + Date: Mon, 23 Jun 2025 16:34:00 +0800 Subject: [PATCH 04/17] =?UTF-8?q?=F0=9F=90=9B=20fix(ratio-sync):=20reset?= =?UTF-8?q?=20pagination=20when=20filter/search=20changes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- web/src/pages/Setting/Ratio/ModelSettingsVisualEditor.js | 1 + web/src/pages/Setting/Ratio/UpstreamRatioSync.js | 6 +++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/web/src/pages/Setting/Ratio/ModelSettingsVisualEditor.js b/web/src/pages/Setting/Ratio/ModelSettingsVisualEditor.js index ca3e396d6..b897968fc 100644 --- a/web/src/pages/Setting/Ratio/ModelSettingsVisualEditor.js +++ b/web/src/pages/Setting/Ratio/ModelSettingsVisualEditor.js @@ -455,6 +455,7 @@ export default function ModelSettingsVisualEditor(props) { setCurrentPage(1); }} style={{ width: 200 }} + showClear /> { + setCurrentPage(1); + }, [ratioTypeFilter, searchKeyword]); + const fetchAllChannels = async () => { setLoading(true); try { From 2591ca3d60063093ba4bb15a0aa7365aa4058e0a Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Mon, 23 Jun 2025 17:35:39 +0800 Subject: [PATCH 05/17] =?UTF-8?q?=F0=9F=9A=80=20chore(ui):=20Refactor=20Up?= =?UTF-8?q?streamRatioSync=20with=20conflict-modal=20component,=20performa?= =?UTF-8?q?nce=20hooks=20&=20cleanup=20(#1286)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- web/src/i18n/locales/en.json | 6 +- .../pages/Setting/Ratio/UpstreamRatioSync.js | 224 +++++++++++++++--- 2 files changed, 198 insertions(+), 32 deletions(-) diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 8c808d01f..d7f87efd9 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -1728,5 +1728,9 @@ "自适应列表": "Adaptive list", "紧凑列表": "Compact list", "仅显示矛盾倍率": "Only show conflicting ratios", - "矛盾": "Conflict" + "矛盾": "Conflict", + "确认冲突项修改": "Confirm conflict item modification", + "该模型存在固定价格与倍率计费方式冲突,请确认选择": "The model has a fixed price and ratio billing method conflict, please confirm the selection", + "当前计费": "Current billing", + "修改为": "Modify to" } \ No newline at end of file diff --git a/web/src/pages/Setting/Ratio/UpstreamRatioSync.js b/web/src/pages/Setting/Ratio/UpstreamRatioSync.js index 2dd6a0094..0794d6067 100644 --- a/web/src/pages/Setting/Ratio/UpstreamRatioSync.js +++ b/web/src/pages/Setting/Ratio/UpstreamRatioSync.js @@ -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) =>
{text}
, + }, + { + title: t('修改为'), + dataIndex: 'newVal', + render: (text) =>
{text}
, + }, + ]; + + return ( + +
+ + ); +} + export default function UpstreamRatioSync(props) { const { t } = useTranslation(); const [modalVisible, setModalVisible] = useState(false); @@ -56,6 +86,10 @@ 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(() => { @@ -159,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 = { @@ -177,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), @@ -229,7 +359,7 @@ export default function UpstreamRatioSync(props) { } finally { setLoading(false); } - }; + }, [resolutions, props.options, props.refresh]); const getCurrentPageData = (dataSource) => { const startIndex = (currentPage - 1) * pageSize; @@ -304,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}`, @@ -312,6 +446,7 @@ export default function UpstreamRatioSync(props) { current: diff.current, upstreams: diff.upstreams, confidence: diff.confidence || {}, + billingConflict, }); }); }); @@ -369,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 {typeMap[text] || text}; + const baseTag = {typeMap[text] || text}; + if (record?.billingConflict) { + return ( +
+ {baseTag} + + + +
+ ); + } + return baseTag; }, }, { @@ -444,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); + } + }); + } 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 newRes; - }); + } }; return { @@ -593,6 +738,23 @@ export default function UpstreamRatioSync(props) { channelEndpoints={channelEndpoints} updateChannelEndpoint={updateChannelEndpoint} /> + + { + 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)} + /> ); } \ No newline at end of file From bc371778b6a27e6bd327859655ad157c3df4d5b1 Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Mon, 23 Jun 2025 23:40:34 +0800 Subject: [PATCH 06/17] =?UTF-8?q?=F0=9F=9A=80=20feat:=20add=20enabled/disa?= =?UTF-8?q?bled=20channel=20filtering=20&=20optimize=20type-based=20pagina?= =?UTF-8?q?tion=20(#1289)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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” ` { + localStorage.setItem('channel-status-filter', v); + setStatusFilter(v); + setActivePage(1); + loadChannels(1, pageSize, idSort, enableTagMode, activeTypeKey, v); + }} + size="small" + > + {t('全部')} + {t('已启用')} + {t('已禁用')} + + diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index d7f87efd9..b3b5b37af 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -1732,5 +1732,6 @@ "确认冲突项修改": "Confirm conflict item modification", "该模型存在固定价格与倍率计费方式冲突,请确认选择": "The model has a fixed price and ratio billing method conflict, please confirm the selection", "当前计费": "Current billing", - "修改为": "Modify to" + "修改为": "Modify to", + "状态筛选": "Status filter" } \ No newline at end of file From 1ab75b8a92cc6a8dec0792d52399f67ca75d4bc4 Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Tue, 24 Jun 2025 00:02:22 +0800 Subject: [PATCH 07/17] =?UTF-8?q?=F0=9F=8E=A8=20feat(EditChannel):=20impro?= =?UTF-8?q?ve=20model=20selection=20UX,=20clipboard=20feedback=20&=20round?= =?UTF-8?q?ed=20styling=20(#1290)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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" --- web/src/i18n/locales/en.json | 5 ++- web/src/pages/Channel/EditChannel.js | 53 ++++++++++++++++------------ 2 files changed, 35 insertions(+), 23 deletions(-) diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index ff182af56..ade70ea57 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -1734,5 +1734,8 @@ "该模型存在固定价格与倍率计费方式冲突,请确认选择": "The model has a fixed price and ratio billing method conflict, please confirm the selection", "当前计费": "Current billing", "修改为": "Modify to", - "状态筛选": "Status filter" + "状态筛选": "Status filter", + "没有模型可以复制": "No models to copy", + "模型列表已复制到剪贴板": "Model list copied to clipboard", + "复制失败": "Copy failed" } \ No newline at end of file diff --git a/web/src/pages/Channel/EditChannel.js b/web/src/pages/Channel/EditChannel.js index 6879eb535..2e8baddf5 100644 --- a/web/src/pages/Channel/EditChannel.js +++ b/web/src/pages/Channel/EditChannel.js @@ -300,27 +300,27 @@ 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(() => { + // 使用 Map 来避免重复,以 value 为键 + const modelMap = new Map(); + + // 先添加原始模型选项 + originModelOptions.forEach(option => { + modelMap.set(option.value, option); + }); + + // 再添加当前选中的模型(如果不存在) + inputs.models.forEach(model => { + if (!modelMap.has(model)) { + modelMap.set(model, { + label: model, + value: model, + }); + } + }); + + setModelOptions(Array.from(modelMap.values())); + }, [originModelOptions, inputs.models]); useEffect(() => { fetchModels().then(); @@ -835,7 +835,16 @@ useEffect(() => {
` 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. --- web/src/components/table/ChannelsTable.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/components/table/ChannelsTable.js b/web/src/components/table/ChannelsTable.js index 572e495fa..5045307a1 100644 --- a/web/src/components/table/ChannelsTable.js +++ b/web/src/components/table/ChannelsTable.js @@ -1876,7 +1876,7 @@ const ChannelsTable = () => { } className="rounded-xl overflow-hidden" size="middle" - loading={loading} + loading={loading || searching} /> From 44495b153a049f1e47f04db3b8447601e6bba1d1 Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Tue, 24 Jun 2025 17:46:08 +0800 Subject: [PATCH 15/17] =?UTF-8?q?=F0=9F=9A=80=20feat:=20enhance=20model=20?= =?UTF-8?q?testing=20UI=20with=20bulk=20selection,=20copy=20&=20success-fi?= =?UTF-8?q?lter=20buttons=20(#1288)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- web/src/components/table/ChannelsTable.js | 182 +++++++++++++++++----- web/src/constants/channel.constants.js | 2 + web/src/i18n/locales/en.json | 8 +- web/src/index.css | 2 + 4 files changed, 155 insertions(+), 39 deletions(-) diff --git a/web/src/components/table/ChannelsTable.js b/web/src/components/table/ChannelsTable.js index 5045307a1..65076926c 100644 --- a/web/src/components/table/ChannelsTable.js +++ b/web/src/components/table/ChannelsTable.js @@ -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, @@ -63,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'; @@ -684,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); @@ -697,6 +699,7 @@ const ChannelsTable = () => { searchGroup: '', searchModel: '', }; + const allSelectingRef = useRef(false); // Filter columns based on visibility settings const getVisibleColumns = () => { @@ -1131,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])); @@ -1194,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); }; @@ -1217,6 +1241,8 @@ const ChannelsTable = () => { } else { setShowModelTestModal(false); setModelSearchKeyword(''); + setSelectedModelKeys([]); + setModelTablePage(1); } }; @@ -1912,13 +1938,73 @@ const ChannelsTable = () => { - - {currentTestChannel.name} {t('渠道的模型测试')} - - - {t('共')} {currentTestChannel.models.split(',').length} {t('个模型')} - +
+
+ + {currentTestChannel.name} {t('渠道的模型测试')} + + + {t('共')} {currentTestChannel.models.split(',').length} {t('个模型')} + +
+ + {/* 搜索与操作按钮 */} +
+ { + setModelSearchKeyword(v); + setModelTablePage(1); + }} + className="!w-full !rounded-full" + prefix={} + showClear + /> + + + + +
) } @@ -1968,22 +2054,11 @@ const ChannelsTable = () => { } maskClosable={!isBatchTesting} className="!rounded-lg" - size="large" + size={isMobile() ? 'full-width' : 'large'} > -
+
{currentTestChannel && (
-
- setModelSearchKeyword(v)} - className="w-64 !rounded-full" - prefix={} - showClear - /> -
-
{ } } ]} - 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), + }} /> )} diff --git a/web/src/constants/channel.constants.js b/web/src/constants/channel.constants.js index c4220bd46..4018aa4f3 100644 --- a/web/src/constants/channel.constants.js +++ b/web/src/constants/channel.constants.js @@ -131,3 +131,5 @@ export const CHANNEL_OPTIONS = [ label: '可灵', }, ]; + +export const MODEL_TABLE_PAGE_SIZE = 10; diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index a618ee97e..408371b42 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -1737,5 +1737,11 @@ "状态筛选": "Status filter", "没有模型可以复制": "No models to copy", "模型列表已复制到剪贴板": "Model list copied to clipboard", - "复制失败": "Copy failed" + "复制失败": "Copy failed", + "复制已选": "Copy selected", + "选择成功": "Selection successful", + "暂无成功模型": "No successful models", + "请先选择模型!": "Please select a model first!", + "已复制 ${count} 个模型": "Copied ${count} models", + "复制失败,请手动复制": "Copy failed, please copy manually" } \ No newline at end of file diff --git a/web/src/index.css b/web/src/index.css index c95e6db40..45d2d3786 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -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, From 0c5d4ca0a7462958c366327280f21e15c87cf09a Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Tue, 24 Jun 2025 18:02:34 +0800 Subject: [PATCH 16/17] =?UTF-8?q?=F0=9F=8E=A8=20style(channels-table):=20s?= =?UTF-8?q?tandardize=20operation=20component=20size=20to=20`small`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- web/src/components/table/ChannelsTable.js | 28 ++++++++++++++++++----- 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/web/src/components/table/ChannelsTable.js b/web/src/components/table/ChannelsTable.js index 65076926c..f401b5067 100644 --- a/web/src/components/table/ChannelsTable.js +++ b/web/src/components/table/ChannelsTable.js @@ -1528,6 +1528,7 @@ const ChannelsTable = () => {