From d316a9f620ee24350aa286e8ebfa1add4b3ddf3a Mon Sep 17 00:00:00 2001 From: "yichi.zhang" <2497196604@qq.com> Date: Sun, 6 Sep 2026 16:12:44 +0800 Subject: [PATCH] feat(api-access): sync drag order into credential priority behind a toggle - Add an opt-in Schedule-by-list-order switch on the API Access page - Dragging a connection writes its list position into the priority field (first row highest), so the core fails over strictly from top to bottom within the category - Enabling the switch syncs priorities from the current category order immediately - Persist the toggle as api-access-order-scheduling in the GUI config (default off, backward compatible) - Add en/zh-CN labels and GUI config regression tests --- src-tauri/src/app_settings.rs | 17 ++++++ src-tauri/src/main.rs | 11 ++++ src-tauri/src/tests/app_settings.rs | 16 ++++++ src-tauri/src/tests/core_config.rs | 1 + src-tauri/src/usage.rs | 1 + src/i18n/ja.ts | 4 ++ src/i18n/locales/en.ts | 4 ++ src/i18n/locales/zh-CN.ts | 4 ++ src/pages/ApiAccessPage.tsx | 88 ++++++++++++++++++++++++++++- 9 files changed, 144 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/app_settings.rs b/src-tauri/src/app_settings.rs index 5e5589b6..d237f4fe 100644 --- a/src-tauri/src/app_settings.rs +++ b/src-tauri/src/app_settings.rs @@ -37,6 +37,23 @@ pub(crate) fn get_gui_settings( Ok(GuiSettings::from(&config)) } +#[tauri::command] +pub(crate) fn get_api_access_order_scheduling( + gui_config_state: tauri::State<'_, GuiConfigState>, +) -> Result { + let config = gui_config_state.snapshot()?; + Ok(config.api_access_order_scheduling) +} + +#[tauri::command] +pub(crate) fn set_api_access_order_scheduling( + enabled: bool, + gui_config_state: tauri::State<'_, GuiConfigState>, +) -> Result { + gui_config_state.set_api_access_order_scheduling(enabled)?; + Ok(enabled) +} + #[tauri::command] pub(crate) fn resolve_api_access_remarks( queries: Vec, diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index b8e5c859..73096ed9 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -597,6 +597,7 @@ struct GuiConfigFile { #[serde(deserialize_with = "deserialize_gui_api_keys")] api_keys: Vec, api_access_remarks: Vec, + api_access_order_scheduling: bool, management_secret_key: String, debug: bool, commercial_mode: bool, @@ -889,6 +890,7 @@ impl Default for GuiConfigFile { auth_dir: DEFAULT_AUTH_DIR.to_string(), api_keys: vec![default_api_key_entry()], api_access_remarks: Vec::new(), + api_access_order_scheduling: false, // Populated with an OS-generated secret while loading the GUI // configuration. Core hashes the value written into config.yaml. management_secret_key: String::new(), @@ -1976,6 +1978,13 @@ impl GuiConfigState { }) } + fn set_api_access_order_scheduling(&self, enabled: bool) -> Result { + self.update(|config| { + config.api_access_order_scheduling = enabled; + Ok(()) + }) + } + fn set_software_preferences( &self, close_behavior: WindowsCloseBehavior, @@ -2481,6 +2490,8 @@ fn main() { resolve_windows_close_request, get_software_settings, save_software_settings, + get_api_access_order_scheduling, + set_api_access_order_scheduling, get_agent_config_statuses, refresh_agent_config_statuses, get_agent_models, diff --git a/src-tauri/src/tests/app_settings.rs b/src-tauri/src/tests/app_settings.rs index d005f8a1..d8816043 100644 --- a/src-tauri/src/tests/app_settings.rs +++ b/src-tauri/src/tests/app_settings.rs @@ -103,6 +103,7 @@ fn gui_config_defaults_are_stable() { assert!(content.contains("max-retry-credentials = 0")); assert!(content.contains("max-retry-interval = 30")); assert!(content.contains("streaming-bootstrap-retries = 0")); + assert!(content.contains("api-access-order-scheduling = false")); } #[test] @@ -200,3 +201,18 @@ fn physical_window_size_uses_display_scale_and_ignores_minimized_sizes() { assert!(logical_window_size_from_physical(&tauri::PhysicalSize::new(0, 0), 1.0).is_none()); assert!(logical_window_size_from_physical(&physical_size, 0.0).is_none()); } + +#[test] +fn api_access_order_scheduling_defaults_off_and_round_trips() { + let legacy = toml::from_str::("port = 8317\n").unwrap(); + assert!(!legacy.api_access_order_scheduling); + + let config = GuiConfigFile { + api_access_order_scheduling: true, + ..GuiConfigFile::default() + }; + let content = toml::to_string_pretty(&config).unwrap(); + assert!(content.contains("api-access-order-scheduling = true")); + let restored = toml::from_str::(&content).unwrap(); + assert!(restored.api_access_order_scheduling); +} diff --git a/src-tauri/src/tests/core_config.rs b/src-tauri/src/tests/core_config.rs index d5243652..b4c43489 100644 --- a/src-tauri/src/tests/core_config.rs +++ b/src-tauri/src/tests/core_config.rs @@ -1120,6 +1120,7 @@ fn startup_preserves_all_user_owned_yaml_and_only_applies_gui_managed_values() { }, ], api_access_remarks: Vec::new(), + api_access_order_scheduling: false, management_secret_key: String::new(), debug: true, commercial_mode: true, diff --git a/src-tauri/src/usage.rs b/src-tauri/src/usage.rs index 05cd3345..12d24e86 100644 --- a/src-tauri/src/usage.rs +++ b/src-tauri/src/usage.rs @@ -4095,6 +4095,7 @@ mod tests { auth_dir: String::new(), api_keys: Vec::new(), api_access_remarks: Vec::new(), + api_access_order_scheduling: false, management_secret_key: "123456".to_string(), debug: false, commercial_mode: false, diff --git a/src/i18n/ja.ts b/src/i18n/ja.ts index f6eff294..70bdce63 100644 --- a/src/i18n/ja.ts +++ b/src/i18n/ja.ts @@ -897,6 +897,10 @@ export const jaOverrides = { 'apiAccess.notice.deleted': '接続を削除しました', 'apiAccess.notice.enabled': '接続を有効にしました', 'apiAccess.notice.disabled': '接続を無効にしました', + 'apiAccess.orderScheduling': 'リスト順序でスケジュール', + 'apiAccess.orderSchedulingHint': + '有効にすると、接続をドラッグした際にそのリスト位置が priority フィールドに書き込まれ、コアはこのカテゴリ内で上から下へ厳密にフェイルオーバーします。次回のドラッグで未操作の接続の既存優先度が上書きされます。スイッチをオフにしても書き込み済みの priority はクリアされません。', + 'apiAccess.orderSchedulingApplied': '現在のリスト順序から優先度を同期しました', 'apiAccess.title': 'API 接続', 'apiAccess.count': '接続 {count} 件', 'apiAccess.add': '追加', diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index f0bab3d4..0ced3ce6 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -897,6 +897,10 @@ export const en: Record = { 'apiAccess.notice.deleted': 'Connection deleted', 'apiAccess.notice.enabled': 'Connection enabled', 'apiAccess.notice.disabled': 'Connection disabled', + 'apiAccess.orderScheduling': 'Schedule by list order', + 'apiAccess.orderSchedulingHint': + 'When enabled, dragging a connection writes its list position into the priority field, so the core fails over strictly from top to bottom within this category. Existing priorities of untouched connections are overwritten on the next drag. Turning it off does not clear written priorities.', + 'apiAccess.orderSchedulingApplied': 'Priorities synced from the current list order', 'apiAccess.title': 'API Access', 'apiAccess.count': '{count} connections', 'apiAccess.add': 'Add', diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index bf0ada2b..2fdb1a2a 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -896,6 +896,10 @@ export const zhCN = { 'apiAccess.notice.deleted': '接入已删除', 'apiAccess.notice.enabled': '接入已启用', 'apiAccess.notice.disabled': '接入已停用', + 'apiAccess.orderScheduling': '按列表顺序调度', + 'apiAccess.orderSchedulingHint': + '启用后,拖动接入会将其列表位置写入 priority 字段,核心在该分类内严格按从上到下的顺序故障转移。下次拖动会覆盖未拖动接入的已有优先级。关闭开关不会清除已写入的 priority。', + 'apiAccess.orderSchedulingApplied': '已按当前列表顺序同步优先级', 'apiAccess.title': 'API 接入', 'apiAccess.count': '{count} 个接入', 'apiAccess.add': '新增', diff --git a/src/pages/ApiAccessPage.tsx b/src/pages/ApiAccessPage.tsx index 71b07f5e..967f0155 100644 --- a/src/pages/ApiAccessPage.tsx +++ b/src/pages/ApiAccessPage.tsx @@ -773,6 +773,28 @@ export const reorderProviderRecords = ( return next; }; +// After a reorder, a scoped row's display position equals its record index sorted +// ascending within the scope block, so priorities are derived straight from position. +export const applyOrderSchedulingPriorities = ( + records: Record[], + scopedRows: ProviderRecordIdentity[], +): Record[] => { + const scopedIndexes = scopedRows + .map((row) => resolveProviderRecordIndex(records, row)) + .filter((index, position, indexes) => index >= 0 && indexes.indexOf(index) === position) + .sort((left, right) => left - right); + if (scopedIndexes.length === 0) return records; + const next = records.map(stripResponseFields); + scopedIndexes.forEach((recordIndex, position) => { + const record = { ...next[recordIndex] }; + const priority = scopedIndexes.length - 1 - position; + if (priority > 0) record.priority = priority; + else delete record.priority; + next[recordIndex] = record; + }); + return next; +}; + export const providerRecordWithDisabledState = ( section: ProviderSection, record: Record, @@ -810,6 +832,7 @@ export function ApiAccessPage() { const [apiAccessRemarks, setApiAccessRemarks] = useState>({}); const [healthDialogRow, setHealthDialogRow] = useState(null); const [dragOverId, setDragOverId] = useState(null); + const [orderScheduling, setOrderScheduling] = useState(false); const activeDefinition = definitionFor(activeCategory); const activeSection = activeDefinition.section; const dragSensors = useSensors( @@ -817,6 +840,18 @@ export function ApiAccessPage() { useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), ); + useEffect(() => { + let disposed = false; + void invoke('get_api_access_order_scheduling') + .then((enabled) => { + if (!disposed) setOrderScheduling(enabled); + }) + .catch(() => {}); + return () => { + disposed = true; + }; + }, []); + const loadProviders = useCallback(async (showLoading = true) => { if (showLoading) setLoading(true); setError(''); @@ -1127,6 +1162,36 @@ export function ApiAccessPage() { } }; + const toggleOrderScheduling = async (enabled: boolean) => { + setOrderScheduling(enabled); + try { + await invoke('set_api_access_order_scheduling', { enabled }); + } catch (requestError) { + setNotice(requestErrorMessage(requestError), 'error'); + return; + } + if (!enabled) return; + setBusy(true); + setNotice(''); + try { + const latestConfig = await managementApi.get('/config'); + const latestRows = sectionRecordsFromConfig(latestConfig, activeSection); + const categoryRows = latestRows + .map((record, index) => rowFromRecord(activeSection, record, index)) + .filter((row) => providerCategoryMatchesRecord(activeCategory, row.record)); + if (categoryRows.length > 0) { + const prioritized = applyOrderSchedulingPriorities(latestRows, categoryRows); + await managementApi.put(`/${activeSection}`, prioritized); + } + await loadProviders(false); + setNotice(t('apiAccess.orderSchedulingApplied')); + } catch (requestError) { + setNotice(requestErrorMessage(requestError), 'error'); + } finally { + setBusy(false); + } + }; + const reorderProviders = async (source: ProviderRow, target: ProviderRow) => { if (source.section !== target.section || source.index === target.index) return; setFeedbackRow(providerDragId(source)); @@ -1138,7 +1203,8 @@ export function ApiAccessPage() { const latestRows = sectionRecordsFromConfig(latestConfig, source.section); const nextRows = reorderProviderRecords(latestRows, rows, source, target); if (!nextRows) throw new Error(t('apiAccess.error.stale')); - await managementApi.put(`/${source.section}`, nextRows); + const scheduledRows = orderScheduling ? applyOrderSchedulingPriorities(nextRows, rows) : nextRows; + await managementApi.put(`/${source.section}`, scheduledRows); await loadProviders(false); } catch (requestError) { await loadProviders(false); @@ -1162,7 +1228,12 @@ export function ApiAccessPage() { target, ); if (optimisticRows) { - setRecords((current) => ({ ...current, [source.section]: optimisticRows })); + setRecords((current) => ({ + ...current, + [source.section]: orderScheduling + ? applyOrderSchedulingPriorities(optimisticRows, rows) + : optimisticRows, + })); } void reorderProviders(source, target); }; @@ -1181,6 +1252,19 @@ export function ApiAccessPage() {

{t('apiAccess.title')}

+ + + {t('apiAccess.orderScheduling')} + {t('apiAccess.count', { count: totalCount })}