@@ -4,49 +4,38 @@
< meta charset = "utf-8" > < meta name = "viewport" content = "width=device-width,initial-scale=1" >
< title > PIGWay Pi Control< / title >
< style >
: root { color-scheme : dark ; --bg : #0b1118 ; --panel : #121b25 ; --line : #263546 ; --text : #e7edf5 ; --muted : #91a4b8 ; --good : #50d890 ; --warn : #ffbd4a ; --bad : #ff6b6b ; --blue : #58a6ff } * { box-sizing : border-box } body { margin : 0 ; background : radial-gradient ( circle at top right , #14263b 0 , #0b1118 42 % ) ; color : var ( - - text ) ; font : 14 px / 1.45 system-ui , - apple-system , sans-serif } main { max-width : 1180 px ; margin : auto ; padding : 28 px 20 px 60 px } header { display : flex ; justify-content : space-between ; align-items : end ; gap : 18 px ; margin-bottom : 22 px } h1 { font-size : 27 px ; margin : 0 } h2 { font-size : 17 px ; margin : 0 0 14 px } . sub , . muted { color : var ( - - muted ) } . live { display : flex ; align-items : center ; gap : 8 px } . dot { width : 9 px ; height : 9 px ; border-radius : 50 % ; background : var ( - - bad ) } . dot . ok { background : var ( - - good ) ; box-shadow : 0 0 12 px var ( - - good ) } nav { display : flex ; gap : 8 px ; margin : 18 px 0 } button { border : 1 px solid var ( - - line ) ; background : #162230 ; color : var ( - - text ) ; border-radius : 8 px ; padding : 9 px 13 px ; cursor : pointer } button : hover , button . active { border-color : var ( - - blue ) ; background : #1b3047 } . grid { display : grid ; grid-template-columns : repeat ( 4 , minmax ( 0 , 1 fr ) ) ; gap : 12 px } . card , . panel { background : color-mix ( in srgb , var ( - - panel ) 94 % , transparent ) ; border : 1 px solid var ( - - line ) ; border-radius : 12 px ; padding : 16 px ; box-shadow : 0 12 px 28 px #0004 } . value { font-size : 23 px ; font-weight : 700 ; margin-top : 4 px } . wide { grid-column : span 2 } . panel { margin-top : 12 px } . rows { display : grid ; gap : 8 px } . row { display : flex ; justify-content : space-between ; gap : 16 px ; padding : 8 px 0 ; border-bottom : 1 px solid #203040 } . row : last-child { border : 0 } . badge { border-radius : 999 px ; padding : 3 px 8 px ; background : #243447 } . badge . good { color : var ( - - good ) } . badge . bad { color : var ( - - bad ) } . hidden { display : none } . log { font : 12 px / 1.5 ui-monospace , SFMono-Regular , monospace ; white-space : pre-wrap ; padding : 8 px 0 ; border-bottom : 1 px solid #203040 } . config-section { margin : 10 px 0 ; border : 1 px solid var ( - - line ) ; border-radius : 10 px ; padding : 12 px } . config-section summary { cursor : pointer ; font-weight : 700 } . fields { display : grid ; grid-template-columns : repeat ( 2 , minmax ( 0 , 1 fr ) ) ; gap : 10 px ; margin-top : 12 px } . field label { display : block ; color : var ( - - muted ) ; font-size : 12 px } . field input , . field select { width : 100 % ; margin-top : 4 px ; border : 1 px solid var ( - - line ) ; border-radius : 7 px ; background : #0d151e ; color : var ( - - text ) ; padding : 8 px } . hint { color : var ( - - muted ) ; font-size : 11 px ; margin-top : 4 px } . toolbar { display : flex ; gap : 10 px ; align-items : center ; flex-wrap : wrap ; margin : 12 px 0 } . toolbar input { min-width : 280 px ; flex : 1 ; border : 1 px solid var ( - - line ) ; border-radius : 7 px ; background : #0d151e ; color : var ( - - text ) ; padding : 9 px } . error { color : var ( - - bad ) } @ media ( max-width : 800px ) { . grid { grid-template-columns : repeat ( 2 , 1 fr ) } . fields { grid-template-columns : 1 fr } . wide { grid-column : span 2 } } @ media ( max-width : 470px ) { . grid { grid-template-columns : 1 fr } . wide { grid-column : span 1 } header { align-items : start ; flex-direction : column } }
: root { color-scheme : dark ; --bg : #0b1118 ; --panel : #121b25 ; --panel2 : #0f1822 ; --line : #263546 ; --text : #e7edf5 ; --muted : #91a4b8 ; --good : #50d890 ; --bad : #ff6b6b ; --blue : #58a6ff } * { box-sizing : border-box } body { margin : 0 ; background : radial-gradient ( circle at top right , #14263b 0 , #0b1118 42 % ) ; color : var ( - - text ) ; font : 14 px / 1.45 system-ui , - apple-system , sans-serif } main { max-width : 1180 px ; margin : auto ; padding : 28 px 20 px 70 px } header { display : flex ; justify-content : space-between ; align-items : end ; gap : 18 px ; margin-bottom : 22 px } h1 { font-size : 27 px ; margin : 0 } h2 { font-size : 18 px ; margin : 0 0 8 px } h3 { font-size : 15 px ; margin : 0 } . sub , . muted , . hint { color : var ( - - muted ) } . hint { font-size : 12 px ; margin-top : 4 px } . live { display : flex ; align-items : center ; gap : 8 px } . dot { width : 9 px ; height : 9 px ; border-radius : 50 % ; background : var ( - - bad ) } . dot . ok { background : var ( - - good ) ; box-shadow : 0 0 12 px var ( - - good ) } nav { display : flex ; gap : 8 px ; margin : 18 px 0 ; position : sticky ; top : 0 ; z-index : 5 ; padding : 8 px 0 ; background : #0b1118 e8 ; backdrop-filter : blur ( 8 px ) } button { border : 1 px solid var ( - - line ) ; background : #162230 ; color : var ( - - text ) ; border-radius : 8 px ; padding : 9 px 13 px ; cursor : pointer } button : hover , button . active { border-color : var ( - - blue ) ; background : #1b3047 } . grid { display : grid ; grid-template-columns : repeat ( 4 , minmax ( 0 , 1 fr ) ) ; gap : 12 px } . card , . panel , . group { background : color-mix ( in srgb , var ( - - panel ) 94 % , transparent ) ; border : 1 px solid var ( - - line ) ; border-radius : 12 px ; padding : 16 px ; box-shadow : 0 12 px 28 px #0004 } . group { background : var ( - - panel2 ) ; box-shadow : none ; margin-top : 12 px } . group-head { display : flex ; align-items : center ; justify-content : space-between ; gap : 12 px ; margin-bottom : 12 px } . value { font-size : 23 px ; font-weight : 700 ; margin-top : 4 px } . wide { grid-column : span 2 } . panel { margin-top : 12 px } . rows { display : grid ; gap : 8 px } . row { display : flex ; justify-content : space-between ; gap : 16 px ; padding : 8 px 0 ; border-bottom : 1 px solid #203040 } . row : last-child { border : 0 } . badge { border-radius : 999 px ; padding : 3 px 8 px ; background : #243447 } . badge . good { color : var ( - - good ) } . badge . bad { color : var ( - - bad ) } . hidden { display : none !important } . log { font : 12 px / 1.5 ui-monospace , SFMono-Regular , monospace ; white-space : pre-wrap ; padding : 8 px 0 ; border-bottom : 1 px solid #203040 } . log-label { font : 12 px system-ui ; color : var ( - - blue ) ; margin-right : 8 px } . config-shell { display : grid ; grid-template-columns : 210 px minmax ( 0 , 1 fr ) ; gap : 14 px ; margin-top : 14 px } . config-menu { display : flex ; flex-direction : column ; gap : 7 px ; align-self : start ; position : sticky ; top : 66 px } . config-menu button { text-align : left } . config-page { display : none } . config-page . active { display : block } . fields { display : grid ; grid-template-columns : repeat ( 2 , minmax ( 0 , 1 fr ) ) ; gap : 12 px } . field label { display : block ; color : var ( - - muted ) ; font-size : 12 px } . field input , . field select { width : 100 % ; margin-top : 5 px ; border : 1 px solid var ( - - line ) ; border-radius : 7 px ; background : #0d151e ; color : var ( - - text ) ; padding : 9 px } . field input : focus , . field select : focus { outline : 1 px solid var ( - - blue ) ; border-color : var ( - - blue ) } . span2 { grid-column : span 2 } . rgb-row { display : grid ; grid-template-columns : 48 px repeat ( 3 , 1 fr ) ; gap : 9 px ; align-items : end } . swatch { width : 38 px ; height : 38 px ; border-radius : 50 % ; border : 2 px solid #fff3 ; box-shadow : 0 0 12 px #0008 ; margin-bottom : 2 px } . toolbar { display : flex ; gap : 10 px ; align-items : center ; flex-wrap : wrap ; margin : 12 px 0 } . toolbar input { min-width : 280 px ; flex : 1 ; border : 1 px solid var ( - - line ) ; border-radius : 7 px ; background : #0d151e ; color : var ( - - text ) ; padding : 9 px } . savebar { position : sticky ; bottom : 10 px ; z-index : 4 ; background : #101b27 ef ; border : 1 px solid var ( - - line ) ; border-radius : 10 px ; padding : 10 px ; box-shadow : 0 8 px 30 px #0008 } . error { color : var ( - - bad ) } . success { color : var ( - - good ) } @ media ( max-width : 800px ) { . grid { grid-template-columns : repeat ( 2 , 1 fr ) } . fields { grid-template-columns : 1 fr } . span2 { grid-column : span 1 } . wide { grid-column : span 2 } . config-shell { grid-template-columns : 1 fr } . config-menu { position : static ; display : grid ; grid-template-columns : repeat ( 2 , 1 fr ) } . rgb-row { grid-template-columns : 38 px 1 fr } . rgb-row . field { grid-column : 2 } . swatch { grid-row : 1 / span 3 } } @ media ( max-width : 470px ) { . grid { grid-template-columns : 1 fr } . wide { grid-column : span 1 } header { align-items : start ; flex-direction : column } . config-menu { grid-template-columns : 1 fr } }
< / style >
< / head >
< body > < main >
< header > < div > < h1 > PIGWay Pi Control< / h1 > < div class = "sub" id = "identity" > 正在连接 Raspberry Pi …< / div > < / div > < div class = "live" > < span class = "dot" id = "dot" > < / span > < span id = "liveText" > 离线< / span > < / div > < / header >
< nav > < button class = "active" data-tab = "overview" > 状态< / button > < button data-tab = "logs" > 日志< / button > < button data-tab = "config" > 配置< / button > < / nav >
< section id = "overview" >
< div class = "grid" >
< div class = "card" > < div class = "muted" > CPU < / div > < div class = "value" id = "cpu" > -- < / div > < div class = "muted " id = "freq " > -- < / div > < / div >
< div class = "card " > < div class = "muted " > 温度 < / div > < div class = "value" id = "temp "> -- < / div > < div class = "muted" id = "fan "> -- < / div > < / div >
< div class = "card" > < div class = "muted" > 内存 < / div > < div class = "value" id = "memory" > -- < / div > < / div >
< div class = "card" > < div class = "muted" > 磁盘 < / div > < div class = "value" id = "disk" > -- < / div > < / div >
< div class = "card wide" > < div class = "muted" > 网络< / div > < div class = "value" id = "network" > --< / div > < div class = "muted" id = "address" > --< / div > < / div >
< div class = "card wide" > < div class = "muted" > 显示控制< / div > < div class = "value" id = "owner" > --< / div > < div class = "muted" id = "rgb" > --< / div > < / div >
< / div >
< div class = "panel" > < h2 > 当前告警< / h2 > < div class = "rows" id = "alerts" > < div class = "muted" > 无告警< / div > < / div > < / div >
< div class = "panel" > < h2 > 服务与进程< / h2 > < div class = "rows" id = "watches" > < / div > < / div >
< / section >
< section id = "logs" class = "hidden" > < div class = "panel" > < div class = "toolbar" > < button id = "refreshLogs" > 刷新日志< / button > < span class = "muted" > 最近200条 Agent journal< / span > < / div > < div id = "logRows" > < / div > < / div > < / section >
< section id = "config" class = "hidden" > < div class = "panel" > < h2 > 用户配置< / h2 > < p class = "muted" > 保存前请输入安装时生成的 API token。修改会备份配置并重启硬件 Agent;API 端口变化会自动重启本服务。< / p > < div class = "toolbar" > < input id = "token" type = "password" placeholder = "Bearer token" > < button id = "saveConfig" > 保存并应用< / button > < span id = "saveState" > < / span > < / div > < div id = "configRows" > < / div > < / div > < / section >
< / main >
< script >
const $ = id => document . getElementById ( id ) , tabs = [ ... document . querySelectorAll ( 'nav button' ) ] ; let original = { } ;
const esc = s => String ( s ? ? '' ) . replace ( /[&<>"']/g , c => ( { '&' : '&' , '<' : '<' , '> ' : '> ', '" ' : '"' , "'" : ''' } [ c ] ) ) ;
const configOptions = {
'led.normal_mode' : [ [ 'off' , '全灭' ] , [ 'solid ', '自定义常亮 ' ] , [ 'flash' , '自定义闪烁' ] , [ 'flow' , '流水灯' ] , [ 'breathe' , '呼吸灯 '] , [ 'marquee' , '跑马灯' ] , [ 'rainbow' , '彩虹灯' ] , [ 'colorful' , '炫彩灯' ] ] ,
'led.normal_effect_speed' : [ [ '1' , '慢' ] , [ '2' , '中' ] , [ '3 ' , '快' ] ] ,
'led.normal_effect_color' : [ [ 'red' , '红' ] , [ 'green' , '绿 ' ] , [ 'blue' , '蓝' ] , [ 'yellow' , '黄' ] , [ 'purple' , '紫' ] , [ 'cyan' , '青 ' ] , [ 'white' , '白' ] ]
} ;
const configHints = {
'led.normal_mo de': '仅在无告警时生效;告警会立即抢占。' ,
'led.normal_r' : '常亮/闪烁红通道,0..255。' , 'led.normal_g' : '常亮/闪烁绿通道,0..255。' , 'led.normal_b' : '常亮/闪烁蓝通道,0..255。' ,
'led.normal_flash_on_ms' : '闪烁亮灯时间,50..60000 ms。' , 'led.normal_flash_off_ms' : '闪烁灭灯时间,50..60000 ms。' ,
'led.normal_effect_speed' : 'MCU 内置效果速度。' , 'led.normal_effect_color' : '仅流水灯和呼吸灯使用;其他内置效果忽略。'
} ;
function configField ( section , key , value ) { const id = ` ${ section } . ${ key } ` , opts = configOptions [ id ] , hint = configHints [ id ] || '' ; const control = opts ? ` <select data-section=" ${ esc ( section ) } " data-key=" ${ esc ( key ) } "> ${ opts . map ( ( [ v , l ] ) => ` <option value=" ${ v } " ${ String ( value ) === v ? 'selected' : '' } > ${ l } ( ${ v } )</option> ` ) . join ( '' ) } </select> ` : ` <input data-section=" ${ esc ( section ) } " data-key=" ${ esc ( key ) } " value=" ${ esc ( value ) } "> ` ; return ` <div class="field"><label> ${ esc ( key ) } ${ control } </label> ${ hint ? ` <div class="hint"> ${ esc ( hint ) } </div> ` : '' } </div> ` }
tabs . forEach ( b => b . onclick = ( ) => { tabs . forEach ( x => x . classList . toggle ( 'active' , x === b ) ) ; [ 'overview' , 'logs' , 'config' ] . forEach ( id => $ ( id ) . classList . toggle ( 'hidden' , id !== b . dataset . tab ) ) ; if ( b . dataset . tab === 'logs' ) loadLogs ( ) ; if ( b . dataset . tab === 'config' ) loadConfig ( ) } ) ;
async function get ( url , opt ) { const r = await fetch ( url , opt ) ; const j = await r . json ( ) ; if ( ! r . ok ) throw Error ( j . error || r . statusText ) ; return j }
function rows ( target , items , empty = '暂无数据' ) { $ ( target ) . innerHTML = items . length ? items . join ( '' ) : ` <div class="muted"> ${ empty } </div> ` }
async function loadStatus ( ) { try { const s = await get ( '/api/v1/status' ) , y = s . system , n = s . network , d = s . display ; $ ( 'dot' ) . classList . add ( 'ok' ) ; $ ( 'liveText' ) . textContent = '在线' ; $ ( 'identity' ) . textContent = ` v ${ s . version } · 运行 ${ Math . floor ( s . uptime _seconds / 3600 ) } 小时 · ${ new Date ( s . timestamp * 1000 ) . toLocaleTimeString ( ) } ` ; $ ( 'cpu' ) . textContent = ` ${ y . cpu _percent } % ` ; $ ( 'freq' ) . textContent = ` ${ y . cpu _freq _mhz } MHz ` ; $ ( 'temp' ) . textContent = ` ${ y . temperature _c } °C ` ; $ ( 'fan' ) . textContent = ` 风扇 ${ y . fan _name } ` ; $ ( 'memory' ) . textContent = ` ${ y . memory _percent } % ` ; $ ( 'disk' ) . textContent = ` ${ y . disk _percent } % ` ; $ ( 'network' ) . textContent = ` ${ n . kind } ${ n . metric } ` ; $ ( 'address' ) . textContent = ` ${ n . interface } · ${ n . ip _label } ${ n . ip } · Internet ${ n . internet ? 'OK' : 'DOWN' } ` ; $ ( 'owner' ) . textContent = d . owner ; $ ( 'rgb' ) . textContent = ` OLED ${ d . oled _page } · RGB ${ d . rgb _mode } ` ; rows ( 'alerts' , d . alerts . map ( a => ` <div class="row"><span><b> ${ esc ( a . title ) } </b><br><span class="muted"> ${ esc ( a . l2 ) } ${ esc ( a . l3 ) } </span></span><span class="badge bad">S ${ a . severity } · P ${ a . priority } </span></div> ` ) , '无告警' ) ; rows ( 'watches' , s . watches . map ( w => ` <div class="row"><span><b> ${ esc ( w . name ) } </b><br><span class="muted"> ${ esc ( w . type ) } · ${ esc ( w . target ) } </span></span><span class="badge ${ w . up ? 'good' : 'bad' } "> ${ w . up ? 'UP' : 'DOWN' } </span></div> ` ) ) } catch ( e ) { $ ( 'dot' ) . classList . remove ( 'ok' ) ; $ ( 'liveText' ) . textContent = '离线' ; $ ( 'identity' ) . textContent = e . message } }
async function loadLogs ( ) { try { const d = await get ( '/api/v1/logs?limit=200' ) ; rows ( 'logRows' , d . logs . reverse ( ) . map ( x => ` <div class="log"> ${ esc ( x . message ) } </div> ` ) ) } catch ( e ) { $ ( 'logRows' ) . innerHTML = ` <div class="error"> ${ esc ( e . message ) } </div> ` } }
async function loadConfig ( ) { try { const d = await get ( '/api/v1/config' ) ; original = d . config ; $ ( 'configRows' ) . innerHTML = Object . entries ( original ) . map ( ( [ section , values ] ) => ` <details class="config-section" ${ section === 'led' ? 'open' : '' } ><summary>[ ${ esc ( section ) } ]</summary><div class="fields"> ${ Object . entries ( values ) . map ( ( [ k , v ] ) => configField ( section , k , v ) ) . join ( '' ) } </div></details> ` ) . join ( '' ) } catch ( e ) { $ ( 'configRows' ) . innerHTML = ` <div class="error"> ${ esc ( e . message ) } </div> ` } }
$ ( 'refreshLogs' ) . onclick = loadLogs ; $ ( 'token' ) . value = localStorage . getItem ( 'pigwayToken' ) || '' ;
$ ( 'saveConfig' ) . onclick = async ( ) => { const token = $ ( 'token' ) . value . trim ( ) ; localStorage . setItem ( 'pigwayToken' , token ) ; const updates = { } ; document . querySelectorAll ( '#configRows [data-section]' ) . forEach ( i => { if ( i . value !== original [ i . dataset . section ] [ i . dataset . key ] ) { ( updates [ i . dataset . section ] ? ? = { } ) [ i . dataset . key ] = i . value } } ) ; if ( ! Object . keys ( updates ) . length ) { $ ( 'saveState' ) . textContent = '没有变化' ; return } try { $ ( 'saveState' ) . textContent = '正在应用…' ; const d = await get ( '/api/v1/config' , { method : 'PUT' , headers : { 'Content-Type' : 'application/json' , 'Authorization' : ` Bearer ${ token } ` } , body : JSON . stringify ( { updates } ) } ) ; $ ( 'saveState' ) . textContent = ` 已保存,备份 ${ d . backup } ` ; setTimeout ( loadConfig , 1500 ) } catch ( e ) { $ ( 'saveState' ) . textContent = e . message ; $ ( 'saveState' ) . className = 'error' } } ;
loadStatus ( ) ; setInterval ( loadStatus , 3000 ) ;
< header > < div > < h1 > PIGWay Pi Control< / h1 > < div class = "sub" id = "identity" > 正在连接树莓派 …< / div > < / div > < div class = "live" > < span class = "dot" id = "dot" > < / span > < span id = "liveText" > 离线< / span > < / div > < / header >
< nav > < button class = "active" data-tab = "overview" > 运行 状态< / button > < button data-tab = "logs" > 事件 日志< / button > < button data-tab = "config" > 系统 配置< / button > < / nav >
< section id = "overview" > < div class = "grid" >
< div class = "card" > < div class = "muted" > 处理器负载 < / div > < div class = "value" id = "cpu" > -- < / div > < div class = "muted" id = "freq" > -- < / div > < / div > < div class = "card" > < div class = "muted" > 处理器温度 < / div > < div class = "value" id = "temp" > -- < / div > < div class = "muted" id = "fan" > -- < / div > < / div > < div class = "card" > < div class = "muted" > 内存占用 < / div > < div class = "value" id = "memory" > -- < / div > < / div > < div class = "card" > < div class = "muted" > 磁盘占用 < / div > < div class = "value" id = "disk" > -- < / div > < / div > < div class = "card wide" > < div class = "muted" > 主网络 < / div > < div class = "value" id = "network" > -- < / div > < div class = "muted" id = "address" > -- < / div > < / div > < div class = "card wide" > < div class = "muted" > 显示状态 < / div > < div class = "value" id = "owner" > -- < / div > < div class = "muted" id = "rgb" > -- < / div > < / div >
< / div > < div class = "panel" > < h2 > 当前告警 < / h2 > < div class = "rows" id = "alerts" > < div class = "muted" > 无告警 < / div > < / div > < / div > < div class = "panel" > < h2 > 服务与进程监控 < / h2 > < div class = "rows " id = "watches " > < / div > < / div > < / section >
< section id = "logs" class = "hidden " > < div class = "panel " > < div class = "toolbar" > < button id = "refreshLogs "> 刷新日志 < / button > < span class = "muted" > 最近 200 条硬件 Agent 事件 < / span > < / div > < div id = "logRows "> < / div > < / div > < / section >
< section id = "config" class = "hidden" > < div class = "panel" > < h2 > 系统配置 < / h2 > < p class = "muted" > 配置按用途分组,只显示当前模式需要的选项。保存时会备份配置并重启硬件 Agent。 < / p > < div class = "toolbar" > < input id = "token" type = "password" placeholder = "配置写入令牌(Bearer Token) " > < button id = "toggleToken" > 显示令牌 < / button > < / div > < div class = "config-shell" > < aside class = "config-menu" id = "configMenu" > < / aside > < div id = "configRows" > < / div > < / div > < div class = "savebar" > < button id = "saveConfig" > 保存并应用 < / button > < span id = "saveState" class = "muted" > 尚未修改 < / span > < / div > < / div > < / section >
< / main > < script >
const $ = id => document . getElementById ( id ) , tabs = [ ... document . querySelectorAll ( 'nav button' ) ] ; let original = { } ; const esc = s => String ( s ? ? '' ) . replace ( /[&<>"']/g , c => ( { '&' : '&' , '<' : '<' , '>' : '>' , '"' : '"' , "'" : ''' } [ c ] ) ) ;
const sectionMeta = { led : [ '灯光设置' , '正常灯效与告警颜色' ] , alerts : [ '告警阈值' , '处理器、内存、温度、磁盘和 Wi-Fi' ] , fan : [ '风扇温控' , '温度档位与降档回差' ] , timing : [ '刷新周期' , '传感器、屏幕、网络和健康检查' ] , display : [ '屏幕布局' , 'OLED 四行文字位置' ] , dark _mode : [ '板载指示灯' , '树莓派电源灯与活动灯' ] , services : [ '服务监控' , '监控 systemd 服务' ] , processes : [ '进程监控' , '监控普通进程关键字' ] , api : [ 'Web 与 API' , '监听地址、端口和日志数量' ] } ;
const labels = { 'led.normal_mode' : '正常状态灯效' , 'led.normal_r' : '红色通道' , 'led.normal_g' : '绿色通道' , 'led.normal_b' : '蓝色通道' , 'led.normal_flash_on_ms' : '亮灯时间' , 'led.normal_flash_off_ms' : '灭灯时间' , 'led.normal_effect_speed' : '内置效果速度' , 'led.normal_effect_color' : '内置效果颜色' , 'led.warning_flash_on_ms' : '普通告警亮灯' , 'led.warning_flash_off_ms' : '普通告警灭灯' , 'led.critical_flash_on_ms' : '严重告警亮灯' , 'led.critical_flash_off_ms' : '严重告警灭灯' , 'led.emergency_flash_on_ms' : '紧急告警亮灯' , 'led.emergency_flash_off_ms' : '紧急告警灭灯' , 'led.write_delay_ms' : 'RGB 寄存器写入间隔' , 'alerts.cpu_high' : '处理器高负载阈值' , 'alerts.cpu_recover' : '处理器恢复阈值' , 'alerts.cpu_high_seconds' : '处理器持续时间' , 'alerts.memory_high' : '内存高占用阈值' , 'alerts.memory_recover' : '内存恢复阈值' , 'alerts.memory_high_seconds' : '内存持续时间' , 'alerts.temp_high' : '高温告警阈值' , 'alerts.temp_critical' : '严重高温阈值' , 'alerts.disk_high' : '磁盘高占用阈值' , 'alerts.disk_critical' : '磁盘严重阈值' , 'alerts.wifi_weak' : 'Wi-Fi 弱信号阈值' , 'alerts.wifi_recover' : 'Wi-Fi 恢复阈值' , 'alerts.wifi_weak_seconds' : '弱信号持续时间' , 'fan.start' : '风扇启动温度' , 'fan.level2' : '二档起始温度' , 'fan.level3' : '三档起始温度' , 'fan.level4' : '四档起始温度' , 'fan.max' : '全速起始温度' , 'fan.hysteresis' : '降档温度回差' , 'timing.sensor_interval' : '传感器采样周期' , 'timing.oled_refresh_interval' : 'OLED 刷新周期' , 'timing.page_interval' : 'OLED 页面轮播周期' , 'timing.network_interval' : '网络检查周期' , 'timing.service_interval' : '服务检查周期' , 'timing.health_interval' : '健康检查周期' , 'display.row1_y' : '第一行纵坐标' , 'display.row2_y' : '第二行纵坐标' , 'display.row3_y' : '第三行纵坐标' , 'display.row4_y' : '第四行纵坐标' , 'dark_mode.pi_pwr_led' : '树莓派电源灯' , 'dark_mode.pi_act_led' : '树莓派活动灯' , 'api.bind' : '监听地址' , 'api.port' : '监听端口' , 'api.log_limit' : '默认日志条数' } ;
[ 'cpu' , 'power' , 'memory' , 'storage' , 'network' , 'service' ] . forEach ( g => [ 'r' , 'g' , 'b' ] . forEach ( c => labels [ ` led. ${ g } _ ${ c } ` ] = ` ${ ( { r : '红' , g : '绿' , b : '蓝' } )[c]}色通道 ` ) ) ;
const hints = { 'led.normal_mode' : '仅在没有当前告警时生效;告警会立即抢占。' , 'led.normal_r' : '范围 0– 255。' , 'led.normal_g' : '范围 0– 255。' , 'led.normal_b' : '范围 0– 255。' , 'led.normal_flash_on_ms' : '范围 50– 60000 毫秒。' , 'led.normal_flash_off_ms' : '范围 50– 60000 毫秒。' , 'led.normal_effect_speed' : 'MCU 内置速度档位。' , 'led.normal_effect_color' : '仅流水灯和呼吸灯使用。' , 'led.write_delay_ms' : '硬件校准项,通常保持 10 毫秒。' , 'alerts.wifi_weak' : '单位 dBm,数值越接近 0 信号越强。' , 'alerts.wifi_recover' : '单位 dBm,应高于弱信号阈值。' , 'fan.hysteresis' : '避免临界温度附近频繁切换档位。' , 'api.bind' : '0.0.0.0 允许局域网访问;127.0.0.1 仅本机。' , 'api.port' : '范围 1– 65535;保存后 API 自动重启。' , 'dark_mode.pi_pwr_led' : '选择“关闭”时由 PIGWay 熄灭板载灯。' , 'dark_mode.pi_act_led' : '选择“关闭”时由 PIGWay 熄灭板载灯。' } ;
const options = { 'led.normal_mode' : [ [ 'off' , '全灭' ] , [ 'solid' , '自定义颜色常亮' ] , [ 'flash' , '自定义颜色闪烁' ] , [ 'flow' , '流水灯' ] , [ 'breathe' , '呼吸灯' ] , [ 'marquee' , '跑马灯' ] , [ 'rainbow' , '彩虹灯' ] , [ 'colorful' , '炫彩灯' ] ] , 'led.normal_effect_speed' : [ [ '1' , '慢速' ] , [ '2' , '中速' ] , [ '3' , '快速' ] ] , 'led.normal_effect_color' : [ [ 'red' , '红色' ] , [ 'green' , '绿色' ] , [ 'blue' , '蓝色' ] , [ 'yellow' , '黄色' ] , [ 'purple' , '紫色' ] , [ 'cyan' , '青色' ] , [ 'white' , '白色' ] ] , 'dark_mode.pi_pwr_led' : [ [ 'off' , '关闭' ] , [ 'on' , '不干预' ] ] , 'dark_mode.pi_act_led' : [ [ 'off' , '关闭' ] , [ 'on' , '不干预' ] ] } ;
const numberRules = { 'led.normal_r' : [ 0 , 255 , 1 ] , 'led.normal_g' : [ 0 , 255 , 1 ] , 'led.normal_b' : [ 0 , 255 , 1 ] , 'led.normal_flash_on_ms' : [ 50 , 60000 , 10 ] , 'led.normal_flash_off_ms' : [ 50 , 60000 , 10 ] , 'led.warning_flash_on_ms' : [ 50 , 60000 , 10 ] , 'led.warning_flash_off_ms' : [ 50 , 60000 , 10 ] , 'led.critical_flash_on_ms' : [ 50 , 60000 , 10 ] , 'led.critical_flash_off_ms' : [ 50 , 60000 , 10 ] , 'led.emergency_flash_on_ms' : [ 50 , 60000 , 10 ] , 'led.emergency_flash_off_ms' : [ 50 , 60000 , 10 ] , 'led.write_delay_ms' : [ 1 , 1000 , 1 ] , 'alerts.cpu_high' : [ 0 , 100 , 1 ] , 'alerts.cpu_recover' : [ 0 , 100 , 1 ] , 'alerts.cpu_high_seconds' : [ 1 , 86400 , 1 ] , 'alerts.memory_high' : [ 0 , 100 , 1 ] , 'alerts.memory_recover' : [ 0 , 100 , 1 ] , 'alerts.memory_high_seconds' : [ 1 , 86400 , 1 ] , 'alerts.temp_high' : [ 0 , 120 , . 1 ] , 'alerts.temp_critical' : [ 0 , 120 , . 1 ] , 'alerts.disk_high' : [ 0 , 100 , 1 ] , 'alerts.disk_critical' : [ 0 , 100 , 1 ] , 'alerts.wifi_weak' : [ - 120 , 0 , 1 ] , 'alerts.wifi_recover' : [ - 120 , 0 , 1 ] , 'alerts.wifi_weak_seconds' : [ 1 , 86400 , 1 ] , 'fan.start' : [ 0 , 100 , . 1 ] , 'fan.level2' : [ 0 , 100 , . 1 ] , 'fan.level3' : [ 0 , 100 , . 1 ] , 'fan.level4' : [ 0 , 100 , . 1 ] , 'fan.max' : [ 0 , 100 , . 1 ] , 'fan.hysteresis' : [ 0 , 20 , . 1 ] , 'timing.sensor_interval' : [ . 2 , 3600 , . 1 ] , 'timing.oled_refresh_interval' : [ . 2 , 3600 , . 1 ] , 'timing.page_interval' : [ 1 , 3600 , . 1 ] , 'timing.network_interval' : [ 1 , 3600 , . 1 ] , 'timing.service_interval' : [ 1 , 3600 , . 1 ] , 'timing.health_interval' : [ . 2 , 3600 , . 1 ] , 'display.row1_y' : [ 0 , 31 , 1 ] , 'display.row2_y' : [ 0 , 31 , 1 ] , 'display.row3_y' : [ 0 , 31 , 1 ] , 'display.row4_y' : [ 0 , 31 , 1 ] , 'api.port' : [ 1 , 65535 , 1 ] , 'api.log_limit' : [ 1 , 1000 , 1 ] } ; [ 'cpu' , 'power' , 'memory' , 'storage' , 'network' , 'service' ] . forEach ( g => [ 'r' , 'g' , 'b' ] . forEach ( c => numberRules [ ` led. ${ g } _ ${ c } ` ] = [ 0 , 255 , 1 ] ) ) ;
const sectionOrder = [ 'led' , 'alerts' , 'fan' , 'timing' , 'display' , 'dark_mode' , 'services' , 'processes' , 'api' ] , alertColorGroups = [ [ 'cpu' , '处理器、温度与散热' ] , [ 'power' , '供电与欠压' ] , [ 'memory' , '内存' ] , [ 'storage' , '存储' ] , [ 'network' , '网络与 Wi-Fi' ] , [ 'service' , '服务与进程' ] ] ;
const ownerNames = { NORMAL _HOME : '正常主页' , ROOT _READONLY : '根文件系统只读' , TEMP _CRITICAL : '处理器温度严重' , POWER _UNDERVOLT : '供电欠压' , THROTTLED : '处理器降频' , FREQ _CAPPED : '频率受限' , NETWORK _DOWN : '网络断开' , INTERNET _DOWN : '互联网断开' , SERVICE _DOWN : '服务停止' , PROCESS _DOWN : '进程停止' , DISK _CRITICAL : '磁盘严重不足' , TEMP _HIGH : '处理器温度偏高' , CPU _HIGH : '处理器负载过高' , MEMORY _HIGH : '内存占用过高' , DISK _HIGH : '磁盘空间不足' , WIFI _WEAK : 'Wi-Fi 信号弱' } , rgbNames = { OFF : '全灭' , NORMAL _SOLID : '正常 · 自定义常亮' , NORMAL _FLASH : '正常 · 自定义闪烁' , NORMAL _FLOW : '正常 · 流水灯' , NORMAL _BREATHE : '正常 · 呼吸灯' , NORMAL _MARQUEE : '正常 · 跑马灯' , NORMAL _RAINBOW : '正常 · 彩虹灯' , NORMAL _COLORFUL : '正常 · 炫彩灯' } , eventNames = { START : '程序启动' , STOP : '程序停止' , ALERT _ACTIVE : '告警出现' , ALERT _RECOVERED : '告警恢复' , DISPLAY _OWNER : '显示对象切换' , RGB _MODE : 'RGB 模式切换' , INTERNET _DOWN : '互联网断开' , INTERNET _RECOVERED : '互联网恢复' , NETWORK _DOWN : '网络断开' , NETWORK _RECOVERED : '网络恢复' , POWER _LOW : '供电欠压' , POWER _RECOVERED : '供电恢复' } ;
function ownerName ( id ) { const [ type , suffix ] = String ( id || '' ) . split ( '|' , 2 ) ; return ( ownerNames [ type ] || type ) + ( suffix ? ` · ${ suffix } ` : '' ) } function rgbName ( mode ) { if ( rgbNames [ mode ] ) return rgbNames [ mode ] ; const p = String ( mode || '' ) . split ( '_' ) , groups = { CPU : '处理器' , POWER : '供电' , MEMORY : '内存' , STORAGE : '存储' , NETWORK : '网络' , SERVICE : '服务' } , levels = { WARNING : '普通告警' , CRITICAL : '严重告警' , EMERGENCY : '紧急告警' } ; return ` ${ groups [ p [ 0 ] ] || p [ 0 ] } · ${ levels [ p [ 1 ] ] || p [ 1 ] } 闪烁 ` }
function field ( section , key , value , extra = '' ) { const id = ` ${ section } . ${ key } ` , opts = options [ id ] , rule = numberRules [ id ] , label = labels [ id ] || key , help = hints [ id ] || '' ; const control = opts ? ` <select data-section=" ${ esc ( section ) } " data-key=" ${ esc ( key ) } "> ${ opts . map ( ( [ v , l ] ) => ` <option value=" ${ v } " ${ String ( value ) === v ? 'selected' : '' } > ${ l } </option> ` ) . join ( '' ) } </select> ` : ` <input ${ rule ? 'type="number"' : '' } ${ rule ? ` min=" ${ rule [ 0 ] } " max=" ${ rule [ 1 ] } " step=" ${ rule [ 2 ] } " ` : '' } data-section=" ${ esc ( section ) } " data-key=" ${ esc ( key ) } " value=" ${ esc ( value ) } "> ` ; return ` <div class="field ${ extra } "><label> ${ esc ( label ) } ${ control } </label> ${ help ? ` <div class="hint"> ${ esc ( help ) } </div> ` : '' } </div> ` }
function rgbGroup ( values , prefix , title ) { const r = values [ ` ${ prefix } _r ` ] , g = values [ ` ${ prefix } _g ` ] , b = values [ ` ${ prefix } _b ` ] ; return ` <div class="group"><div class="group-head"><h3> ${ title } </h3><span class="muted">RGB 自定义颜色</span></div><div class="rgb-row"><span class="swatch" data-swatch=" ${ prefix } " style="background:rgb( ${ r } , ${ g } , ${ b } )"></span> ${ [ 'r ' , 'g ', 'b ' ] . map ( c => field ( 'led' , ` ${ prefix } _ ${ c } ` , values [ ` ${ prefix } _ ${ c } ` ] ) ) . join ( '' ) } </div></div> ` }
function normalGroup ( v ) { return ` <div class="group"><div class="group-head"><div><h3>正常运行灯效</h3><div class="hint">没有告警时显示;告警出现后自动让位。</div></div></div><div class="fields"> ${ field ( 'led' , 'normal_mode' , v . normal _mode , 'span2' ) } <div class="normal-field span2" data-modes="solid flash"> ${ rgbGroup ( v , 'normal' , '自定义颜色' ) } </div><div class="normal-field" data-modes="flash"> ${ field ( 'led' , 'normal_flash_on_ms' , v . normal _flash _on _ms ) } </div><div class="normal-field" data-modes="flash"> ${ field ( 'led' , 'normal_flash_off_ms' , v . normal _flash _off _ms ) } </div><div class="normal-field" data-modes="flow breathe marquee rainbow colorful"> ${ field ( 'led' , 'normal_effect_speed' , v . normal _effect _speed ) } </div><div class="normal-field" data-modes="flow breathe"> ${ field ( 'led' , 'normal_effect_color' , v . normal _effect _color ) } </div></div></div> ` }
function renderLed ( v ) { const flash = [ 'warning' , 'critical' , 'emergency' ] . map ( ( x , i ) => ` <div class="group"><h3> ${ [ '普通告警' , '严重告警 ', '紧急告警 ' ] [ i ] } 闪烁节奏</h3><div class="fields"> ${ field ( 'led' , ` ${ x } _flash_on_ms ` , v [ ` ${ x } _flash_on_ms ` ] ) } ${ field ( 'led' , ` ${ x } _flash_off_ms ` , v [ ` ${ x } _flash_off_ms ` ] ) } </div></div> ` ) . join ( ' ') , advanced = v . write _delay _ms === undefined ? '' : ` <div class="group"><h3>硬件高级参数</h3><div class="fields"> ${ field ( 'led' , 'write_delay_ms' , v . write _delay _ms ) } </div></div> ` ; return normalGroup ( v ) + ` <div class="group"><div class="group-head"><div><h3>告警对象颜色</h3><div class="hint">告警对象决定颜色,严重程度决定闪烁速度。</div></div></div> ${ alertColorGroups . map ( ( [ p , t ] ) => rgbGroup ( v , p , t ) ) . join ( '' ) } </div> ` + flash + advanced }
function renderGeneral ( s , v ) { const [ t , d ] = sectionMeta [ s ] || [ s , '' ] ; return ` <div class="group"><div class="group-head"><div><h3> ${ t } </h3><div class="hint"> ${ d } </div></div></div><div class="fields"> ${ Object . entries ( v ) . map ( ( [ k , x ] ) => field ( s , k , x ) ) . join ( '' ) || '<div class="muted">暂无配置项</div>' } </div></div> ` }
function updateConditional ( ) { const mode = document . querySelector ( '[data-section="led"][data-key="normal_mode"]' ) ? . value || 'off' ; document . querySelectorAll ( '.normal-field' ) . forEach ( x => x . classList . toggle ( 'hidden' , ! x . dataset . modes . split ( ' ' ) . includes ( mode ) ) ) ; updateSwatches ( ) } function updateSwatches ( ) { document . querySelectorAll ( '[data-swatch]' ) . forEach ( s => { const p = s . dataset . swatch , read = c => Number ( document . querySelector ( ` [data-section="led"][data-key=" ${ p } _ ${ c } "] ` ) ? . value || 0 ) ; s . style . background = ` rgb( ${ read ( 'r' ) } , ${ read ( 'g ' ) } , ${ read ( 'b' ) } ) ` } ) }
function renderConfig ( ) { const ss = sectionOrder . filter ( s => original [ s ] ) ; $ ( 'configMenu' ) . innerHTML = ss . map ( ( s , i ) => ` <button class=" ${ i === 0 ? 'active' : '' } " data-config-section=" ${ s } "> ${ sectionMeta [ s ] ? . [ 0 ] || s } </button> ` ) . join ( '' ) ; $ ( 'configRows' ) . innerHTML = ss . map ( ( s , i ) => ` <section class="config-page ${ i === 0 ? 'active' : '' } " data-config-page=" ${ s } "><h2> ${ sectionMeta [ s ] ? . [ 0 ] || s } </h2><p class="muted"> ${ sectionMeta [ s ] ? . [ 1 ] || '' } </p> ${ s === 'led' ? renderLed ( original [ s ] ) : renderGeneral ( s , original [ s ] ) } </section> ` ) . join ( '' ) ; $ ( 'configMenu' ) . onclick = e => { const b = e . target . closest ( 'button' ) ; if ( ! b ) return ; document . querySelectorAll ( '[data-config-section]' ) . forEach ( x => x . classList . toggle ( 'active' , x === b ) ) ; document . querySelectorAll ( '[data-config-page]' ) . forEach ( x => x . classList . toggle ( 'active' , x . dataset . configPage === b . dataset . configSection ) ) } ; $ ( 'configRows' ) . oninput = e => { if ( e . target . matches ( '[data-section="led"]' ) ) updateConditional ( ) ; markChanged ( ) } ; updateConditional ( ) }
function markChanged ( ) { let n = 0 ; document . querySelectorAll ( '#configRows [data-section]' ) . forEach ( i => { if ( i . value !== original [ i . dataset . section ] [ i . dataset . key ] ) n ++ } ) ; $ ( 'saveState' ) . textContent = n ? ` 有 ${ n } 项尚未保存 ` : '尚未修改' ; $ ( 'saveState' ) . className = n ? '' : 'muted' }
tabs . forEach ( b => b . onclick = ( ) => { tabs . forEach ( x => x . classList . toggle ( 'active' , x === b ) ) ; [ 'overview' , 'logs' , 'config' ] . forEach ( id => $ ( id ) . classList . toggle ( 'hid den ', id !== b . dataset . tab ) ) ; if ( b . dataset . tab === 'logs' ) loadLogs ( ) ; if ( b . dataset . tab === 'config' ) loadConfig ( ) } ) ; async function get ( url , opt ) { const r = await fetch ( url , opt ) , text = await r . text ( ) ; let j ; try { j = JSON . parse ( text ) } catch { throw Error ( ` 服务器返回异常 ( ${ r . status } ) ` ) } if ( ! r . ok ) throw Error ( j . error || ` 请求失败 ( ${ r . status } ) ` ) ; return j } function rows ( target , items , empty = '暂无数据' ) { $ ( target ) . innerHTML = items . length ? items . join ( '' ) : ` <div class="muted"> ${ empty } </div> ` }
async function loadStatus ( ) { try { const s = await get ( '/api/v1/status' ) , y = s . system , n = s . network , d = s . display ; $ ( 'dot' ) . classList . add ( 'ok' ) ; $ ( 'liveText' ) . textContent = '在线' ; $ ( 'identity' ) . textContent = ` 版本 ${ s . version } · 已运行 ${ Math . floor ( s . uptime _seconds / 3600 ) } 小时 · ${ new Date ( s . timestamp * 1000 ) . toLocaleTimeString ( ) } ` ; $ ( 'cpu' ) . textContent = ` ${ y . cpu _percent } % ` ; $ ( 'freq' ) . textContent = ` 频率 ${ y . cpu _freq _mhz } MHz ` ; $ ( 'temp' ) . textContent = ` ${ y . temperature _c } °C ` ; $ ( 'fan' ) . textContent = ` 风扇 ${ ( { OFF : '关闭' , L1 : '一档' , L2 : '二档' , L3 : '三档' , L4 : '四档' , MAX : '全速' } )[y.fan_name]||y.fan_name} ` ; $ ( 'memory' ) . textContent = ` ${ y . memory _percent } % ` ; $ ( 'disk' ) . textContent = ` ${ y . disk _percent } % ` ; $ ( 'network' ) . textContent = ` ${ n . kind === 'WIF' ? '无线网络' : n . kind === 'ETH' ? '有线网络' : n . kind } ${ n . metric } ` ; $ ( 'address' ) . textContent = ` ${ n . interface } · ${ n . ip _label === 'IP4' ? 'IPv4' : 'IPv6' } ${ n . ip } · 互联网 ${ n . internet ? '正常' : '断开' } ` ; $ ( 'owner' ) . textContent = ownerName ( d . owner ) ; $ ( 'rgb' ) . textContent = ` OLED: ${ ownerName ( d . oled _page ) } · RGB: ${ rgbName ( d . rgb _mode ) } ` ; rows ( 'a lerts' , d . alerts . map ( a => ` <div class="row"><span><b> ${ esc ( ownerName ( a . id ) ) } </b><br><span class="muted"> ${ esc ( a . l2 ) } ${ esc ( a . l3 ) } </span></span><span class="badge bad">等级 ${ a . severity } · 优先级 ${ a . priority } </span></div> ` ) , '当前没有告警' ) ; rows ( 'watches' , s . watches . map ( w => ` <div class="row"><span><b> ${ esc ( w . name ) } </b><br><span class="muted"> ${ w . type === 'service' ? '系统服务' : '普通进程' } · ${ esc ( w . target ) } </span></span><span class="badge ${ w . up ? 'good' : 'bad' } "> ${ w . up ? '正常' : '停止' } </span></div> ` ) ) } catch ( e ) { $ ( 'dot' ) . classList . remove ( 'ok' ) ; $ ( 'liveText' ) . textContent = '离线' ; $ ( 'identity' ) . textContent = e . message } }
async function loadLogs ( ) { try { const d = await get ( '/api/v1/logs?limit=200' ) ; rows ( 'logRows' , d . logs . reverse ( ) . map ( x => { const m = String ( x . message ) . match ( /event=([^ ]+)/ ) , label = m ? ( eventNames [ m [ 1 ] ] || m [ 1 ] ) : '系统事件' ; return ` <div class="log"><span class="log-label"> ${ esc ( label ) } </span> ${ esc ( x . message ) } </div> ` } ) ) } catch ( e ) { $ ( 'logRows' ) . innerHTML = ` <div class="error"> ${ esc ( e . message ) } </div> ` } } async function loadConfig ( ) { try { const d = await get ( '/api/v1/config' ) ; original = d . config ; renderConfig ( ) ; markChanged ( ) } catch ( e ) { $ ( 'configRows' ) . innerHTML = ` <div class="error"> ${ esc ( e . message ) } </div> ` } }
$ ( 'refreshLogs' ) . onclick = loadLogs ; $ ( 'token' ) . value = localStorage . getItem ( 'pigwayToken' ) || '' ; $ ( 'toggleToken' ) . onclick = ( ) => { const i = $ ( 'token' ) , show = i . type === 'password' ; i . type = show ? 'text' : 'password' ; $ ( 'toggleToken' ) . textContent = show ? '隐藏令牌' : '显示令牌' } ; $ ( 'saveConfig' ) . onclick = async ( ) => { const token = $ ( 'token' ) . value . trim ( ) ; localStorage . setItem ( 'pigwayToken' , token ) ; const updates = { } ; document . querySelectorAll ( '#configRows [data-section]' ) . forEach ( i => { if ( i . value !== original [ i . dataset . section ] [ i . dataset . key ] ) { ( updates [ i . dataset . section ] ? ? = { } ) [ i . dataset . key ] = i . value } } ) ; if ( ! Object . keys ( updates ) . length ) { $ ( 'saveState' ) . textContent = '没有需要保存的修改' ; return } try { $ ( 'saveConfig' ) . disabled = true ; $ ( 'saveState' ) . textContent = '正在保存并应用…' ; const d = await get ( '/api/v1/config' , { method : 'PUT' , headers : { 'Content-Type' : 'application/json' , 'Authorization' : ` Bearer ${ token } ` } , body : JSON . stringify ( { updates } ) } ) ; $ ( 'saveState' ) . textContent = ` 保存成功,已创建备份 ${ d . backup } ` ; $ ( 'saveState' ) . className = 'success' ; setTimeout ( loadConfig , 1500 ) } catch ( e ) { $ ( 'saveState' ) . textContent = e . message ; $ ( 'saveState' ) . className = 'error' } finally { $ ( 'saveConfig' ) . disabled = false } } ; loadStatus ( ) ; setInterval ( loadStatus , 3000 ) ;
< / script > < / body > < / html >