diff --git a/src/node_service/Node_Service.h b/src/node_service/Node_Service.h index 046e435..3954e49 100644 --- a/src/node_service/Node_Service.h +++ b/src/node_service/Node_Service.h @@ -81,6 +81,7 @@ struct Auto_Switch_Config { }; struct Table_Layout_Config { std::unordered_map> column_orders; + std::unordered_map> sticky_columns; }; struct Service_Config { std::string listen_host = "127.0.0.1"; @@ -152,6 +153,7 @@ struct Proxy_Group_Def { struct Group_Switch_State { std::string name; std::string type; + std::vector proxies; std::vector candidates; std::string current; std::string best_candidate; @@ -298,6 +300,7 @@ public: json table_protocol_snapshot(Service_State& service); json table_rows_snapshot(Service_State& service); void update_table_column_order(Service_State& service, const std::string& table_id, const std::vector& column_order); +void update_table_sticky_columns(Service_State& service, const std::string& table_id, const std::vector& sticky_columns); void update_table_cell(Service_State& service, const std::string& table_id, const std::string& row_id, const std::string& column_id, const json& value); json state_snapshot(Service_State& service); std::string html_page(); diff --git a/src/node_service/Node_Service_Auto_Switch.cpp b/src/node_service/Node_Service_Auto_Switch.cpp index d5f7bb7..ddc058b 100644 --- a/src/node_service/Node_Service_Auto_Switch.cpp +++ b/src/node_service/Node_Service_Auto_Switch.cpp @@ -15,6 +15,7 @@ std::set strategy_parameter_keys() { } json switch_strategy_definitions_to_json() { json result = json::array(); + result.push_back(strategy_def("none", "不操作", "不执行自动切换,只保留当前选择。", "", json::array())); result.push_back(strategy_def("stability", "稳定性优先", "只按节点稳定性评分选择候选节点。", R"LATEX(S = stability)LATEX", json::array())); json parameters = json::array(); parameters.push_back(strategy_parameter_def("stability_weight", "稳定性权重", 1.0, 0.0, 10.0, 0.1, "", "稳定性评分权重。")); @@ -74,7 +75,11 @@ std::string group_switch_strategy(const Auto_Switch_Config& config, const std::s return normalize_switch_strategy(config.default_strategy); } double node_switch_score(const Auto_Switch_Config& config, const Node_State& node, const std::string& strategy) { - if (normalize_switch_strategy(strategy) == "stability") { + const auto normalized = normalize_switch_strategy(strategy); + if (normalized == "none") { + return 0.0; + } + if (normalized == "stability") { return node.stability_score; } const double risk = node.risk < 0 ? 50.0 : static_cast(node.risk); @@ -149,6 +154,21 @@ void perform_auto_switch(Service_State& service) { service_log(std::format("代理组 {}:{}", group.name, group.last_decision)); continue; } + if (group.strategy == "none") { + try { + group.current = request_group_current(config, group.name); + group.last_decision = "策略不操作,保持当前选择"; + reset_better_tracking(group); + } + catch (const std::exception& error) { + group.last_error = error_text_to_utf8(error.what()); + group.last_decision = "策略不操作,但读取当前选择失败"; + } + std::lock_guard lock(service.mutex); + note_group_decision(service, group); + service_log(std::format("代理组 {}:{}", group.name, group.last_decision)); + continue; + } if (!group.switchable) { group.last_decision = "没有可切换的直接节点候选"; group.last_error = "代理组 proxies 里没有直接节点名"; diff --git a/src/node_service/Node_Service_Config.cpp b/src/node_service/Node_Service_Config.cpp index e9b846f..b14aa43 100644 --- a/src/node_service/Node_Service_Config.cpp +++ b/src/node_service/Node_Service_Config.cpp @@ -99,31 +99,48 @@ void load_auto_switch_config(Auto_Switch_Config& config, const json& data) { json table_layout_config_to_json(const Table_Layout_Config& config) { json result = json::object(); for (const auto& [table_id, order] : config.column_orders) { - result[table_id] = json{{"column_order", order}}; + result[table_id]["column_order"] = order; + } + for (const auto& [table_id, columns] : config.sticky_columns) { + result[table_id]["sticky_columns"] = columns; + } + return result; +} +std::vector load_string_array(const json& value) { + std::vector result; + if (!value.is_array()) { + return result; + } + for (const auto& item : value) { + if (!item.is_string()) { + continue; + } + auto id = trim(item.get()); + if (!id.empty()) { + result.push_back(std::move(id)); + } } return result; } void load_table_layout_config(Table_Layout_Config& config, const json& data) { config.column_orders.clear(); + config.sticky_columns.clear(); if (!data.is_object()) { return; } for (const auto& item : data.items()) { const auto& table = item.value(); - if (!table.is_object() || !table.contains("column_order") || !table["column_order"].is_array()) { + if (!table.is_object()) { continue; } - std::vector order; - for (const auto& column : table["column_order"]) { - if (column.is_string()) { - auto id = trim(column.get()); - if (!id.empty()) { - order.push_back(std::move(id)); - } + if (table.contains("column_order")) { + auto order = load_string_array(table["column_order"]); + if (!order.empty()) { + config.column_orders[item.key()] = std::move(order); } } - if (!order.empty()) { - config.column_orders[item.key()] = std::move(order); + if (table.contains("sticky_columns")) { + config.sticky_columns[item.key()] = load_string_array(table["sticky_columns"]); } } } diff --git a/src/node_service/Node_Service_State.cpp b/src/node_service/Node_Service_State.cpp index ccd42e4..20c5495 100644 --- a/src/node_service/Node_Service_State.cpp +++ b/src/node_service/Node_Service_State.cpp @@ -24,6 +24,7 @@ json group_to_json(const Group_Switch_State& group) { json result; result["name"] = group.name; result["type"] = group.type; + result["proxies"] = group.proxies; result["candidates"] = group.candidates; result["current"] = group.current; result["best_candidate"] = group.best_candidate; @@ -180,8 +181,8 @@ std::string enum_label(const json& options, std::string_view name, std::string_v } json enum_options_to_json() { json result; - result["alive_category"] = json::array({enum_item("alive", "alive"), enum_item("dead", "dead")}); - result["stability_category"] = json::array({enum_item("excellent", "稳定 ≥ 80"), enum_item("normal", "稳定 50~80"), enum_item("poor", "稳定 < 50")}); + result["alive_category"] = json::array({enum_item("alive", "alive"), enum_item("dead", "dead"), enum_item("special", "特殊")}); + result["stability_category"] = json::array({enum_item("excellent", "稳定 ≥ 80"), enum_item("normal", "稳定 50~80"), enum_item("poor", "稳定 < 50"), enum_item("unknown", "特殊/未知")}); result["rate_category"] = json::array({enum_item("excellent", "通过率 ≥ 95%"), enum_item("usable", "通过率 80~95%"), enum_item("poor", "通过率 < 80%"), enum_item("unknown", "未检测")}); result["ai_category"] = json::array({enum_item("high", "AI 4~5 星"), enum_item("usable", "AI 3 星"), enum_item("weak", "AI < 3 星"), enum_item("unknown", "AI 未知")}); result["ip_type_category"] = json::array({enum_item("idc", "IDC/机房"), enum_item("residential", "家庭宽带"), enum_item("mobile", "移动网络"), enum_item("education", "教育网络"), enum_item("other", "其他"), enum_item("unknown", "未知")}); @@ -503,6 +504,7 @@ void reload_switch_groups_locked(Service_State& service) { } group.name = def.name; group.type = def.type; + group.proxies = def.proxies; group.managed = should_manage_group(service.config.auto_switch, def); group.strategy = group_switch_strategy(service.config.auto_switch, group.name); group.candidates.clear(); diff --git a/src/node_service/Node_Service_Table.cpp b/src/node_service/Node_Service_Table.cpp index 76f2385..b9ddc8f 100644 --- a/src/node_service/Node_Service_Table.cpp +++ b/src/node_service/Node_Service_Table.cpp @@ -3,7 +3,7 @@ namespace { json enum_item(std::string_view value, std::string_view label) { return json{{"value", std::string(value)}, {"label", std::string(label)}}; } -json column(std::string_view id, std::string_view label, std::string_view type, int width, bool visible, bool sortable, bool filterable, std::string_view sort_rule = "none", std::string_view filter_type = "none", std::string_view enum_name = "", std::string_view label_field = "", std::string_view align = "left", bool editable = false) { +json column(std::string_view id, std::string_view label, std::string_view type, int width, bool visible, bool sortable, bool filterable, std::string_view sort_rule = "none", std::string_view filter_type = "none", std::string_view enum_name = "", std::string_view label_field = "", std::string_view align = "left", bool editable = false, std::string_view sticky = "", int sticky_order = -1) { json result; result["id"] = std::string(id); result["label"] = std::string(label); @@ -22,6 +22,10 @@ json column(std::string_view id, std::string_view label, std::string_view type, if (!label_field.empty()) { result["label_field"] = std::string(label_field); } + if (!sticky.empty()) { + result["sticky"] = std::string(sticky); + result["sticky_order"] = sticky_order; + } return result; } json table(std::string_view id, std::string_view name, std::string_view row_id_field, std::string_view default_sort_column, std::string_view default_sort_direction, json columns, json keyword_fields) { @@ -33,6 +37,8 @@ json table(std::string_view id, std::string_view name, std::string_view row_id_f result["columns"] = std::move(columns); result["keyword_fields"] = std::move(keyword_fields); result["column_order_editable"] = true; + result["sticky_editable"] = true; + result["layout"] = json{{"sticky_header", true}, {"top_scrollbar", true}}; return result; } void apply_column_order(json& table, const Table_Layout_Config& layout) { @@ -65,6 +71,25 @@ void apply_column_order(json& table, const Table_Layout_Config& layout) { } table["columns"] = std::move(ordered); } +void apply_sticky_columns(json& table, const Table_Layout_Config& layout) { + const auto table_id = table.value("id", std::string{}); + const auto item = layout.sticky_columns.find(table_id); + if (item == layout.sticky_columns.end() || !table.contains("columns") || !table["columns"].is_array()) { + return; + } + std::set sticky_ids(item->second.begin(), item->second.end()); + int order = 0; + for (auto& column : table["columns"]) { + const auto id = column.value("id", std::string{}); + if (!sticky_ids.contains(id)) { + column.erase("sticky"); + column.erase("sticky_order"); + continue; + } + column["sticky"] = "left"; + column["sticky_order"] = order++; + } +} json edit_schema(std::string_view type, std::string_view enum_name = "", double min_value = 0.0, double max_value = 0.0, double step = 1.0) { json result; result["type"] = std::string(type); @@ -115,6 +140,7 @@ json group_row_values(const Group_Switch_State& group) { const auto raw = group_to_json(group); json values = raw; values["id"] = group.name; + values["proxy_count"] = group.proxies.size(); values["candidate_count"] = group.candidates.size(); values["managed_category"] = group.managed ? "managed" : "unmanaged"; values["managed_category_label"] = group.managed ? "受控" : "未受控"; @@ -166,8 +192,8 @@ json protocol_enums(const Service_State* service) { } json proxy_node_columns() { return json::array({ - column("action", "操作", "action", 150, true, false, false), - column("name", "节点", "text", 420, true, true, false, "locale_asc"), + column("action", "操作", "action", 150, true, false, false, "none", "none", "", "", "left", false, "left", 0), + column("name", "节点", "text", 420, true, true, false, "locale_asc", "none", "", "", "left", false, "left", 1), column("alive_category", "存活", "enum", 92, true, true, true, "enum_order", "enum", "alive_category", "alive_category_label"), column("exit_ipv4", "IPv4", "text", 150, true, true, false, "locale_asc"), column("exit_ipv6", "IPv6", "text", 220, true, true, false, "locale_asc"), @@ -195,14 +221,15 @@ json proxy_node_columns() { } json proxy_group_columns() { return json::array({ - column("action", "操作", "action", 120, true, false, false), - column("name", "代理组", "text", 260, true, true, false, "locale_asc"), + column("action", "操作", "action", 120, true, false, false, "none", "none", "", "", "left", false, "left", 0), + column("name", "代理组", "text", 260, true, true, false, "locale_asc", "none", "", "", "left", false, "left", 1), column("type_category", "类型", "enum", 120, true, true, true, "enum_order", "enum", "proxy_group_type", "type_category_label"), column("managed_category", "管理", "enum", 110, true, true, true, "enum_order", "enum", "group_managed_category", "managed_category_label"), column("switchable_category", "可切换", "enum", 110, true, true, true, "enum_order", "enum", "group_switchable_category", "switchable_category_label"), column("strategy", "策略", "enum", 190, true, true, true, "enum_order", "enum", "switch_strategy", "", "left", true), column("current", "当前选择", "text", 300, true, true, false, "locale_asc"), - column("candidate_count", "候选", "integer", 90, true, true, false, "number_desc", "none", "", "", "right"), + column("proxy_count", "成员", "integer", 90, true, true, false, "number_desc", "none", "", "", "right"), + column("candidate_count", "自动候选", "integer", 110, true, true, false, "number_desc", "none", "", "", "right"), column("best_candidate", "最佳候选", "text", 300, true, true, false, "locale_asc"), column("better_rounds", "更好确认", "integer", 120, true, true, false, "number_desc", "none", "", "", "right"), column("switch_count", "切换次数", "integer", 100, true, true, false, "number_desc", "none", "", "", "right"), @@ -406,6 +433,7 @@ json ordered_protocol(Service_State const* service) { if (service) { for (auto& item : result["tables"].items()) { apply_column_order(item.value(), service->config.table_layout); + apply_sticky_columns(item.value(), service->config.table_layout); } } return result; @@ -489,6 +517,12 @@ void update_table_column_order(Service_State& service, const std::string& table_ save_runtime_config(service.config); save_service_index(service); } +void update_table_sticky_columns(Service_State& service, const std::string& table_id, const std::vector& sticky_columns) { + std::lock_guard lock(service.mutex); + service.config.table_layout.sticky_columns[table_id] = sticky_columns; + save_runtime_config(service.config); + save_service_index(service); +} void update_table_cell(Service_State& service, const std::string& table_id, const std::string& row_id, const std::string& column_id, const json& value) { std::lock_guard lock(service.mutex); if (table_id == "proxy_groups" && column_id == "strategy") { diff --git a/src/node_service/Node_Service_Web.cpp b/src/node_service/Node_Service_Web.cpp index 23516f8..741ae30 100644 --- a/src/node_service/Node_Service_Web.cpp +++ b/src/node_service/Node_Service_Web.cpp @@ -124,6 +124,28 @@ void start_web_server(Service_State& service, asio::io_context& io, httplib::Ser res.set_content(json{{"success", false}, {"error", error_text_to_utf8(error.what())}}.dump(), "application/json; charset=utf-8"); } }); + server.Post("/api/table/sticky", [&service](const httplib::Request& req, httplib::Response& res) { + try { + const auto body = json::parse(req.body, nullptr, true, true); + const auto table_id = body.at("table").get(); + std::vector columns; + for (const auto& item : body.at("sticky_columns")) { + if (item.is_string()) { + auto id = trim(item.get()); + if (!id.empty()) { + columns.push_back(std::move(id)); + } + } + } + update_table_sticky_columns(service, table_id, columns); + service_log(std::format("表格固定列已保存:{},{} 项", table_id, columns.size())); + res.set_content(json{{"success", true}, {"message", "表格固定列已保存"}, {"state", state_snapshot(service)}}.dump(), "application/json; charset=utf-8"); + } + catch (const std::exception& error) { + res.status = 400; + res.set_content(json{{"success", false}, {"error", error_text_to_utf8(error.what())}}.dump(), "application/json; charset=utf-8"); + } + }); server.Post("/api/table/cell", [&service](const httplib::Request& req, httplib::Response& res) { try { const auto body = json::parse(req.body, nullptr, true, true); diff --git a/src/node_service/web/src/App.tsx b/src/node_service/web/src/App.tsx index 981bcb5..ddb7551 100644 --- a/src/node_service/web/src/App.tsx +++ b/src/node_service/web/src/App.tsx @@ -30,6 +30,7 @@ import { saveConfig, updateTableCell, updateTableOrder, + updateTableSticky, } from "./api.tsx"; import { ConfigPage } from "./pages/ConfigPage.tsx"; import { GroupsPage } from "./pages/GroupsPage.tsx"; @@ -306,6 +307,17 @@ export default function App() { setBusy(false); } }; + const editTableSticky = async (table: string, stickyColumns: string[]) => { + setBusy(true); + try { + const result = await updateTableSticky(table, stickyColumns); + setNotice(result.message || "表格固定列已保存"); + if (result.state) setState(result.state); + else await reload(); + } finally { + setBusy(false); + } + }; const runSwitch = async () => { setBusy(true); try { @@ -343,6 +355,7 @@ export default function App() { detailCollapsed={nodeDetailCollapsed} onEditTableCell={(table,row,column,value) => safeRun(() => editTableCell(table,row,column,value))} onEditTableOrder={(table,columnOrder) => safeRun(() => editTableOrder(table,columnOrder))} + onEditTableSticky={(table,stickyColumns) => safeRun(() => editTableSticky(table,stickyColumns))} /> ) : null} {page === "groups" ? ( @@ -357,6 +370,7 @@ export default function App() { groupListCollapsed={groupListCollapsed} onEditTableCell={(table,row,column,value) => safeRun(() => editTableCell(table,row,column,value))} onEditTableOrder={(table,columnOrder) => safeRun(() => editTableOrder(table,columnOrder))} + onEditTableSticky={(table,stickyColumns) => safeRun(() => editTableSticky(table,stickyColumns))} onSwitchGroupNode={(group,node) => safeRun(() => switchGroupNode(group,node))} switchingNodes={switchingNodes} /> @@ -383,6 +397,7 @@ export default function App() { setAutoRefresh={setAutoRefresh} onEditTableCell={(table,row,column,value) => safeRun(() => editTableCell(table,row,column,value))} onEditTableOrder={(table,columnOrder) => safeRun(() => editTableOrder(table,columnOrder))} + onEditTableSticky={(table,stickyColumns) => safeRun(() => editTableSticky(table,stickyColumns))} /> ) : null} diff --git a/src/node_service/web/src/api.tsx b/src/node_service/web/src/api.tsx index eceda79..05041be 100644 --- a/src/node_service/web/src/api.tsx +++ b/src/node_service/web/src/api.tsx @@ -42,6 +42,10 @@ export async function updateTableOrder(table:string,columnOrder:string[]):Promis const response=await fetch('/api/table/order',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({table,column_order:columnOrder})}); return await readJsonResponse(response); } +export async function updateTableSticky(table:string,stickyColumns:string[]):Promise { + const response=await fetch('/api/table/sticky',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({table,sticky_columns:stickyColumns})}); + return await readJsonResponse(response); +} export async function updateTableCell(table:string,row:string,column:string,value:unknown):Promise { const response=await fetch('/api/table/cell',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({table,row,column,value})}); return await readJsonResponse(response); diff --git a/src/node_service/web/src/components/GroupListPanel.tsx b/src/node_service/web/src/components/GroupListPanel.tsx index e61112c..feaa491 100644 --- a/src/node_service/web/src/components/GroupListPanel.tsx +++ b/src/node_service/web/src/components/GroupListPanel.tsx @@ -6,16 +6,16 @@ import {switchStrategyLabel,tableProtocol,tableRows,timeText} from '../utils.tsx function GroupRefreshButton({name,onRefresh,refreshing}:{name?:string;onRefresh:(name?:string)=>void;refreshing:boolean}) { return ; } -export function GroupListPanel({state,selectedGroup,setSelectedGroup,onRefreshGroup,refreshingGroups,onEditTableCell,onEditTableOrder}:{state:ServiceState;selectedGroup?:ProxyGroup | null;setSelectedGroup:(group:ProxyGroup)=>void;onRefreshGroup:(name?:string)=>void;refreshingGroups:Set;onEditTableCell:(table:string,row:string,column:string,value:unknown)=>void;onEditTableOrder:(table:string,columnOrder:string[])=>void}) { +export function GroupListPanel({state,selectedGroup,setSelectedGroup,onRefreshGroup,refreshingGroups,onEditTableCell,onEditTableOrder,onEditTableSticky}:{state:ServiceState;selectedGroup?:ProxyGroup | null;setSelectedGroup:(group:ProxyGroup)=>void;onRefreshGroup:(name?:string)=>void;refreshingGroups:Set;onEditTableCell:(table:string,row:string,column:string,value:unknown)=>void;onEditTableOrder:(table:string,columnOrder:string[])=>void;onEditTableSticky:(table:string,stickyColumns:string[])=>void}) { const protocol=tableProtocol(state,'proxy_groups'); const rows=tableRows(state,'proxy_groups'); if (!protocol) { return 后端没有返回代理组表格协议。; } - return state={state} protocol={protocol} rows={rows} selectedId={selectedGroup?.name} onSelectRow={(row)=>setSelectedGroup(row.raw)} actionRenderer={(row:TableRowObject)=>} storageKey="node_service_group_table_widths" title="代理组表格" description="代理组字段、过滤、排序和策略编辑同样由后端协议定义。" fillHeight extraToolbar={} onEditCell={(row:TableRowObject,column:TableColumnProtocol,value:unknown)=>onEditTableCell(protocol.id,row.id,column.id,value)} onColumnOrderChange={onEditTableOrder}/>; + return state={state} protocol={protocol} rows={rows} selectedId={selectedGroup?.name} onSelectRow={(row)=>setSelectedGroup(row.raw)} actionRenderer={(row:TableRowObject)=>} storageKey="node_service_group_table_widths" title="代理组表格" description="代理组字段、过滤、排序和策略编辑同样由后端协议定义。" fillHeight extraToolbar={} onEditCell={(row:TableRowObject,column:TableColumnProtocol,value:unknown)=>onEditTableCell(protocol.id,row.id,column.id,value)} onColumnOrderChange={onEditTableOrder} onColumnStickyChange={onEditTableSticky}/>; } export function SelectedGroupHeader({group,state,onEditStrategy,onLocateCurrent,onRefreshGroup}:{group?:ProxyGroup | null;state?:ServiceState;onEditStrategy?:(strategy:string)=>void;onLocateCurrent?:()=>void;onRefreshGroup?:(name:string)=>void}) { if (!group) return 未选择代理组从右侧代理组表格选择一个代理组。; const options=state?.switch_strategy_definitions || []; - return {group.name}{onRefreshGroup ? : null}决策:{group.last_decision || '等待自动切换判定'}{group.last_error ? `,错误:${group.last_error}` : ''}策略选择是代理组页顶部快捷控件;代理组表格里的策略列仍可编辑并持久化。; + return {group.name}{onRefreshGroup ? : null}决策:{group.last_decision || '等待自动切换判定'}{group.last_error ? `,错误:${group.last_error}` : ''}策略选择是代理组页顶部快捷控件;代理组表格里的策略列仍可编辑并持久化。; } diff --git a/src/node_service/web/src/components/NodeTable.tsx b/src/node_service/web/src/components/NodeTable.tsx index c097328..0b3da1d 100644 --- a/src/node_service/web/src/components/NodeTable.tsx +++ b/src/node_service/web/src/components/NodeTable.tsx @@ -5,15 +5,36 @@ import {NodeRefreshButton} from './Display.tsx'; import {ProtocolObjectTable} from './ProtocolObjectTable.tsx'; import {tableProtocol,tableRows} from '../utils.tsx'; function NodeActionCell({node,onRefreshNode,refreshing,onSwitchNode,switching,isCurrent}:{node:NodeState;onRefreshNode:(name:string)=>void;refreshing:boolean;onSwitchNode?:(name:string)=>void;switching?:boolean;isCurrent?:boolean}) { - return event.stopPropagation()}>{onSwitchNode ? : null}; + const refreshable=!node.special_proxy_kind; + return event.stopPropagation()}>{refreshable ? : null}{onSwitchNode ? : null}; } -export function NodeTable({state,sourceNodes,selected,setSelected,onRefreshNode,refreshingNodes,maxHeight='calc(100vh - 330px)',fillHeight=true,storageKey='node_service_node_table_widths',title='节点表格',onEditTableCell,onEditTableOrder,highlightedName,scrollToName,scrollSignal,onSwitchNode,switchingNodes}:{state:ServiceState;sourceNodes:NodeState[];selected?:NodeState | null;setSelected?:(node:NodeState)=>void;onRefreshNode:(name:string)=>void;refreshingNodes:Set;maxHeight?:string;fillHeight?:boolean;storageKey?:string;title?:string;onEditTableCell?:(table:string,row:string,column:string,value:unknown)=>void;onEditTableOrder?:(table:string,columnOrder:string[])=>void;highlightedName?:string | null;scrollToName?:string | null;scrollSignal?:number;onSwitchNode?:(name:string)=>void;switchingNodes?:Set}) { +function specialRow(name:string,state:ServiceState):TableRowObject { + const group=(state.groups || []).find(item=>item.name === name); + const kind=name === 'DIRECT' ? 'direct' : group ? 'group' : 'unknown'; + const label=kind === 'direct' ? 'DIRECT' : group ? '代理组' : '特殊项'; + const type=kind === 'direct' ? 'DIRECT' : group ? `proxy_group:${group.type || 'unknown'}` : 'unknown'; + const raw:NodeState={name,special_proxy_kind:kind,type,provider:label,alive:true,stability_score:0,success_rate:0,recent_success_rate:0,check_count:0,pass_count:0,recent_check_count:0,recent_pass_count:0,probes:[]}; + const values={...raw,id:name,alive_category:'special',alive_category_label:label,stability_category:'unknown',stability_category_label:label,total_rate_category:'unknown',total_rate_category_label:label,recent_rate_category:'unknown',recent_rate_category_label:label,ai_category:'unknown',ai_category_label:'-',ip_type_category:'unknown',ip_type_category_label:label,risk_category:'unknown',risk_category_label:'-',shared_category:'unknown',shared_category_label:'-',native_category:'unknown',native_category_label:'-',ip_version_category:'none',ip_version_category_label:'-',risk_text:'-',ai_text:'-',rate_summary:{check_count:0,pass_count:0,success_rate:0,recent_check_count:0,recent_pass_count:0,recent_success_rate:0},proxy_groups:group ? [group.name] : [],probe_summary:[]}; + return {id:name,values,raw}; +} +function orderedRows(state:ServiceState,rows:TableRowObject[],names:string[]):TableRowObject[] { + const rowMap=new Map(rows.map(row=>[row.id,row])); + const result:TableRowObject[]=[]; + const used=new Set(); + for (const name of names) { + if (used.has(name)) continue; + used.add(name); + result.push(rowMap.get(name) || specialRow(name,state)); + } + return result; +} +export function NodeTable({state,sourceNodes,sourceNames,selected,setSelected,onRefreshNode,refreshingNodes,maxHeight='calc(100vh - 330px)',fillHeight=true,storageKey='node_service_node_table_widths',title='节点表格',onEditTableCell,onEditTableOrder,onEditTableSticky,highlightedName,scrollToName,scrollSignal,onSwitchNode,switchingNodes}:{state:ServiceState;sourceNodes:NodeState[];sourceNames?:string[];selected?:NodeState | null;setSelected?:(node:NodeState)=>void;onRefreshNode:(name:string)=>void;refreshingNodes:Set;maxHeight?:string;fillHeight?:boolean;storageKey?:string;title?:string;onEditTableCell?:(table:string,row:string,column:string,value:unknown)=>void;onEditTableOrder?:(table:string,columnOrder:string[])=>void;onEditTableSticky?:(table:string,stickyColumns:string[])=>void;highlightedName?:string | null;scrollToName?:string | null;scrollSignal?:number;onSwitchNode?:(name:string)=>void;switchingNodes?:Set}) { const protocol=tableProtocol(state,'proxy_nodes'); const rows=tableRows(state,'proxy_nodes'); - const sourceNames=useMemo(()=>new Set(sourceNodes.map(node=>node.name)),[sourceNodes]); - const filteredRows=useMemo(()=>rows.filter(row=>sourceNames.has(row.id)),[rows,sourceNames]); + const names=useMemo(()=>sourceNames || sourceNodes.map(node=>node.name),[sourceNames,sourceNodes]); + const filteredRows=useMemo(()=>orderedRows(state,rows,names),[state,rows,names]); if (!protocol) { return 后端没有返回代理节点表格协议。; } - return state={state} protocol={protocol} rows={filteredRows} selectedId={selected?.name} highlightedId={highlightedName} scrollToId={scrollToName} scrollSignal={scrollSignal} onSelectRow={(row)=>setSelected?.(row.raw)} actionRenderer={(row:TableRowObject)=>} storageKey={storageKey} title={title} description="表格列、枚举、过滤、排序规则都由后端协议定义。" fillHeight={fillHeight} maxHeight={maxHeight} onEditCell={onEditTableCell ? (row:TableRowObject,column:TableColumnProtocol,value:unknown)=>onEditTableCell(protocol.id,row.id,column.id,value) : undefined} onColumnOrderChange={onEditTableOrder}/>; + return state={state} protocol={protocol} rows={filteredRows} selectedId={selected?.name} highlightedId={highlightedName} scrollToId={scrollToName} scrollSignal={scrollSignal} onSelectRow={(row)=>setSelected?.(row.raw)} actionRenderer={(row:TableRowObject)=>} storageKey={storageKey} title={title} description="表格列、枚举、过滤、排序规则都由后端协议定义。" fillHeight={fillHeight} maxHeight={maxHeight} onEditCell={onEditTableCell ? (row:TableRowObject,column:TableColumnProtocol,value:unknown)=>onEditTableCell(protocol.id,row.id,column.id,value) : undefined} onColumnOrderChange={onEditTableOrder} onColumnStickyChange={onEditTableSticky}/>; } diff --git a/src/node_service/web/src/components/ProtocolObjectTable.tsx b/src/node_service/web/src/components/ProtocolObjectTable.tsx index d2ced5c..e748bd1 100644 --- a/src/node_service/web/src/components/ProtocolObjectTable.tsx +++ b/src/node_service/web/src/components/ProtocolObjectTable.tsx @@ -2,7 +2,7 @@ import React,{useEffect,useMemo,useRef,useState} from 'react'; import {Box,Button,Chip,FormControlLabel,Menu,MenuItem,Select,Stack,Switch,TableCell,TableRow,TextField,Typography} from '@mui/material'; import type {FormControlProtocol,FormProtocol,ServiceState,TableCellEditSchema,TableColumn,TableColumnProtocol,TableProtocol,TableRowObject,TableSortDirection} from '../types.tsx'; import {CategoryChip,ProbeIcons,ScoreCell} from './Display.tsx'; -import {HeaderResizeGrip,ResizableObjectTable,tableCellSx,usePersistentColumns} from './ResizableObjectTable.tsx'; +import {HeaderResizeGrip,ResizableObjectTable,stickyOffsets,tableCellSx,usePersistentColumns} from './ResizableObjectTable.tsx'; import {columnDefaultDirection,defaultSortValue,enumOptions,msText,protocolFilterMatch,protocolKeywordMatch,rateText,rowLabel,rowValue,sortProtocolRows,timeText,valueText} from '../utils.tsx'; function widthDefaults(protocol:TableProtocol):Record { const result:Record={}; @@ -145,12 +145,23 @@ function orderedIdsAfterMove(protocol:TableProtocol,columnId:string,direction:-1 [ids[index],ids[next]]=[ids[next],ids[index]]; return ids; } -export function ProtocolObjectTable({state,protocol,rows,selectedId,highlightedId,scrollToId,scrollSignal,onSelectRow,actionRenderer,storageKey,title,description,fillHeight=true,maxHeight='calc(100vh - 320px)',extraToolbar,onEditCell,onColumnOrderChange}:{state:ServiceState;protocol:TableProtocol;rows:TableRowObject[];selectedId?:string | null;highlightedId?:string | null;scrollToId?:string | null;scrollSignal?:number;onSelectRow?:(row:TableRowObject)=>void;actionRenderer?:(row:TableRowObject)=>React.ReactNode;storageKey:string;title?:string;description?:string;fillHeight?:boolean;maxHeight?:string;extraToolbar?:React.ReactNode;onEditCell?:(row:TableRowObject,column:TableColumnProtocol,value:unknown)=>void;onColumnOrderChange?:(tableId:string,columnOrder:string[])=>void}) { +function stickyIdsAfterToggle(protocol:TableProtocol,columnId:string):string[] { + const enabled=new Set(protocol.columns.filter(column=>column.sticky === 'left').map(column=>column.id)); + if (enabled.has(columnId)) enabled.delete(columnId); + else enabled.add(columnId); + return protocol.columns.filter(column=>column.visible !== false && enabled.has(column.id)).map(column=>column.id); +} +function stickyColumnCount(protocol:TableProtocol):number { + return protocol.columns.filter(column=>column.visible !== false && column.sticky === 'left').length; +} +export function ProtocolObjectTable({state,protocol,rows,selectedId,highlightedId,scrollToId,scrollSignal,onSelectRow,actionRenderer,storageKey,title,description,fillHeight=true,maxHeight='calc(100vh - 320px)',extraToolbar,onEditCell,onColumnOrderChange,onColumnStickyChange}:{state:ServiceState;protocol:TableProtocol;rows:TableRowObject[];selectedId?:string | null;highlightedId?:string | null;scrollToId?:string | null;scrollSignal?:number;onSelectRow?:(row:TableRowObject)=>void;actionRenderer?:(row:TableRowObject)=>React.ReactNode;storageKey:string;title?:string;description?:string;fillHeight?:boolean;maxHeight?:string;extraToolbar?:React.ReactNode;onEditCell?:(row:TableRowObject,column:TableColumnProtocol,value:unknown)=>void;onColumnOrderChange?:(tableId:string,columnOrder:string[])=>void;onColumnStickyChange?:(tableId:string,stickyColumns:string[])=>void}) { const [keyword,setKeyword]=useState(''); const [filters,setFilters]=useState>({}); const [sortValue,setSortValue]=useState(defaultSortValue(protocol)); const [filterColumn,setFilterColumn]=useState(null); const [filterAnchor,setFilterAnchor]=useState(null); + const [stickyEditorOpen,setStickyEditorOpen]=useState(false); + const [orderEditorOpen,setOrderEditorOpen]=useState(false); const rowRefs=useRef>({}); const visibleColumns=useMemo(()=>protocol.columns.filter(column=>column.visible !== false),[protocol]); const sortedRows=useMemo(()=>{ @@ -159,6 +170,7 @@ export function ProtocolObjectTable({state,protocol,rows,selectedId,highlight },[rows,protocol,keyword,filters,sortValue,state]); const autoWidths=useMemo(()=>autoColumnWidths(protocol,visibleColumns,rows,state),[protocol,visibleColumns,rows,state]); const {widths,setWidth,reset}=usePersistentColumns(storageKey,widthDefaults(protocol)); + const offsets=useMemo(()=>stickyOffsets(visibleColumns,widths),[visibleColumns,widths]); useEffect(()=>{ if (!scrollToId) return; const row=rowRefs.current[scrollToId]; @@ -183,13 +195,22 @@ export function ProtocolObjectTable({state,protocol,rows,selectedId,highlight if (!protocol.column_order_editable || !onColumnOrderChange) return; onColumnOrderChange(protocol.id,orderedIdsAfterMove(protocol,column.id,direction)); }; + const toggleSticky=(event:React.MouseEvent,column:TableColumnProtocol)=>{ + event.stopPropagation(); + if (!protocol.sticky_editable || !onColumnStickyChange) return; + onColumnStickyChange(protocol.id,stickyIdsAfterToggle(protocol,column.id)); + }; + const clearSticky=()=>{ + if (!protocol.sticky_editable || !onColumnStickyChange) return; + onColumnStickyChange(protocol.id,[]); + }; const headerCell=(input:TableColumn)=>{ const column=input as TableColumnProtocol; const current=splitSort(sortValue); const activeFilter=filters[column.id] && filters[column.id] !== 'all'; const sortMark=current.column === column.id ? (current.direction === 'desc' ? '↓' : '↑') : ''; - return toggleSort(column)} sx={{...tableCellSx(column,widths),position:'relative',fontWeight:900,userSelect:'none',cursor:column.sortable ? 'pointer' : 'default',bgcolor:activeFilter ? 'rgba(37,99,235,0.08)' : undefined}}>{protocol.column_order_editable && onColumnOrderChange ? : null}{column.label}{sortMark ? {sortMark} : null}{column.editable ? 可编辑 : null}{column.filterable ? : null}{protocol.column_order_editable && onColumnOrderChange ? : null}; + return toggleSort(column)} sx={{...tableCellSx(column,widths,{position:'relative',fontWeight:900,userSelect:'none',cursor:column.sortable ? 'pointer' : 'default',bgcolor:activeFilter ? 'rgba(37,99,235,0.08)' : undefined},offsets[column.id],true)}}>{orderEditorOpen && protocol.column_order_editable && onColumnOrderChange ? : null}{column.label}{sortMark ? {sortMark} : null}{column.editable ? 可编辑 : null}{stickyEditorOpen && protocol.sticky_editable && onColumnStickyChange ? : null}{column.filterable ? : null}{orderEditorOpen && protocol.column_order_editable && onColumnOrderChange ? : null}; }; const filterOptions=filterColumn ? enumOptions(state,filterColumn.enum) : []; - return {title || protocol.name}{description ? {description} : null}显示行:{sortedRows.length}{extraToolbar}setKeyword(event.target.value)} sx={{minWidth:280,flexBasis:360,flexGrow:1,maxWidth:520}}/>{rowRefs.current[row.id]=element;}} hover selected={selectedId === row.id} onClick={()=>onSelectRow?.(row)} sx={{cursor:onSelectRow ? 'pointer' : 'default',bgcolor:selectedId === row.id ? undefined : highlightedId === row.id ? 'rgba(16,185,129,0.12)' : undefined,'&:hover':{bgcolor:highlightedId === row.id ? 'rgba(16,185,129,0.18)' : undefined},'&.Mui-selected':{bgcolor:'rgba(37,99,235,0.14)'},'&.Mui-selected:hover':{bgcolor:'rgba(37,99,235,0.22)'}}}>{visibleColumns.map(column=>)}}/>{filterColumn && (filterColumn.filter_type === 'enum' || filterColumn.filter_type === 'dynamic_enum' || filterColumn.filter_type === 'multi_enum') ? [{setFilter(filterColumn.id,'all');closeFilter();}}>全部,...filterOptions.map(option=>{setFilter(filterColumn.id,option.value);closeFilter();}}>{option.label})] : null}{filterColumn && !(filterColumn.filter_type === 'enum' || filterColumn.filter_type === 'dynamic_enum' || filterColumn.filter_type === 'multi_enum') ? setFilter(filterColumn.id,event.target.value)}/> : null}; + return {title || protocol.name}{description ? {description} : null}显示行:{sortedRows.length}{extraToolbar}setKeyword(event.target.value)} sx={{minWidth:280,flexBasis:360,flexGrow:1,maxWidth:520}}/>{protocol.sticky_editable && onColumnStickyChange ? : null}{stickyEditorOpen && protocol.sticky_editable && onColumnStickyChange ? : null}{protocol.column_order_editable && onColumnOrderChange ? : null}{rowRefs.current[row.id]=element;}} hover selected={selectedId === row.id} onClick={()=>onSelectRow?.(row)} sx={{cursor:onSelectRow ? 'pointer' : 'default',bgcolor:selectedId === row.id ? undefined : highlightedId === row.id ? 'rgba(16,185,129,0.12)' : undefined,'&:hover':{bgcolor:highlightedId === row.id ? 'rgba(16,185,129,0.18)' : undefined},'&.Mui-selected':{bgcolor:'rgba(37,99,235,0.14)'},'&.Mui-selected:hover':{bgcolor:'rgba(37,99,235,0.22)'}}}>{visibleColumns.map(column=>)}}/>{filterColumn && (filterColumn.filter_type === 'enum' || filterColumn.filter_type === 'dynamic_enum' || filterColumn.filter_type === 'multi_enum') ? [{setFilter(filterColumn.id,'all');closeFilter();}}>全部,...filterOptions.map(option=>{setFilter(filterColumn.id,option.value);closeFilter();}}>{option.label})] : null}{filterColumn && !(filterColumn.filter_type === 'enum' || filterColumn.filter_type === 'dynamic_enum' || filterColumn.filter_type === 'multi_enum') ? setFilter(filterColumn.id,event.target.value)}/> : null}; } diff --git a/src/node_service/web/src/components/ResizableObjectTable.tsx b/src/node_service/web/src/components/ResizableObjectTable.tsx index a7d6272..10a7221 100644 --- a/src/node_service/web/src/components/ResizableObjectTable.tsx +++ b/src/node_service/web/src/components/ResizableObjectTable.tsx @@ -3,6 +3,7 @@ import {Box,Table,TableBody,TableCell,TableHead,TableRow} from '@mui/material'; import type {SxProps,Theme} from '@mui/material/styles'; import type {TableColumn} from '../types.tsx'; export type ColumnWidths=Record; +export type StickyOffsets=Record; export function usePersistentColumns(key:string,defaults:ColumnWidths) { const [widths,setWidths]=useState(()=>{ try { @@ -20,9 +21,23 @@ export function usePersistentColumns(key:string,defaults:ColumnWidths) { export function columnWidth(columns:TableColumn[],widths:ColumnWidths):number { return columns.reduce((sum,column)=>sum + Number(widths[column.id] || column.width || 120),0); } -export function tableCellSx(column:TableColumn,widths:ColumnWidths,extra:SxProps={}):SxProps { +export function stickyOffsets(columns:TableColumn[],widths:ColumnWidths):StickyOffsets { + const result:StickyOffsets={}; + let left=0; + for (const column of columns) { + if (column.sticky !== 'left') continue; + result[column.id]=left; + left += Number(widths[column.id] || column.width || 120); + } + return result; +} +export function tableCellSx(column:TableColumn,widths:ColumnWidths,extra:SxProps={},stickyLeft?:number,isHeader=false):SxProps { const width=Number(widths[column.id] || column.width || 120); - return {width,minWidth:width,maxWidth:width,overflow:'hidden',textOverflow:'ellipsis',whiteSpace:'nowrap',...extra}; + const sx:SxProps={width,minWidth:width,maxWidth:width,overflow:'hidden',textOverflow:'ellipsis',whiteSpace:'nowrap',...extra}; + if (column.sticky === 'left' && stickyLeft !== undefined) { + return {...sx,position:'sticky',left:stickyLeft,zIndex:isHeader ? 9 : 4,bgcolor:isHeader ? '#f8fafc' : 'background.paper',boxShadow:'2px 0 0 rgba(148,163,184,0.18)'}; + } + return sx; } function scrollbarSx() { return {'&::-webkit-scrollbar':{height:14,width:14},'&::-webkit-scrollbar-track':{backgroundColor:'#eef2f7',borderRadius:8},'&::-webkit-scrollbar-thumb':{backgroundColor:'#94a3b8',borderRadius:8,border:'3px solid #eef2f7'},'&::-webkit-scrollbar-thumb:hover':{backgroundColor:'#64748b'}}; @@ -41,16 +56,18 @@ export function HeaderResizeGrip({column,widths,setWidth}:{column:TableColumn document.addEventListener('mousemove',move); document.addEventListener('mouseup',up); }; + if (column.resizable === false) return null; return ; } -function ResizeHeaderCell({column,widths,setWidth}:{column:TableColumn;widths:ColumnWidths;setWidth:(id:string,width:number)=>void}) { - return {column.label}; +function ResizeHeaderCell({column,widths,setWidth,stickyLeft}:{column:TableColumn;widths:ColumnWidths;setWidth:(id:string,width:number)=>void;stickyLeft?:number}) { + return {column.label}; } -function TopScrollTable({columns,widths,setWidth,fillHeight,maxHeight,children,renderHeaderCell}:{columns:TableColumn[];widths:ColumnWidths;setWidth:(id:string,width:number)=>void;fillHeight:boolean;maxHeight:string;children:React.ReactNode;renderHeaderCell?:(column:TableColumn)=>React.ReactNode}) { +function TopScrollTable({columns,widths,setWidth,fillHeight,maxHeight,children,renderHeaderCell,stickyHeader=true,topScrollbar=true}:{columns:TableColumn[];widths:ColumnWidths;setWidth:(id:string,width:number)=>void;fillHeight:boolean;maxHeight:string;children:React.ReactNode;renderHeaderCell?:(column:TableColumn)=>React.ReactNode;stickyHeader?:boolean;topScrollbar?:boolean}) { const topRef=useRef(null); const bodyRef=useRef(null); const syncing=useRef(false); const minWidth=columnWidth(columns,widths); + const offsets=stickyOffsets(columns,widths); const sync=(from:'top'|'body')=>{ if (syncing.current) return; const source=from === 'top' ? topRef.current : bodyRef.current; @@ -60,8 +77,8 @@ function TopScrollTable({columns,widths,setWidth,fillHeight,maxHeight,childre target.scrollLeft=source.scrollLeft; requestAnimationFrame(()=>{syncing.current=false;}); }; - return sync('top')} sx={{overflowX:'auto',overflowY:'hidden',height:20,minHeight:20,borderTop:'1px solid #d8e2f0',borderBottom:'1px solid #eef2f7',...scrollbarSx()}}>sync('body')} sx={{overflow:'auto',maxHeight:fillHeight ? undefined : maxHeight,minHeight:0,flex:fillHeight ? 1 : undefined,...scrollbarSx()}}>{columns.map(column=>renderHeaderCell ? renderHeaderCell(column) : )}{children}
; + return {topScrollbar ? sync('top')} sx={{overflowX:'auto',overflowY:'hidden',height:20,minHeight:20,borderTop:'1px solid #d8e2f0',borderBottom:'1px solid #eef2f7',...scrollbarSx()}}> : null}sync('body')} sx={{overflow:'auto',maxHeight:fillHeight ? undefined : maxHeight,minHeight:0,flex:fillHeight ? 1 : undefined,...scrollbarSx()}}>{columns.map(column=>renderHeaderCell ? renderHeaderCell(column) : )}{children}
; } -export function ResizableObjectTable({columns,widths,setWidth,maxHeight='calc(100vh - 320px)',fillHeight=false,rows,renderRow,emptyText,renderHeaderCell}:{columns:TableColumn[];widths:ColumnWidths;setWidth:(id:string,width:number)=>void;maxHeight?:string;fillHeight?:boolean;rows:T[];renderRow:(row:T,index:number)=>React.ReactNode;emptyText?:string;renderHeaderCell?:(column:TableColumn)=>React.ReactNode}) { - return {rows.length ? rows.map(renderRow) : {emptyText || '没有数据'}}; +export function ResizableObjectTable({columns,widths,setWidth,maxHeight='calc(100vh - 320px)',fillHeight=false,rows,renderRow,emptyText,renderHeaderCell,stickyHeader=true,topScrollbar=true}:{columns:TableColumn[];widths:ColumnWidths;setWidth:(id:string,width:number)=>void;maxHeight?:string;fillHeight?:boolean;rows:T[];renderRow:(row:T,index:number)=>React.ReactNode;emptyText?:string;renderHeaderCell?:(column:TableColumn)=>React.ReactNode;stickyHeader?:boolean;topScrollbar?:boolean}) { + return {rows.length ? rows.map(renderRow) : {emptyText || '没有数据'}}; } diff --git a/src/node_service/web/src/pages/ConfigPage.tsx b/src/node_service/web/src/pages/ConfigPage.tsx index 6e5c779..d9b31df 100644 --- a/src/node_service/web/src/pages/ConfigPage.tsx +++ b/src/node_service/web/src/pages/ConfigPage.tsx @@ -14,16 +14,16 @@ function ServicePanel({state,notice,busy,fullRefreshRequesting,onRefresh,onReloa const progress=total > 0 ? Math.min(100,(done * 100) / total) : 0; return
服务状态轮次 {state.round ?? 0} | {state.status_text || "等待服务状态"} | 更新时间 {timeText(state.updated_time)}配置 {state.config_path || "-"} | 状态目录 {state.state_dir || "-"}最近请求 {timeText(state.last_refresh_request_time)} | 开始 {timeText(state.last_refresh_begin_time)} | 结束 {timeText(state.last_refresh_end_time)}
setAutoRefresh(event.target.checked)}/>} label="页面自动刷新"/>
0 ? "determinate" : "indeterminate"} value={progress} sx={{mt:2}}/>{notice}
; } -function AutoSwitchProtocolTable({state,tableId,storageKey,title,description,busy,onRun,onEditTableCell,onEditTableOrder}:{state:ServiceState;tableId:string;storageKey:string;title:string;description:string;busy?:boolean;onRun?:()=>void;onEditTableCell:(table:string,row:string,column:string,value:unknown)=>void;onEditTableOrder:(table:string,columnOrder:string[])=>void}) { +function AutoSwitchProtocolTable({state,tableId,storageKey,title,description,busy,onRun,onEditTableCell,onEditTableOrder,onEditTableSticky}:{state:ServiceState;tableId:string;storageKey:string;title:string;description:string;busy?:boolean;onRun?:()=>void;onEditTableCell:(table:string,row:string,column:string,value:unknown)=>void;onEditTableOrder:(table:string,columnOrder:string[])=>void;onEditTableSticky:(table:string,stickyColumns:string[])=>void}) { const protocol=tableProtocol(state,tableId); const rows=tableRows(state,tableId); if (!protocol) return 后端没有返回 {title} 协议。; - return 立即执行切换判定 : null} onEditCell={(row:TableRowObject,column:TableColumnProtocol,value:unknown)=>onEditTableCell(protocol.id,row.id,column.id,value)} onColumnOrderChange={onEditTableOrder}/>; + return 立即执行切换判定 : null} onEditCell={(row:TableRowObject,column:TableColumnProtocol,value:unknown)=>onEditTableCell(protocol.id,row.id,column.id,value)} onColumnOrderChange={onEditTableOrder} onColumnStickyChange={onEditTableSticky}/>; } -function AutoSwitchProtocolPanel({state,busy,onRun,onEditTableCell,onEditTableOrder}:{state:ServiceState;busy:boolean;onRun:()=>void;onEditTableCell:(table:string,row:string,column:string,value:unknown)=>void;onEditTableOrder:(table:string,columnOrder:string[])=>void}) { - return ; +function AutoSwitchProtocolPanel({state,busy,onRun,onEditTableCell,onEditTableOrder,onEditTableSticky}:{state:ServiceState;busy:boolean;onRun:()=>void;onEditTableCell:(table:string,row:string,column:string,value:unknown)=>void;onEditTableOrder:(table:string,columnOrder:string[])=>void;onEditTableSticky:(table:string,stickyColumns:string[])=>void}) { + return ; } -export function ConfigPage({state,notice,intervalValue,setIntervalValue,httpLog,setHttpLog,onSaveInterval,onSaveHttpLog,busy,onSaveSwitch,onRunSwitch,onRefresh,fullRefreshRequesting,onReload,autoRefresh,setAutoRefresh,onEditTableCell,onEditTableOrder}:{state:ServiceState;notice:string;intervalValue:string;setIntervalValue:(value:string)=>void;httpLog:boolean;setHttpLog:(value:boolean)=>void;onSaveInterval:()=>void;onSaveHttpLog:(value:boolean)=>void;busy:boolean;onSaveSwitch:(config:AutoSwitchConfig)=>void;onRunSwitch:()=>void;onRefresh:()=>void;fullRefreshRequesting:boolean;onReload:()=>void;autoRefresh:boolean;setAutoRefresh:(value:boolean)=>void;onEditTableCell:(table:string,row:string,column:string,value:unknown)=>void;onEditTableOrder:(table:string,columnOrder:string[])=>void}) { +export function ConfigPage({state,notice,intervalValue,setIntervalValue,httpLog,setHttpLog,onSaveInterval,onSaveHttpLog,busy,onSaveSwitch,onRunSwitch,onRefresh,fullRefreshRequesting,onReload,autoRefresh,setAutoRefresh,onEditTableCell,onEditTableOrder,onEditTableSticky}:{state:ServiceState;notice:string;intervalValue:string;setIntervalValue:(value:string)=>void;httpLog:boolean;setHttpLog:(value:boolean)=>void;onSaveInterval:()=>void;onSaveHttpLog:(value:boolean)=>void;busy:boolean;onSaveSwitch:(config:AutoSwitchConfig)=>void;onRunSwitch:()=>void;onRefresh:()=>void;fullRefreshRequesting:boolean;onReload:()=>void;autoRefresh:boolean;setAutoRefresh:(value:boolean)=>void;onEditTableCell:(table:string,row:string,column:string,value:unknown)=>void;onEditTableOrder:(table:string,columnOrder:string[])=>void;onEditTableSticky:(table:string,stickyColumns:string[])=>void}) { void onSaveSwitch; - return 综合配置setIntervalValue(event.target.value)} inputProps={{min:5,max:3600,step:1}}/>{setHttpLog(event.target.checked);onSaveHttpLog(event.target.checked);}}/>} label="后台 HTTP 日志"/>服务配置:{state.service_config_path || "-"}全局统计、服务状态、刷新全部节点和策略表格都放在综合配置页。; + return 综合配置setIntervalValue(event.target.value)} inputProps={{min:5,max:3600,step:1}}/>{setHttpLog(event.target.checked);onSaveHttpLog(event.target.checked);}}/>} label="后台 HTTP 日志"/>服务配置:{state.service_config_path || "-"}全局统计、服务状态、刷新全部节点和策略表格都放在综合配置页。; } diff --git a/src/node_service/web/src/pages/GroupsPage.tsx b/src/node_service/web/src/pages/GroupsPage.tsx index e506ef0..bd246f7 100644 --- a/src/node_service/web/src/pages/GroupsPage.tsx +++ b/src/node_service/web/src/pages/GroupsPage.tsx @@ -3,14 +3,18 @@ import {Grid,Stack} from '@mui/material'; import type {NodeState,ProxyGroup,ServiceState} from '../types.tsx'; import {GroupListPanel,SelectedGroupHeader} from '../components/GroupListPanel.tsx'; import {NodeTable} from '../components/NodeTable.tsx'; -export function GroupsPage({state,selectedGroup,setSelectedGroup,onRefreshGroup,refreshingGroups,onRefreshNode,refreshingNodes,groupListCollapsed,onEditTableCell,onEditTableOrder,onSwitchGroupNode,switchingNodes}:{state:ServiceState;selectedGroup:ProxyGroup | null;setSelectedGroup:(group:ProxyGroup)=>void;onRefreshGroup:(name?:string)=>void;refreshingGroups:Set;onRefreshNode:(name:string)=>void;refreshingNodes:Set;groupListCollapsed:boolean;onEditTableCell:(table:string,row:string,column:string,value:unknown)=>void;onEditTableOrder:(table:string,columnOrder:string[])=>void;onSwitchGroupNode:(group:string,node:string)=>void;switchingNodes:Set}) { +export function GroupsPage({state,selectedGroup,setSelectedGroup,onRefreshGroup,refreshingGroups,onRefreshNode,refreshingNodes,groupListCollapsed,onEditTableCell,onEditTableOrder,onEditTableSticky,onSwitchGroupNode,switchingNodes}:{state:ServiceState;selectedGroup:ProxyGroup | null;setSelectedGroup:(group:ProxyGroup)=>void;onRefreshGroup:(name?:string)=>void;refreshingGroups:Set;onRefreshNode:(name:string)=>void;refreshingNodes:Set;groupListCollapsed:boolean;onEditTableCell:(table:string,row:string,column:string,value:unknown)=>void;onEditTableOrder:(table:string,columnOrder:string[])=>void;onEditTableSticky:(table:string,stickyColumns:string[])=>void;onSwitchGroupNode:(group:string,node:string)=>void;switchingNodes:Set}) { const [selectedNode,setSelectedNode]=useState(null); const [scrollSignal,setScrollSignal]=useState(0); - const groupNodes=useMemo(()=>{ + const groupNodeNames=useMemo(()=>{ if (!selectedGroup) return []; - const allowed=new Set(selectedGroup.candidates || []); + const names=selectedGroup.proxies?.length ? selectedGroup.proxies : selectedGroup.candidates || []; + return names.filter((name,index)=>!!name && names.indexOf(name) === index); + },[selectedGroup]); + const groupNodes=useMemo(()=>{ + const allowed=new Set(groupNodeNames); return (state.nodes || []).filter(node=>allowed.has(node.name)); - },[state.nodes,selectedGroup]); + },[state.nodes,groupNodeNames]); useEffect(()=>{ if (!selectedGroup?.current) return; const current=(state.nodes || []).find(node=>node.name === selectedGroup.current); @@ -19,13 +23,11 @@ export function GroupsPage({state,selectedGroup,setSelectedGroup,onRefreshGroup, },[selectedGroup?.name,selectedGroup?.current,state.nodes]); const locateCurrent=()=>{ if (!selectedGroup?.current) return; - const current=(state.nodes || []).find(node=>node.name === selectedGroup.current); - if (current) setSelectedNode(current); setScrollSignal(value=>value + 1); }; const switchNode=(node:string)=>{ if (!selectedGroup) return; onSwitchGroupNode(selectedGroup.name,node); }; - return selectedGroup && onEditTableCell('proxy_groups',selectedGroup.name,'strategy',strategy)} onLocateCurrent={locateCurrent} onRefreshGroup={onRefreshGroup}/>{!groupListCollapsed ? : null}; + return selectedGroup && onEditTableCell('proxy_groups',selectedGroup.name,'strategy',strategy)} onLocateCurrent={locateCurrent} onRefreshGroup={onRefreshGroup}/>{!groupListCollapsed ? : null}; } diff --git a/src/node_service/web/src/pages/NodesPage.tsx b/src/node_service/web/src/pages/NodesPage.tsx index 3ec06c6..3227cc6 100644 --- a/src/node_service/web/src/pages/NodesPage.tsx +++ b/src/node_service/web/src/pages/NodesPage.tsx @@ -3,6 +3,6 @@ import {Button,Grid,Stack} from '@mui/material'; import type {NodeState,ServiceState} from '../types.tsx'; import {NodeTable} from '../components/NodeTable.tsx'; import {NodeDetailPanel} from '../components/NodeDetailPanel.tsx'; -export function NodesPage({state,selected,setSelected,onRefreshNode,refreshingNodes,onRefreshAll,fullRefreshRequesting,detailCollapsed,onEditTableCell,onEditTableOrder}:{state:ServiceState;selected:NodeState | null;setSelected:(node:NodeState)=>void;onRefreshNode:(name:string)=>void;refreshingNodes:Set;onRefreshAll:()=>void;fullRefreshRequesting:boolean;detailCollapsed:boolean;onEditTableCell:(table:string,row:string,column:string,value:unknown)=>void;onEditTableOrder:(table:string,columnOrder:string[])=>void}) { - return {!detailCollapsed ? : null}; +export function NodesPage({state,selected,setSelected,onRefreshNode,refreshingNodes,onRefreshAll,fullRefreshRequesting,detailCollapsed,onEditTableCell,onEditTableOrder,onEditTableSticky}:{state:ServiceState;selected:NodeState | null;setSelected:(node:NodeState)=>void;onRefreshNode:(name:string)=>void;refreshingNodes:Set;onRefreshAll:()=>void;fullRefreshRequesting:boolean;detailCollapsed:boolean;onEditTableCell:(table:string,row:string,column:string,value:unknown)=>void;onEditTableOrder:(table:string,columnOrder:string[])=>void;onEditTableSticky:(table:string,stickyColumns:string[])=>void}) { + return {!detailCollapsed ? : null}; } diff --git a/src/node_service/web/src/types.tsx b/src/node_service/web/src/types.tsx index e0ae3ca..d9f196f 100644 --- a/src/node_service/web/src/types.tsx +++ b/src/node_service/web/src/types.tsx @@ -4,8 +4,8 @@ export type StrategyParameterDef={key:string;label:string;type:'number'|'boolean export type StrategyDefinition={id:string;name:string;description?:string;formula_latex?:string;parameters:StrategyParameterDef[]}; export type ProbeResult={success?:boolean;delay_ms?:number;http_status?:number;error?:string}; export type Probe={target:string;url?:string;success?:boolean;delay_ms?:number;error?:string;result?:ProbeResult}; -export type NodeState={name:string;type?:string;provider?:string;exit_ipv4?:string;exit_ipv6?:string;ai_score?:number;risk?:number;risk_level?:string;multiplier?:number;multiplier_text?:string;shared_users?:string;ip_type?:string;native_type?:string;country?:string;alive?:boolean;alive_category?:string;alive_category_label?:string;stability_category?:string;stability_category_label?:string;total_rate_category?:string;total_rate_category_label?:string;recent_rate_category?:string;recent_rate_category_label?:string;ai_category?:string;ai_category_label?:string;ip_type_category?:string;ip_type_category_label?:string;risk_category?:string;risk_category_label?:string;shared_category?:string;shared_category_label?:string;native_category?:string;native_category_label?:string;ip_version_category?:string;ip_version_category_label?:string;srtt_ms?:number;rttvar_ms?:number;samples?:number;check_count?:number;success_count?:number;pass_count?:number;failure_count?:number;consecutive_failures?:number;recent_results?:number[];success_rate?:number;recent_check_count?:number;recent_pass_count?:number;recent_success_rate?:number;last_delay_ms?:number;last_success_delay_ms?:number;stability_score?:number;last_error?:string;last_check_time?:string;updated_time?:string;probes?:Probe[]}; -export type ProxyGroup={name:string;type?:string;candidates?:string[];current?:string;best_candidate?:string;better_rounds?:number;switch_count?:number;last_switch_unix?:number;last_switch_time?:string;last_decision?:string;last_error?:string;switchable?:boolean;managed?:boolean;strategy?:string}; +export type NodeState={name:string;special_proxy_kind?:'direct'|'group'|'unknown';type?:string;provider?:string;exit_ipv4?:string;exit_ipv6?:string;ai_score?:number;risk?:number;risk_level?:string;multiplier?:number;multiplier_text?:string;shared_users?:string;ip_type?:string;native_type?:string;country?:string;alive?:boolean;alive_category?:string;alive_category_label?:string;stability_category?:string;stability_category_label?:string;total_rate_category?:string;total_rate_category_label?:string;recent_rate_category?:string;recent_rate_category_label?:string;ai_category?:string;ai_category_label?:string;ip_type_category?:string;ip_type_category_label?:string;risk_category?:string;risk_category_label?:string;shared_category?:string;shared_category_label?:string;native_category?:string;native_category_label?:string;ip_version_category?:string;ip_version_category_label?:string;srtt_ms?:number;rttvar_ms?:number;samples?:number;check_count?:number;success_count?:number;pass_count?:number;failure_count?:number;consecutive_failures?:number;recent_results?:number[];success_rate?:number;recent_check_count?:number;recent_pass_count?:number;recent_success_rate?:number;last_delay_ms?:number;last_success_delay_ms?:number;stability_score?:number;last_error?:string;last_check_time?:string;updated_time?:string;probes?:Probe[]}; +export type ProxyGroup={name:string;type?:string;proxies?:string[];candidates?:string[];current?:string;best_candidate?:string;better_rounds?:number;switch_count?:number;last_switch_unix?:number;last_switch_time?:string;last_decision?:string;last_error?:string;switchable?:boolean;managed?:boolean;strategy?:string}; export type AutoSwitchConfig={enabled:boolean;skip_direct:boolean;switch_cooldown_seconds:number;fail_switch_count:number;better_confirm_rounds:number;keep_score_threshold:number;weak_score_threshold:number;weak_improve_margin:number;better_improve_margin:number;default_strategy:string;strategy_parameters:Record;managed_groups:string[];group_strategies:Record}; export type FormControlType='number'|'boolean'|'string'|'enum'; export type FormControlProtocol={key:string;label:string;type:FormControlType;value?:unknown;enum?:string;min?:number;max?:number;step?:number;unit?:string;help?:string}; @@ -13,12 +13,12 @@ export type FormProtocol={id:string;title?:string;layout?:'inline'|'grid';contro export type TableColumnType='action'|'text'|'number'|'integer'|'boolean'|'enum'|'multi_enum'|'score'|'ms'|'percent'|'datetime'|'rate_summary'|'probe_icons'|'form'; export type TableCellEditSchema={type:'number'|'boolean'|'string'|'enum';enum?:string;min?:number;max?:number;step?:number}; export type TableSortDirection='asc'|'desc'; -export type TableColumnProtocol={id:string;label:string;type:TableColumnType;enum?:string;label_field?:string;width?:number;visible?:boolean;sortable?:boolean;filterable?:boolean;sort_rule?:string;filter_type?:string;align?:'left'|'center'|'right';editable?:boolean;edit_type?:string}; -export type TableProtocol={id:string;name:string;row_id_field:string;default_sort?:{column:string;direction:TableSortDirection};keyword_fields?:string[];columns:TableColumnProtocol[];column_order_editable?:boolean}; +export type TableColumnProtocol={id:string;label:string;type:TableColumnType;enum?:string;label_field?:string;width?:number;visible?:boolean;sortable?:boolean;filterable?:boolean;sort_rule?:string;filter_type?:string;align?:'left'|'center'|'right';editable?:boolean;edit_type?:string;sticky?:'left'|'none';sticky_order?:number;resizable?:boolean}; +export type TableProtocol={id:string;name:string;row_id_field:string;default_sort?:{column:string;direction:TableSortDirection};keyword_fields?:string[];columns:TableColumnProtocol[];column_order_editable?:boolean;sticky_editable?:boolean;layout?:{sticky_header?:boolean;top_scrollbar?:boolean}}; export type TableProtocolBundle={version:number;enums:Record;tables:Record}; export type TableRowObject={id:string;values:Record & {_edit?:Record;_forms?:Record};raw:T}; export type TableRowsBundle={proxy_nodes?:TableRowObject[];proxy_groups?:TableRowObject[];[key:string]:TableRowObject[] | undefined}; export type ServiceState={started_time?:string;updated_time?:string;controller_origin?:string;config_path?:string;state_dir?:string;web_dir?:string;service_config_path?:string;http_request_log_enabled?:boolean;check_interval_seconds?:number;delay_refresh_interval_seconds?:number;status_text?:string;refresh_done_nodes?:number;refresh_total_nodes?:number;refresh_request_count?:number;last_refresh_request_time?:string;last_refresh_begin_time?:string;last_refresh_end_time?:string;checking?:boolean;force_refresh?:boolean;round?:number;enum_options?:Record;dynamic_filter_options?:Record;switch_strategy_definitions?:StrategyDefinition[];auto_switch?:AutoSwitchConfig;groups?:ProxyGroup[];nodes?:NodeState[];table_protocol?:TableProtocolBundle;table_rows?:TableRowsBundle}; export type ApiResult={success?:boolean;message?:string;error?:string;state?:ServiceState;config?:Partial;[key:string]:unknown}; export type PageName='nodes'|'groups'|'config'; -export type TableColumn={id:string;label:string;width?:number;align?:'left'|'center'|'right';render?:(row:T)=>React.ReactNode}; +export type TableColumn={id:string;label:string;width?:number;align?:'left'|'center'|'right';sticky?:'left'|'none';sticky_order?:number;resizable?:boolean;render?:(row:T)=>React.ReactNode};