Files
openwrt_packages/luci-app-bandix/htdocs/luci-static/resources/view/bandix/index.js
2025-11-05 00:13:10 +08:00

3260 lines
139 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
'require view';
'require ui';
'require uci';
'require rpc';
'require poll';
'use strict';
const translations = {
'zh-cn': {
'Bandix 流量监控': 'Bandix 流量监控',
'正在加载数据...': '正在加载数据...',
'无法获取数据': '无法获取数据',
'无法获取历史数据': '无法获取历史数据',
'主机名': '主机名',
'IP地址': 'IP地址',
'MAC地址': 'MAC地址',
'下载速度': '下载速度',
'上传速度': '上传速度',
'总下载量': '总下载量',
'总上传量': '总上传量',
'下载限速': '下载限速',
'上传限速': '上传限速',
'界面语言': '界面语言',
'选择 Bandix 流量监控的显示语言': '选择 Bandix 流量监控的显示语言',
'设备信息': '设备信息',
'设备列表': '设备列表',
'LAN 流量': 'LAN 流量',
'WAN 流量': 'WAN 流量',
'限速设置': '限速设置',
'操作': '操作',
'在线设备': '在线设备',
'仅限WAN 流量': '仅限WAN 流量',
'设置': '设置',
'设备设置': '设备设置',
'限速设置': '限速设置',
'取消限速': '取消限速',
'保存': '保存',
'取消': '取消',
'设置限速': '设置限速',
'设备': '设备',
'上传限速': '上传限速',
'下载限速': '下载限速',
'主机名': '主机名',
'设置主机名': '设置主机名',
'请输入主机名': '请输入主机名',
'主机名设置成功': '主机名设置成功',
'主机名设置失败': '主机名设置失败',
'无限制': '无限制',
'设置成功': '设置成功',
'设置失败': '设置失败',
'请输入有效的速度值': '请输入有效的速度值',
'速度值必须大于0': '速度值必须大于0',
'保存中...': '保存中...',
'限速功能仅对 WAN 流量生效。': '限速功能仅对 WAN 流量生效。',
'提示:输入 0 表示无限制': '提示:输入 0 表示无限制',
'历史流量趋势': '历史流量趋势',
'选择设备': '选择设备',
'所有设备': '所有设备',
'时间范围': '时间范围',
'最近5分钟': '最近5分钟',
'最近30分钟': '最近30分钟',
'最近2小时': '最近2小时',
'类型': '类型',
'总流量': '总流量',
'LAN 流量': 'LAN 流量',
'WAN 流量': 'WAN 流量',
'刷新': '刷新',
'上传速率': '上传速率',
'下载速率': '下载速率',
'最近': '最近',
'秒': '秒',
'分钟': '分钟',
'小时': '小时',
'天': '天',
'周': '周',
'其他速率': '其他速率',
'累计流量': '累计流量',
'总上传': '总上传',
'总下载': '总下载',
'LAN 已上传': 'LAN 已上传',
'LAN 已下载': 'LAN 已下载',
'WAN 已上传': 'WAN 已上传',
'WAN 已下载': 'WAN 已下载',
'总上传速率': '总上传速率',
'总下载速率': '总下载速率',
'LAN 上传速率': 'LAN 上传速率',
'LAN 下载速率': 'LAN 下载速率',
'WAN 上传速率': 'WAN 上传速率',
'WAN 下载速率': 'WAN 下载速率',
'从未上线': '从未上线',
'刚刚': '刚刚',
'分钟前': '分钟前',
'小时前': '小时前',
'天前': '天前',
'个月前': '个月前',
'年前': '年前',
'最后上线': '最后上线',
'缩放': '缩放',
'排序方式': '排序方式',
'在线状态': '在线状态',
'总流量': '总流量',
'升序': '升序',
'降序': '降序',
'按速度排序': '按速度排序',
'按用量排序': '按用量排序',
'简易模式': '简易模式',
'详细模式': '详细模式'
},
'zh-tw': {
'Bandix 流量监控': 'Bandix 流量監控',
'正在加载数据...': '正在載入資料...',
'无法获取数据': '無法獲取資料',
'无法获取历史数据': '無法獲取歷史資料',
'主机名': '主機名',
'IP地址': 'IP地址',
'MAC地址': 'MAC地址',
'下载速度': '下載速度',
'上传速度': '上傳速度',
'总下载量': '總下載量',
'总上传量': '總上傳量',
'下载限速': '下載限速',
'上传限速': '上傳限速',
'界面语言': '介面語言',
'选择 Bandix 流量监控的显示语言': '選擇 Bandix 流量監控的顯示語言',
'设备信息': '設備資訊',
'设备列表': '設備列表',
'LAN 流量': '局域網流量',
'WAN 流量': '跨網路流量',
'限速设置': '限速設定',
'操作': '操作',
'在线设备': '線上設備',
'仅限WAN 流量': '僅限跨網路',
'设置': '設定',
'设备设置': '設備設定',
'限速设置': '限速設定',
'取消限速': '取消限速',
'保存': '儲存',
'取消': '取消',
'设置限速': '設定限速',
'设备': '設備',
'上传限速': '上傳限速',
'下载限速': '下載限速',
'主机名': '主機名',
'设置主机名': '設定主機名',
'请输入主机名': '請輸入主機名',
'主机名设置成功': '主機名設定成功',
'主机名设置失败': '主機名設定失敗',
'无限制': '無限制',
'设置成功': '設定成功',
'设置失败': '設定失敗',
'请输入有效的速度值': '請輸入有效的速度值',
'速度值必须大于0': '速度值必須大於0',
'保存中...': '儲存中...',
'限速功能仅对 WAN 流量生效。': '限速功能僅對跨網路流量生效。',
'提示:输入 0 表示无限制': '提示:輸入 0 表示無限制',
'历史流量趋势': '歷史流量趨勢',
'选择设备': '選擇設備',
'所有设备': '所有設備',
'时间范围': '時間範圍',
'最近5分钟': '最近5分鐘',
'最近30分钟': '最近30分鐘',
'最近2小时': '最近2小時',
'类型': '類型',
'总流量': '總流量',
'LAN 流量': '局域網',
'WAN 流量': '跨網路',
'刷新': '重新整理',
'上传速率': '上傳速率',
'下载速率': '下載速率',
'最近': '最近',
'秒': '秒',
'分钟': '分鐘',
'小时': '小時',
'天': '天',
'周': '週',
'其他速率': '其他速率',
'累计流量': '累計流量',
'总上传': '總上傳',
'总下载': '總下載',
'LAN 已上传': 'LAN 已上傳',
'LAN 已下载': 'LAN 已下載',
'WAN 已上传': 'WAN 已上傳',
'WAN 已下载': 'WAN 已下載',
'总上传速率': '總上傳速率',
'总下载速率': '總下載速率',
'LAN 上传速率': '局域上傳速率',
'LAN 下载速率': '局域下載速率',
'WAN 上传速率': '跨網上傳速率',
'WAN 下载速率': '跨網下載速率',
'从未上线': '從未上線',
'刚刚': '剛剛',
'分钟前': '分鐘前',
'小时前': '小時前',
'天前': '天前',
'个月前': '個月前',
'年前': '年前',
'最后上线': '最後上線',
'缩放': '縮放',
'排序方式': '排序方式',
'在线状态': '線上狀態',
'总流量': '總流量',
'升序': '升序',
'降序': '降序',
'按速度排序': '按速度排序',
'按用量排序': '按用量排序',
'简易模式': '簡易模式',
'详细模式': '詳細模式'
},
'en': {
'Bandix 流量监控': 'Bandix Traffic Monitor',
'正在加载数据...': 'Loading data...',
'无法获取数据': 'Unable to fetch data',
'无法获取历史数据': 'Unable to fetch history data',
'主机名': 'Hostname',
'IP地址': 'IP Address',
'MAC地址': 'MAC Address',
'下载速度': 'Download Speed',
'上传速度': 'Upload Speed',
'总下载量': 'Total Download',
'总上传量': 'Total Upload',
'下载限速': 'Download Limit',
'上传限速': 'Upload Limit',
'界面语言': 'Interface Language',
'选择 Bandix 流量监控的显示语言': 'Select the display language for Bandix Traffic Monitor',
'设备信息': 'Device Info',
'设备列表': 'Device List',
'LAN 流量': 'LAN Traffic',
'WAN 流量': 'WAN Traffic',
'限速设置': 'Rate Limit',
'操作': 'Actions',
'在线设备': 'Online Devices',
'仅限WAN 流量': 'WAN Only',
'设置': 'Settings',
'设备设置': 'Device Settings',
'限速设置': 'Rate Limit Settings',
'取消限速': 'Remove Rate Limit',
'保存': 'Save',
'取消': 'Cancel',
'设置限速': 'Set Rate Limit',
'设备': 'Device',
'上传限速': 'Upload Limit',
'下载限速': 'Download Limit',
'主机名': 'Hostname',
'设置主机名': 'Set Hostname',
'请输入主机名': 'Please enter hostname',
'主机名设置成功': 'Hostname set successfully',
'主机名设置失败': 'Failed to set hostname',
'无限制': 'Unlimited',
'设置成功': 'Settings saved successfully',
'设置失败': 'Failed to save settings',
'请输入有效的速度值': 'Please enter a valid speed value',
'速度值必须大于0': 'Speed value must be greater than 0',
'保存中...': 'Saving...',
'限速功能仅对 WAN 流量生效。': 'Rate limiting only applies to WAN traffic.',
'提示:输入 0 表示无限制': 'Tip: Enter 0 for unlimited',
'历史流量趋势': 'Traffic History',
'选择设备': 'Select Device',
'所有设备': 'All Devices',
'时间范围': 'Time Range',
'最近5分钟': 'Last 5 minutes',
'最近30分钟': 'Last 30 minutes',
'最近2小时': 'Last 2 hours',
'类型': 'Type',
'总流量': 'Total',
'LAN 流量': 'LAN',
'WAN 流量': 'WAN',
'刷新': 'Refresh',
'上传速率': 'Upload Rate',
'下载速率': 'Download Rate',
'最近': 'Last',
'秒': 'second',
'分钟': 'minute',
'小时': 'hour',
'天': 'day',
'周': 'week',
'其他速率': 'Other Rates',
'累计流量': 'Cumulative',
'总上传': 'Total Uploaded',
'总下载': 'Total Downloaded',
'LAN 已上传': 'LAN Uploaded',
'LAN 已下载': 'LAN Downloaded',
'WAN 已上传': 'WAN Uploaded',
'WAN 已下载': 'WAN Downloaded',
'总上传速率': 'Total Upload',
'总下载速率': 'Total Download',
'LAN 上传速率': 'LAN Upload',
'LAN 下载速率': 'LAN Download',
'WAN 上传速率': 'WAN Upload',
'WAN 下载速率': 'WAN Download',
'从未上线': 'Never Online',
'刚刚': 'Just Now',
'分钟前': 'min ago',
'小时前': 'h ago',
'天前': 'days ago',
'个月前': 'months ago',
'年前': 'years ago',
'最后上线': 'Last Online',
'缩放': 'Zoom',
'排序方式': 'Sort By',
'在线状态': 'Online Status',
'总流量': 'Total Traffic',
'升序': 'Ascending',
'降序': 'Descending',
'按速度排序': 'Sort by Speed',
'按用量排序': 'Sort by Traffic',
'简易模式': 'Simple Mode',
'详细模式': 'Detailed Mode'
},
'fr': {
'Bandix 流量监控': 'Moniteur de Trafic Bandix',
'正在加载数据...': 'Chargement des données...',
'无法获取数据': 'Impossible de récupérer les données',
'无法获取历史数据': 'Impossible de récupérer les données historiques',
'主机名': 'Nom d\'hôte',
'IP地址': 'Adresse IP',
'MAC地址': 'Adresse MAC',
'下载速度': 'Vitesse de téléchargement',
'上传速度': 'Vitesse de téléversement',
'总下载量': 'Téléchargement total',
'总上传量': 'Téléversement total',
'下载限速': 'Limite de téléchargement',
'上传限速': 'Limite de téléversement',
'界面语言': 'Langue de l\'interface',
'选择 Bandix 流量监控的显示语言': 'Sélectionner la langue d\'affichage pour le Moniteur de Trafic Bandix',
'设备信息': 'Informations sur l\'appareil',
'设备列表': 'Liste des appareils',
'LAN 流量': 'Trafic LAN',
'WAN 流量': 'Trafic WAN',
'限速设置': 'Limitation de débit',
'操作': 'Actions',
'在线设备': 'Appareils en ligne',
'仅限WAN 流量': 'WAN uniquement',
'设置': 'Paramètres',
'设备设置': 'Paramètres de l\'appareil',
'限速设置': 'Paramètres de limitation',
'取消限速': 'Supprimer la limitation',
'保存': 'Enregistrer',
'取消': 'Annuler',
'设置限速': 'Définir la limitation',
'设备': 'Appareil',
'上传限速': 'Limite de téléversement',
'下载限速': 'Limite de téléchargement',
'无限制': 'Illimité',
'设置成功': 'Paramètres enregistrés avec succès',
'设置失败': 'Échec de l\'enregistrement des paramètres',
'请输入有效的速度值': 'Veuillez entrer une valeur de vitesse valide',
'速度值必须大于0': 'La valeur de vitesse doit être supérieure à 0',
'保存中...': 'Enregistrement...',
'限速功能仅对 WAN 流量生效。': 'La limitation de débit ne s\'applique qu\'au trafic WAN.',
'提示:输入 0 表示无限制': 'Conseil : Entrez 0 pour illimité',
'历史流量趋势': 'Historique du trafic',
'选择设备': 'Sélectionner l\'appareil',
'所有设备': 'Tous les appareils',
'时间范围': 'Plage de temps',
'最近5分钟': '5 dernières minutes',
'最近30分钟': '30 dernières minutes',
'最近2小时': '2 dernières heures',
'类型': 'Type',
'总流量': 'Total',
'LAN 流量': 'LAN',
'WAN 流量': 'WAN',
'刷新': 'Actualiser',
'上传速率': 'Débit montant',
'下载速率': 'Débit descendant',
'最近': 'Dernières',
'秒': 'seconde',
'分钟': 'minute',
'小时': 'heure',
'天': 'jour',
'周': 'semaine',
'其他速率': 'Autres débits',
'累计流量': 'Trafic cumulé',
'总上传': 'Total téléversé',
'总下载': 'Total téléchargé',
'LAN 已上传': 'LAN Téléversé',
'LAN 已下载': 'LAN Téléchargé',
'WAN 已上传': 'WAN Téléversé',
'WAN 已下载': 'WAN Téléchargé',
'总上传速率': 'Vitesse de téléversement totale',
'总下载速率': 'Vitesse de téléchargement totale',
'LAN 上传速率': 'Vitesse de téléversement LAN',
'LAN 下载速率': 'Vitesse de téléchargement LAN',
'WAN 上传速率': 'Vitesse de téléversement WAN',
'WAN 下载速率': 'Vitesse de téléchargement WAN',
'从未上线': 'Jamais en ligne',
'刚刚': 'À l\'instant',
'分钟前': 'min',
'小时前': 'h',
'天前': 'j',
'个月前': 'mois',
'年前': 'an',
'最后上线': 'Dernière connexion',
'缩放': 'Zoom',
'排序方式': 'Trier par',
'在线状态': 'Statut en ligne',
'总流量': 'Trafic total',
'升序': 'Croissant',
'降序': 'Décroissant',
'按速度排序': 'Trier par vitesse',
'按用量排序': 'Trier par volume',
'简易模式': 'Mode simple',
'详细模式': 'Mode détaillé'
},
'ja': {
'Bandix 流量监控': 'Bandix トラフィックモニター',
'正在加载数据...': 'データを読み込み中...',
'无法获取数据': 'データを取得できません',
'无法获取历史数据': '履歴データを取得できません',
'主机名': 'ホスト名',
'IP地址': 'IPアドレス',
'MAC地址': 'MACアドレス',
'下载速度': 'ダウンロード速度',
'上传速度': 'アップロード速度',
'总下载量': '総ダウンロード量',
'总上传量': '総アップロード量',
'下载限速': 'ダウンロード制限',
'上传限速': 'アップロード制限',
'界面语言': 'インターフェース言語',
'选择 Bandix 流量监控的显示语言': 'Bandix トラフィックモニターの表示言語を選択',
'设备信息': 'デバイス情報',
'设备列表': 'デバイスリスト',
'LAN 流量': 'LAN トラフィック',
'WAN 流量': 'WAN トラフィック',
'限速设置': '速度制限',
'操作': '操作',
'在线设备': 'オンラインデバイス',
'仅限WAN 流量': 'WAN のみ',
'设置': '設定',
'设备设置': 'デバイス設定',
'限速设置': '速度制限設定',
'取消限速': '速度制限を削除',
'保存': '保存',
'取消': 'キャンセル',
'设置限速': '速度制限を設定',
'设备': 'デバイス',
'上传限速': 'アップロード制限',
'下载限速': 'ダウンロード制限',
'无限制': '無制限',
'设置成功': '設定が正常に保存されました',
'设置失败': '設定の保存に失敗しました',
'请输入有效的速度值': '有効な速度値を入力してください',
'速度值必须大于0': '速度値は0より大きい必要があります',
'保存中...': '保存中...',
'限速功能仅对 WAN 流量生效。': '速度制限はWANトラフィックにのみ適用されます。',
'提示:输入 0 表示无限制': 'ヒント0を入力すると無制限になります',
'历史流量趋势': 'トラフィック履歴',
'选择设备': 'デバイスを選択',
'所有设备': 'すべてのデバイス',
'时间范围': '時間範囲',
'最近5分钟': '最近5分',
'最近30分钟': '最近30分',
'最近2小时': '最近2時間',
'类型': 'タイプ',
'总流量': '合計',
'LAN 流量': 'LAN',
'WAN 流量': 'WAN',
'刷新': '更新',
'上传速率': 'アップロードレート',
'下载速率': 'ダウンロードレート',
'最近': '直近',
'秒': '秒',
'分钟': '分',
'小时': '時間',
'天': '日',
'周': '週間',
'其他速率': 'その他の速度',
'累计流量': '累計トラフィック',
'总上传': '総アップロード',
'总下载': '総ダウンロード',
'LAN 已上传': 'LAN アップロード済み',
'LAN 已下载': 'LAN ダウンロード済み',
'WAN 已上传': 'WAN アップロード済み',
'WAN 已下载': 'WAN ダウンロード済み',
'总上传速率': '総アップロード速度',
'总下载速率': '総ダウンロード速度',
'LAN 上传速率': 'LAN アップロード速度',
'LAN 下载速率': 'LAN ダウンロード速度',
'WAN 上传速率': 'WAN アップロード速度',
'WAN 下载速率': 'WAN ダウンロード速度',
'从未上线': 'オンライン未経験',
'刚刚': '今',
'分钟前': '分前',
'小时前': '時間前',
'天前': '日前',
'个月前': 'ヶ月前',
'年前': '年前',
'最后上线': '最終オンライン',
'缩放': 'ズーム',
'排序方式': '並び順',
'在线状态': 'オンライン状態',
'总流量': '総トラフィック',
'升序': '昇順',
'降序': '降順',
'按速度排序': '速度順',
'按用量排序': '使用量順',
'简易模式': 'シンプルモード',
'详细模式': '詳細モード'
},
'ru': {
'Bandix 流量监控': 'Монитор Трафика Bandix',
'正在加载数据...': 'Загрузка данных...',
'无法获取数据': 'Не удалось получить данные',
'无法获取历史数据': 'Не удалось получить исторические данные',
'主机名': 'Имя хоста',
'IP地址': 'IP-адрес',
'MAC地址': 'MAC-адрес',
'下载速度': 'Скорость загрузки',
'上传速度': 'Скорость выгрузки',
'总下载量': 'Общая загрузка',
'总上传量': 'Общая выгрузка',
'下载限速': 'Ограничение загрузки',
'上传限速': 'Ограничение выгрузки',
'界面语言': 'Язык интерфейса',
'选择 Bandix 流量监控的显示语言': 'Выберите язык отображения для Монитора Трафика Bandix',
'设备信息': 'Информация об устройстве',
'设备列表': 'Список устройств',
'LAN 流量': 'Трафик LAN',
'WAN 流量': 'Трафик WAN',
'限速设置': 'Ограничение скорости',
'操作': 'Действия',
'在线设备': 'Онлайн устройства',
'仅限WAN 流量': 'Только WAN',
'设置': 'Настройки',
'设备设置': 'Настройки устройства',
'限速设置': 'Настройки ограничения',
'取消限速': 'Удалить ограничение',
'保存': 'Сохранить',
'取消': 'Отмена',
'设置限速': 'Установить ограничение',
'设备': 'Устройство',
'上传限速': 'Ограничение выгрузки',
'下载限速': 'Ограничение загрузки',
'无限制': 'Без ограничений',
'设置成功': 'Настройки успешно сохранены',
'设置失败': 'Не удалось сохранить настройки',
'请输入有效的速度值': 'Пожалуйста, введите допустимое значение скорости',
'速度值必须大于0': 'Значение скорости должно быть больше 0',
'保存中...': 'Сохранение...',
'限速功能仅对 WAN 流量生效。': 'Ограничение скорости применяется только к WAN-трафику.',
'提示:输入 0 表示无限制': 'Совет: Введите 0 для снятия ограничений',
'历史流量趋势': 'История трафика',
'选择设备': 'Выбрать устройство',
'所有设备': 'Все устройства',
'时间范围': 'Временной диапазон',
'最近5分钟': 'Последние 5 минут',
'最近30分钟': 'Последние 30 минут',
'最近2小时': 'Последние 2 часа',
'类型': 'Тип',
'总流量': 'Общий',
'LAN 流量': 'LAN',
'WAN 流量': 'WAN',
'刷新': 'Обновить',
'上传速率': 'Скорость отправки',
'下载速率': 'Скорость загрузки',
'最近': 'За последние',
'秒': 'сек.',
'分钟': 'мин.',
'小时': 'ч.',
'天': 'дн.',
'周': 'нед.',
'其他速率': 'Другие скорости',
'累计流量': 'Суммарный трафик',
'总上传': 'Всего отправлено',
'总下载': 'Всего получено',
'LAN 已上传': 'LAN Отправлено',
'LAN 已下载': 'LAN Получено',
'WAN 已上传': 'WAN Отправлено',
'WAN 已下载': 'WAN Получено',
'总上传速率': 'Общая скорость отправки',
'总下载速率': 'Общая скорость загрузки',
'LAN 上传速率': 'Скорость отправки LAN',
'LAN 下载速率': 'Скорость загрузки LAN',
'WAN 上传速率': 'Скорость отправки WAN',
'WAN 下载速率': 'Скорость загрузки WAN',
'从未上线': 'Никогда не был онлайн',
'刚刚': 'Только что',
'分钟前': 'мин назад',
'小时前': 'ч назад',
'天前': 'дн назад',
'个月前': 'мес назад',
'年前': 'лет назад',
'最后上线': 'Последний онлайн',
'缩放': 'Масштаб',
'排序方式': 'Сортировка',
'在线状态': 'Статус онлайн',
'总流量': 'Общий трафик',
'升序': 'По возрастанию',
'降序': 'По убыванию',
'按速度排序': 'По скорости',
'按用量排序': 'По объёму',
'简易模式': 'Простой режим',
'详细模式': 'Подробный режим'
}
};
function getTranslation(key, language) {
return translations[language]?.[key] || key;
}
function getSystemLanguage() {
// 尝试获取 LuCI 的语言设置
var luciLang = uci.get('luci', 'main', 'lang');
if (luciLang && translations[luciLang]) {
return luciLang;
}
// 如果没有 LuCI 语言设置,尝试获取浏览器语言作为回退
var systemLang = document.documentElement.lang || 'en';
if (translations[systemLang]) {
return systemLang;
}
// 最终回退到英语
return 'en';
}
function isDarkMode() {
// 首先检查用户设置的主题
var userTheme = uci.get('bandix', 'general', 'theme');
if (userTheme) {
if (userTheme === 'dark') {
return true;
} else if (userTheme === 'light') {
return false;
}
// 如果是 'auto',继续检查系统主题
}
// 获取 LuCI 主题设置
var mediaUrlBase = uci.get('luci', 'main', 'mediaurlbase');
if (mediaUrlBase && mediaUrlBase.toLowerCase().includes('dark')) {
return true;
}
// 如果是 argon 主题,检查 argon 配置
if (mediaUrlBase && mediaUrlBase.toLowerCase().includes('argon')) {
var argonMode = uci.get('argon', '@global[0]', 'mode');
if (argonMode) {
if (argonMode.toLowerCase() === 'dark') {
return true;
} else if (argonMode.toLowerCase() === 'light') {
return false;
}
// 如果是 'normal' 或 'auto',使用浏览器检测系统颜色偏好
if (argonMode.toLowerCase() === 'normal' || argonMode.toLowerCase() === 'auto') {
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
return true;
}
return false;
}
}
}
// 默认情况下也使用浏览器检测系统颜色偏好
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
return true;
}
return false;
}
function formatSize(bytes) {
if (bytes === 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return parseFloat((bytes / Math.pow(1024, i)).toFixed(2)) + ' ' + units[i];
}
function formatByterate(bytes_per_sec, unit) {
if (bytes_per_sec === 0) {
return unit === 'bits' ? '0 bps' : '0 B/s';
}
if (unit === 'bits') {
// 转换为比特单位
const bits_per_sec = bytes_per_sec * 8;
const units = ['bps', 'Kbps', 'Mbps', 'Gbps', 'Tbps'];
const i = Math.floor(Math.log(bits_per_sec) / Math.log(1000));
return parseFloat((bits_per_sec / Math.pow(1000, i)).toFixed(2)) + ' ' + units[i];
} else {
// 默认字节单位
const units = ['B/s', 'KB/s', 'MB/s', 'GB/s', 'TB/s'];
const i = Math.floor(Math.log(bytes_per_sec) / Math.log(1024));
return parseFloat((bytes_per_sec / Math.pow(1024, i)).toFixed(2)) + ' ' + units[i];
}
}
// 解析速度字符串为字节/秒
function parseSpeed(speedStr) {
if (!speedStr || speedStr === '0' || speedStr === '0 B/s' || speedStr === '0 bps') return 0;
// 匹配字节单位
const bytesMatch = speedStr.match(/^([\d.]+)\s*([KMGT]?B\/s)$/i);
if (bytesMatch) {
const value = parseFloat(bytesMatch[1]);
const unit = bytesMatch[2].toUpperCase();
const bytesMultipliers = {
'B/S': 1,
'KB/S': 1024,
'MB/S': 1024 * 1024,
'GB/S': 1024 * 1024 * 1024,
'TB/S': 1024 * 1024 * 1024 * 1024
};
return value * (bytesMultipliers[unit] || 1);
}
// 匹配比特单位
const bitsMatch = speedStr.match(/^([\d.]+)\s*([KMGT]?bps)$/i);
if (bitsMatch) {
const value = parseFloat(bitsMatch[1]);
const unit = bitsMatch[2].toLowerCase();
const bitsMultipliers = {
'bps': 1,
'kbps': 1000,
'mbps': 1000 * 1000,
'gbps': 1000 * 1000 * 1000,
'tbps': 1000 * 1000 * 1000 * 1000
};
// 转换为字节/秒
return (value * (bitsMultipliers[unit] || 1)) / 8;
}
return 0;
}
// 过滤 LAN IPv6 地址(排除本地链路地址)
function filterLanIPv6(ipv6Addresses) {
if (!ipv6Addresses || !Array.isArray(ipv6Addresses)) return [];
const lanPrefixes = [
'fd', // ULA
'fc' // ULA
];
const lanAddresses = ipv6Addresses.filter(addr => {
const lowerAddr = addr.toLowerCase();
return lanPrefixes.some(prefix => lowerAddr.startsWith(prefix));
});
// 最多返回 2 个 LAN IPv6 地址
return lanAddresses.slice(0, 2);
}
var callStatus = rpc.declare({
object: 'luci.bandix',
method: 'getStatus',
expect: {}
});
var callSetRateLimit = rpc.declare({
object: 'luci.bandix',
method: 'setRateLimit',
params: ['mac', 'wide_tx_rate_limit', 'wide_rx_rate_limit'],
expect: { success: true }
});
var callSetHostname = rpc.declare({
object: 'luci.bandix',
method: 'setHostname',
params: ['mac', 'hostname'],
expect: { success: true }
});
// 历史指标 RPC
var callGetMetrics = rpc.declare({
object: 'luci.bandix',
method: 'getMetrics',
params: ['mac'],
expect: {}
});
return view.extend({
load: function () {
return Promise.all([
uci.load('bandix'),
uci.load('luci'),
uci.load('argon').catch(function() {
// argon 配置可能不存在,忽略错误
return null;
})
]);
},
render: function (data) {
var language = uci.get('bandix', 'general', 'language');
if (!language || language === 'auto') {
language = getSystemLanguage();
}
var darkMode = isDarkMode();
// 添加现代化样式,支持暗黑模式
var style = E('style', {}, `
.bandix-container {
padding: 8px;
background-color: ${darkMode ? '#1E1E1E' : '#f8fafc'};
min-height: 100vh;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
color: ${darkMode ? '#e2e8f0' : '#1f2937'};
}
.bandix-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.bandix-title {
font-size: 1.5rem;
font-weight: 700;
color: ${darkMode ? '#f1f5f9' : '#1f2937'};
margin: 0;
}
.bandix-header-right {
display: flex;
align-items: center;
gap: 12px;
}
.device-mode-group {
display: inline-flex;
border-radius: 4px;
overflow: hidden;
border: 1px solid ${darkMode ? '#252526' : '#d1d5db'};
}
.device-mode-btn {
background-color: ${darkMode ? '#333333' : '#ffffff'};
border: none;
border-right: 1px solid ${darkMode ? '#252526' : '#d1d5db'};
padding: 0 8px;
font-size: 0.75rem;
line-height: 1.4;
color: ${darkMode ? '#94a3b8' : '#6b7280'};
cursor: pointer;
user-select: none;
transition: all 0.15s ease;
white-space: nowrap;
height: 20px;
}
.device-mode-btn:last-child {
border-right: none;
}
.device-mode-btn:hover:not(.active) {
background-color: ${darkMode ? '#3a3a3a' : '#f9fafb'};
color: ${darkMode ? '#e2e8f0' : '#374151'};
}
.device-mode-btn.active {
background-color: #3b82f6;
color: white;
}
.bandix-badge {
background-color: ${darkMode ? '#333333' : '#f3f4f6'};
border: 1px solid ${darkMode ? '#252526' : '#d1d5db'};
border-radius: 6px;
padding: 4px 12px;
font-size: 0.875rem;
color: ${darkMode ? '#e2e8f0' : '#374151'};
}
.bandix-alert {
background-color: ${darkMode ? '#451a03' : '#fef3c7'};
border: 1px solid ${darkMode ? '#92400e' : '#f59e0b'};
border-radius: 8px;
padding: 8px;
margin-bottom: 12px;
display: flex;
align-items: center;
gap: 8px;
color: ${darkMode ? '#fbbf24' : '#92400e'};
}
.bandix-alert-icon {
color: ${darkMode ? '#fbbf24' : '#f59e0b'};
font-size: 1rem;
}
.bandix-card {
background-color: ${darkMode ? '#252526' : 'white'};
border-radius: 12px;
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, ${darkMode ? '0.3' : '0.1'});
overflow: hidden;
margin-bottom: 8px;
border: 1px solid ${darkMode ? '#252526' : '#3333331c'};
}
.bandix-card-header {
padding: 20px 12px;
border-bottom: 1px solid ${darkMode ? '#252526' : '#e5e7eb'};
background-color: ${darkMode ? '#333333' : '#fafafa'};
}
.bandix-card-title {
font-size: 1.25rem;
font-weight: 600;
color: ${darkMode ? '#f1f5f9' : '#1f2937'};
margin: 0;
display: flex;
align-items: center;
gap: 8px;
}
.bandix-table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
.bandix-table th {
background-color: ${darkMode ? '#333333' : '#f9fafb'};
padding: 6px 12px;
text-align: left;
font-weight: 600;
color: ${darkMode ? '#e2e8f0' : '#374151'};
border: none;
font-size: 0.875rem;
cursor: pointer;
user-select: none;
position: relative;
transition: background-color 0.2s ease;
}
.bandix-table th:hover {
background-color: ${darkMode ? '#3a3a3a' : '#f3f4f6'};
}
.bandix-table th.sortable::after {
content: '⇅';
margin-left: 6px;
opacity: 0.3;
font-size: 0.75rem;
}
.bandix-table th.sortable.active::after {
opacity: 1;
color: #3b82f6;
}
.bandix-table th.sortable.asc::after {
content: '↑';
}
.bandix-table th.sortable.desc::after {
content: '↓';
}
.th-split-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.th-split-section {
display: flex;
align-items: center;
gap: 4px;
cursor: pointer;
padding: 2px 6px;
border-radius: 4px;
transition: background-color 0.2s ease;
}
.th-split-section:hover {
background-color: ${darkMode ? '#3a3a3a' : '#e5e7eb'};
}
.th-split-section.active {
background-color: ${darkMode ? '#3a3a3a' : '#e5e7eb'};
}
.th-split-icon {
font-size: 0.7rem;
opacity: 0.5;
}
.th-split-section.active .th-split-icon {
opacity: 1;
color: #3b82f6;
}
.th-split-divider {
width: 1px;
height: 16px;
background-color: ${darkMode ? '#3a3a3a' : '#d1d5db'};
opacity: 0.5;
}
.bandix-table td {
padding: 6px 12px;
border: none;
vertical-align: middle;
word-wrap: break-word;
overflow-wrap: break-word;
color: ${darkMode ? '#cbd5e1' : 'inherit'};
}
.bandix-table th:nth-child(1),
.bandix-table td:nth-child(1) {
width: 25%;
}
.bandix-table th:nth-child(2),
.bandix-table td:nth-child(2) {
width: 22%;
}
.bandix-table th:nth-child(3),
.bandix-table td:nth-child(3) {
width: 22%;
}
.bandix-table th:nth-child(4),
.bandix-table td:nth-child(4) {
width: 22%;
}
.bandix-table th:nth-child(5),
.bandix-table td:nth-child(5) {
width: 9%;
}
/* 类型联动的高亮与弱化 */
.bandix-table .hi { font-weight: 700; }
.bandix-table .dim { opacity: 0.6; }
.device-info {
display: flex;
flex-direction: column;
gap: 2px;
}
.device-name {
font-weight: 600;
color: ${darkMode ? '#f1f5f9' : '#1f2937'};
display: flex;
align-items: center;
gap: 8px;
}
.device-status {
width: 8px;
height: 8px;
border-radius: 50%;
display: inline-block;
}
.device-status.online {
background-color: #10b981;
}
.device-status.offline {
background-color: #9ca3af;
}
.device-ip {
color: ${darkMode ? '#94a3b8' : '#6b7280'};
font-size: 0.875rem;
}
.device-ipv6 {
color: ${darkMode ? '#94a3b8' : '#6b7280'};
font-size: 0.75rem;
font-family: monospace;
}
.device-mac {
color: ${darkMode ? '#64748b' : '#9ca3af'};
font-size: 0.75rem;
}
.traffic-info {
display: flex;
flex-direction: column;
gap: 4px;
}
.traffic-row {
display: flex;
align-items: center;
gap: 4px;
}
.traffic-icon {
font-size: 0.75rem;
font-weight: bold;
}
.traffic-icon.upload {
color: #f97316;
}
.traffic-icon.download {
color: #06b6d4;
}
.traffic-speed {
font-weight: 600;
font-size: 0.875rem;
}
.traffic-speed {
color: ${darkMode ? '#e2e8f0' : '#374151'};
}
.traffic-total {
font-size: 0.75rem;
color: #64748b;
margin-left: 4px;
}
.limit-info {
display: flex;
flex-direction: column;
gap: 4px;
}
.limit-badge {
background-color: ${darkMode ? '#333333' : '#f3f4f6'};
color: ${darkMode ? '#94a3b8' : '#6b7280'};
padding: 2px 8px;
border-radius: 4px;
font-size: 0.75rem;
text-align: center;
margin-top: 4px;
}
.action-button {
background-color: ${darkMode ? '#333333' : '#f3f4f6'};
border: 1px solid ${darkMode ? '#252526' : '#d1d5db'};
border-radius: 6px;
padding: 8px 12px;
cursor: pointer;
font-size: 0.875rem;
color: ${darkMode ? '#e2e8f0' : 'inherit'};
}
.loading {
text-align: center;
padding: 40px;
color: ${darkMode ? '#94a3b8' : '#6b7280'};
font-style: italic;
}
.error {
text-align: center;
padding: 40px;
color: ${darkMode ? '#f87171' : '#ef4444'};
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 20px;
margin-bottom: 8px;
}
.stats-card {
background-color: ${darkMode ? '#252526' : 'white'};
border-radius: 12px;
padding: 24px;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, ${darkMode ? '0.3' : '0.1'}), 0 2px 4px -1px rgba(0, 0, 0, ${darkMode ? '0.2' : '0.06'});
border: 1px solid ${darkMode ? '#252526' : 'transparent'};
position: relative;
overflow: hidden;
}
.stats-card-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 20px;
}
.stats-card-title {
font-size: 0.875rem;
font-weight: 500;
color: ${darkMode ? '#9ca3af' : '#6b7280'};
margin: 0;
}
.stats-card-icon {
font-size: 1.5rem;
opacity: 0.8;
}
.stats-card-main-value {
font-size: 2.25rem;
font-weight: 700;
color: ${darkMode ? '#f1f5f9' : '#1f2937'};
margin: 0 0 8px 0;
line-height: 1;
}
.stats-card-sub-value {
font-size: 0.875rem;
color: ${darkMode ? '#9ca3af' : '#6b7280'};
margin: 0;
}
.stats-card-details {
margin-top: 16px;
display: flex;
flex-direction: column;
gap: 8px;
}
.stats-detail-row {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.875rem;
}
.stats-detail-label {
color: ${darkMode ? '#9ca3af' : '#6b7280'};
font-weight: 500;
}
.stats-detail-value {
font-weight: 600;
}
.stats-title {
font-size: 0.875rem;
font-weight: 600;
color: ${darkMode ? '#e2e8f0' : '#374151'};
margin-bottom: 8px;
display: flex;
align-items: center;
gap: 6px;
}
.stats-value {
font-size: 1.25rem;
font-weight: 700;
color: ${darkMode ? '#f1f5f9' : '#1f2937'};
}
/* 模态框样式 */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
opacity: 0;
visibility: hidden;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.modal-overlay.show {
background-color: rgba(0, 0, 0, 0.5);
opacity: 1;
visibility: visible;
}
.modal {
background-color: ${darkMode ? '#252526' : 'white'};
border-radius: 12px;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, ${darkMode ? '0.4' : '0.1'});
max-width: 500px;
width: 90%;
max-height: 90vh;
overflow-y: auto;
transform: scale(0.9) translateY(20px);
opacity: 0;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
border: 1px solid ${darkMode ? '#252526' : 'transparent'};
}
.modal-overlay.show .modal {
transform: scale(1) translateY(0);
opacity: 1;
}
.modal-header {
padding: 24px 24px 0 24px;
border-bottom: 1px solid ${darkMode ? '#252526' : '#e5e7eb'};
padding-bottom: 16px;
}
.modal-title {
font-size: 1.25rem;
font-weight: 600;
color: ${darkMode ? '#f1f5f9' : '#1f2937'};
margin: 0;
}
.modal-body {
padding: 10px;
}
.modal-footer {
padding: 16px 24px 24px 24px;
display: flex;
gap: 12px;
justify-content: flex-end;
}
.form-group {
margin-bottom: 20px;
}
.form-label {
display: block;
font-weight: 600;
color: ${darkMode ? '#e2e8f0' : '#374151'};
margin-bottom: 8px;
font-size: 0.875rem;
}
.form-input {
width: 100%;
border: 1px solid ${darkMode ? '#252526' : '#d1d5db'};
border-radius: 6px;
font-size: 0.875rem;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
box-sizing: border-box;
transform: translateY(0);
background-color: ${darkMode ? '#333333' : 'white'};
color: ${darkMode ? '#e2e8f0' : 'inherit'};
}
.form-input:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, ${darkMode ? '0.2' : '0.1'});
transform: translateY(-1px);
}
.form-select {
width: 100%;
border: 1px solid ${darkMode ? '#252526' : '#d1d5db'};
border-radius: 6px;
font-size: 0.875rem;
background-color: ${darkMode ? '#333333' : 'white'};
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
box-sizing: border-box;
transform: translateY(0);
color: ${darkMode ? '#e2e8f0' : 'inherit'};
}
.form-select:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, ${darkMode ? '0.2' : '0.1'});
transform: translateY(-1px);
}
.btn {
padding: 10px 20px;
border-radius: 6px;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
border: none;
transform: translateY(0);
}
.btn-primary {
background-color: #3b82f6;
color: white;
}
.btn-primary:hover {
background-color: #2563eb;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3);
}
.btn-secondary {
background-color: ${darkMode ? '#374151' : '#f3f4f6'};
color: ${darkMode ? '#e2e8f0' : '#374151'};
border: 1px solid ${darkMode ? '#252526' : '#d1d5db'};
}
.btn-secondary:hover {
background-color: ${darkMode ? '#252526' : '#e5e7eb'};
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 0, 0, ${darkMode ? '0.3' : '0.1'});
}
.device-summary {
background-color: ${darkMode ? '#333333' : '#f9fafb'};
border: 1px solid ${darkMode ? '#252526' : '#e5e7eb'};
border-radius: 6px;
padding: 12px;
margin-bottom: 16px;
}
.device-summary-name {
font-weight: 600;
color: ${darkMode ? '#f1f5f9' : '#1f2937'};
margin-bottom: 4px;
}
.device-summary-details {
color: ${darkMode ? '#94a3b8' : '#6b7280'};
font-size: 0.875rem;
}
/* 加载动画 */
.loading-spinner {
display: inline-block;
width: 16px;
height: 16px;
border: 2px solid #f3f4f6;
border-radius: 50%;
border-top-color: #3b82f6;
animation: spin 1s ease-in-out infinite;
margin-right: 8px;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.btn-loading {
opacity: 0.7;
pointer-events: none;
}
/* 历史趋势 */
.history-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.history-controls {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
padding: 8px 12px; /* 更窄的内边距 */
border-bottom: 1px solid ${darkMode ? '#252526' : '#f1f5f9'}; /* 更轻的分割线 */
background-color: ${darkMode ? '#333333' : '#fafafa'};
}
.history-controls .form-select,
.history-controls .form-input {
width: auto;
min-width: 160px;
}
.history-card-body {
padding: 8px 12px 12px 12px; /* 更紧凑 */
position: relative;
}
.history-legend {
margin-left: auto;
display: flex;
align-items: center;
gap: 12px;
}
.legend-item { display: flex; align-items: center; gap: 6px; font-size: 0.875rem; color: ${darkMode ? '#e2e8f0' : '#374151'}; }
.legend-dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; }
.legend-up { background-color: #f97316; }
.legend-down { background-color: #06b6d4; }
#history-canvas { width: 100%; height: 200px; display: block; } /* 变窄的高度 */
.history-tooltip {
position: fixed;
display: none;
width: 320px;
box-sizing: border-box;
background-color: ${darkMode ? 'rgba(37, 37, 38, 0.95)' : 'rgba(255, 255, 255, 0.98)'};
color: ${darkMode ? '#e2e8f0' : '#1f2937'};
border: 1px solid ${darkMode ? '#3a3a3a' : '#e5e7eb'};
border-radius: 8px;
box-shadow: 0 10px 15px -3px rgba(0,0,0,0.2), 0 4px 6px -4px rgba(0,0,0,0.2);
padding: 10px 12px;
z-index: 10;
pointer-events: none;
font-size: 12px;
line-height: 1.4;
white-space: nowrap;
}
.history-tooltip .ht-title { font-weight: 700; margin-bottom: 6px; }
.history-tooltip .ht-row { display: flex; justify-content: space-between; gap: 12px; }
.history-tooltip .ht-key { color: ${darkMode ? '#94a3b8' : '#6b7280'}; }
.history-tooltip .ht-val { color: ${darkMode ? '#e2e8f0' : '#111827'}; }
.history-tooltip .ht-device { margin-top: 4px; margin-bottom: 6px; color: ${darkMode ? '#94a3b8' : '#6b7280'}; font-size: 0.75rem; }
/* 强调关键信息的排版 */
.history-tooltip .ht-kpis { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-top: 2px; margin-bottom: 6px; }
.history-tooltip .ht-kpi .ht-k-label { color: ${darkMode ? '#94a3b8' : '#6b7280'}; font-size: 0.75rem; }
.history-tooltip .ht-kpi .ht-k-value { font-size: 1rem; font-weight: 700; }
.history-tooltip .ht-kpi.down .ht-k-value { color: #06b6d4; }
.history-tooltip .ht-kpi.up .ht-k-value { color: #f97316; }
.history-tooltip .ht-divider { height: 1px; background-color: ${darkMode ? '#3a3a3a' : '#e5e7eb'}; margin: 8px 0; }
.history-tooltip .ht-section-title { font-weight: 600; font-size: 0.75rem; color: ${darkMode ? '#94a3b8' : '#6b7280'}; margin: 4px 0 6px 0; }
`);
document.head.appendChild(style);
var view = E('div', { 'class': 'bandix-container' }, [
// 头部
E('div', { 'class': 'bandix-header' }, [
E('h1', { 'class': 'bandix-title' }, getTranslation('Bandix 流量监控', language)),
E('div', { 'class': 'bandix-badge', 'id': 'device-count' }, getTranslation('在线设备', language) + ': 0 / 0')
]),
// 警告提示
E('div', { 'class': 'bandix-alert' }, [
E('span', { 'class': 'bandix-alert-icon' }, '⚠️'),
E('span', {}, getTranslation('限速功能仅对 WAN 流量生效。', language))
]),
// 统计卡片
E('div', { 'class': 'stats-grid', 'id': 'stats-grid' }),
// 历史趋势卡片(无时间范围筛选)
E('div', { 'class': 'bandix-card', 'id': 'history-card' }, [
E('div', { 'class': 'bandix-card-header history-header' }, [
E('div', { 'class': 'bandix-card-title' }, [
getTranslation('历史流量趋势', language)
]),
E('div', { 'class': 'history-legend' }, [
E('div', { 'class': 'legend-item' }, [
E('span', { 'class': 'legend-dot legend-up' }),
getTranslation('上传速率', language)
]),
E('div', { 'class': 'legend-item' }, [
E('span', { 'class': 'legend-dot legend-down' }),
getTranslation('下载速率', language)
])
])
]),
E('div', { 'class': 'history-controls' }, [
E('label', { 'class': 'form-label', 'style': 'margin: 0;' }, getTranslation('选择设备', language)),
E('select', { 'class': 'form-select', 'id': 'history-device-select' }, [
E('option', { 'value': '' }, getTranslation('所有设备', language))
]),
E('label', { 'class': 'form-label', 'style': 'margin: 0;' }, getTranslation('类型', language)),
E('select', { 'class': 'form-select', 'id': 'history-type-select' }, [
E('option', { 'value': 'total' }, getTranslation('总流量', language)),
E('option', { 'value': 'lan' }, getTranslation('LAN 流量', language)),
E('option', { 'value': 'wan' }, getTranslation('WAN 流量', language))
]),
E('span', { 'class': 'bandix-badge', 'id': 'history-zoom-level', 'style': 'margin-left: 16px; display: none;' }, ''),
E('span', { 'class': 'bandix-badge', 'id': 'history-retention', 'style': 'margin-left: auto;' }, '')
]),
E('div', { 'class': 'history-card-body' }, [
E('canvas', { 'id': 'history-canvas', 'height': '240' }),
E('div', { 'class': 'history-tooltip', 'id': 'history-tooltip' })
])
]),
// 主要内容卡片
E('div', { 'class': 'bandix-card' }, [
E('div', { 'class': 'bandix-card-header history-header' }, [
E('div', { 'class': 'bandix-card-title' }, [
getTranslation('设备列表', language)
]),
E('div', { 'class': 'device-mode-group' }, [
E('button', {
'class': 'device-mode-btn' + (localStorage.getItem('bandix_device_mode') !== 'detailed' ? ' active' : ''),
'data-mode': 'simple'
}, getTranslation('简易模式', language)),
E('button', {
'class': 'device-mode-btn' + (localStorage.getItem('bandix_device_mode') === 'detailed' ? ' active' : ''),
'data-mode': 'detailed'
}, getTranslation('详细模式', language))
])
]),
E('div', { 'id': 'traffic-status' }, [
E('table', { 'class': 'bandix-table' }, [
E('thead', {}, [
E('tr', {}, [
E('th', {}, getTranslation('设备信息', language)),
E('th', {}, getTranslation('LAN 流量', language)),
E('th', {}, getTranslation('WAN 流量', language)),
E('th', {}, getTranslation('限速设置', language)),
E('th', {}, getTranslation('操作', language))
])
]),
E('tbody', {})
])
])
])
]);
// 设备信息模式切换
var deviceModeButtons = view.querySelectorAll('.device-mode-btn');
deviceModeButtons.forEach(function(btn) {
btn.addEventListener('click', function() {
var newMode = this.getAttribute('data-mode');
// 如果已经是当前模式,不做任何操作
if (this.classList.contains('active')) {
return;
}
// 保存到 localStorage
localStorage.setItem('bandix_device_mode', newMode);
// 更新按钮状态
deviceModeButtons.forEach(function(b) {
b.classList.remove('active');
});
this.classList.add('active');
// 刷新设备列表以应用新的显示模式
updateDeviceData();
});
});
// 创建限速设置模态框
var modal = E('div', { 'class': 'modal-overlay', 'id': 'rate-limit-modal' }, [
E('div', { 'class': 'modal' }, [
E('div', { 'class': 'modal-header' }, [
E('h3', { 'class': 'modal-title' }, getTranslation('设备设置', language))
]),
E('div', { 'class': 'modal-body' }, [
E('div', { 'class': 'device-summary', 'id': 'modal-device-summary' }),
E('div', { 'class': 'form-group' }, [
E('label', { 'class': 'form-label' }, getTranslation('主机名', language)),
E('input', { 'type': 'text', 'class': 'form-input', 'id': 'device-hostname-input', 'placeholder': getTranslation('请输入主机名', language) }),
E('div', { 'style': 'font-size: 0.75rem; color: #6b7280; margin-top: 4px;' }, getTranslation('设置主机名', language))
]),
E('div', { 'class': 'form-group' }, [
E('label', { 'class': 'form-label' }, getTranslation('上传限速', language)),
E('div', { 'style': 'display: flex; gap: 8px;' }, [
E('input', { 'type': 'number', 'class': 'form-input', 'id': 'upload-limit-value', 'min': '0', 'step': '1', 'placeholder': '0' }),
E('select', { 'class': 'form-select', 'id': 'upload-limit-unit', 'style': 'width: 100px;' })
]),
E('div', { 'style': 'font-size: 0.75rem; color: #6b7280; margin-top: 4px;' }, getTranslation('提示:输入 0 表示无限制', language))
]),
E('div', { 'class': 'form-group' }, [
E('label', { 'class': 'form-label' }, getTranslation('下载限速', language)),
E('div', { 'style': 'display: flex; gap: 8px;' }, [
E('input', { 'type': 'number', 'class': 'form-input', 'id': 'download-limit-value', 'min': '0', 'step': '1', 'placeholder': '0' }),
E('select', { 'class': 'form-select', 'id': 'download-limit-unit', 'style': 'width: 100px;' })
]),
E('div', { 'style': 'font-size: 0.75rem; color: #6b7280; margin-top: 4px;' }, getTranslation('提示:输入 0 表示无限制', language))
])
]),
E('div', { 'class': 'modal-footer' }, [
E('button', { 'class': 'btn btn-secondary', 'id': 'modal-cancel' }, getTranslation('取消', language)),
E('button', { 'class': 'btn btn-primary', 'id': 'modal-save' }, getTranslation('保存', language))
])
])
]);
document.body.appendChild(modal);
// 模态框事件处理
var currentDevice = null;
var showRateLimitModal;
// 显示模态框
showRateLimitModal = function (device) {
currentDevice = device;
var modal = document.getElementById('rate-limit-modal');
var deviceSummary = document.getElementById('modal-device-summary');
var speedUnit = uci.get('bandix', 'traffic', 'speed_unit') || 'bytes';
// 动态填充单位选择器
var uploadUnitSelect = document.getElementById('upload-limit-unit');
var downloadUnitSelect = document.getElementById('download-limit-unit');
// 清空现有选项
uploadUnitSelect.innerHTML = '';
downloadUnitSelect.innerHTML = '';
if (speedUnit === 'bits') {
// 比特单位选项 - 直接设置为对应的字节数
uploadUnitSelect.appendChild(E('option', { 'value': '125' }, 'Kbps')); // 1000 bits/s / 8 = 125 bytes/s
uploadUnitSelect.appendChild(E('option', { 'value': '125000' }, 'Mbps')); // 1000000 bits/s / 8 = 125000 bytes/s
uploadUnitSelect.appendChild(E('option', { 'value': '125000000' }, 'Gbps')); // 1000000000 bits/s / 8 = 125000000 bytes/s
downloadUnitSelect.appendChild(E('option', { 'value': '125' }, 'Kbps'));
downloadUnitSelect.appendChild(E('option', { 'value': '125000' }, 'Mbps'));
downloadUnitSelect.appendChild(E('option', { 'value': '125000000' }, 'Gbps'));
} else {
// 字节单位选项
uploadUnitSelect.appendChild(E('option', { 'value': '1024' }, 'KB/s'));
uploadUnitSelect.appendChild(E('option', { 'value': '1048576' }, 'MB/s'));
uploadUnitSelect.appendChild(E('option', { 'value': '1073741824' }, 'GB/s'));
downloadUnitSelect.appendChild(E('option', { 'value': '1024' }, 'KB/s'));
downloadUnitSelect.appendChild(E('option', { 'value': '1048576' }, 'MB/s'));
downloadUnitSelect.appendChild(E('option', { 'value': '1073741824' }, 'GB/s'));
}
// 更新设备信息
deviceSummary.innerHTML = E('div', {}, [
E('div', { 'class': 'device-summary-name' }, device.hostname || device.ip),
E('div', { 'class': 'device-summary-details' }, device.ip + ' (' + device.mac + ')')
]).innerHTML;
// 设置当前hostname值
document.getElementById('device-hostname-input').value = device.hostname || '';
// 设置当前限速值
var uploadLimit = device.wide_tx_rate_limit || 0;
var downloadLimit = device.wide_rx_rate_limit || 0;
// 设置上传限速值
var uploadValue = uploadLimit;
var uploadUnit;
if (uploadValue === 0) {
document.getElementById('upload-limit-value').value = 0;
uploadUnit = speedUnit === 'bits' ? '125' : '1024';
} else {
if (speedUnit === 'bits') {
// 转换为比特单位显示
var uploadBits = uploadValue * 8;
if (uploadBits >= 1000000000) {
uploadValue = uploadBits / 1000000000;
uploadUnit = '125000000'; // Gbps对应的字节倍数
} else if (uploadBits >= 1000000) {
uploadValue = uploadBits / 1000000;
uploadUnit = '125000'; // Mbps对应的字节倍数
} else {
uploadValue = uploadBits / 1000;
uploadUnit = '125'; // Kbps对应的字节倍数
}
} else {
// 字节单位显示
if (uploadValue >= 1073741824) {
uploadValue = uploadValue / 1073741824;
uploadUnit = '1073741824';
} else if (uploadValue >= 1048576) {
uploadValue = uploadValue / 1048576;
uploadUnit = '1048576';
} else {
uploadValue = uploadValue / 1024;
uploadUnit = '1024';
}
}
document.getElementById('upload-limit-value').value = Math.round(uploadValue);
}
document.getElementById('upload-limit-unit').value = uploadUnit;
// 设置下载限速值
var downloadValue = downloadLimit;
var downloadUnit;
if (downloadValue === 0) {
document.getElementById('download-limit-value').value = 0;
downloadUnit = speedUnit === 'bits' ? '125' : '1024';
} else {
if (speedUnit === 'bits') {
// 转换为比特单位显示
var downloadBits = downloadValue * 8;
if (downloadBits >= 1000000000) {
downloadValue = downloadBits / 1000000000;
downloadUnit = '125000000'; // Gbps对应的字节倍数
} else if (downloadBits >= 1000000) {
downloadValue = downloadBits / 1000000;
downloadUnit = '125000'; // Mbps对应的字节倍数
} else {
downloadValue = downloadBits / 1000;
downloadUnit = '125'; // Kbps对应的字节倍数
}
} else {
// 字节单位显示
if (downloadValue >= 1073741824) {
downloadValue = downloadValue / 1073741824;
downloadUnit = '1073741824';
} else if (downloadValue >= 1048576) {
downloadValue = downloadValue / 1048576;
downloadUnit = '1048576';
} else {
downloadValue = downloadValue / 1024;
downloadUnit = '1024';
}
}
document.getElementById('download-limit-value').value = Math.round(downloadValue);
}
document.getElementById('download-limit-unit').value = downloadUnit;
// 显示模态框并添加动画
modal.classList.add('show');
}
// 隐藏模态框
function hideRateLimitModal() {
var modal = document.getElementById('rate-limit-modal');
modal.classList.remove('show');
// 等待动画完成后清理
setTimeout(function () {
currentDevice = null;
}, 300);
}
// 保存限速设置
function saveRateLimit() {
if (!currentDevice) return;
var saveButton = document.getElementById('modal-save');
var originalText = saveButton.textContent;
// 显示加载状态
saveButton.innerHTML = '<span class="loading-spinner"></span>' + getTranslation('保存中...', language);
saveButton.classList.add('btn-loading');
var uploadLimit = 0;
var downloadLimit = 0;
var speedUnit = uci.get('bandix', 'traffic', 'speed_unit') || 'bytes';
// 获取hostname值
var newHostname = document.getElementById('device-hostname-input').value.trim();
// 获取上传限速值
var uploadValue = parseInt(document.getElementById('upload-limit-value').value) || 0;
var uploadUnit = parseInt(document.getElementById('upload-limit-unit').value);
if (uploadValue > 0) {
// 选择器的值已经是正确的字节倍数,直接计算即可
uploadLimit = uploadValue * uploadUnit;
}
// 获取下载限速值
var downloadValue = parseInt(document.getElementById('download-limit-value').value) || 0;
var downloadUnit = parseInt(document.getElementById('download-limit-unit').value);
if (downloadValue > 0) {
// 选择器的值已经是正确的字节倍数,直接计算即可
downloadLimit = downloadValue * downloadUnit;
}
// console.log("mac", currentDevice.mac)
// console.log("uploadLimit", uploadLimit)
// console.log("downloadLimit", downloadLimit)
// console.log("newHostname", newHostname)
// 创建Promise数组来并行处理hostname和限速设置
var promises = [];
// 如果hostname有变化添加hostname设置Promise
if (newHostname !== (currentDevice.hostname || '')) {
promises.push(
callSetHostname(currentDevice.mac, newHostname).catch(function(error) {
return { hostnameError: error };
})
);
}
// 添加限速设置Promise
promises.push(
callSetRateLimit(currentDevice.mac, uploadLimit, downloadLimit).catch(function(error) {
return { rateLimitError: error };
})
);
// 并行执行所有设置
Promise.all(promises).then(function (results) {
// 恢复按钮状态
saveButton.innerHTML = originalText;
saveButton.classList.remove('btn-loading');
var hasError = false;
var errorMessages = [];
// 检查结果
results.forEach(function(result, index) {
if (result && result.hostnameError) {
hasError = true;
errorMessages.push(getTranslation('主机名设置失败', language));
} else if (result && result.rateLimitError) {
hasError = true;
errorMessages.push(getTranslation('设置失败', language));
} else if (result !== true && result !== undefined) {
// 检查是否有其他错误
if (result && result.error) {
hasError = true;
errorMessages.push(result.error);
}
}
});
if (hasError) {
ui.addNotification(null, E('p', {}, errorMessages.join(', ')), 'error');
} else {
// 所有设置都成功
hideRateLimitModal();
}
}).catch(function (error) {
// 恢复按钮状态
saveButton.innerHTML = originalText;
saveButton.classList.remove('btn-loading');
ui.addNotification(null, E('p', {}, getTranslation('设置失败', language)), 'error');
});
}
// 绑定模态框事件
document.getElementById('modal-cancel').addEventListener('click', hideRateLimitModal);
document.getElementById('modal-save').addEventListener('click', saveRateLimit);
// 点击模态框背景关闭
document.getElementById('rate-limit-modal').addEventListener('click', function (e) {
if (e.target === this) {
hideRateLimitModal();
}
});
// 历史趋势:状态与工具
var latestDevices = [];
var lastHistoryData = null; // 最近一次拉取的原始 metrics 数据
var isHistoryLoading = false; // 防止轮询重入
// 排序状态管理
var currentSortBy = localStorage.getItem('bandix_sort_by') || 'online'; // 默认按在线状态排序
var currentSortOrder = localStorage.getItem('bandix_sort_order') === 'true'; // false = 降序, true = 升序
// 当鼠标悬停在历史图表上时,置为 true轮询将暂停刷新实现"鼠标在趋势图上时不自动滚动"
var historyHover = false;
// 鼠标悬停时的索引(独立于 canvas.__bandixChart避免重绘覆盖问题
var historyHoverIndex = null;
// 缩放功能相关变量
var zoomEnabled = false; // 缩放是否启用
var zoomScale = 1; // 缩放比例
var zoomOffsetX = 0; // X轴偏移
var zoomTimer = null; // 延迟启用缩放的计时器
function updateDeviceOptions(devices) {
var select = document.getElementById('history-device-select');
if (!select) return;
// 对设备列表进行排序在线设备在前离线设备在后然后按IP地址从小到大排序
var sortedDevices = devices.slice().sort(function(a, b) {
var aOnline = isDeviceOnline(a);
var bOnline = isDeviceOnline(b);
// 首先按在线状态排序:在线设备在前
if (aOnline && !bOnline) return -1;
if (!aOnline && bOnline) return 1;
// 在线状态相同时按IP地址排序
var aIp = a.ip || '';
var bIp = b.ip || '';
// 将IP地址转换为数字进行比较
var aIpParts = aIp.split('.').map(function(part) { return parseInt(part) || 0; });
var bIpParts = bIp.split('.').map(function(part) { return parseInt(part) || 0; });
// 逐段比较IP地址
for (var i = 0; i < 4; i++) {
var aPart = aIpParts[i] || 0;
var bPart = bIpParts[i] || 0;
if (aPart !== bPart) {
return aPart - bPart;
}
}
// IP地址相同时按MAC地址排序
return (a.mac || '').localeCompare(b.mac || '');
});
// 对比是否需要更新
var currentValues = Array.from(select.options).map(o => o.value);
var desiredValues = [''].concat(sortedDevices.map(d => d.mac));
var same = currentValues.length === desiredValues.length && currentValues.every((v, i) => v === desiredValues[i]);
if (same) return;
var prev = select.value;
// 重建选项
select.innerHTML = '';
select.appendChild(E('option', { 'value': '' }, getTranslation('所有设备', language)));
sortedDevices.forEach(function (d) {
var label = (d.hostname || d.ip || d.mac || '-') + (d.ip ? ' (' + d.ip + ')' : '') + (d.mac ? ' [' + d.mac + ']' : '');
select.appendChild(E('option', { 'value': d.mac }, label));
});
// 尽量保留之前选择
if (desiredValues.indexOf(prev) !== -1) select.value = prev;
}
function getTypeKeys(type) {
if (type === 'lan') return { up: 'local_tx_rate', down: 'local_rx_rate' };
if (type === 'wan') return { up: 'wide_tx_rate', down: 'wide_rx_rate' };
return { up: 'total_tx_rate', down: 'total_rx_rate' };
}
function fetchMetricsData(mac) {
// 通过 ubus RPC 获取,避免跨域与鉴权问题
return callGetMetrics(mac || '').then(function (res) { return res || { metrics: [] }; });
}
// 辅助函数:使用当前缩放设置绘制图表
function drawHistoryChartWithZoom(canvas, labels, upSeries, downSeries) {
drawHistoryChart(canvas, labels, upSeries, downSeries, zoomScale, zoomOffsetX);
}
// 更新缩放倍率显示
function updateZoomLevelDisplay() {
var zoomLevelElement = document.getElementById('history-zoom-level');
if (!zoomLevelElement) return;
if (zoomScale <= 1) {
zoomLevelElement.style.display = 'none';
} else {
zoomLevelElement.style.display = 'inline-block';
zoomLevelElement.textContent = getTranslation('缩放', language) + ': ' + zoomScale.toFixed(1) + 'x';
}
}
function drawHistoryChart(canvas, labels, upSeries, downSeries, scale, offsetX) {
if (!canvas) return;
// 缩放参数默认值
scale = scale || 1;
offsetX = offsetX || 0;
var dpr = window.devicePixelRatio || 1;
var rect = canvas.getBoundingClientRect();
var cssWidth = rect.width;
var cssHeight = rect.height;
canvas.width = Math.max(1, Math.floor(cssWidth * dpr));
canvas.height = Math.max(1, Math.floor(cssHeight * dpr));
var ctx = canvas.getContext('2d');
ctx.scale(dpr, dpr);
var width = cssWidth;
var height = cssHeight;
// 预留更大边距,避免标签被裁剪
var padding = { left: 90, right: 50, top: 16, bottom: 36 };
// 背景
ctx.clearRect(0, 0, width, height);
// 根据缩放和偏移处理数据
var originalLabels = labels;
var originalUpSeries = upSeries;
var originalDownSeries = downSeries;
if (scale > 1) {
var totalLen = labels.length;
var visibleLen = Math.ceil(totalLen / scale);
var startIdx = Math.max(0, Math.floor(offsetX));
var endIdx = Math.min(totalLen, startIdx + visibleLen);
labels = labels.slice(startIdx, endIdx);
upSeries = upSeries.slice(startIdx, endIdx);
downSeries = downSeries.slice(startIdx, endIdx);
}
var speedUnit = uci.get('bandix', 'traffic', 'speed_unit') || 'bytes';
var maxVal = 0;
for (var i = 0; i < upSeries.length; i++) maxVal = Math.max(maxVal, upSeries[i] || 0);
for (var j = 0; j < downSeries.length; j++) maxVal = Math.max(maxVal, downSeries[j] || 0);
if (!isFinite(maxVal) || maxVal <= 0) maxVal = 1;
// 动态测量Y轴最大标签宽度增大左边距
ctx.font = '12px sans-serif';
var maxLabelText = formatByterate(maxVal, speedUnit);
var zeroLabelText = formatByterate(0, speedUnit);
var maxLabelWidth = Math.max(ctx.measureText(maxLabelText).width, ctx.measureText(zeroLabelText).width);
padding.left = Math.max(padding.left, Math.ceil(maxLabelWidth) + 30);
// 保证右侧时间不被裁剪
var rightMin = 50; // 最小右边距
padding.right = Math.max(padding.right, rightMin);
var innerW = Math.max(1, width - padding.left - padding.right);
var innerH = Math.max(1, height - padding.top - padding.bottom);
// 记录用于交互的几何信息;保留已有的 hoverIndex 避免在重绘时丢失
var prevHover = (canvas.__bandixChart && typeof canvas.__bandixChart.hoverIndex === 'number') ? canvas.__bandixChart.hoverIndex : undefined;
canvas.__bandixChart = {
padding: padding,
innerW: innerW,
innerH: innerH,
width: width,
height: height,
labels: labels,
upSeries: upSeries,
downSeries: downSeries,
// 缩放相关信息
scale: scale,
offsetX: offsetX,
originalLabels: originalLabels,
originalUpSeries: originalUpSeries,
originalDownSeries: originalDownSeries
};
if (typeof prevHover === 'number') canvas.__bandixChart.hoverIndex = prevHover;
// 网格与Y轴刻度更细更淡
var gridLines = 4;
ctx.strokeStyle = (darkMode ? 'rgba(148,163,184,0.06)' : 'rgba(148,163,184,0.08)');
ctx.lineWidth = 0.8;
for (var g = 0; g <= gridLines; g++) {
var y = padding.top + (innerH * g / gridLines);
ctx.beginPath();
ctx.moveTo(padding.left, y);
ctx.lineTo(width - padding.right, y);
ctx.stroke();
var val = Math.round(maxVal * (gridLines - g) / gridLines);
ctx.fillStyle = (darkMode ? 'rgba(148,163,184,0.7)' : '#9ca3af');
ctx.font = '12px sans-serif';
ctx.textAlign = 'right';
ctx.textBaseline = 'middle';
var yLabelY = (g === gridLines) ? y - 4 : y; // 底部刻度上移避免贴近X轴
ctx.fillText(formatByterate(val, speedUnit), padding.left - 8, yLabelY);
}
function drawAreaSeries(series, color, gradientFrom, gradientTo) {
if (!series || series.length === 0) return;
var n = series.length;
var stepX = n > 1 ? (innerW / (n - 1)) : 0;
// 先绘制填充区域路径
ctx.beginPath();
for (var k = 0; k < n; k++) {
var v = Math.max(0, series[k] || 0);
var x = padding.left + (n > 1 ? stepX * k : innerW / 2);
var y = padding.top + innerH - (v / maxVal) * innerH;
if (k === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
}
// 关闭到底部以形成区域
ctx.lineTo(padding.left + innerW, padding.top + innerH);
ctx.lineTo(padding.left, padding.top + innerH);
ctx.closePath();
// 创建渐变填充
var grad = ctx.createLinearGradient(0, padding.top, 0, padding.top + innerH);
grad.addColorStop(0, gradientFrom);
grad.addColorStop(1, gradientTo);
ctx.fillStyle = grad;
ctx.fill();
// 然后绘制细线
ctx.beginPath();
for (var k2 = 0; k2 < n; k2++) {
var v2 = Math.max(0, series[k2] || 0);
var x2 = padding.left + (n > 1 ? stepX * k2 : innerW / 2);
var y2 = padding.top + innerH - (v2 / maxVal) * innerH;
if (k2 === 0) ctx.moveTo(x2, y2); else ctx.lineTo(x2, y2);
}
ctx.strokeStyle = color;
ctx.lineWidth = 1.2; // 更细的线
ctx.stroke();
// 圆点已移除,只保留线条
}
// 橙色上行,青色下行,使用半透明渐变
drawAreaSeries(upSeries, '#f97316', 'rgba(249,115,22,0.16)', 'rgba(249,115,22,0.02)');
drawAreaSeries(downSeries, '#06b6d4', 'rgba(6,182,212,0.12)', 'rgba(6,182,212,0.02)');
// X 轴时间标签(首尾)
if (labels && labels.length > 0) {
ctx.fillStyle = '#9ca3af';
ctx.font = '12px sans-serif';
ctx.textBaseline = 'top';
var firstX = padding.left;
var lastX = width - padding.right;
var yBase = height - padding.bottom + 4;
// 左侧时间靠左对齐
ctx.textAlign = 'left';
ctx.fillText(labels[0], firstX, yBase);
// 右侧时间靠右对齐,避免被裁剪
if (labels.length > 1) {
ctx.textAlign = 'right';
ctx.fillText(labels[labels.length - 1], lastX, yBase);
}
}
// 如果存在 hoverIndex则绘制垂直虚线鼠标对着的 x 轴)
try {
var info = canvas.__bandixChart || {};
var useIdx = null;
if (typeof historyHoverIndex === 'number') useIdx = historyHoverIndex;
else if (typeof info.hoverIndex === 'number') useIdx = info.hoverIndex;
if (useIdx !== null && info.labels && info.labels.length > 0) {
var n = info.labels.length;
var stepX = n > 1 ? (innerW / (n - 1)) : 0;
var hoverIdx = useIdx;
// 在缩放状态下,需要将原始索引转换为显示索引
if (scale > 1 && originalLabels && originalLabels.length > 0) {
var startIdx = Math.floor(offsetX || 0);
hoverIdx = useIdx - startIdx;
// 检查索引是否在当前显示范围内
if (hoverIdx < 0 || hoverIdx >= n) {
hoverIdx = null; // 不在显示范围内,不绘制虚线
}
}
if (hoverIdx !== null) {
hoverIdx = Math.max(0, Math.min(n - 1, hoverIdx));
var hoverX = info.padding.left + (n > 1 ? stepX * hoverIdx : innerW / 2);
ctx.save();
var hoverColor = (typeof darkMode !== 'undefined' && darkMode) ? 'rgba(148,163,184,0.7)' : 'rgba(156,163,175,0.9)';
ctx.strokeStyle = hoverColor;
ctx.lineWidth = 1;
ctx.setLineDash([6, 4]);
ctx.beginPath();
ctx.moveTo(hoverX, padding.top);
ctx.lineTo(hoverX, padding.top + innerH);
ctx.stroke();
ctx.setLineDash([]);
ctx.restore();
}
}
} catch (e) { /* 安全兜底 */ }
}
function msToTimeLabel(ts) {
var d = new Date(ts);
var hh = ('' + d.getHours()).padStart(2, '0');
var mm = ('' + d.getMinutes()).padStart(2, '0');
var ss = ('' + d.getSeconds()).padStart(2, '0');
return hh + ':' + mm + ':' + ss;
}
function buildTooltipHtml(point, language) {
if (!point) return '';
var lines = [];
var zh = (language === 'zh-cn' || language === 'zh-tw');
var typeSel = (typeof document !== 'undefined' ? document.getElementById('history-type-select') : null);
var selType = (typeSel && typeSel.value) ? typeSel.value : 'total';
var speedUnit = uci.get('bandix', 'traffic', 'speed_unit') || 'bytes';
function row(label, val) {
lines.push('<div class="ht-row"><span class="ht-key">' + label + '</span><span class="ht-val">' + val + '</span></div>');
}
function rateValue(key) {
return formatByterate(point[key] || 0, speedUnit);
}
function bytesValue(key) {
return formatSize(point[key] || 0);
}
function labelsFor(type) {
if (type === 'lan') return { up: getTranslation('LAN 上传速率', language), down: getTranslation('LAN 下载速率', language) };
if (type === 'wan') return { up: getTranslation('WAN 上传速率', language), down: getTranslation('WAN 下载速率', language) };
return { up: getTranslation('总上传速率', language), down: getTranslation('总下载速率', language) };
}
function rateKeysFor(type) {
if (type === 'lan') return { up: 'local_tx_rate', down: 'local_rx_rate' };
if (type === 'wan') return { up: 'wide_tx_rate', down: 'wide_rx_rate' };
return { up: 'total_tx_rate', down: 'total_rx_rate' };
}
function bytesKeysFor(type) {
if (type === 'lan') return { up: 'local_tx_bytes', down: 'local_rx_bytes' };
if (type === 'wan') return { up: 'wide_tx_bytes', down: 'wide_rx_bytes' };
return { up: 'total_tx_bytes', down: 'total_rx_bytes' };
}
lines.push('<div class="ht-title">' + msToTimeLabel(point.ts_ms) + '</div>');
// 若选择了设备,显示设备信息
try {
var macSel = (typeof document !== 'undefined' ? document.getElementById('history-device-select') : null);
var macVal = (macSel && macSel.value) ? macSel.value : '';
if (macVal && Array.isArray(latestDevices)) {
var dev = latestDevices.find(function(d){ return d.mac === macVal; });
if (dev) {
var ipv6Info = '';
var lanIPv6 = filterLanIPv6(dev.ipv6_addresses);
if (lanIPv6.length > 0) {
ipv6Info = ' | IPv6: ' + lanIPv6.join(', ');
}
var devLabel = (dev.hostname || '-') + (dev.ip ? ' (' + dev.ip + ')' : '') + (dev.mac ? ' [' + dev.mac + ']' : '') + ipv6Info;
lines.push('<div class="ht-device">' + getTranslation('设备', language) + ': ' + devLabel + '</div>');
}
}
} catch (e) {}
// 关键信息:选中类型的上下行速率(大号显示)
var kpiLabels = labelsFor(selType);
var kpiRateKeys = rateKeysFor(selType);
lines.push(
'<div class="ht-kpis">' +
'<div class="ht-kpi up">' +
'<div class="ht-k-label">' + kpiLabels.up + '</div>' +
'<div class="ht-k-value">' + rateValue(kpiRateKeys.up) + '</div>' +
'</div>' +
'<div class="ht-kpi down">' +
'<div class="ht-k-label">' + kpiLabels.down + '</div>' +
'<div class="ht-k-value">' + rateValue(kpiRateKeys.down) + '</div>' +
'</div>' +
'</div>'
);
// 次要信息:其余类型的速率(精简展示)
var otherTypes = ['total', 'lan', 'wan'].filter(function (t) { return t !== selType; });
if (otherTypes.length) {
lines.push('<div class="ht-section-title">' + getTranslation('其他速率', language) + '</div>');
otherTypes.forEach(function (t) {
var lbs = labelsFor(t);
var ks = rateKeysFor(t);
row(lbs.up, rateValue(ks.up));
row(lbs.down, rateValue(ks.down));
});
}
// 累计区分LAN 流量与公网
lines.push('<div class="ht-divider"></div>');
lines.push('<div class="ht-section-title">' + getTranslation('累计流量', language) + '</div>');
row(getTranslation('总上传', language), bytesValue('total_tx_bytes'));
row(getTranslation('总下载', language), bytesValue('total_rx_bytes'));
row(getTranslation('LAN 已上传', language), bytesValue('local_tx_bytes'));
row(getTranslation('LAN 已下载', language), bytesValue('local_rx_bytes'));
row(getTranslation('WAN 已上传', language), bytesValue('wide_tx_bytes'));
row(getTranslation('WAN 已下载', language), bytesValue('wide_rx_bytes'));
return lines.join('');
}
// 排序逻辑函数
function sortDevices(devices, sortBy, ascending) {
if (!devices || !Array.isArray(devices)) return devices;
var sortedDevices = devices.slice();
switch (sortBy) {
case 'online':
sortedDevices.sort(function(a, b) {
var aOnline = isDeviceOnline(a);
var bOnline = isDeviceOnline(b);
if (aOnline === bOnline) return 0;
return ascending ? (aOnline ? -1 : 1) : (aOnline ? 1 : -1);
});
break;
case 'ip':
sortedDevices.sort(function(a, b) {
var aIp = a.ip || '';
var bIp = b.ip || '';
// 将IP地址转换为数字进行比较
var aIpParts = aIp.split('.').map(function(part) { return parseInt(part) || 0; });
var bIpParts = bIp.split('.').map(function(part) { return parseInt(part) || 0; });
// 逐段比较IP地址
for (var i = 0; i < 4; i++) {
var aPart = aIpParts[i] || 0;
var bPart = bIpParts[i] || 0;
if (aPart !== bPart) {
return ascending ? (aPart - bPart) : (bPart - aPart);
}
}
return 0;
});
break;
case 'hostname':
sortedDevices.sort(function(a, b) {
// 先按在线状态排序
var aOnline = isDeviceOnline(a);
var bOnline = isDeviceOnline(b);
if (aOnline !== bOnline) {
return aOnline ? -1 : 1; // 在线设备始终在前
}
// 在线状态相同时按IP地址排序
var aIp = a.ip || '';
var bIp = b.ip || '';
var aIpParts = aIp.split('.').map(function(part) { return parseInt(part) || 0; });
var bIpParts = bIp.split('.').map(function(part) { return parseInt(part) || 0; });
for (var i = 0; i < 4; i++) {
var aPart = aIpParts[i] || 0;
var bPart = bIpParts[i] || 0;
if (aPart !== bPart) {
return ascending ? (aPart - bPart) : (bPart - aPart);
}
}
// IP相同时按MAC地址排序
return (a.mac || '').localeCompare(b.mac || '');
});
break;
case 'mac':
sortedDevices.sort(function(a, b) {
var aMac = (a.mac || '').toLowerCase();
var bMac = (b.mac || '').toLowerCase();
if (aMac === bMac) return 0;
return ascending ? aMac.localeCompare(bMac) : bMac.localeCompare(aMac);
});
break;
case 'upload_speed':
sortedDevices.sort(function(a, b) {
var aSpeed = (a.wide_tx_rate || 0) + (a.local_tx_rate || 0);
var bSpeed = (b.wide_tx_rate || 0) + (b.local_tx_rate || 0);
return ascending ? (aSpeed - bSpeed) : (bSpeed - aSpeed);
});
break;
case 'download_speed':
sortedDevices.sort(function(a, b) {
var aSpeed = (a.wide_rx_rate || 0) + (a.local_rx_rate || 0);
var bSpeed = (b.wide_rx_rate || 0) + (b.local_rx_rate || 0);
return ascending ? (aSpeed - bSpeed) : (bSpeed - aSpeed);
});
break;
case 'lan_speed':
sortedDevices.sort(function(a, b) {
var aSpeed = (a.local_tx_rate || 0) + (a.local_rx_rate || 0);
var bSpeed = (b.local_tx_rate || 0) + (b.local_rx_rate || 0);
return ascending ? (aSpeed - bSpeed) : (bSpeed - aSpeed);
});
break;
case 'wan_speed':
sortedDevices.sort(function(a, b) {
var aSpeed = (a.wide_tx_rate || 0) + (a.wide_rx_rate || 0);
var bSpeed = (b.wide_tx_rate || 0) + (b.wide_rx_rate || 0);
return ascending ? (aSpeed - bSpeed) : (bSpeed - aSpeed);
});
break;
case 'total_traffic':
sortedDevices.sort(function(a, b) {
var aTotal = (a.wide_tx_bytes || 0) + (a.wide_rx_bytes || 0) + (a.local_tx_bytes || 0) + (a.local_rx_bytes || 0);
var bTotal = (b.wide_tx_bytes || 0) + (b.wide_rx_bytes || 0) + (b.local_tx_bytes || 0) + (b.local_rx_bytes || 0);
return ascending ? (aTotal - bTotal) : (bTotal - aTotal);
});
break;
case 'last_online':
sortedDevices.sort(function(a, b) {
var aTime = a.last_online_ts || 0;
var bTime = b.last_online_ts || 0;
return ascending ? (aTime - bTime) : (bTime - aTime);
});
break;
case 'lan_traffic':
sortedDevices.sort(function(a, b) {
var aTraffic = (a.local_tx_bytes || 0) + (a.local_rx_bytes || 0);
var bTraffic = (b.local_tx_bytes || 0) + (b.local_rx_bytes || 0);
return ascending ? (aTraffic - bTraffic) : (bTraffic - aTraffic);
});
break;
case 'wan_traffic':
sortedDevices.sort(function(a, b) {
var aTraffic = (a.wide_tx_bytes || 0) + (a.wide_rx_bytes || 0);
var bTraffic = (b.wide_tx_bytes || 0) + (b.wide_rx_bytes || 0);
return ascending ? (aTraffic - bTraffic) : (bTraffic - aTraffic);
});
break;
default:
// 默认按在线状态和IP地址排序
sortedDevices.sort(function(a, b) {
var aOnline = isDeviceOnline(a);
var bOnline = isDeviceOnline(b);
if (aOnline !== bOnline) {
return aOnline ? -1 : 1;
}
// 在线状态相同时按IP地址排序
var aIp = a.ip || '';
var bIp = b.ip || '';
var aIpParts = aIp.split('.').map(function(part) { return parseInt(part) || 0; });
var bIpParts = bIp.split('.').map(function(part) { return parseInt(part) || 0; });
for (var i = 0; i < 4; i++) {
var aPart = aIpParts[i] || 0;
var bPart = bIpParts[i] || 0;
if (aPart !== bPart) {
return aPart - bPart;
}
}
return (a.mac || '').localeCompare(b.mac || '');
});
}
return sortedDevices;
}
// 判断设备是否在线(基于 last_online_ts
function isDeviceOnline(device) {
// 如果没有 last_online_ts 字段,使用原有的 online 字段
if (typeof device.last_online_ts === 'undefined') {
return device.online !== false;
}
// 如果 last_online_ts 为 0 或无效值,认为离线
if (!device.last_online_ts || device.last_online_ts <= 0) {
return false;
}
// 计算当前时间与最后在线时间的差值(毫秒)
var currentTime = Date.now();
// 如果时间戳小于1000000000000说明是秒级时间戳需要转换为毫秒
var lastOnlineTime = device.last_online_ts < 1000000000000 ? device.last_online_ts * 1000 : device.last_online_ts;
var timeDiff = currentTime - lastOnlineTime;
// 从UCI配置获取离线超时时间默认10分钟
var offlineTimeoutSeconds = uci.get('bandix', 'traffic', 'offline_timeout') || 600;
var offlineThreshold = offlineTimeoutSeconds * 1000; // 转换为毫秒
return timeDiff <= offlineThreshold;
}
// 格式化最后上线时间
function formatLastOnlineTime(lastOnlineTs, language) {
if (!lastOnlineTs || lastOnlineTs <= 0) {
return getTranslation('从未上线', language);
}
// 如果时间戳小于1000000000000说明是秒级时间戳需要转换为毫秒
var lastOnlineTime = lastOnlineTs < 1000000000000 ? lastOnlineTs * 1000 : lastOnlineTs;
var currentTime = Date.now();
var timeDiff = currentTime - lastOnlineTime;
// 转换为分钟
var minutesDiff = Math.floor(timeDiff / (60 * 1000));
// 1分钟以内显示"刚刚"
if (minutesDiff < 1) {
return getTranslation('刚刚', language);
}
// 10分钟以内显示具体的"几分钟前"
if (minutesDiff <= 10) {
return minutesDiff + getTranslation('分钟前', language);
}
// 转换为小时
var hoursDiff = Math.floor(timeDiff / (60 * 60 * 1000));
// 如果不满1小时显示分钟
if (hoursDiff < 1) {
return minutesDiff + getTranslation('分钟前', language);
}
// 转换为天
var daysDiff = Math.floor(timeDiff / (24 * 60 * 60 * 1000));
// 如果不满1天显示小时忽略分钟
if (daysDiff < 1) {
return hoursDiff + getTranslation('小时前', language);
}
// 转换为月按30天计算
var monthsDiff = Math.floor(daysDiff / 30);
// 如果不满1个月显示天忽略小时
if (monthsDiff < 1) {
return daysDiff + getTranslation('天前', language);
}
// 转换为年按365天计算
var yearsDiff = Math.floor(daysDiff / 365);
// 如果不满1年显示月忽略天
if (yearsDiff < 1) {
return monthsDiff + getTranslation('个月前', language);
}
// 超过1年显示年忽略月
return yearsDiff + getTranslation('年前', language);
}
function formatRetentionSeconds(seconds, language) {
if (!seconds || seconds <= 0) return '';
var value;
var unitKey;
if (seconds < 60) {
value = Math.round(seconds);
unitKey = '秒';
} else if (seconds < 3600) {
value = Math.round(seconds / 60);
if (value < 1) value = 1;
unitKey = '分钟';
} else if (seconds < 86400) {
value = Math.round(seconds / 3600);
if (value < 1) value = 1;
unitKey = '小时';
} else if (seconds < 604800) {
value = Math.round(seconds / 86400);
if (value < 1) value = 1;
unitKey = '天';
} else {
value = Math.round(seconds / 604800);
if (value < 1) value = 1;
unitKey = '周';
}
// 多语言格式化
if (language === 'zh-cn' || language === 'zh-tw') {
return getTranslation('最近', language) + value + getTranslation(unitKey, language);
}
if (language === 'ja') {
return getTranslation('最近', language) + value + getTranslation(unitKey, language);
}
if (language === 'fr') {
// 法语单复数:值>1 用复数,天/周/小时/分钟/秒分别加 s
var unitFr = getTranslation(unitKey, 'fr');
if (value > 1) unitFr = unitFr + 's';
return getTranslation('最近', 'fr') + ' ' + value + ' ' + unitFr;
}
if (language === 'ru') {
// 俄语用缩写,避免复杂变格
return getTranslation('最近', 'ru') + ' ' + value + ' ' + getTranslation(unitKey, 'ru');
}
// 英语默认
var unitEn = getTranslation(unitKey, 'en');
if (value > 1) unitEn = unitEn + 's';
return getTranslation('最近', 'en') + ' ' + value + ' ' + unitEn;
}
function refreshHistory() {
// 若鼠标在历史图上悬停,则暂停刷新以避免自动滚动
if (historyHover) return Promise.resolve();
var mac = document.getElementById('history-device-select')?.value || '';
var type = document.getElementById('history-type-select')?.value || 'total';
var canvas = document.getElementById('history-canvas');
var tooltip = document.getElementById('history-tooltip');
if (!canvas) return Promise.resolve();
if (isHistoryLoading) return Promise.resolve();
isHistoryLoading = true;
return fetchMetricsData(mac).then(function (res) {
var data = Array.isArray(res && res.metrics) ? res.metrics.slice() : [];
lastHistoryData = data;
var retentionBadge = document.getElementById('history-retention');
if (retentionBadge) {
var text = formatRetentionSeconds(res && res.retention_seconds, language);
retentionBadge.textContent = text || '';
}
if (!data.length) {
var ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, canvas.width, canvas.height);
drawHistoryChart(canvas, [], [], [], 1, 0);
return;
}
// 不做时间过滤,按时间升序排序,完整展示
var filtered = data.slice();
filtered.sort(function (a, b) { return (a.ts_ms || 0) - (b.ts_ms || 0); });
var keys = getTypeKeys(type);
var upSeries = filtered.map(function (x) { return x[keys.up] || 0; });
var downSeries = filtered.map(function (x) { return x[keys.down] || 0; });
var labels = filtered.map(function (x) { return msToTimeLabel(x.ts_ms); });
drawHistoryChartWithZoom(canvas, labels, upSeries, downSeries);
// 绑定或更新鼠标事件用于展示浮窗
function findNearestIndex(evt) {
var rect = canvas.getBoundingClientRect();
var x = evt.clientX - rect.left;
var info = canvas.__bandixChart;
if (!info || !info.labels || info.labels.length === 0) return -1;
// 当前显示的数据长度(缩放后)
var n = info.labels.length;
var stepX = n > 1 ? (info.innerW / (n - 1)) : 0;
var minIdx = 0;
var minDist = Infinity;
// 在当前显示的数据范围内找最近的点
for (var k = 0; k < n; k++) {
var px = info.padding.left + (n > 1 ? stepX * k : info.innerW / 2);
var dist = Math.abs(px - x);
if (dist < minDist) { minDist = dist; minIdx = k; }
}
// 如果处于缩放状态,需要将显示索引映射回原始数据索引
if (info.scale && info.scale > 1 && info.originalLabels) {
var startIdx = Math.floor(info.offsetX || 0);
return startIdx + minIdx;
}
return minIdx;
}
function onMove(evt) {
if (!tooltip) return;
var idx = findNearestIndex(evt);
if (idx < 0 || !lastHistoryData || !lastHistoryData[idx]) {
tooltip.style.display = 'none';
// 清除 hover 状态并请求重绘去掉虚线
historyHover = false;
try { if (canvas && canvas.__bandixChart) { delete canvas.__bandixChart.hoverIndex; drawHistoryChart(canvas, canvas.__bandixChart.originalLabels || [], canvas.__bandixChart.originalUpSeries || [], canvas.__bandixChart.originalDownSeries || [], zoomScale, zoomOffsetX); } } catch(e){}
return;
}
var point = lastHistoryData[idx];
// 设置 hover 状态,暂停历史轮询刷新
historyHover = true;
historyHoverIndex = idx;
// 立即重绘以显示垂直虚线
try { drawHistoryChart(canvas, canvas.__bandixChart && canvas.__bandixChart.originalLabels ? canvas.__bandixChart.originalLabels : labels, canvas.__bandixChart && canvas.__bandixChart.originalUpSeries ? canvas.__bandixChart.originalUpSeries : upSeries, canvas.__bandixChart && canvas.__bandixChart.originalDownSeries ? canvas.__bandixChart.originalDownSeries : downSeries, zoomScale, zoomOffsetX); } catch(e){}
tooltip.innerHTML = buildTooltipHtml(point, language);
// 先显示以计算尺寸
tooltip.style.display = 'block';
tooltip.style.left = '-9999px';
tooltip.style.top = '-9999px';
var tw = tooltip.offsetWidth || 0;
var th = tooltip.offsetHeight || 0;
var padding = 12;
var maxX = (typeof window !== 'undefined' ? window.innerWidth : document.documentElement.clientWidth) - 4;
var maxY = (typeof window !== 'undefined' ? window.innerHeight : document.documentElement.clientHeight) - 4;
var cx = evt.clientX;
var cy = evt.clientY;
var baseX = cx + padding; // 右上(水平向右)
var baseY = cy - th - padding; // 上方
// 若右侧溢出,改为左上
if (baseX + tw > maxX) {
baseX = cx - tw - padding;
}
// 边界收缩(不改动上方定位的语义)
if (baseX < 4) baseX = 4;
if (baseY < 4) baseY = 4;
tooltip.style.left = baseX + 'px';
tooltip.style.top = baseY + 'px';
}
function onLeave() {
if (tooltip) tooltip.style.display = 'none';
// 清除 hover 状态并请求重绘去掉虚线
historyHover = false;
historyHoverIndex = null;
// 重置缩放状态
if (zoomTimer) {
clearTimeout(zoomTimer);
zoomTimer = null;
}
zoomEnabled = false;
zoomScale = 1;
zoomOffsetX = 0;
// 更新缩放倍率显示
updateZoomLevelDisplay();
// 清除canvas中的hover信息
if (canvas && canvas.__bandixChart) {
delete canvas.__bandixChart.hoverIndex;
}
try { drawHistoryChart(canvas, canvas.__bandixChart && canvas.__bandixChart.originalLabels ? canvas.__bandixChart.originalLabels : labels, canvas.__bandixChart && canvas.__bandixChart.originalUpSeries ? canvas.__bandixChart.originalUpSeries : upSeries, canvas.__bandixChart && canvas.__bandixChart.originalDownSeries ? canvas.__bandixChart.originalDownSeries : downSeries, 1, 0); } catch(e){}
}
// 鼠标进入事件:启动延迟计时器
canvas.onmouseenter = function() {
if (zoomTimer) clearTimeout(zoomTimer);
zoomTimer = setTimeout(function() {
zoomEnabled = true;
zoomTimer = null;
}, 1000); // 1秒后启用缩放
};
// 鼠标滚轮事件:处理缩放
canvas.onwheel = function(evt) {
if (!zoomEnabled) return;
evt.preventDefault();
var delta = evt.deltaY > 0 ? 0.9 : 1.1;
var newScale = zoomScale * delta;
// 限制缩放范围
if (newScale < 1) newScale = 1;
if (newScale > 10) newScale = 10;
var rect = canvas.getBoundingClientRect();
var mouseX = evt.clientX - rect.left;
var info = canvas.__bandixChart;
if (!info || !info.originalLabels) return;
// 计算鼠标在数据中的相对位置
var relativeX = (mouseX - info.padding.left) / info.innerW;
var totalLen = info.originalLabels.length;
var mouseDataIndex = relativeX * totalLen;
// 调整偏移以保持鼠标位置为缩放中心
var oldVisibleLen = totalLen / zoomScale;
var newVisibleLen = totalLen / newScale;
var centerShift = (oldVisibleLen - newVisibleLen) * (mouseDataIndex / totalLen);
zoomScale = newScale;
zoomOffsetX = Math.max(0, Math.min(totalLen - newVisibleLen, zoomOffsetX + centerShift));
// 更新缩放倍率显示
updateZoomLevelDisplay();
// 重绘图表 - 保持当前的hover状态
try {
drawHistoryChart(canvas, info.originalLabels, info.originalUpSeries, info.originalDownSeries, zoomScale, zoomOffsetX);
// 如果有当前的hover索引重新绘制虚线
if (typeof historyHoverIndex === 'number' && canvas.__bandixChart) {
canvas.__bandixChart.hoverIndex = historyHoverIndex;
}
} catch(e){}
};
canvas.onmousemove = onMove;
canvas.onmouseleave = onLeave;
}).catch(function () {
var ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, canvas.width, canvas.height);
drawHistoryChart(canvas, [], [], [], 1, 0);
// ui.addNotification(null, E('p', {}, getTranslation('无法获取历史数据', language)), 'error');
}).finally(function () {
isHistoryLoading = false;
});
}
// 历史趋势:事件绑定
(function initHistoryControls() {
var typeSel = document.getElementById('history-type-select');
var devSel = document.getElementById('history-device-select');
if (typeSel) typeSel.value = 'total';
// 初始化缩放倍率显示
updateZoomLevelDisplay();
function onFilterChange() {
refreshHistory();
// 同步刷新表格(立即生效,不等轮询)
try { window.__bandixRenderTable && window.__bandixRenderTable(); } catch (e) {}
}
if (typeSel) typeSel.addEventListener('change', onFilterChange);
if (devSel) devSel.addEventListener('change', onFilterChange);
window.addEventListener('resize', function () {
if (lastHistoryData && lastHistoryData.length) {
// 重新绘制当前数据(保持当前筛选)
var type = document.getElementById('history-type-select')?.value || 'total';
var canvas = document.getElementById('history-canvas');
if (!canvas) return;
var filtered = lastHistoryData.slice();
filtered.sort(function (a, b) { return (a.ts_ms || 0) - (b.ts_ms || 0); });
var keys = getTypeKeys(type);
var upSeries = filtered.map(function (x) { return x[keys.up] || 0; });
var downSeries = filtered.map(function (x) { return x[keys.down] || 0; });
var labels = filtered.map(function (x) { return msToTimeLabel(x.ts_ms); });
drawHistoryChartWithZoom(canvas, labels, upSeries, downSeries);
} else {
refreshHistory();
}
});
// 首次加载
refreshHistory();
})();
// 历史趋势轮询每1秒
poll.add(function () {
return refreshHistory();
},1);
// 定义更新设备数据的函数
function updateDeviceData() {
return callStatus().then(function (result) {
var trafficDiv = document.getElementById('traffic-status');
var deviceCountDiv = document.getElementById('device-count');
var statsGrid = document.getElementById('stats-grid');
var language = uci.get('bandix', 'general', 'language');
if (!language || language === 'auto') {
language = getSystemLanguage();
}
var speedUnit = uci.get('bandix', 'traffic', 'speed_unit') || 'bytes';
var stats = result;
if (!stats || !stats.devices) {
trafficDiv.innerHTML = '<div class="error">' + getTranslation('无法获取数据', language) + '</div>';
return;
}
// 更新设备计数
var onlineCount = stats.devices.filter(d => isDeviceOnline(d)).length;
deviceCountDiv.textContent = getTranslation('在线设备', language) + ': ' + onlineCount + ' / ' + stats.devices.length;
// 计算统计数据(包含所有设备)
var totalLanUp = stats.devices.reduce((sum, d) => sum + (d.local_tx_bytes || 0), 0);
var totalLanDown = stats.devices.reduce((sum, d) => sum + (d.local_rx_bytes || 0), 0);
var totalWanUp = stats.devices.reduce((sum, d) => sum + (d.wide_tx_bytes || 0), 0);
var totalWanDown = stats.devices.reduce((sum, d) => sum + (d.wide_rx_bytes || 0), 0);
var totalLanSpeedUp = stats.devices.reduce((sum, d) => sum + (d.local_tx_rate || 0), 0);
var totalLanSpeedDown = stats.devices.reduce((sum, d) => sum + (d.local_rx_rate || 0), 0);
var totalWanSpeedUp = stats.devices.reduce((sum, d) => sum + (d.wide_tx_rate || 0), 0);
var totalWanSpeedDown = stats.devices.reduce((sum, d) => sum + (d.wide_rx_rate || 0), 0);
var totalSpeedUp = totalLanSpeedUp + totalWanSpeedUp;
var totalSpeedDown = totalLanSpeedDown + totalWanSpeedDown;
var totalUp = totalLanUp + totalWanUp;
var totalDown = totalLanDown + totalWanDown;
// 更新统计卡片
statsGrid.innerHTML = '';
// LAN 流量卡片
statsGrid.appendChild(E('div', { 'class': 'stats-card' }, [
E('div', { 'class': 'stats-card-header' }, [
E('div', { 'class': 'stats-card-title' }, getTranslation('LAN 流量', language)),
E('div', { 'class': 'stats-card-icon', 'style': 'color: #3b82f6;' }, '🖥️')
]),
E('div', { 'style': 'margin-top: 12px; display: flex; flex-direction: column; gap: 8px;' }, [
// 上传行
E('div', { 'style': 'display: flex; align-items: center; gap: 4px;' }, [
E('span', { 'style': 'color: #f97316; font-size: 0.75rem; font-weight: bold;' }, '↑'),
E('span', { 'style': 'color: #f97316; font-size: 1.125rem; font-weight: 700;' }, formatByterate(totalLanSpeedUp, speedUnit)),
E('span', { 'style': 'font-size: 0.75rem; color: #64748b; margin-left: 4px;' }, '(' + formatSize(totalLanUp) + ')')
]),
// 下载行
E('div', { 'style': 'display: flex; align-items: center; gap: 4px;' }, [
E('span', { 'style': 'color: #06b6d4; font-size: 0.75rem; font-weight: bold;' }, '↓'),
E('span', { 'style': 'color: #06b6d4; font-size: 1.125rem; font-weight: 700;' }, formatByterate(totalLanSpeedDown, speedUnit)),
E('span', { 'style': 'font-size: 0.75rem; color: #64748b; margin-left: 4px;' }, '(' + formatSize(totalLanDown) + ')')
])
])
]));
// WAN 流量卡片
statsGrid.appendChild(E('div', { 'class': 'stats-card' }, [
E('div', { 'class': 'stats-card-header' }, [
E('div', { 'class': 'stats-card-title' }, getTranslation('WAN 流量', language)),
E('div', { 'class': 'stats-card-icon', 'style': 'color: #22c55e;' }, '🌐')
]),
E('div', { 'style': 'margin-top: 12px; display: flex; flex-direction: column; gap: 8px;' }, [
// 上传行
E('div', { 'style': 'display: flex; align-items: center; gap: 4px;' }, [
E('span', { 'style': 'color: #f97316; font-size: 0.75rem; font-weight: bold;' }, '↑'),
E('span', { 'style': 'color: #f97316; font-size: 1.125rem; font-weight: 700;' }, formatByterate(totalWanSpeedUp, speedUnit)),
E('span', { 'style': 'font-size: 0.75rem; color: #64748b; margin-left: 4px;' }, '(' + formatSize(totalWanUp) + ')')
]),
// 下载行
E('div', { 'style': 'display: flex; align-items: center; gap: 4px;' }, [
E('span', { 'style': 'color: #06b6d4; font-size: 0.75rem; font-weight: bold;' }, '↓'),
E('span', { 'style': 'color: #06b6d4; font-size: 1.125rem; font-weight: 700;' }, formatByterate(totalWanSpeedDown, speedUnit)),
E('span', { 'style': 'font-size: 0.75rem; color: #64748b; margin-left: 4px;' }, '(' + formatSize(totalWanDown) + ')')
])
])
]));
// 总流量卡片
statsGrid.appendChild(E('div', { 'class': 'stats-card' }, [
E('div', { 'class': 'stats-card-header' }, [
E('div', { 'class': 'stats-card-title' }, getTranslation('总流量', language)),
E('div', { 'class': 'stats-card-icon', 'style': 'color: ' + (darkMode ? '#f1f5f9' : '#1f2937') + ';' }, '⚡')
]),
E('div', { 'style': 'margin-top: 12px; display: flex; flex-direction: column; gap: 8px;' }, [
// 上传行
E('div', { 'style': 'display: flex; align-items: center; gap: 4px;' }, [
E('span', { 'style': 'color: #f97316; font-size: 0.75rem; font-weight: bold;' }, '↑'),
E('span', { 'style': 'color: #f97316; font-size: 1.125rem; font-weight: 700;' }, formatByterate(totalSpeedUp, speedUnit)),
E('span', { 'style': 'font-size: 0.75rem; color: #64748b; margin-left: 4px;' }, '(' + formatSize(totalUp) + ')')
]),
// 下载行
E('div', { 'style': 'display: flex; align-items: center; gap: 4px;' }, [
E('span', { 'style': 'color: #06b6d4; font-size: 0.75rem; font-weight: bold;' }, '↓'),
E('span', { 'style': 'color: #06b6d4; font-size: 1.125rem; font-weight: 700;' }, formatByterate(totalSpeedDown, speedUnit)),
E('span', { 'style': 'font-size: 0.75rem; color: #64748b; margin-left: 4px;' }, '(' + formatSize(totalDown) + ')')
])
])
]));
// 创建表头点击处理函数
function createSortableHeader(text, sortKey) {
var th = E('th', {
'class': 'sortable' + (currentSortBy === sortKey ? ' active ' + (currentSortOrder ? 'asc' : 'desc') : ''),
'data-sort': sortKey
}, text);
th.addEventListener('click', function() {
var newSortBy = this.getAttribute('data-sort');
if (currentSortBy === newSortBy) {
// 同一列,切换升降序
currentSortOrder = !currentSortOrder;
} else {
// 不同列,默认降序(对于速度和流量,降序更有意义)
currentSortBy = newSortBy;
if (newSortBy === 'hostname' || newSortBy === 'ip' || newSortBy === 'mac') {
currentSortOrder = true; // 文本类默认升序
} else {
currentSortOrder = false; // 数值类默认降序
}
}
// 保存状态
localStorage.setItem('bandix_sort_by', currentSortBy);
localStorage.setItem('bandix_sort_order', currentSortOrder.toString());
// 触发重新渲染
if (window.__bandixRenderTable) {
window.__bandixRenderTable();
}
});
return th;
}
// 创建分栏表头(速度 | 用量)
function createSplitHeader(text, speedKey, trafficKey) {
var th = E('th', {});
var header = E('div', { 'class': 'th-split-header' }, [
E('span', {}, text)
]);
var controls = E('div', { 'style': 'display: flex; align-items: center; gap: 4px;' });
// 速度排序按钮
var speedBtn = E('div', {
'class': 'th-split-section' + (currentSortBy === speedKey ? ' active' : ''),
'data-sort': speedKey,
'title': getTranslation('按速度排序', language)
}, [
E('span', { 'class': 'th-split-icon' }, '⚡'),
E('span', { 'style': 'font-size: 0.75rem;' }, currentSortBy === speedKey ? (currentSortOrder ? '↑' : '↓') : '')
]);
// 分隔线
var divider = E('div', { 'class': 'th-split-divider' });
// 用量排序按钮
var trafficBtn = E('div', {
'class': 'th-split-section' + (currentSortBy === trafficKey ? ' active' : ''),
'data-sort': trafficKey,
'title': getTranslation('按用量排序', language)
}, [
E('span', { 'class': 'th-split-icon' }, '∑'),
E('span', { 'style': 'font-size: 0.75rem;' }, currentSortBy === trafficKey ? (currentSortOrder ? '↑' : '↓') : '')
]);
controls.appendChild(speedBtn);
controls.appendChild(divider);
controls.appendChild(trafficBtn);
header.appendChild(controls);
th.appendChild(header);
// 速度按钮点击事件
speedBtn.addEventListener('click', function(e) {
e.stopPropagation();
var newSortBy = this.getAttribute('data-sort');
if (currentSortBy === newSortBy) {
currentSortOrder = !currentSortOrder;
} else {
currentSortBy = newSortBy;
currentSortOrder = false; // 速度默认降序
}
localStorage.setItem('bandix_sort_by', currentSortBy);
localStorage.setItem('bandix_sort_order', currentSortOrder.toString());
if (window.__bandixRenderTable) {
window.__bandixRenderTable();
}
});
// 用量按钮点击事件
trafficBtn.addEventListener('click', function(e) {
e.stopPropagation();
var newSortBy = this.getAttribute('data-sort');
if (currentSortBy === newSortBy) {
currentSortOrder = !currentSortOrder;
} else {
currentSortBy = newSortBy;
currentSortOrder = false; // 用量默认降序
}
localStorage.setItem('bandix_sort_by', currentSortBy);
localStorage.setItem('bandix_sort_order', currentSortOrder.toString());
if (window.__bandixRenderTable) {
window.__bandixRenderTable();
}
});
return th;
}
// 创建表格
var table = E('table', { 'class': 'bandix-table' }, [
E('thead', {}, [
E('tr', {}, [
createSortableHeader(getTranslation('设备信息', language), 'hostname'),
createSplitHeader(getTranslation('LAN 流量', language), 'lan_speed', 'lan_traffic'),
createSplitHeader(getTranslation('WAN 流量', language), 'wan_speed', 'wan_traffic'),
E('th', {}, getTranslation('限速设置', language)),
E('th', {}, getTranslation('操作', language))
])
]),
E('tbody', {})
]);
var tbody = table.querySelector('tbody');
// 过滤:按选择设备
var selectedMac = (typeof document !== 'undefined' ? (document.getElementById('history-device-select')?.value || '') : '');
var filteredDevices = (!selectedMac) ? stats.devices : stats.devices.filter(function(d){ return (d.mac === selectedMac); });
// 应用排序
filteredDevices = sortDevices(filteredDevices, currentSortBy, currentSortOrder);
// 检查是否有任何设备有 IPv6 地址
var hasAnyIPv6 = filteredDevices.some(function(device) {
var lanIPv6 = filterLanIPv6(device.ipv6_addresses);
return lanIPv6.length > 0;
});
// 填充数据
filteredDevices.forEach(function (device) {
var isOnline = isDeviceOnline(device);
var actionButton = E('button', {
'class': 'action-button',
'title': getTranslation('设置', language)
}, '⚙️');
// 绑定点击事件
actionButton.addEventListener('click', function () {
showRateLimitModal(device);
});
// 获取当前显示模式
var deviceMode = localStorage.getItem('bandix_device_mode') || 'simple';
var isDetailedMode = deviceMode === 'detailed';
// 构建设备信息元素
var deviceInfoElements = [
E('div', { 'class': 'device-name' }, [
E('span', {
'class': 'device-status ' + (isOnline ? 'online' : 'offline')
}),
device.hostname || '-'
]),
E('div', { 'class': 'device-ip' }, device.ip)
];
// 详细模式下显示更多信息
if (isDetailedMode) {
// 只有当有设备有 IPv6 时才添加 IPv6 行
if (hasAnyIPv6) {
var lanIPv6 = filterLanIPv6(device.ipv6_addresses);
if (lanIPv6.length > 0) {
var allIPv6 = device.ipv6_addresses ? device.ipv6_addresses.join(', ') : '';
deviceInfoElements.push(E('div', {
'class': 'device-ipv6',
'title': allIPv6
}, lanIPv6.join(', ')));
} else {
deviceInfoElements.push(E('div', { 'class': 'device-ipv6' }, '-'));
}
}
// 添加 MAC 和最后上线信息
deviceInfoElements.push(
E('div', { 'class': 'device-mac' }, device.mac),
E('div', { 'class': 'device-last-online' }, [
E('span', { 'style': 'color: #6b7280; font-size: 0.75rem;' }, getTranslation('最后上线', language) + ': '),
E('span', { 'style': 'color: #9ca3af; font-size: 0.75rem;' }, formatLastOnlineTime(device.last_online_ts, language))
])
);
}
var row = E('tr', {}, [
// 设备信息
E('td', {}, [
E('div', { 'class': 'device-info' }, deviceInfoElements)
]),
// LAN 流量
E('td', {}, [
E('div', { 'class': 'traffic-info' }, [
E('div', { 'class': 'traffic-row' }, [
E('span', { 'class': 'traffic-icon upload' }, '↑'),
E('span', { 'class': 'traffic-speed lan' }, formatByterate(device.local_tx_rate || 0, speedUnit)),
E('span', { 'class': 'traffic-total' }, '(' + formatSize(device.local_tx_bytes || 0) + ')')
]),
E('div', { 'class': 'traffic-row' }, [
E('span', { 'class': 'traffic-icon download' }, '↓'),
E('span', { 'class': 'traffic-speed lan' }, formatByterate(device.local_rx_rate || 0, speedUnit)),
E('span', { 'class': 'traffic-total' }, '(' + formatSize(device.local_rx_bytes || 0) + ')')
])
])
]),
// WAN 流量
E('td', {}, [
E('div', { 'class': 'traffic-info' }, [
E('div', { 'class': 'traffic-row' }, [
E('span', { 'class': 'traffic-icon upload' }, '↑'),
E('span', { 'class': 'traffic-speed wan' }, formatByterate(device.wide_tx_rate || 0, speedUnit)),
E('span', { 'class': 'traffic-total' }, '(' + formatSize(device.wide_tx_bytes || 0) + ')')
]),
E('div', { 'class': 'traffic-row' }, [
E('span', { 'class': 'traffic-icon download' }, '↓'),
E('span', { 'class': 'traffic-speed wan' }, formatByterate(device.wide_rx_rate || 0, speedUnit)),
E('span', { 'class': 'traffic-total' }, '(' + formatSize(device.wide_rx_bytes || 0) + ')')
])
])
]),
// 限速设置
E('td', {}, [
E('div', { 'class': 'limit-info' }, [
E('div', { 'class': 'traffic-row' }, [
E('span', { 'class': 'traffic-icon upload', 'style': 'font-size: 0.75rem;' }, '↑'),
E('span', { 'style': 'font-size: 0.875rem;' }, formatByterate(device.wide_tx_rate_limit || 0, speedUnit))
]),
E('div', { 'class': 'traffic-row' }, [
E('span', { 'class': 'traffic-icon download', 'style': 'font-size: 0.75rem;' }, '↓'),
E('span', { 'style': 'font-size: 0.875rem;' }, formatByterate(device.wide_rx_rate_limit || 0, speedUnit))
]),
])
]),
// 操作
E('td', {}, [
actionButton
])
]);
tbody.appendChild(row);
});
// 更新表格内容
trafficDiv.innerHTML = '';
trafficDiv.appendChild(table);
// 暴露一个立即重绘表格的函数,供筛选变化时调用
try { window.__bandixRenderTable = function(){
// 重新触发完整的数据更新和渲染
updateDeviceData();
}; } catch (e) {}
// 更新历史趋势中的设备下拉
try {
latestDevices = stats.devices || [];
updateDeviceOptions(latestDevices);
} catch (e) {}
});
}
// 轮询获取数据
poll.add(updateDeviceData, 1);
// 立即执行一次,不等待轮询
updateDeviceData();
return view;
}
});