// 管理 Tab:分组/日志/用户
// ================== Tab 3: 分组 ==================
async function loadGroups(){
const r=await apiGet('/api/groups');if(!r)return;
_groupsList=r.groups||[];
const dr=await apiGet('/api/devices');
// items 带名称(设备身份);老接口只有 serial 列表时兜底成对象
_devicesList=(dr&&dr.items)?dr.items
:((dr&&dr.devices)||[]).map(s=>({serial:s,name:'',model:''}));
renderGroupTable();
}
function renderGroupTable(){
const groups=_groupsList||[];
setListPager('groups',{
data:()=>groups,
filter:(g,kw)=>((g.name||'').toLowerCase().includes(kw)||(g.description||'').toLowerCase().includes(kw)||(g.serials||[]).join(' ').toLowerCase().includes(kw)),
sortValue:(g,k)=>{ if(k==='count')return (g.serials||[]).length; return g[k]||''; },
render:renderGroupRow,
tbody:'tb-groups',pager:'pager-groups',pageSize:10,
empty:'
| 暂无分组,点击"新建分组"创建 |
'
});
applyListPager('groups');
initListSearch('groups','search-groups');
}
function renderGroupRow(g){
const serials=g.serials||[];
return ''+
'| '+esc(g.name)+' | '+
''+esc(g.description||'-')+' | '+
''+serials.length+' | '+
''+(serials.length?serials.map(esc).join(', '):'-')+' | '+
''+
(_can('tasks')?(
''+
''
):'只读')+
' | '+
'
';
}
function openGroupModal(name){
const group=name?(_groupsList||[]).find(g=>g.name===name):null;
const isEdit=!!group;
document.getElementById('modal-title').textContent=isEdit?'编辑分组':'新建分组';
const g=group||{name:'',description:'',serials:[]};
const current= new Set(g.serials||[]);
const nameOf={};
(_devicesList||[]).forEach(x=>{ if(x&&x.serial) nameOf[x.serial]=x.name||''; });
const allSerials=[...new Set([...(_devicesList||[]).map(x=>x.serial||x), ...(g.serials||[])])].sort();
const deviceCheckboxes=allSerials.map(s=>
''
).join('');
document.getElementById('modal-body').innerHTML=
''+
''+
'';
document.getElementById('modal-footer').innerHTML=
''+
'';
showModal();
}
async function saveGroup(name){
const newName=document.getElementById('g-name').value.trim();
if(!newName){showToast('分组名不能为空','error');return;}
const desc=document.getElementById('g-description').value;
const serials=Array.from(document.querySelectorAll('.device-list input:checked')).map(c=>c.value);
const data={name:newName,description:desc,serials:serials};
if(name){
const r=await apiPut('/api/groups/'+encodeURIComponent(name),data);
handleResult(r,()=>{closeModal();loadGroups();});
}else{
const r=await apiPost('/api/groups',data);
handleResult(r,()=>{closeModal();loadGroups();});
}
}
async function deleteGroup(name){
if(!confirm('确认删除分组"'+name+'"吗?'))return;
const r=await apiDelete('/api/groups/'+encodeURIComponent(name));
handleResult(r,()=>loadGroups());
}
// ================== Tab 4: 日志 ==================
// 条件(文件/关键字/级别/时间)→ /api/logs(结构化行);下载走 /api/logs/download
function _logParams(){
const v=id=>document.getElementById(id);
const p=new URLSearchParams();
p.set('file', v('log-file').value||'');
p.set('lines', v('log-lines').value);
const q=v('log-q').value.trim(); if(q)p.set('q',q);
const lv=v('log-level').value; if(lv)p.set('level',lv);
const since=v('log-since').value; if(since)p.set('since',since);
const until=v('log-until').value; if(until)p.set('until',until);
return p;
}
// 文件下拉由接口返回的清单渲染(写死的选项会漏掉新增模块,如 notify.log)
function _renderLogFiles(files,current){
const sel=document.getElementById('log-file');
const sig=(files||[]).map(f=>f.name).join(',');
if(sel.dataset.sig!==sig){ // 只在清单变化时重建,避免打断当前选择
sel.dataset.sig=sig;
sel.innerHTML=(files||[]).map(f=>
'').join('');
}
if(current)sel.value=current;
}
async function loadLogs(){
const el=document.getElementById('log-content');
if(!el)return;
// 用户正往上翻看历史时不要被自动刷新拽回底部
const atBottom=el.scrollHeight-el.scrollTop-el.clientHeight<40;
const r=await apiGet('/api/logs?'+_logParams().toString());
if(!r)return;
_renderLogFiles(r.files,r.file);
const hint=document.getElementById('log-hint');
if(!r.ok){
el.innerHTML=''+esc(r.error||'读取失败')+'';
hint.textContent='';
return;
}
const rows=r.rows||[], kw=document.getElementById('log-q').value.trim();
el.innerHTML=rows.length
? rows.map(row=>_logRowHtml(row,kw)).join('')
: '(无匹配行)';
let text=r.file+' · 命中 '+r.matched+' 行'
+(rows.length['+esc(lvl)+'] ['
+esc(r.module)+'] ';
return ''+head+_logHl(r.msg,kw)+'
';
}
// 关键字高亮:**先转义再匹配**——反过来的话消息里的 < > 会先被吃成实体
function _logHl(text,kw){
const safe=esc(text||'');
if(!kw)return safe;
const k=esc(kw);
if(!k)return safe;
const re=new RegExp(k.replace(/[.*+?^${}()|[\]\\]/g,'\\$&'),'gi');
return safe.replace(re,m=>''+m+'');
}
function downloadLogs(){
window.location='/api/logs/download?'+_logParams().toString();
}
function resetLogFilters(){
['log-q','log-since','log-until'].forEach(id=>{document.getElementById(id).value='';});
document.getElementById('log-level').value='';
loadLogs();
}
function toggleLogAuto(){
if(_logTimer){clearInterval(_logTimer);_logTimer=null;}
if(document.getElementById('log-auto').checked && _activeTab==='logs'){
_logTimer=setInterval(loadLogs,3000);
}
}
// ================== Tab 5: 用户 ==================
async function loadUsers(){
const r=await apiGet('/api/users');if(!r)return;
const users=r.users||[];
setListPager('users',{
data:()=>users,
filter:(u,kw)=>(u.username||'').toLowerCase().includes(kw),
sortValue:(u,k)=>{ if(k==='is_admin')return u.is_admin?1:0; return u[k]||''; },
render:renderUserRow,
tbody:'tb-users',pager:'pager-users',pageSize:10,
empty:'| 暂无用户 |
'
});
applyListPager('users');
initListSearch('users','search-users');
}
function renderUserRow(u){
const admin=u.is_admin?'管理员':'普通';
const perms=(u.perms||[]).map(p=>''+(PERM_LABELS[p]||p)+'').join(' ')
||'-';
return ''+
'| '+u.id+' | '+
''+esc(u.username)+' | '+
''+admin+' | '+
''+perms+' | '+
''+
''+
(u.username==='admin'?'':'')+
' | '+
'
';
}
function togglePermBoxes(){
// 勾选"管理员权限"时:权限位全选并禁用(管理员拥有全部权限)
const isAdmin=document.getElementById('u-is_admin').checked;
document.querySelectorAll('.u-perm').forEach(cb=>{
cb.disabled=isAdmin;
if(isAdmin)cb.checked=true;
});
}
function openUserModal(uid){
const isEdit=!!uid;
document.getElementById('modal-title').textContent=isEdit?'编辑用户':'新建用户';
const permBoxes=Object.keys(PERM_LABELS).map(p=>
''
).join('');
document.getElementById('modal-body').innerHTML=
''+
''+
''+
'';
document.getElementById('modal-footer').innerHTML=
''+
'';
if(isEdit){
// 预填充(从表格数据获取)
apiGet('/api/users').then(r=>{
const u=(r&&r.users||[]).find(x=>x.id===uid);
if(u){
document.getElementById('u-username').value=u.username;
document.getElementById('u-is_admin').checked=u.is_admin;
document.querySelectorAll('.u-perm').forEach(cb=>{
cb.checked=(u.perms||[]).includes(cb.value);
});
togglePermBoxes();
}
});
}else{
togglePermBoxes();
}
showModal();
}
async function saveUser(uid){
const username=document.getElementById('u-username').value.trim();
const password=document.getElementById('u-password').value;
const is_admin=document.getElementById('u-is_admin').checked;
const perms=[...document.querySelectorAll('.u-perm:checked')].map(cb=>cb.value);
if(uid){
const data={is_admin:is_admin,perms:perms};
if(password)data.password=password;
const r=await apiPut('/api/users/'+uid,data);
handleResult(r,()=>{closeModal();loadUsers();});
}else{
if(!username||!password){showToast('用户名和密码不能为空','error');return;}
const r=await apiPost('/api/users',{username:username,password:password,is_admin:is_admin,perms:perms});
handleResult(r,()=>{closeModal();loadUsers();});
}
}
async function deleteUser(uid){
if(!confirm('确认删除此用户吗?'))return;
const r=await apiDelete('/api/users/'+uid);
handleResult(r,()=>loadUsers());
}
// ================== 初始化 ==================
initCsrf(); // 获取 CSRF token(非 GET 请求需携带)
loadMe(); // 获取当前用户权限,隐藏无权限的功能入口
showTab('monitor');