- 在 normalizeUsageCandidate 中添加缺失的令牌计数回退字段(inputTokenCount/outputTokenCount) - 确保凭证切换重试上下文始终可用 - 为令牌数量添加紧凑格式化函数(K/M/G 单位),在多个统计页面中应用 - 更新版本号至 2.13.4
129 lines
22 KiB
HTML
129 lines
22 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<meta name="theme-color" content="#059669">
|
||
<title>模型用量统计</title>
|
||
<link rel="stylesheet" href="app/base.css">
|
||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||
<style>
|
||
body{background:var(--bg-secondary);min-height:100vh}.navbar{position:sticky;top:0;z-index:100;background:var(--bg-primary);border-bottom:1px solid var(--border-color);box-shadow:var(--shadow-sm)}.navbar-inner,.main{max-width:1400px;margin:0 auto;padding:0 24px}.navbar-inner{height:64px;display:flex;align-items:center;justify-content:space-between;gap:16px}.brand{display:flex;align-items:center;gap:12px;min-width:0}.brand-icon{width:40px;height:40px;border-radius:12px;display:inline-flex;align-items:center;justify-content:center;background:linear-gradient(135deg,var(--primary-color),var(--primary-light));color:#fff;box-shadow:var(--shadow-md)}.brand-title{font-size:18px;font-weight:700;color:var(--text-primary)}.brand-sub{font-size:12px;color:var(--text-secondary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.nav-actions{display:flex;align-items:center;gap:10px}.nav-btn,.theme-toggle,.btn{display:inline-flex;align-items:center;justify-content:center;gap:8px;min-height:40px;padding:0 14px;border-radius:var(--radius-lg);border:1px solid var(--border-color);background:var(--bg-primary);color:var(--text-primary);text-decoration:none;cursor:pointer;font-size:14px;font-weight:600;transition:var(--transition)}.theme-toggle{width:40px;padding:0;color:var(--text-secondary)}.nav-btn:hover,.theme-toggle:hover,.btn:hover{border-color:var(--primary-color);box-shadow:var(--shadow-md);transform:translateY(-1px)}.theme-toggle .fa-sun{display:none}[data-theme="dark"] .theme-toggle .fa-sun{display:inline-block}[data-theme="dark"] .theme-toggle .fa-moon{display:none}.main{padding:30px 24px 48px}.panel,.card,.provider,.table-card,.empty{background:var(--bg-primary);border:1px solid var(--border-color);border-radius:var(--radius-xl);box-shadow:var(--shadow-sm)}.hero,.split,.stats{display:grid;gap:24px}.hero{grid-template-columns:minmax(0,1.2fr) minmax(320px,.8fr);margin-bottom:24px}.split{grid-template-columns:repeat(2,minmax(0,1fr));margin-bottom:24px}.stats{grid-template-columns:repeat(4,minmax(0,1fr));margin-bottom:24px}.hero-main,.auth,.panel,.table-card{padding:24px}.eyebrow,.pill,.tag{display:inline-flex;align-items:center;gap:8px;padding:8px 12px;border-radius:999px;font-size:12px;font-weight:600}.eyebrow{background:var(--bg-secondary);border:1px solid var(--border-color);color:var(--text-secondary)}.pill{background:var(--success-10);border:1px solid var(--primary-30);color:var(--success-text)}.pill.idle{background:var(--bg-secondary);border-color:var(--border-color);color:var(--text-secondary)}h1{margin:14px 0 12px;font-size:30px;line-height:1.2;color:var(--text-primary)}.copy,.sub,.help,.footer,.note{color:var(--text-secondary);font-size:14px;line-height:1.7}.meta{display:flex;gap:10px;flex-wrap:wrap;margin-top:16px}.meta .tag{background:var(--bg-secondary);border:1px solid var(--border-color);color:var(--text-secondary)}.title,.tools{display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap}.title{margin-bottom:12px}.section-title{margin:0 0 16px;font-size:18px;font-weight:700;color:var(--text-primary);display:flex;align-items:center;gap:10px}.label{display:block;margin-bottom:8px;font-size:13px;font-weight:600;color:var(--text-secondary)}.input,.select,.search{width:100%;min-height:42px;padding:0 14px;background:var(--bg-secondary);border:1px solid var(--border-color);border-radius:var(--radius-lg);color:var(--text-primary);font-size:14px;transition:var(--transition)}.input{padding-top:10px;padding-bottom:10px}.input:focus,.select:focus,.search:focus{outline:none;border-color:var(--primary-color);box-shadow:0 0 0 4px var(--primary-10)}.group{margin-bottom:16px}.row,.tool-row{display:flex;gap:10px;flex-wrap:wrap}.status{display:none;align-items:center;gap:10px;padding:12px 14px;border-radius:var(--radius-lg);margin-top:16px;font-size:13px;border:1px solid transparent}.status.show{display:flex}.status.info{background:var(--info-bg-alt);border-color:var(--info-border);color:var(--info-text-dark)}.status.success{background:var(--success-bg-light);border-color:var(--primary-30);color:var(--success-text)}.status.error{background:var(--danger-bg-light);border-color:var(--danger-border);color:var(--danger-text)}.btn{padding:0 16px;border:1px solid transparent}.btn-primary{background:linear-gradient(135deg,var(--primary-color),var(--primary-light));color:#fff}.btn-secondary{background:var(--bg-secondary);border-color:var(--border-color);color:var(--text-primary)}.btn-danger{background:var(--danger-color);color:#fff}.card{padding:20px;position:relative;overflow:hidden;transition:var(--transition)}.card:hover,.provider:hover{transform:translateY(-4px);box-shadow:var(--shadow-lg);border-color:var(--primary-30)}.card:before{content:'';position:absolute;top:0;left:0;right:0;height:3px}.card.green:before{background:linear-gradient(90deg,var(--primary-color),var(--primary-light))}.card.blue:before{background:linear-gradient(90deg,var(--info-color),#22d3ee)}.card.indigo:before{background:linear-gradient(90deg,var(--indigo-500),var(--indigo-600))}.card.orange:before{background:linear-gradient(90deg,var(--warning-color),#fbbf24)}.card-label{font-size:12px;letter-spacing:.04em;text-transform:uppercase;color:var(--text-secondary)}.card-value{margin-top:10px;font-size:32px;font-weight:700;color:var(--text-primary);line-height:1.1}.card.green .card-value{color:var(--primary-color)}.card.blue .card-value{color:var(--info-color)}.card.indigo .card-value{color:var(--indigo-500)}.card.orange .card-value{color:var(--warning-color)}.note{margin-top:10px;font-size:12px;color:var(--text-tertiary)}.bars{display:flex;flex-direction:column;gap:14px}.bar{display:flex;flex-direction:column;gap:6px}.bar-head{display:flex;justify-content:space-between;gap:12px;font-size:13px}.bar-name{color:var(--text-primary);font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.bar-value{color:var(--text-secondary)}.track{height:8px;background:var(--bg-tertiary);border-radius:999px;overflow:hidden}.fill{height:100%;background:linear-gradient(90deg,var(--primary-color),var(--primary-light));border-radius:inherit}.providers{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:16px;margin-bottom:24px}.provider{padding:20px;transition:var(--transition)}.provider-head{display:flex;justify-content:space-between;align-items:flex-start;gap:12px;margin-bottom:16px}.provider-name{font-size:16px;font-weight:700;color:var(--text-primary);margin-bottom:6px}.provider-sub{font-size:12px;color:var(--text-secondary)}.provider-badge{display:inline-flex;align-items:center;gap:6px;padding:6px 10px;border-radius:999px;background:var(--bg-secondary);border:1px solid var(--border-color);font-size:12px;font-weight:600;color:var(--text-secondary);white-space:nowrap}.mini{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}.mini-box{padding:12px;border-radius:var(--radius-lg);background:var(--bg-secondary);border:1px solid var(--border-color)}.mini-label{font-size:12px;color:var(--text-secondary)}.mini-value{margin-top:6px;font-size:18px;font-weight:700;color:var(--text-primary)}.table-wrap{overflow:auto;border:1px solid var(--border-color);border-radius:var(--radius-lg)}table{width:100%;border-collapse:collapse}th,td{padding:14px 16px;text-align:left;border-bottom:1px solid var(--border-color)}th{background:var(--bg-secondary);font-size:12px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:var(--text-secondary)}td{font-size:14px;color:var(--text-primary)}tr:last-child td{border-bottom:none}.mono{font-family:"JetBrains Mono",Consolas,monospace}.tag{background:var(--bg-secondary);border:1px solid var(--border-color);color:var(--text-secondary)}.empty{text-align:center;padding:36px 24px;color:var(--text-secondary);display:none}.empty i{font-size:32px;color:var(--text-tertiary);margin-bottom:12px}.footer{text-align:center;margin-top:20px;font-size:12px;color:var(--text-tertiary)}@media (max-width:1100px){.hero,.split,.stats{grid-template-columns:1fr}}@media (max-width:760px){.navbar-inner,.main{padding-left:16px;padding-right:16px}.brand-sub,.nav-btn span{display:none}.mini{grid-template-columns:1fr}.tool-row,.search,.select{width:100%}}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<header class="navbar">
|
||
<div class="navbar-inner">
|
||
<div class="brand">
|
||
<span class="brand-icon"><i class="fas fa-chart-line"></i></span>
|
||
<div>
|
||
<div class="brand-title">模型用量统计</div>
|
||
<div class="brand-sub">统一查看 Provider / Model 的请求次数与 Token 累计</div>
|
||
</div>
|
||
</div>
|
||
<div class="nav-actions">
|
||
<a class="nav-btn" href="/"><i class="fas fa-arrow-left"></i><span>返回控制台</span></a>
|
||
<button class="theme-toggle" id="themeToggle" type="button" aria-label="切换主题"><i class="fas fa-moon"></i><i class="fas fa-sun"></i></button>
|
||
</div>
|
||
</div>
|
||
</header>
|
||
<main class="main">
|
||
<section class="hero">
|
||
<div class="panel hero-main">
|
||
<span class="eyebrow"><i class="fas fa-chart-pie"></i> 统计面板</span>
|
||
<h1>模型用量统计面板</h1>
|
||
<div class="copy">通过 <code>/api/model-usage-stats</code> 查看系统已累计的请求数、Prompt Tokens、Completion Tokens 和 Provider / Model 维度分布。</div>
|
||
<div class="meta">
|
||
<span class="tag"><i class="fas fa-key"></i> 支持后台 Token 与 API Key</span>
|
||
<span class="tag"><i class="fas fa-database"></i> 基于插件持久化统计</span>
|
||
<span class="tag"><i class="fas fa-filter"></i> 支持搜索、排序、刷新与重置</span>
|
||
</div>
|
||
</div>
|
||
<aside class="panel auth">
|
||
<div class="title"><h2 class="section-title" style="margin:0"><i class="fas fa-shield-halved"></i>访问凭证</h2><span class="pill idle" id="authBadge">未连接</span></div>
|
||
<div class="sub">与其他管理页面一致,这里不会自动读取服务端敏感配置。输入凭证后可加载统计接口,刷新后会从本地缓存恢复。</div>
|
||
<div class="group">
|
||
<label class="label" for="credentialValue">访问凭证</label>
|
||
<input class="input" id="credentialValue" type="password" placeholder="输入 Token 或 API Key">
|
||
<div class="help" id="credentialHelp">这里直接填后台登录 Token 或服务 API Key。页面会自动同时尝试 <code>Authorization: Bearer</code> 和 <code>x-api-key</code> 两种方式。</div>
|
||
</div>
|
||
<div class="row">
|
||
<button class="btn btn-primary" id="connectBtn" type="button"><i class="fas fa-plug"></i><span>连接并加载</span></button>
|
||
<button class="btn btn-secondary" id="clearBtn" type="button"><i class="fas fa-eraser"></i><span>清空凭证</span></button>
|
||
</div>
|
||
<div class="status info show" id="status"><i class="fas fa-circle-info"></i><span>尚未加载统计数据。</span></div>
|
||
</aside>
|
||
</section>
|
||
<section class="stats">
|
||
<article class="card green"><div class="card-label">总请求数</div><div class="card-value" id="totalRequests">0</div><div class="note">累计成功落库的模型调用次数</div></article>
|
||
<article class="card blue"><div class="card-label">Prompt Tokens</div><div class="card-value" id="promptTokens">0</div><div class="note">输入 token 的累计值</div></article>
|
||
<article class="card indigo"><div class="card-label">Completion Tokens</div><div class="card-value" id="completionTokens">0</div><div class="note">输出 token 的累计值</div></article>
|
||
<article class="card orange"><div class="card-label">总 Tokens</div><div class="card-value" id="totalTokens">0</div><div class="note" id="updatedAt">等待数据</div></article>
|
||
</section>
|
||
<section class="split">
|
||
<div class="panel"><h2 class="section-title"><i class="fas fa-sitemap"></i>Provider 分布</h2><div class="bars" id="providerBars"></div></div>
|
||
<div class="panel"><h2 class="section-title"><i class="fas fa-fire"></i>Top Models</h2><div class="bars" id="topModelBars"></div></div>
|
||
</section>
|
||
<section>
|
||
<div class="tools">
|
||
<h2 class="section-title" style="margin:0"><i class="fas fa-layer-group"></i>Provider 视图</h2>
|
||
<div class="row">
|
||
<button class="btn btn-secondary" id="refreshBtn" type="button"><i class="fas fa-rotate-right"></i><span>刷新</span></button>
|
||
<button class="btn btn-danger" id="resetBtn" type="button"><i class="fas fa-trash-can"></i><span>重置统计</span></button>
|
||
</div>
|
||
</div>
|
||
<div class="providers" id="providerCards"></div>
|
||
</section>
|
||
<section class="table-card">
|
||
<div class="tools">
|
||
<h2 class="section-title" style="margin:0"><i class="fas fa-table"></i>模型明细</h2>
|
||
<div class="tool-row">
|
||
<input class="search" id="searchInput" type="search" placeholder="搜索 provider 或 model">
|
||
<select class="select" id="sortSelect">
|
||
<option value="totalTokens-desc">按总 Tokens 降序</option>
|
||
<option value="requestCount-desc">按请求数降序</option>
|
||
<option value="promptTokens-desc">按 Prompt Tokens 降序</option>
|
||
<option value="completionTokens-desc">按 Completion Tokens 降序</option>
|
||
<option value="provider-asc">按 Provider 升序</option>
|
||
<option value="model-asc">按 Model 升序</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div class="table-wrap">
|
||
<table>
|
||
<thead><tr><th>Provider</th><th>Model</th><th>请求数</th><th>Prompt</th><th>Completion</th><th>Total</th><th>最近使用</th></tr></thead>
|
||
<tbody id="tableBody"></tbody>
|
||
</table>
|
||
</div>
|
||
</section>
|
||
<section class="empty" id="emptyState"><i class="fas fa-chart-pie"></i><h3>暂无统计数据</h3><p>先发起几次模型请求,再回来查看这里的可视化结果。</p></section>
|
||
<p class="footer">如果上游响应没有提供 usage,调用次数仍会统计,但 token 数可能保持为 0。</p>
|
||
</main>
|
||
<script type="module">
|
||
import{initThemeSwitcher,setTheme,getCurrentTheme}from'./app/theme-switcher.js';setTheme(getCurrentTheme());initThemeSwitcher('themeToggle');
|
||
</script>
|
||
<script>
|
||
const API_BASE='/api/model-usage-stats',STORAGE_KEY='model_usage_stats_auth';let rows=[];const el=id=>document.getElementById(id),fmt=v=>new Intl.NumberFormat('zh-CN').format(Number(v||0)),fmtToken=v=>{const value=Number(v||0);if(!Number.isFinite(value))return'0';const abs=Math.abs(value);const units=[{threshold:1e9,suffix:'G'},{threshold:1e6,suffix:'M'},{threshold:1e3,suffix:'K'}];for(const unit of units){if(abs>=unit.threshold){const scaled=value/unit.threshold;const digits=Math.abs(scaled)>=100?0:Math.abs(scaled)>=10?1:2;return`${scaled.toFixed(digits).replace(/\.0+$|(\.\d*[1-9])0+$/,'$1')}${unit.suffix}`}}return fmt(value)};
|
||
function rel(iso){if(!iso)return'未记录';const d=new Date(iso);if(Number.isNaN(d.getTime()))return iso;const m=Math.floor((Date.now()-d.getTime())/6e4);if(m<1)return'刚刚';if(m<60)return`${m} 分钟前`;const h=Math.floor(m/60);if(h<24)return`${h} 小时前`;const day=Math.floor(h/24);return day<30?`${day} 天前`:d.toLocaleString('zh-CN')}
|
||
function status(msg,type='info'){const icon=type==='error'?'fa-triangle-exclamation':type==='success'?'fa-circle-check':'fa-circle-info';el('status').className=`status ${type} show`;el('status').innerHTML=`<i class="fas ${icon}"></i><span>${msg}</span>`}
|
||
function badge(text,ok=false){const n=el('authBadge');n.className=ok?'pill':'pill idle';n.textContent=text}
|
||
function credential(){return{value:el('credentialValue').value.trim()}}
|
||
function saveCredential(){const c=credential();if(c.value)localStorage.setItem(STORAGE_KEY,JSON.stringify(c))}
|
||
function restoreCredential(){try{const c=JSON.parse(localStorage.getItem(STORAGE_KEY)||'null');if(!c)return;el('credentialValue').value=c.value||''}catch{}}
|
||
function clearCredential(){localStorage.removeItem(STORAGE_KEY);el('credentialValue').value='';badge('未连接',false);status('已清空本地保存的凭证。')}
|
||
function headers(){const c=credential();if(!c.value)throw new Error('请先输入访问凭证。');return{Authorization:`Bearer ${c.value}`,'x-api-key':c.value}}
|
||
async function request(url,options={}){const res=await fetch(url,{...options,headers:{...(options.headers||{}),...headers()}}),text=await res.text();let payload=null;try{payload=text?JSON.parse(text):null}catch{throw new Error(`接口返回了非 JSON 内容: ${text||'(empty)'}`)}if(!res.ok){if(payload?.error?.code==='PLUGIN_DISABLED')throw new Error('插件未启用:请先在插件管理中启用 model-usage-stats。');throw new Error(payload?.error?.message||payload?.message||`请求失败 (${res.status})`)}return payload}
|
||
function flatten(data){const list=[];for(const [provider,pd] of Object.entries(data.providers||{})){for(const [model,md] of Object.entries(pd.models||{}))list.push({provider,model,...md})}return list}
|
||
function bars(id,items,getValue,getLabel){const box=el(id);box.innerHTML='';if(!items.length){box.innerHTML='<div class="mini-box"><div class="mini-label">暂无数据</div><div class="mini-value" style="font-size:14px">等待统计写入</div></div>';return}const max=Math.max(...items.map(getValue),1);items.forEach(item=>{const v=Number(getValue(item)||0),w=Math.max(v/max*100,2),node=document.createElement('div');node.className='bar';node.innerHTML=`<div class="bar-head"><div class="bar-name" title="${getLabel(item)}">${getLabel(item)}</div><div class="bar-value">${fmtToken(v)}</div></div><div class="track"><div class="fill" style="width:${w}%"></div></div>`;box.appendChild(node)})}
|
||
function renderSummary(data){const s=data.summary||{};el('totalRequests').textContent=fmt(s.requestCount);el('promptTokens').textContent=fmtToken(s.promptTokens);el('completionTokens').textContent=fmtToken(s.completionTokens);el('totalTokens').textContent=fmtToken(s.totalTokens);el('updatedAt').textContent=data.updatedAt?`更新于 ${new Date(data.updatedAt).toLocaleString('zh-CN')}`:'尚未写入'}
|
||
function renderProviders(data){const providers=Object.entries(data.providers||{}).map(([name,p])=>({name,data:p,count:Object.keys(p.models||{}).length})).sort((a,b)=>(b.data.summary?.totalTokens||0)-(a.data.summary?.totalTokens||0));const box=el('providerCards');box.innerHTML='';if(!providers.length){box.innerHTML='<div class="provider"><div class="provider-name">暂无 Provider 统计</div><div class="provider-sub">当前还没有可展示的累计数据。</div></div>';return}providers.forEach(p=>{const s=p.data.summary||{};const node=document.createElement('article');node.className='provider';node.innerHTML=`<div class="provider-head"><div><div class="provider-name">${p.name}</div><div class="provider-sub">包含 ${fmt(p.count)} 个模型</div></div><span class="provider-badge"><i class="fas fa-bolt"></i>${fmt(s.requestCount)} 次调用</span></div><div class="mini"><div class="mini-box"><div class="mini-label">Total Tokens</div><div class="mini-value">${fmtToken(s.totalTokens)}</div></div><div class="mini-box"><div class="mini-label">Prompt Tokens</div><div class="mini-value">${fmtToken(s.promptTokens)}</div></div><div class="mini-box"><div class="mini-label">Completion Tokens</div><div class="mini-value">${fmtToken(s.completionTokens)}</div></div><div class="mini-box"><div class="mini-label">最近使用</div><div class="mini-value" style="font-size:14px">${rel(s.lastUsedAt)}</div></div></div>`;box.appendChild(node)})}
|
||
function filteredRows(){const keyword=el('searchInput').value.trim().toLowerCase();const [field,dir]=el('sortSelect').value.split('-');const list=keyword?rows.filter(r=>r.provider.toLowerCase().includes(keyword)||r.model.toLowerCase().includes(keyword)):rows.slice();list.sort((a,b)=>{if(field==='provider'||field==='model'){const l=String(a[field]||''),r=String(b[field]||'');return dir==='desc'?r.localeCompare(l):l.localeCompare(r)}const l=Number(a[field]||0),r=Number(b[field]||0);return dir==='desc'?r-l:l-r});return list}
|
||
function renderTable(){const tbody=el('tableBody'),list=filteredRows();tbody.innerHTML='';if(!list.length){tbody.innerHTML='<tr><td colspan="7" style="text-align:center;color:var(--text-tertiary)">没有匹配的数据</td></tr>';return}list.forEach(r=>{const tr=document.createElement('tr');tr.innerHTML=`<td><span class="tag"><i class="fas fa-server"></i>${r.provider}</span></td><td>${r.model}</td><td class="mono">${fmt(r.requestCount)}</td><td class="mono">${fmtToken(r.promptTokens)}</td><td class="mono">${fmtToken(r.completionTokens)}</td><td class="mono">${fmtToken(r.totalTokens)}</td><td>${rel(r.lastUsedAt)}</td>`;tbody.appendChild(tr)})}
|
||
function render(data){rows=flatten(data);el('emptyState').style.display=rows.length?'none':'block';renderSummary(data);const providers=Object.entries(data.providers||{}).map(([name,p])=>({name,totalTokens:Number(p.summary?.totalTokens||0)})).sort((a,b)=>b.totalTokens-a.totalTokens).slice(0,8);const topModels=rows.slice().sort((a,b)=>Number(b.totalTokens||0)-Number(a.totalTokens||0)).slice(0,8);bars('providerBars',providers,i=>i.totalTokens,i=>i.name);bars('topModelBars',topModels,i=>Number(i.totalTokens||0),i=>`${i.provider} / ${i.model}`);renderProviders(data);renderTable()}
|
||
async function loadData(){try{saveCredential();status('正在加载统计数据...');const payload=await request(API_BASE);render(payload.data||payload);badge('已连接',true);status(`已加载 ${fmt(rows.length)} 条模型统计。`,'success')}catch(error){console.error(error);badge('连接失败',false);status(error.message,'error')}}
|
||
async function resetData(){if(!confirm('确认重置全部模型统计吗?此操作会清空已落库的累计数据。'))return;try{status('正在重置统计数据...');const payload=await request(`${API_BASE}/reset`,{method:'POST'});render(payload.data||payload);status('统计数据已重置。','success')}catch(error){console.error(error);status(error.message,'error')}}
|
||
el('connectBtn').addEventListener('click',loadData);el('refreshBtn').addEventListener('click',loadData);el('resetBtn').addEventListener('click',resetData);el('clearBtn').addEventListener('click',clearCredential);el('searchInput').addEventListener('input',renderTable);el('sortSelect').addEventListener('change',renderTable);el('credentialValue').addEventListener('keydown',e=>{if(e.key==='Enter')loadData()});
|
||
restoreCredential();if(el('credentialValue').value.trim())loadData();
|
||
</script>
|
||
</body>
|
||
</html>
|